diff libervia/frontends/quick_frontend/quick_contact_list.py @ 4074:26b7ed2817da

refactoring: rename `sat_frontends` to `libervia.frontends`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 14:12:38 +0200
parents sat_frontends/quick_frontend/quick_contact_list.py@4b842c1fb686
children b47f21f2b8fa
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/frontends/quick_frontend/quick_contact_list.py	Fri Jun 02 14:12:38 2023 +0200
@@ -0,0 +1,1113 @@
+#!/usr/bin/env python3
+
+# helper class for making a SàT frontend contact lists
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Contact List handling multi profiles at once,
+    should replace quick_contact_list module in the future"""
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.frontends.quick_frontend.quick_widgets import QuickWidget
+from libervia.frontends.quick_frontend.constants import Const as C
+from libervia.frontends.tools import jid
+from collections import OrderedDict
+
+log = getLogger(__name__)
+
+try:
+    # FIXME: to be removed when an acceptable solution is here
+    str("")  # XXX: unicode doesn't exist in pyjamas
+except (TypeError, AttributeError):  # Error raised is not the same depending on
+    # pyjsbuild options
+    # XXX: pyjamas' max doesn't support key argument, so we implement it ourself
+    pyjamas_max = max
+
+    def max(iterable, key):
+        iter_cpy = list(iterable)
+        iter_cpy.sort(key=key)
+        return pyjamas_max(iter_cpy)
+
+    # next doesn't exist in pyjamas
+    def next(iterable, *args):
+        try:
+            return iterable.__next__()
+        except StopIteration as e:
+            if args:
+                return args[0]
+            raise e
+
+
+handler = None
+
+
+class ProfileContactList(object):
+    """Contact list data for a single profile"""
+
+    def __init__(self, profile):
+        self.host = handler.host
+        self.profile = profile
+        # contain all jids in roster or not,
+        # bare jids as keys, resources are used in data
+        # XXX: we don't mutualise cache, as values may differ
+        # for different profiles (e.g. directed presence)
+        self._cache = {}
+
+        # special entities (groupchat, gateways, etc)
+        # may be bare or full jid
+        self._specials = set()
+
+        # group data contain jids in groups and misc frontend data
+        # None key is used for jids with no group
+        self._groups = {}  # groups to group data map
+
+        # contacts in roster (bare jids)
+        self._roster = set()
+
+        # selected entities, full jid
+        self._selected = set()
+
+        # options
+        self.show_disconnected = False
+        self._show_empty_groups = True
+        self.show_resources = False
+        self.show_status = False
+        # do we show entities with notifications?
+        # if True, entities will be show even if they normally would not
+        # (e.g. not in contact list) if they have notifications attached
+        self.show_entities_with_notifs = True
+
+        self.host.bridge.param_get_a_async(
+            C.SHOW_EMPTY_GROUPS,
+            "General",
+            profile_key=profile,
+            callback=self._show_empty_groups_cb,
+        )
+
+        self.host.bridge.param_get_a_async(
+            C.SHOW_OFFLINE_CONTACTS,
+            "General",
+            profile_key=profile,
+            callback=self._show_offline_contacts,
+        )
+
+        self.host.addListener("presence", self.on_presence_update, [self.profile])
+        self.host.addListener("nicknames", self.on_nicknames_update, [self.profile])
+        self.host.addListener("notification", self.on_notification, [self.profile])
+        # on_notification only updates the entity, so we can re-use it
+        self.host.addListener("notificationsClear", self.on_notification, [self.profile])
+
+    @property
+    def whoami(self):
+        return self.host.profiles[self.profile].whoami
+
+    def _show_empty_groups_cb(self, show_str):
+        # Called only by __init__
+        # self.update is not wanted here, as it is done by
+        # handler when all profiles are ready
+        self.show_empty_groups(C.bool(show_str))
+
+    def _show_offline_contacts(self, show_str):
+        # same comments as for _show_empty_groups
+        self.show_offline_contacts(C.bool(show_str))
+
+    def __contains__(self, entity):
+        """Check if entity is in contact list
+
+        An entity can be in contact list even if not in roster
+        use is_in_roster to check if entity is in roster.
+        @param entity (jid.JID): jid of the entity (resource is not ignored,
+            use bare jid if needed)
+        """
+        if entity.resource:
+            try:
+                return entity.resource in self.getCache(entity.bare, C.CONTACT_RESOURCES)
+            except exceptions.NotFound:
+                return False
+        return entity in self._cache
+
+    @property
+    def roster(self):
+        """Return all the bare JIDs of the roster entities.
+
+        @return (set[jid.JID])
+        """
+        return self._roster
+
+    @property
+    def roster_connected(self):
+        """Return all the bare JIDs of the roster entities that are connected.
+
+        @return (set[jid.JID])
+        """
+        return set(
+            [
+                entity
+                for entity in self._roster
+                if self.getCache(entity, C.PRESENCE_SHOW, default=None) is not None
+            ]
+        )
+
+    @property
+    def roster_entities_by_group(self):
+        """Return a dictionary binding the roster groups to their entities bare JIDs.
+
+        This also includes the empty group (None key).
+        @return (dict[unicode,set(jid.JID)])
+        """
+        return {group: self._groups[group]["jids"] for group in self._groups}
+
+    @property
+    def roster_groups_by_entities(self):
+        """Return a dictionary binding the entities bare JIDs to their roster groups
+
+        @return (dict[jid.JID, set(unicode)])
+        """
+        result = {}
+        for group, data in self._groups.items():
+            for entity in data["jids"]:
+                result.setdefault(entity, set()).add(group)
+        return result
+
+    @property
+    def selected(self):
+        """Return contacts currently selected
+
+        @return (set): set of selected entities
+        """
+        return self._selected
+
+    @property
+    def all_iter(self):
+        """return all know entities in cache as an iterator of tuples
+
+        entities are not sorted
+        """
+        return iter(self._cache.items())
+
+    @property
+    def items(self):
+        """Return item representation for all visible entities in cache
+
+        entities are not sorted
+        key: bare jid, value: data
+        """
+        return {
+            jid_: cache
+            for jid_, cache in self._cache.items()
+            if self.entity_visible(jid_)
+        }
+
+    def get_item(self, entity):
+        """Return item representation of requested entity
+
+        @param entity(jid.JID): bare jid of entity
+        @raise (KeyError): entity is unknown
+        """
+        return self._cache[entity]
+
+    def _got_contacts(self, contacts):
+        """Add contacts and notice parent that contacts are filled
+
+        Called during initial contact list filling
+        @param contacts(tuple): all contacts
+        """
+        for contact in contacts:
+            entity = jid.JID(contact[0])
+            if entity.resource:
+                # we use entity's bare jid to cache data, so a resource here
+                # will cause troubles
+                log.warning(
+                    "Roster entities with resources are not managed, ignoring {entity}"
+                    .format(entity=entity))
+                continue
+            self.host.contact_new_handler(*contact, profile=self.profile)
+        handler._contacts_filled(self.profile)
+
+    def _fill(self):
+        """Get all contacts from backend
+
+        Contacts will be cleared before refilling them
+        """
+        self.clear_contacts(keep_cache=True)
+        self.host.bridge.contacts_get(self.profile, callback=self._got_contacts)
+
+    def fill(self):
+        handler.fill(self.profile)
+
+    def getCache(
+        self, entity, name=None, bare_default=True, create_if_not_found=False,
+        default=Exception):
+        """Return a cache value for a contact
+
+        @param entity(jid.JID): entity of the contact from who we want data
+            (resource is used if given)
+            if a resource specific information is requested:
+                - if no resource is given (bare jid), the main resource is used,
+                    according to priority
+                - if resource is given, it is used
+        @param name(unicode): name the data to get, or None to get everything
+        @param bare_default(bool, None): if True and entity is a full jid,
+            the value of bare jid will be returned if not value is found for
+            the requested resource.
+            If False, None is returned if no value is found for the requested resource.
+            If None, bare_default will be set to False if entity is in a room, True else
+        @param create_if_not_found(bool): if True, create contact if it's not found
+            in cache
+        @param default(object): value to return when name is not found in cache
+            if Exception is used, a KeyError will be returned
+            otherwise, the given value will be used
+        @return: full cache if no name is given, or value of "name", or None
+        @raise NotFound: entity not found in cache
+        @raise KeyError: name not found in cache
+        """
+        # FIXME: resource handling need to be reworked
+        # FIXME: bare_default work for requesting full jid to get bare jid,
+        #        but not the other way
+        #        e.g.: if we have set an avatar for user@server.tld/resource
+        #        and we request user@server.tld
+        #        we won't get the avatar set in the resource
+        try:
+            cache = self._cache[entity.bare]
+        except KeyError:
+            if create_if_not_found:
+                self.set_contact(entity)
+                cache = self._cache[entity.bare]
+            else:
+                raise exceptions.NotFound
+
+        if name is None:
+            if default is not Exception:
+                raise exceptions.InternalError(
+                    "default value can only Exception when name is not specified"
+                )
+            # full cache is requested
+            return cache
+
+        if name in ("status", C.PRESENCE_STATUSES, C.PRESENCE_PRIORITY, C.PRESENCE_SHOW):
+            # these data are related to the resource
+            if not entity.resource:
+                main_resource = cache[C.CONTACT_MAIN_RESOURCE]
+                if main_resource is None:
+                    # we ignore presence info if we don't have any resource in cache
+                    # FIXME: to be checked
+                    return
+                cache = cache[C.CONTACT_RESOURCES].setdefault(main_resource, {})
+            else:
+                cache = cache[C.CONTACT_RESOURCES].setdefault(entity.resource, {})
+
+            if name == "status":  # XXX: we get the first status for 'status' key
+                # TODO: manage main language for statuses
+                return cache[C.PRESENCE_STATUSES].get(C.PRESENCE_STATUSES_DEFAULT, "")
+
+        elif entity.resource:
+            try:
+                return cache[C.CONTACT_RESOURCES][entity.resource][name]
+            except KeyError as e:
+                if bare_default is None:
+                    bare_default = not self.is_room(entity.bare)
+                if not bare_default:
+                    if default is Exception:
+                        raise e
+                    else:
+                        return default
+
+        try:
+            return cache[name]
+        except KeyError as e:
+            if default is Exception:
+                raise e
+            else:
+                return default
+
+    def set_cache(self, entity, name, value):
+        """Set or update value for one data in cache
+
+        @param entity(JID): entity to update
+        @param name(str): value to set or update
+        """
+        self.set_contact(entity, attributes={name: value})
+
+    def get_full_jid(self, entity):
+        """Get full jid from a bare jid
+
+        @param entity(jid.JID): must be a bare jid
+        @return (jid.JID): bare jid + main resource
+        @raise ValueError: the entity is not bare
+        """
+        if entity.resource:
+            raise ValueError("get_full_jid must be used with a bare jid")
+        main_resource = self.getCache(entity, C.CONTACT_MAIN_RESOURCE)
+        return jid.JID("{}/{}".format(entity, main_resource))
+
+    def set_group_data(self, group, name, value):
+        """Register a data for a group
+
+        @param group: a valid (existing) group name
+        @param name: name of the data (can't be "jids")
+        @param value: value to set
+        """
+        assert name != "jids"
+        self._groups[group][name] = value
+
+    def get_group_data(self, group, name=None):
+        """Return value associated to group data
+
+        @param group: a valid (existing) group name
+        @param name: name of the data or None to get the whole dict
+        @return: registered value
+        """
+        if name is None:
+            return self._groups[group]
+        return self._groups[group][name]
+
+    def is_in_roster(self, entity):
+        """Tell if an entity is in roster
+
+        @param entity(jid.JID): jid of the entity
+            the bare jid will be used
+        """
+        return entity.bare in self._roster
+
+    def is_room(self, entity):
+        """Helper method to know if entity is a MUC room
+
+        @param entity(jid.JID): jid of the entity
+            hint: use bare jid here, as room can't be full jid with MUC
+        @return (bool): True if entity is a room
+        """
+        assert entity.resource is None  # FIXME: this may change when MIX will be handled
+        return self.is_special(entity, C.CONTACT_SPECIAL_GROUP)
+
+    def is_special(self, entity, special_type):
+        """Tell if an entity is of a specialy _type
+
+        @param entity(jid.JID): jid of the special entity
+            if the jid is full, will be added to special extras
+        @param special_type: one of special type (e.g. C.CONTACT_SPECIAL_GROUP)
+        @return (bool): True if entity is from this special type
+        """
+        return self.getCache(entity, C.CONTACT_SPECIAL, default=None) == special_type
+
+    def set_special(self, entity, special_type):
+        """Set special flag on an entity
+
+        @param entity(jid.JID): jid of the special entity
+            if the jid is full, will be added to special extras
+        @param special_type: one of special type (e.g. C.CONTACT_SPECIAL_GROUP)
+            or None to remove special flag
+        """
+        assert special_type in C.CONTACT_SPECIAL_ALLOWED + (None,)
+        self.set_cache(entity, C.CONTACT_SPECIAL, special_type)
+
+    def get_specials(self, special_type=None, bare=False):
+        """Return all the bare JIDs of the special roster entities of with given type.
+
+        @param special_type(unicode, None): if not None, filter by special type
+            (e.g. C.CONTACT_SPECIAL_GROUP)
+        @param bare(bool): return only bare jids if True
+        @return (iter[jid.JID]): found special entities
+        """
+        for entity in self._specials:
+            if bare and entity.resource:
+                continue
+            if (
+                special_type is not None
+                and self.getCache(entity, C.CONTACT_SPECIAL, default=None) != special_type
+            ):
+                continue
+            yield entity
+
+    def disconnect(self):
+        # for now we just clear contacts on disconnect
+        self.clear_contacts()
+
+    def clear_contacts(self, keep_cache=False):
+        """Clear all the contact list
+
+        @param keep_cache: if True, don't reset the cache
+        """
+        self.select(None)
+        if not keep_cache:
+            self._cache.clear()
+        self._groups.clear()
+        self._specials.clear()
+        self._roster.clear()
+        self.update()
+
+    def set_contact(self, entity, groups=None, attributes=None, in_roster=False):
+        """Add a contact to the list if it doesn't exist, else update it.
+
+        This method can be called with groups=None for the purpose of updating
+        the contact's attributes (e.g. nicknames). In that case, the groups
+        attribute must not be set to the default group but ignored. If not,
+        you may move your contact from its actual group(s) to the default one.
+
+        None value for 'groups' has a different meaning than [None]
+        which is for the default group.
+
+        @param entity (jid.JID): entity to add or replace
+            if entity is a full jid, attributes will be cached in for the full jid only
+        @param groups (list): list of groups or None to ignore the groups membership.
+        @param attributes (dict): attibutes of the added jid or to update
+        @param in_roster (bool): True if contact is from roster
+        """
+        if attributes is None:
+            attributes = {}
+
+        entity_bare = entity.bare
+        # we check if the entity is visible before changing anything
+        # this way we know if we need to do an UPDATE_ADD, UPDATE_MODIFY
+        # or an UPDATE_DELETE
+        was_visible = self.entity_visible(entity_bare)
+
+        if in_roster:
+            self._roster.add(entity_bare)
+
+        cache = self._cache.setdefault(
+            entity_bare,
+            {
+                C.CONTACT_RESOURCES: {},
+                C.CONTACT_MAIN_RESOURCE: None,
+                C.CONTACT_SELECTED: set(),
+            },
+        )
+
+        # we don't want forbidden data in attributes
+        assert not C.CONTACT_DATA_FORBIDDEN.intersection(attributes)
+
+        # we set groups and fill self._groups accordingly
+        if groups is not None:
+            if not groups:
+                groups = [None]  # [None] is the default group
+            if C.CONTACT_GROUPS in cache:
+                # XXX: don't use set(cache[C.CONTACT_GROUPS]).difference(groups) because
+                #      it won't work in Pyjamas if None is in cache[C.CONTACT_GROUPS]
+                for group in [
+                    group for group in cache[C.CONTACT_GROUPS] if group not in groups
+                ]:
+                    self._groups[group]["jids"].remove(entity_bare)
+            cache[C.CONTACT_GROUPS] = groups
+            for group in groups:
+                self._groups.setdefault(group, {}).setdefault("jids", set()).add(
+                    entity_bare
+                )
+
+        # special entities management
+        if C.CONTACT_SPECIAL in attributes:
+            if attributes[C.CONTACT_SPECIAL] is None:
+                del attributes[C.CONTACT_SPECIAL]
+                self._specials.remove(entity)
+            else:
+                self._specials.add(entity)
+                cache[C.CONTACT_MAIN_RESOURCE] = None
+                if 'nicknames' in cache:
+                    del cache['nicknames']
+
+        # now the attributes we keep in cache
+        # XXX: if entity is a full jid, we store the value for the resource only
+        cache_attr = (
+            cache[C.CONTACT_RESOURCES].setdefault(entity.resource, {})
+            if entity.resource
+            else cache
+        )
+        for attribute, value in attributes.items():
+            if attribute == "nicknames" and self.is_special(
+                entity, C.CONTACT_SPECIAL_GROUP
+            ):
+                # we don't want to keep nicknames for MUC rooms
+                # FIXME: this is here as plugin XEP-0054 can link resource's nick
+                #        with bare jid which in the case of MUC
+                #        set the nick for the whole MUC
+                #        resulting in bad name displayed in some frontends
+                # FIXME: with plugin XEP-0054 + plugin identity refactoring, this
+                #        may not be needed anymore…
+                continue
+            cache_attr[attribute] = value
+
+        # we can update the display if needed
+        if self.entity_visible(entity_bare):
+            # if the contact was not visible, we need to add a widget
+            # else we just update id
+            update_type = C.UPDATE_MODIFY if was_visible else C.UPDATE_ADD
+            self.update([entity], update_type, self.profile)
+        elif was_visible:
+            # the entity was visible and is not anymore, we remove it
+            self.update([entity], C.UPDATE_DELETE, self.profile)
+
+    def entity_visible(self, entity, check_resource=False):
+        """Tell if the contact should be showed or hidden.
+
+        @param entity (jid.JID): jid of the contact
+        @param check_resource (bool): True if resource must be significant
+        @return (bool): True if that contact should be showed in the list
+        """
+        try:
+            show = self.getCache(entity, C.PRESENCE_SHOW)
+        except (exceptions.NotFound, KeyError):
+            return False
+
+        if check_resource:
+            selected = self._selected
+        else:
+            selected = {selected.bare for selected in self._selected}
+        return (
+            (show is not None and show != C.PRESENCE_UNAVAILABLE)
+            or self.show_disconnected
+            or entity in selected
+            or (
+                self.show_entities_with_notifs
+                and next(self.host.get_notifs(entity.bare, profile=self.profile), None)
+            )
+            or entity.resource is None and self.is_room(entity.bare)
+        )
+
+    def any_entity_visible(self, entities, check_resources=False):
+        """Tell if in a list of entities, at least one should be shown
+
+        @param entities (list[jid.JID]): list of jids
+        @param check_resources (bool): True if resources must be significant
+        @return (bool): True if a least one entity need to be shown
+        """
+        # FIXME: looks inefficient, really needed?
+        for entity in entities:
+            if self.entity_visible(entity, check_resources):
+                return True
+        return False
+
+    def is_entity_in_group(self, entity, group):
+        """Tell if an entity is in a roster group
+
+        @param entity(jid.JID): jid of the entity
+        @param group(unicode): group to check
+        @return (bool): True if the entity is in the group
+        """
+        return entity in self.get_group_data(group, "jids")
+
+    def remove_contact(self, entity):
+        """remove a contact from the list
+
+        @param entity(jid.JID): jid of the entity to remove (bare jid is used)
+        """
+        entity_bare = entity.bare
+        was_visible = self.entity_visible(entity_bare)
+        try:
+            groups = self._cache[entity_bare].get(C.CONTACT_GROUPS, set())
+        except KeyError:
+            log.error(_("Trying to delete an unknow entity [{}]").format(entity))
+        try:
+            self._roster.remove(entity_bare)
+        except KeyError:
+            pass
+        del self._cache[entity_bare]
+        for group in groups:
+            self._groups[group]["jids"].remove(entity_bare)
+            if not self._groups[group]["jids"]:
+                # FIXME: we use pop because of pyjamas:
+                #        http://wiki.goffi.org/wiki/Issues_with_Pyjamas/en
+                self._groups.pop(group)
+        for iterable in (self._selected, self._specials):
+            to_remove = set()
+            for set_entity in iterable:
+                if set_entity.bare == entity.bare:
+                    to_remove.add(set_entity)
+            iterable.difference_update(to_remove)
+        if was_visible:
+            self.update([entity], C.UPDATE_DELETE, self.profile)
+
+    def on_presence_update(self, entity, show, priority, statuses, profile):
+        """Update entity's presence status
+
+        @param entity(jid.JID): entity updated
+        @param show: availability
+        @parap priority: resource's priority
+        @param statuses: dict of statuses
+        @param profile: %(doc_profile)s
+        """
+        # FIXME: cache modification should be done with set_contact
+        #        the resources/presence handling logic should be moved there
+        was_visible = self.entity_visible(entity.bare)
+        cache = self.getCache(entity, create_if_not_found=True)
+        if show == C.PRESENCE_UNAVAILABLE:
+            if not entity.resource:
+                cache[C.CONTACT_RESOURCES].clear()
+                cache[C.CONTACT_MAIN_RESOURCE] = None
+            else:
+                try:
+                    del cache[C.CONTACT_RESOURCES][entity.resource]
+                except KeyError:
+                    log.error(
+                        "Presence unavailable received "
+                        "for an unknown resource [{}]".format(entity)
+                    )
+                if not cache[C.CONTACT_RESOURCES]:
+                    cache[C.CONTACT_MAIN_RESOURCE] = None
+        else:
+            if not entity.resource:
+                log.warning(
+                    _(
+                        "received presence from entity "
+                        "without resource: {}".format(entity)
+                    )
+                )
+            resources_data = cache[C.CONTACT_RESOURCES]
+            resource_data = resources_data.setdefault(entity.resource, {})
+            resource_data[C.PRESENCE_SHOW] = show
+            resource_data[C.PRESENCE_PRIORITY] = int(priority)
+            resource_data[C.PRESENCE_STATUSES] = statuses
+
+            if entity.bare not in self._specials:
+                # we may have resources with no priority
+                # (when a cached value is added for a not connected resource)
+                priority_resource = max(
+                    resources_data,
+                    key=lambda res: resources_data[res].get(
+                        C.PRESENCE_PRIORITY, -2 ** 32
+                    ),
+                )
+                cache[C.CONTACT_MAIN_RESOURCE] = priority_resource
+        if self.entity_visible(entity.bare):
+            update_type = C.UPDATE_MODIFY if was_visible else C.UPDATE_ADD
+            self.update([entity], update_type, self.profile)
+        elif was_visible:
+            self.update([entity], C.UPDATE_DELETE, self.profile)
+
+    def on_nicknames_update(self, entity, nicknames, profile):
+        """Update entity's nicknames
+
+        @param entity(jid.JID): entity updated
+        @param nicknames(list[unicode]): nicknames of the entity
+        @param profile: %(doc_profile)s
+        """
+        assert profile == self.profile
+        self.set_cache(entity, "nicknames", nicknames)
+
+    def on_notification(self, entity, notif, profile):
+        """Update entity with notification
+
+        @param entity(jid.JID): entity updated
+        @param notif(dict): notification data
+        @param profile: %(doc_profile)s
+        """
+        assert profile == self.profile
+        if entity is not None and self.entity_visible(entity):
+            self.update([entity], C.UPDATE_MODIFY, profile)
+
+    def unselect(self, entity):
+        """Unselect an entity
+
+         @param entity(jid.JID): entity to unselect
+        """
+        try:
+            cache = self._cache[entity.bare]
+        except:
+            log.error("Try to unselect an entity not in cache")
+        else:
+            try:
+                cache[C.CONTACT_SELECTED].remove(entity.resource)
+            except KeyError:
+                log.error("Try to unselect a not selected entity")
+            else:
+                self._selected.remove(entity)
+                self.update([entity], C.UPDATE_SELECTION)
+
+    def select(self, entity):
+        """Select an entity
+
+        @param entity(jid.JID, None): entity to select (resource is significant)
+            None to unselect all entities
+        """
+        if entity is None:
+            self._selected.clear()
+            for cache in self._cache.values():
+                cache[C.CONTACT_SELECTED].clear()
+            self.update(type_=C.UPDATE_SELECTION, profile=self.profile)
+        else:
+            log.debug("select %s" % entity)
+            try:
+                cache = self._cache[entity.bare]
+            except:
+                log.error("Try to select an entity not in cache")
+            else:
+                cache[C.CONTACT_SELECTED].add(entity.resource)
+                self._selected.add(entity)
+                self.update([entity], C.UPDATE_SELECTION, profile=self.profile)
+
+    def show_offline_contacts(self, show):
+        """Tell if offline contacts should be shown
+
+        @param show(bool): True if offline contacts should be shown
+        """
+        assert isinstance(show, bool)
+        if self.show_disconnected == show:
+            return
+        self.show_disconnected = show
+        self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile)
+
+    def show_empty_groups(self, show):
+        assert isinstance(show, bool)
+        if self._show_empty_groups == show:
+            return
+        self._show_empty_groups = show
+        self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile)
+
+    def show_resources(self, show):
+        assert isinstance(show, bool)
+        if self.show_resources == show:
+            return
+        self.show_resources = show
+        self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile)
+
+    def plug(self):
+        handler.add_profile(self.profile)
+
+    def unplug(self):
+        handler.remove_profile(self.profile)
+
+    def update(self, entities=None, type_=None, profile=None):
+        handler.update(entities, type_, profile)
+
+
+class QuickContactListHandler(object):
+    def __init__(self, host):
+        super(QuickContactListHandler, self).__init__()
+        self.host = host
+        global handler
+        if handler is not None:
+            raise exceptions.InternalError(
+                "QuickContactListHandler must be instanciated only once"
+            )
+        handler = self
+        self._clist = {}  # key: profile, value: ProfileContactList
+        self._widgets = set()
+        self._update_locked = False  # se to True to ignore updates
+
+    def __getitem__(self, profile):
+        """Return ProfileContactList instance for the requested profile"""
+        return self._clist[profile]
+
+    def __contains__(self, entity):
+        """Check if entity is in contact list
+
+        @param entity (jid.JID): jid of the entity (resource is not ignored,
+            use bare jid if needed)
+        """
+        for contact_list in self._clist.values():
+            if entity in contact_list:
+                return True
+        return False
+
+    @property
+    def roster(self):
+        """Return all the bare JIDs of the roster entities.
+
+        @return (set[jid.JID])
+        """
+        entities = set()
+        for contact_list in self._clist.values():
+            entities.update(contact_list.roster)
+        return entities
+
+    @property
+    def roster_connected(self):
+        """Return all the bare JIDs of the roster entities that are connected.
+
+        @return (set[jid.JID])
+        """
+        entities = set()
+        for contact_list in self._clist.values():
+            entities.update(contact_list.roster_connected)
+        return entities
+
+    @property
+    def roster_entities_by_group(self):
+        """Return a dictionary binding the roster groups to their entities bare
+        JIDs. This also includes the empty group (None key).
+
+        @return (dict[unicode,set(jid.JID)])
+        """
+        groups = {}
+        for contact_list in self._clist.values():
+            groups.update(contact_list.roster_entities_by_group)
+        return groups
+
+    @property
+    def roster_groups_by_entities(self):
+        """Return a dictionary binding the entities bare JIDs to their roster
+        groups.
+
+        @return (dict[jid.JID, set(unicode)])
+        """
+        entities = {}
+        for contact_list in self._clist.values():
+            entities.update(contact_list.roster_groups_by_entities)
+        return entities
+
+    @property
+    def selected(self):
+        """Return contacts currently selected
+
+        @return (set): set of selected entities
+        """
+        entities = set()
+        for contact_list in self._clist.values():
+            entities.update(contact_list.selected)
+        return entities
+
+    @property
+    def all_iter(self):
+        """Return item representation for all entities in cache
+
+        items are unordered
+        """
+        for profile, contact_list in self._clist.items():
+            for bare_jid, cache in contact_list.all_iter:
+                data = cache.copy()
+                data[C.CONTACT_PROFILE] = profile
+                yield bare_jid, data
+
+    @property
+    def items(self):
+        """Return item representation for visible entities in cache
+
+        items are unordered
+        key: bare jid, value: data
+        """
+        items = {}
+        for profile, contact_list in self._clist.items():
+            for bare_jid, cache in contact_list.items.items():
+                data = cache.copy()
+                items[bare_jid] = data
+                data[C.CONTACT_PROFILE] = profile
+        return items
+
+    @property
+    def items_sorted(self):
+        """Return item representation for visible entities in cache
+
+        items are ordered using self.items_sort
+        key: bare jid, value: data
+        """
+        return self.items_sort(self.items)
+
+    def items_sort(self, items):
+        """sort items
+
+       @param items(dict): items to sort (will be emptied !)
+       @return (OrderedDict): sorted items
+       """
+        ordered_items = OrderedDict()
+        bare_jids = sorted(items.keys())
+        for jid_ in bare_jids:
+            ordered_items[jid_] = items.pop(jid_)
+        return ordered_items
+
+    def register(self, widget):
+        """Register a QuickContactList widget
+
+        This method should only be used in QuickContactList
+        """
+        self._widgets.add(widget)
+
+    def unregister(self, widget):
+        """Unregister a QuickContactList widget
+
+        This method should only be used in QuickContactList
+        """
+        self._widgets.remove(widget)
+
+    def add_profiles(self, profiles):
+        """Add a contact list for plugged profiles
+
+        @param profile(iterable[unicode]): plugged profiles
+        """
+        for profile in profiles:
+            if profile not in self._clist:
+                self._clist[profile] = ProfileContactList(profile)
+        return [self._clist[profile] for profile in profiles]
+
+    def add_profile(self, profile):
+        return self.add_profiles([profile])[0]
+
+    def remove_profiles(self, profiles):
+        """Remove given unplugged profiles from contact list
+
+        @param profile(iterable[unicode]): unplugged profiles
+        """
+        for profile in profiles:
+            del self._clist[profile]
+
+    def remove_profile(self, profile):
+        self.remove_profiles([profile])
+
+    def get_special_extras(self, special_type=None):
+        """Return special extras with given type
+
+        If special_type is None, return all special extras.
+
+        @param special_type(unicode, None): one of special type
+            (e.g. C.CONTACT_SPECIAL_GROUP)
+            None to return all special extras.
+        @return (set[jid.JID])
+        """
+        entities = set()
+        for contact_list in self._clist.values():
+            entities.update(contact_list.get_special_extras(special_type))
+        return entities
+
+    def _contacts_filled(self, profile):
+        self._to_fill.remove(profile)
+        if not self._to_fill:
+            del self._to_fill
+            # we need a full update when all contacts are filled
+            self.update()
+        self.host.call_listeners("contactsFilled", profile=profile)
+
+    def fill(self, profile=None):
+        """Get all contacts from backend, and fill the widget
+
+        Contacts will be cleared before refilling them
+        @param profile(unicode, None): profile to fill
+            None to fill all profiles
+        """
+        try:
+            to_fill = self._to_fill
+        except AttributeError:
+            to_fill = self._to_fill = set()
+
+        # we check if profiles have already been filled
+        # to void filling them several times
+        filled = to_fill.copy()
+
+        if profile is not None:
+            assert profile in self._clist
+            to_fill.add(profile)
+        else:
+            to_fill.update(list(self._clist.keys()))
+
+        remaining = to_fill.difference(filled)
+        if remaining != to_fill:
+            log.debug(
+                "Not re-filling already filled contact list(s) for {}".format(
+                    ", ".join(to_fill.intersection(filled))
+                )
+            )
+        for profile in remaining:
+            self._clist[profile]._fill()
+
+    def clear_contacts(self, keep_cache=False):
+        """Clear all the contact list
+
+        @param keep_cache: if True, don't reset the cache
+        """
+        for contact_list in self._clist.values():
+            contact_list.clear_contacts(keep_cache)
+        # we need a full update
+        self.update()
+
+    def select(self, entity):
+        for contact_list in self._clist.values():
+            contact_list.select(entity)
+
+    def unselect(self, entity):
+        for contact_list in self._clist.values():
+            contact_list.select(entity)
+
+    def lock_update(self, locked=True, do_update=True):
+        """Forbid contact list updates
+
+        Used mainly while profiles are plugged, as many updates can occurs, causing
+        an impact on performances
+        @param locked(bool): updates are forbidden if True
+        @param do_update(bool): if True, a full update is done after unlocking
+            if set to False, widget state can be inconsistent, be sure to know
+            what youa re doing!
+        """
+        log.debug(
+            "Contact lists updates are now {}".format(
+                "LOCKED" if locked else "UNLOCKED"
+            )
+        )
+        self._update_locked = locked
+        if not locked and do_update:
+            self.update()
+
+    def update(self, entities=None, type_=None, profile=None):
+        if not self._update_locked:
+            for widget in self._widgets:
+                widget.update(entities, type_, profile)
+
+
+class QuickContactList(QuickWidget):
+    """This class manage the visual representation of contacts"""
+
+    SINGLE = False
+    PROFILES_MULTIPLE = True
+    # Can be linked to no profile (e.g. at the early frontend start)
+    PROFILES_ALLOW_NONE = True
+
+    def __init__(self, host, profiles):
+        super(QuickContactList, self).__init__(host, None, profiles)
+
+        # options
+        # for next values, None means use indivual value per profile
+        # True or False mean override these values for all profiles
+        self.show_disconnected = None  # TODO
+        self._show_empty_groups = None  # TODO
+        self.show_resources = None  # TODO
+        self.show_status = None  # TODO
+
+    def post_init(self):
+        """Method to be called by frontend after widget is initialised"""
+        handler.register(self)
+
+    @property
+    def all_iter(self):
+        return handler.all_iter
+
+    @property
+    def items(self):
+        return handler.items
+
+    @property
+    def items_sorted(self):
+        return handler.items_sorted
+
+    def update(self, entities=None, type_=None, profile=None):
+        """Update the display when something changed
+
+        @param entities(iterable[jid.JID], None): updated entities,
+            None to update the whole contact list
+        @param type_(unicode, None): update type, may be:
+            - C.UPDATE_DELETE: entity deleted
+            - C.UPDATE_MODIFY: entity updated
+            - C.UPDATE_ADD: entity added
+            - C.UPDATE_SELECTION: selection modified
+            - C.UPDATE_STRUCTURE: organisation of items is modified (not items
+              themselves)
+            or None for undefined update
+            Note that events correspond to addition, modification and deletion
+            of items on the whole contact list. If the contact is visible or not
+            has no influence on the type_.
+        @param profile(unicode, None): profile concerned with the update
+            None if all profiles need to be updated
+        """
+        raise NotImplementedError
+
+    def on_delete(self):
+        QuickWidget.on_delete(self)
+        handler.unregister(self)