changeset 1938:011eff37e21d

quick frontend, primitivus: quickContactList refactored to handle several profiles at once
author Goffi <goffi@goffi.org>
date Mon, 18 Apr 2016 18:31:13 +0200
parents 14a33c2b1b2a
children e68483c5a999
files frontends/src/primitivus/contact_list.py frontends/src/primitivus/primitivus frontends/src/quick_frontend/constants.py frontends/src/quick_frontend/quick_app.py frontends/src/quick_frontend/quick_contact_list.py frontends/src/quick_frontend/quick_widgets.py
diffstat 6 files changed, 552 insertions(+), 168 deletions(-) [+]
line wrap: on
line diff
--- a/frontends/src/primitivus/contact_list.py	Sun Apr 17 18:07:55 2016 +0200
+++ b/frontends/src/primitivus/contact_list.py	Mon Apr 18 18:31:13 2016 +0200
@@ -28,13 +28,18 @@
 from sat_frontends.tools import jid
 from sat.core import log as logging
 log = logging.getLogger(__name__)
+from sat_frontends.quick_frontend import quick_widgets
 
 
 class ContactList(PrimitivusWidget, QuickContactList):
+    PROFILES_MULTIPLE=False
+    PROFILES_ALLOW_NONE=False
     signals = ['click','change']
+    # FIXME: Only single profile is managed so far
 
-    def __init__(self, host, on_click=None, on_change=None, user_data=None, profile=None):
-        QuickContactList.__init__(self, host, profile)
+    def __init__(self, host, target, on_click=None, on_change=None, user_data=None, profiles=None):
+        QuickContactList.__init__(self, host, profiles)
+        self.contact_list = self.host.contact_lists[self.profile]
 
         #we now build the widget
         self.status_bar = StatusBar(host)
@@ -45,8 +50,9 @@
         if on_change:
             urwid.connect_signal(self, 'change', on_change, user_data)
 
-    def update(self):
+    def update(self, entities=None, type_=None, profile=None):
         """Update display, keep focus"""
+        # FIXME: full update is done each time, must handle entities, type_ and profile
         widget, position = self.frame.body.get_focus()
         self.frame.body = self._buildList()
         if position:
@@ -65,15 +71,22 @@
                (key == a_key['FOCUS_DOWN'] and self.frame.focus_position == 'footer')):
                 return key
         if key == a_key['STATUS_HIDE']: #user wants to (un)hide contacts' statuses
-            self.show_status = not self.show_status
+            self.contact_list.show_status = not self.contact_list.show_status
             self.update()
         elif key == a_key['DISCONNECTED_HIDE']: #user wants to (un)hide disconnected contacts
-            self.host.bridge.setParam(C.SHOW_OFFLINE_CONTACTS, C.boolConst(not self.show_disconnected), "General", profile_key=self.profile)
+            self.host.bridge.setParam(C.SHOW_OFFLINE_CONTACTS, C.boolConst(not self.contact_list.show_disconnected), "General", profile_key=self.profile)
         elif key == a_key['RESOURCES_HIDE']: #user wants to (un)hide contacts resources
-            self.showResources(not self.show_resources)
+            self.contact_list.showResources(not self.contact_list.show_resources)
             self.update()
         return super(ContactList, self).keypress(size, key)
 
+    # QuickWidget methods
+
+    @staticmethod
+    def getWidgetHash(target, profiles):
+        profiles = sorted(profiles)
+        return tuple(profiles)
+
     # modify the contact list
 
     def setFocus(self, text, select=False):
@@ -122,7 +135,7 @@
 
     def _groupClicked(self, group_wid):
         group = group_wid.getValue()
-        data = self.getGroupData(group)
+        data = self.contact_list.getGroupData(group)
         data[C.GROUP_DATA_FOLDED] =  not data.setdefault(C.GROUP_DATA_FOLDED, False)
         self.setFocus(group)
         self.update()
@@ -136,13 +149,10 @@
         @param selected: boolean returned by the widget, telling if it is selected
         """
         entity = contact_wid.data
-        self.removeAlerts(entity, use_bare_jid)
+        self.contact_list.removeAlerts(entity, use_bare_jid)
         self.host.modeHint(C.MODE_INSERTION)
         self._emit('click', entity)
 
-    def onNickUpdate(self, entity, new_nick, profile):
-        self.update()
-
     # Methods to build the widget
 
     def _buildEntityWidget(self, entity, keys=None, use_bare_jid=False, with_alert=True, with_show_attr=True, markup_prepend=None, markup_append = None):
@@ -163,13 +173,13 @@
         """
         markup = []
         if use_bare_jid:
-            selected = {entity.bare for entity in self._selected}
+            selected = {entity.bare for entity in self.contact_list._selected}
         else:
-            selected = self._selected
+            selected = self.contact_list._selected
         if keys is None:
             entity_txt = entity
         else:
-            cache = self.getCache(entity)
+            cache = self.contact_list.getCache(entity)
             for key in keys:
                 if key.startswith('cache_'):
                     entity_txt = cache.get(key[6:])
@@ -181,7 +191,7 @@
                 entity_txt = entity
 
         if with_show_attr:
-            show = self.getCache(entity, C.PRESENCE_SHOW)
+            show = self.contact_list.getCache(entity, C.PRESENCE_SHOW)
             if show is None:
                 show = C.PRESENCE_UNAVAILABLE
             show_icon, entity_attr = C.PRESENCE.get(show, ('', 'default'))
@@ -189,7 +199,7 @@
         else:
             entity_attr = 'default'
 
-        alerts_count = self.getAlerts(entity, use_bare_jid=use_bare_jid)
+        alerts_count = len(self.contact_list.getAlerts(entity, use_bare_jid=use_bare_jid))
         if with_alert and alerts_count:
             entity_attr = 'alert'
             header = C.ALERT_HEADER % alerts_count
@@ -221,22 +231,22 @@
         widgets = []  # list of built widgets
 
         for entity in entities:
-            if entity in self._specials or not self.entityToShow(entity):
+            if entity in self.contact_list._specials or not self.contact_list.entityToShow(entity):
                 continue
             markup_extra = []
-            if self.show_resources:
-                for resource in self.getCache(entity, C.CONTACT_RESOURCES):
+            if self.contact_list.show_resources:
+                for resource in self.contact_list.getCache(entity, C.CONTACT_RESOURCES):
                     resource_disp = ('resource_main' if resource == self.getCache(entity, C.CONTACT_MAIN_RESOURCE) else 'resource', "\n  " + resource)
                     markup_extra.append(resource_disp)
-                    if self.show_status:
-                        status = self.getCache(jid.JID('%s/%s' % (entity, resource)), 'status')
+                    if self.contact_list.show_status:
+                        status = self.contact_list.getCache(jid.JID('%s/%s' % (entity, resource)), 'status')
                         status_disp = ('status', "\n    " + status) if status else ""
                         markup_extra.append(status_disp)
 
 
             else:
-                if self.show_status:
-                    status = self.getCache(entity, 'status')
+                if self.contact_list.show_status:
+                    status = self.contact_list.getCache(entity, 'status')
                     status_disp = ('status', "\n  " + status) if status else ""
                     markup_extra.append(status_disp)
             widget = self._buildEntityWidget(entity, ('cache_nick', 'cache_name', 'node'), use_bare_jid=True, markup_append=markup_extra)
@@ -249,7 +259,7 @@
 
     def _buildSpecials(self, content):
         """Build the special entities"""
-        specials = list(self._specials)
+        specials = list(self.contact_list._specials)
         specials.sort()
         extra_shown = set()
         for entity in specials:
@@ -258,7 +268,7 @@
             content.append(widget)
 
             # resources which must be displayed (e.g. MUC private conversations)
-            extras = [extra for extra in self._special_extras if extra.bare == entity.bare]
+            extras = [extra for extra in self.contact_list._special_extras if extra.bare == entity.bare]
             extras.sort()
             for extra in extras:
                 widget = self._buildEntityWidget(extra, ('resource',), markup_prepend = '  ')
@@ -266,7 +276,7 @@
                 extra_shown.add(extra)
 
         # entities which must be visible but not resource of current special entities
-        for extra in self._special_extras.difference(extra_shown):
+        for extra in self.contact_list._special_extras.difference(extra_shown):
             widget = self._buildEntityWidget(extra, ('resource',))
             content.append(widget)
 
@@ -275,25 +285,27 @@
         content = urwid.SimpleListWalker([])
 
         self._buildSpecials(content)
-        if self._specials:
+        if self.contact_list._specials:
             content.append(urwid.Divider('='))
 
-        groups = list(self._groups)
+        groups = list(self.contact_list._groups)
         groups.sort(key=lambda x: x.lower() if x else x)
         for group in groups:
-            data = self.getGroupData(group)
+            data = self.contact_list.getGroupData(group)
             folded = data.get(C.GROUP_DATA_FOLDED, False)
             jids = list(data['jids'])
-            if group is not None and (self.anyEntityToShow(jids) or self.show_empty_groups):
+            if group is not None and (self.contact_list.anyEntityToShow(jids) or self.contact_list.show_empty_groups):
                 header = '[-]' if not folded else '[+]'
                 widget = sat_widgets.ClickableText(group, header=header + ' ')
                 content.append(widget)
                 urwid.connect_signal(widget, 'click', self._groupClicked)
             if not folded:
                 self._buildEntities(content, jids)
-        not_in_roster = set(self._cache).difference(self._roster).difference(self._specials).difference((self.whoami.bare,))
+        not_in_roster = set(self.contact_list._cache).difference(self.contact_list._roster).difference(self.contact_list._specials).difference((self.contact_list.whoami.bare,))
         if not_in_roster:
             content.append(urwid.Divider('-'))
             self._buildEntities(content, not_in_roster)
 
         return urwid.ListBox(content)
+
+quick_widgets.register(QuickContactList, ContactList)
--- a/frontends/src/primitivus/primitivus	Sun Apr 17 18:07:55 2016 +0200
+++ b/frontends/src/primitivus/primitivus	Mon Apr 18 18:31:13 2016 +0200
@@ -70,7 +70,7 @@
         for params, see AdvancedEdit"""
         nicks = []
         for profile, clist in self.host.contact_lists.iteritems():
-            for contact in clist.getContacts():
+            for contact in clist.selected:
                 chat = self.host.widgets.getWidget(quick_chat.QuickChat, contact, profile)
                 if chat.type != C.CHAT_GROUP:
                     continue
@@ -464,11 +464,6 @@
         self.main_widget = PrimitivusTopWidget(self.center_part, self.menu_roller, self.notif_bar, self.editBar)
         return self.main_widget
 
-    def addContactList(self, profile):
-        contact_list = ContactList(self, on_click=self.contactSelected, on_change=lambda w: self.redraw(), profile=profile)
-        self.contact_lists_pile.contents.append((contact_list, ('weight', 1)))
-        return contact_list
-
     def plugging_profiles(self):
         self.loop.widget = self._buildMainWidget()
         self.redraw()
@@ -480,6 +475,12 @@
         else:
             del self._early_popup
 
+    def profilePlugged(self, profile):
+        QuickApp.profilePlugged(self, profile)
+        contact_list = self.widgets.getOrCreateWidget(ContactList, None, on_new_widget=None, on_click=self.contactSelected, on_change=lambda w: self.redraw(), profile=profile)
+        self.contact_lists_pile.contents.append((contact_list, ('weight', 1)))
+        return contact_list
+
     def isHidden(self):
         """Tells if the frontend window is hidden.
 
@@ -555,10 +556,9 @@
             log.debug("No menu to delete")
         self.selected_widget = widget
         self._visible_widgets = set([widget]) # XXX: we can only have one widget visible at the time for now
-        for contact_list in self.contact_lists.itervalues():
-            contact_list.unselectAll()
+        self.contact_lists.select(None)
 
-        for wid in self.visible_widgets:
+        for wid in self.visible_widgets: # FIXME: check if widgets.getWidgets is not more appropriate
             if isinstance(wid, Chat):
                 contact_list = self.contact_lists[wid.profile]
                 contact_list.select(wid.target)
@@ -776,7 +776,11 @@
     #MISC CALLBACKS#
 
     def setPresenceStatus(self, show='', status=None, profile=C.PROF_KEY_NONE):
-        self.contact_lists[profile].status_bar.setPresenceStatus(show, status)
+        contact_list_wid = self.widgets.getWidget(ContactList, profiles=profile)
+        if contact_list_wid is not None:
+            contact_list_wid.status_bar.setPresenceStatus(show, status)
+        else:
+            log.warning(u"No ContactList widget found for profile {}".format(profile))
 
 sat = PrimitivusApp()
 sat.start()
--- a/frontends/src/quick_frontend/constants.py	Sun Apr 17 18:07:55 2016 +0200
+++ b/frontends/src/quick_frontend/constants.py	Mon Apr 18 18:31:13 2016 +0200
@@ -47,8 +47,10 @@
     CONTACT_MAIN_RESOURCE = 'main_resource'
     CONTACT_SPECIAL = 'special'
     CONTACT_SPECIAL_GROUP = 'group'  # group chat special entity
-    CONTACT_SPECIAL_ALLOWED = (CONTACT_SPECIAL_GROUP,)  # set of allowed values for special flag
-    CONTACT_DATA_FORBIDDEN = {CONTACT_GROUPS, CONTACT_RESOURCES, CONTACT_MAIN_RESOURCE}  # set of forbidden names for contact data
+    CONTACT_SELECTED = 'selected'
+    CONTACT_PROFILE = 'profile' # used in handler to track where the contact is coming from
+    CONTACT_SPECIAL_ALLOWED = (CONTACT_SPECIAL_GROUP,)  # allowed values for special flag
+    CONTACT_DATA_FORBIDDEN = {CONTACT_GROUPS, CONTACT_RESOURCES, CONTACT_MAIN_RESOURCE, CONTACT_SELECTED, CONTACT_PROFILE}  # set of forbidden names for contact data
 
     # Chats
     CHAT_ONE2ONE = 'one2one'
@@ -61,6 +63,10 @@
         "paused": u"⦷"
     }
 
+    # Alerts
+    ALERT_MESSAGE = "MESSAGE" # New message received
+    ALERT_NICK = "NICK" # our nickname was mentionned
+
     # Blogs
     ENTRY_MODE_TEXT = "text"
     ENTRY_MODE_RICH = "rich"
@@ -73,4 +79,11 @@
     WIDGET_RAISE = 'RAISE'
     WIDGET_RECREATE = 'RECREATE'
 
+    # Updates (generic)
+    UPDATE_DELETE = 'DELETE'
+    UPDATE_MODIFY = 'MODIFY'
+    UPDATE_ADD = 'ADD'
+    UPDATE_SELECTION = 'SELECTION'
+    UPDATE_STRUCTURE = 'STRUCTURE' # high level update (i.e. not item level but organisation of items)
+
     LISTENERS = {'avatar', 'nick', 'presence', 'profilePlugged', 'disconnect', 'gotMenus', 'menu'}
--- a/frontends/src/quick_frontend/quick_app.py	Sun Apr 17 18:07:55 2016 +0200
+++ b/frontends/src/quick_frontend/quick_app.py	Mon Apr 18 18:31:13 2016 +0200
@@ -29,6 +29,7 @@
 from sat_frontends.quick_frontend import quick_menus
 from sat_frontends.quick_frontend import quick_blog
 from sat_frontends.quick_frontend import quick_chat, quick_games
+from sat_frontends.quick_frontend import quick_contact_list
 from sat_frontends.quick_frontend.constants import Const as C
 
 import sys
@@ -95,8 +96,7 @@
 
     def _plug_profile_gotCachedValues(self, cached_values):
         # add the contact list and its listener
-        contact_list = self.host.addContactList(self.profile)
-        self.host.contact_lists[self.profile] = contact_list
+        contact_list = self.host.contact_lists.addProfile(self.profile)
 
         for entity, data in cached_values.iteritems():
             for key, value in data.iteritems():
@@ -192,8 +192,7 @@
 
         # remove the contact list and its listener
         host = self._profiles[profile].host
-        host.contact_lists[profile].onDelete()
-        del host.contact_lists[profile]
+        host.contact_lists[profile].unplug()
 
         del self._profiles[profile]
 
@@ -218,7 +217,7 @@
         self.profiles = ProfilesManager()
         self.ready_profiles = set() # profiles which are connected and ready
         self.signals_cache = {} # used to keep signal received between start of plug_profile and when the profile is actualy ready
-        self.contact_lists = {}
+        self.contact_lists = quick_contact_list.QuickContactListHandler(self)
         self.widgets = quick_widgets.QuickWidgetsManager(self)
         if check_options is not None:
             self.options = check_options()
@@ -296,7 +295,8 @@
     @property
     def alerts_count(self):
         """Count the over whole alerts for all contact lists"""
-        return sum([sum(clist._alerts.values()) for clist in self.contact_lists.values()])
+        # FIXME
+        # return sum([sum(clist._alerts.values()) for clist in self.contact_lists.values()])
 
     def registerSignal(self, function_name, handler=None, iface="core", with_profile=True):
         """Register a handler for a signal
@@ -448,14 +448,6 @@
     def clear_profile(self):
         self.profiles.clear()
 
-    def addContactList(self, profile):
-        """Method to subclass to add a contact list widget
-
-        will be called on each profile session build
-        @return: a ContactList widget
-        """
-        return NotImplementedError
-
     def newWidget(self, widget):
         raise NotImplementedError
 
@@ -473,7 +465,7 @@
     def disconnectedHandler(self, profile):
         """called when the connection is closed"""
         log.debug(_("Disconnected"))
-        self.contact_lists[profile].clearContacts()
+        self.contact_lists[profile].disconnect()
         self.setPresenceStatus(C.PRESENCE_UNAVAILABLE, '', profile=profile)
 
     def actionNewHandler(self, action_data, id_, security_limit, profile):
@@ -481,8 +473,8 @@
 
     def newContactHandler(self, jid_s, attributes, groups, profile):
         entity = jid.JID(jid_s)
-        _groups = list(groups)
-        self.contact_lists[profile].setContact(entity, _groups, attributes, in_roster=True)
+        groups = list(groups)
+        self.contact_lists[profile].setContact(entity, groups, attributes, in_roster=True)
 
     def newMessageHandler(self, from_jid_s, msg, type_, to_jid_s, extra, profile):
         from_jid = jid.JID(from_jid_s)
@@ -516,7 +508,7 @@
                 if isinstance(widget, quick_chat.QuickChat) and widget.manageMessage(from_jid, type_):
                     visible = True
                     break
-            if visible:
+            if visible: # FIXME: à virer gof:
                 if self.isHidden():  # the window is hidden
                     self.updateAlertsCounter(extra_inc=1)
             else:
@@ -730,7 +722,7 @@
 
     def contactDeletedHandler(self, jid_s, profile):
         target = jid.JID(jid_s)
-        self.contact_lists[profile].removeContact(target, in_roster=True)
+        self.contact_lists[profile].removeContact(target)
 
     def entityDataUpdatedHandler(self, entity_s, key, value, profile):
         entity = jid.JID(entity_s)
--- a/frontends/src/quick_frontend/quick_contact_list.py	Sun Apr 17 18:07:55 2016 +0200
+++ b/frontends/src/quick_frontend/quick_contact_list.py	Mon Apr 18 18:31:13 2016 +0200
@@ -17,12 +17,16 @@
 # 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 sat.core.i18n import _
 from sat.core.log import getLogger
 log = getLogger(__name__)
+from sat.core import exceptions
 from sat_frontends.quick_frontend.quick_widgets import QuickWidget
 from sat_frontends.quick_frontend.constants import Const as C
 from sat_frontends.tools import jid
+from collections import OrderedDict
 
 
 try:
@@ -37,14 +41,19 @@
         iter_cpy.sort(key=key)
         return pyjamas_max(iter_cpy)
 
+handler = None
 
-class QuickContactList(QuickWidget):
-    """This class manage the visual representation of contacts"""
+
+class ProfileContactList(object):
+    """Contact list data for a single profile"""
 
-    def __init__(self, host, profile):
-        log.debug(_("Contact List init"))
-        super(QuickContactList, self).__init__(host, profile, 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), bare jids
@@ -53,38 +62,50 @@
         self._special_extras = set()
 
         # group data contain jids in groups and misc frontend data
+        # None key is used for jids with not group
         self._groups = {}  # groups to group data map
 
         # contacts in roster (bare jids)
         self._roster = set()
 
-        # entities with alert(s) and their counts (usually a waiting message), dict{full jid: int)
-        self._alerts = dict()
+        # alerts per entity (key: full jid, value: list of alerts)
+        self._alerts = {}
 
         # selected entities, full jid
         self._selected = set()
 
         # we keep our own jid
-        self.whoami = host.profiles[profile].whoami
+        self.whoami = self.host.profiles[profile].whoami
 
         # options
         self.show_disconnected = False
         self.show_empty_groups = True
         self.show_resources = False
         self.show_status = False
-        # TODO: this may lead to two successive UI refresh and needs an optimization
+
         self.host.bridge.asyncGetParamA(C.SHOW_EMPTY_GROUPS, "General", profile_key=profile, callback=self._showEmptyGroups)
         self.host.bridge.asyncGetParamA(C.SHOW_OFFLINE_CONTACTS, "General", profile_key=profile, callback=self._showOfflineContacts)
 
         # FIXME: workaround for a pyjamas issue: calling hash on a class method always return a different value if that method is defined directly within the class (with the "def" keyword)
         self.presenceListener = self.onPresenceUpdate
-        self.host.addListener('presence', self.presenceListener, [profile])
+        self.host.addListener('presence', self.presenceListener, [self.profile])
         self.nickListener = self.onNickUpdate
-        self.host.addListener('nick', self.nickListener, [profile])
+        self.host.addListener('nick', self.nickListener, [self.profile])
+
+    def _showEmptyGroups(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.showEmptyGroups(C.bool(show_str))
+
+    def _showOfflineContacts(self, show_str):
+        # same comments as for _showEmptyGroups
+        self.showOfflineContacts(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
         @param entity (jid.JID): jid of the entity (resource is not ignored, use bare jid if needed)
         """
         if entity.resource:
@@ -95,61 +116,97 @@
         return entity in self._cache
 
     @property
-    def roster_entities(self):
+    def roster(self):
         """Return all the bare JIDs of the roster entities.
 
-        @return: set(jid.JID)
+        @return (set[jid.JID])
         """
         return self._roster
 
     @property
-    def roster_entities_connected(self):
+    def roster_connected(self):
         """Return all the bare JIDs of the roster entities that are connected.
 
-        @return: set(jid.JID)
+        @return (set[jid.JID])
         """
         return set([entity for entity in self._roster if self.getCache(entity, C.PRESENCE_SHOW) 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 a dictionary binding the roster groups to their entities bare JIDs.
 
-        @return: dict{unicode: set(jid.JID)}
+        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_entity(self):
-        """Return a dictionary binding the entities bare JIDs to their roster
-        groups. The empty group is filtered out.
+    def roster_groups_by_entities(self):
+        """Return a dictionary binding the entities bare JIDs to their roster groups
 
-        @return: dict{jid.JID: set(unicode)}
+        @return (dict[jid.JID, set(unicode)])
         """
         result = {}
         for group, data in self._groups.iteritems():
-            if group is None:
-                continue
             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 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.iteritems() if self.entityToShow(jid_)}
+
+    def getItem(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 getSpecialExtras(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])
+        """
+        if special_type is None:
+            return self._special_extras
+        specials = self.getSpecials(special_type)
+        return {extra for extra in self._special_extras if extra.bare in specials}
+
     def _gotContacts(self, contacts):
+        """Called during filling, add contacts and notice parent that contacts are filled"""
         for contact in contacts:
             self.host.newContactHandler(*contact, profile=self.profile)
+        handler._contactsFilled(self.profile)
 
-    def fill(self):
-        """Get all contacts from backend, and fill the widget
+    def _fill(self):
+        """Get all contacts from backend
 
         Contacts will be cleared before refilling them
         """
         self.clearContacts(keep_cache=True)
-
         self.host.bridge.getContacts(self.profile, callback=self._gotContacts)
 
-    def update(self):
-        """Update the display when something changed"""
-        raise NotImplementedError
+    def fill(self):
+        handler.fill(self.profile)
 
     def getCache(self, entity, name=None):
         """Return a cache value for a contact
@@ -192,7 +249,7 @@
         @param entity(JID): entity to update
         @param name(unicode): value to set or update
         """
-        self.setContact(entity, None, {name: value})
+        self.setContact(entity, attributes={name: value})
 
     def getFullJid(self, entity):
         """Get full jid from a bare jid
@@ -202,7 +259,7 @@
         @raise ValueError: the entity is not bare
         """
         if entity.resource:
-            raise ValueError("getFullJid must be used with a bare jid")
+            raise ValueError(u"getFullJid must be used with a bare jid")
         main_resource = self.getCache(entity, C.CONTACT_MAIN_RESOURCE)
         return jid.JID(u"{}/{}".format(entity, main_resource))
 
@@ -213,7 +270,6 @@
         @param name: name of the data (can't be "jids")
         @param value: value to set
         """
-        # FIXME: this is never used, should it be removed?
         assert name is not 'jids'
         self._groups[group][name] = value
 
@@ -238,9 +294,9 @@
         self.setCache(entity, C.CONTACT_SPECIAL, special_type)
 
     def getSpecials(self, special_type=None):
-        """Return all the bare JIDs of the special roster entities of the type
-        specified by special_type. If special_type is None, return all specials.
+        """Return all the bare JIDs of the special roster entities of with given type.
 
+        If special_type is None, return all specials.
         @param special_type: one of special type (e.g. C.CONTACT_SPECIAL_GROUP) or None to return all specials.
         @return: set(jid.JID)
         """
@@ -248,24 +304,17 @@
             return self._specials
         return set([entity for entity in self._specials if self.getCache(entity, C.CONTACT_SPECIAL) == special_type])
 
-    def getSpecialExtras(self, special_type=None):
-        """Return all the JIDs of the special extras entities that are related
-        to a special entity of the type specified by special_type.
-        If special_type is None, return all special extras.
 
-        @param special_type: one of special type (e.g. C.CONTACT_SPECIAL_GROUP) or None to return all special extras.
-        @return: set(jid.JID)
-        """
-        if special_type is None:
-            return self._special_extras
-        return set([extra for extra in self._special_extras if extra.bare in self.getSpecials(special_type)])
+    def disconnect(self):
+        # for now we just clear contacts on disconnect
+        self.clearContacts()
 
     def clearContacts(self, keep_cache=False):
         """Clear all the contact list
 
         @param keep_cache: if True, don't reset the cache
         """
-        self.unselectAll()
+        self.select(None)
         if not keep_cache:
             self._cache.clear()
         self._groups.clear()
@@ -295,11 +344,13 @@
             attributes = {}
 
         entity_bare = entity.bare
+        update_type = C.UPDATE_MODIFY if entity_bare in self._cache else C.UPDATE_ADD
 
         if in_roster:
             self._roster.add(entity_bare)
 
-        cache = self._cache.setdefault(entity_bare, {C.CONTACT_RESOURCES: {}})
+        cache = self._cache.setdefault(entity_bare, {C.CONTACT_RESOURCES: {},
+                                                     C.CONTACT_SELECTED: set()})
 
         assert not C.CONTACT_DATA_FORBIDDEN.intersection(attributes) # we don't want forbidden data in attributes
 
@@ -324,18 +375,12 @@
                 self._specials.add(entity_bare)
                 cache[C.CONTACT_MAIN_RESOURCE] = None
 
-        # now the attribute we keep in cache
+        # now the attributes we keep in cache
         for attribute, value in attributes.iteritems():
             cache[attribute] = value
 
         # we can update the display
-        self.update()
-
-    def getContacts(self):
-        """Return contacts currently selected
-
-        @return (set): set of selected entities"""
-        return self._selected
+        self.update([entity], update_type, self.profile)
 
     def entityToShow(self, entity, check_resource=False):
         """Tell if the contact should be showed or hidden.
@@ -362,8 +407,9 @@
 
         @param entities (list[jid.JID]): list of jids
         @param check_resources (bool): True if resources must be significant
-        @return: bool
+        @return (bool): True if a least one entity need to be shown
         """
+        # FIXME: looks inefficient, really needed?
         for entity in entities:
             if self.entityToShow(entity, check_resources):
                 return True
@@ -378,24 +424,25 @@
         """
         return entity in self.getGroupData(group, "jids")
 
-    def removeContact(self, entity, in_roster=False):
+    def removeContact(self, entity):
         """remove a contact from the list
 
         @param entity(jid.JID): jid of the entity to remove (bare jid is used)
-        @param in_roster (bool): True if contact is from roster
         """
         entity_bare = entity.bare
         try:
             groups = self._cache[entity_bare].get(C.CONTACT_GROUPS, set())
         except KeyError:
-            log.warning(_(u"Trying to delete an unknow entity [{}]").format(entity))
-        if in_roster:
+            log.error(_(u"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']:
-                self._groups.pop(group)
+                self._groups.pop(group) # FIXME: we use pop because of pyjamas: http://wiki.goffi.org/wiki/Issues_with_Pyjamas/en
         for iterable in (self._selected, self._alerts, self._specials, self._special_extras):
             to_remove = set()
             for set_entity in iterable:
@@ -406,7 +453,7 @@
             else:  # XXX: self._alerts is a dict
                 for item in to_remove:
                     del iterable[item]
-        self.update()
+        self.update([entity], C.UPDATE_DELETE, self.profile)
 
     def onPresenceUpdate(self, entity, show, priority, statuses, profile):
         """Update entity's presence status
@@ -440,7 +487,7 @@
             if entity.bare not in self._specials:
                 priority_resource = max(resources_data, key=lambda res: resources_data[res][C.PRESENCE_PRIORITY])
                 cache[C.CONTACT_MAIN_RESOURCE] = priority_resource
-        self.update()
+        self.update([entity], C.UPDATE_MODIFY, self.profile)
 
     def onNickUpdate(self, entity, new_nick, profile):
         """Update entity's nick
@@ -449,47 +496,82 @@
         @param new_nick(unicode): new nick of the entity
         @param profile: %(doc_profile)s
         """
-        raise NotImplementedError  # Must be implemented by frontends
+        assert profile == self.profile
+        self.setCache(entity, 'nick', new_nick)
+        self.update([entity], C.UPDATE_MODIFY, profile)
+
+    def unselect(self, entity):
+        """Unselect an entity
 
-    def unselectAll(self):
-        """Unselect all contacts"""
-        self._selected.clear()
-        self.update()
+         @param entity(jid.JID): entity to unselect
+        """
+        try:
+            cache = self._cache[entity.bare]
+        except:
+            log.error(u"Try to unselect an entity not in cache")
+        else:
+            try:
+                cache[C.CONTACT_SELECTED].remove(entity.resource)
+            except KeyError:
+                log.error(u"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): entity to select (resource is significant)
+        @param entity(jid.JID, None): entity to select (resource is significant)
+            None to unselect all entities
         """
-        log.debug(u"select %s" % entity)
-        self._selected.add(entity)
-        self.update()
+        if entity is None:
+            self._selected.clear()
+            for cache in self._cache.itervalues():
+                cache[C.CONTACT_SELECTED].clear()
+            self.update(type_=C.UPDATE_SELECTION, profile=self.profile)
+        else:
+            log.debug(u"select %s" % entity)
+            try:
+                cache = self._cache[entity.bare]
+            except:
+                log.error(u"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 getAlerts(self, entity, use_bare_jid=False):
-        """Return the number of alerts set to this entity.
-        
+    def getAlerts(self, entity, use_bare_jid=False, filter_=None):
+        """Return alerts set to this entity.
+
         @param entity (jid.JID): entity
         @param use_bare_jid (bool): if True, cumulate the alerts of all the resources sharing the same bare JID
-        @return int
+        @param filter_(iterable, None): alert to take into account,
+            None to count all of them
+        @return (list[unicode,None]): list of C.ALERT_* or None for undefined ones
         """
         if not use_bare_jid:
-            return self._alerts.get(entity, 0)
-        
-        alerts = {}
-        for contact in self._alerts:
-            alerts.setdefault(contact.bare, 0)
-            alerts[contact.bare] += self._alerts[contact]
-        return alerts.get(entity.bare, 0)
+            alerts = self._alerts.get(entity, [])
+        else:
+            alerts = []
+            for contact, contact_alerts in self._alerts:
+                if contact.bare == entity:
+                    alerts.extend(contact_alerts)
+        if filter_ is None:
+            return alerts
+        else:
+            return [alert for alert in alerts if alert in filter_]
 
-    def addAlert(self, entity):
-        """Increase the alerts counter for this entity (usually for a waiting message)
+    def addAlert(self, entity, type_=None):
+        """Add an alert for this enity
 
-        @param entity(jid.JID): entity which must displayed in alert mode (resource is significant)
+        @param entity(jid.JID): entity who received an alert (resource is significant)
+        @param type_(unicode, None): type of alert (C.ALERT_*)
+            None for generic alert
         """
-        self._alerts.setdefault(entity, 0)
-        self._alerts[entity] += 1
-        self.update()
-        self.host.updateAlertsCounter()
+        self._alerts.setdefault(entity, [])
+        self._alerts[entity].append(type_)
+        self.update([entity], C.UPDATE_MODIFY, self.profile)
+        self.host.updateAlertsCounter() # FIXME: ?
 
     def removeAlerts(self, entity, use_bare_jid=True):
         """Eventually remove an alert on the entity (usually for a waiting message).
@@ -506,16 +588,16 @@
                 return  # nothing changed
             for entity in to_remove:
                 del self._alerts[entity]
+            self.update([to_remove], C.UPDATE_MODIFY, self.profile)
+            self.host.updateAlertsCounter() # FIXME: ?
         else:
             try:
                 del self._alerts[entity]
             except KeyError:
                 return  # nothing changed
-        self.update()
-        self.host.updateAlertsCounter()
-
-    def _showOfflineContacts(self, show_str):
-        self.showOfflineContacts(C.bool(show_str))
+            else:
+                self.update([entity], C.UPDATE_MODIFY, self.profile)
+            self.host.updateAlertsCounter() # FIXME: ?
 
     def showOfflineContacts(self, show):
         """Tell if offline contacts should shown
@@ -526,25 +608,302 @@
         if self.show_disconnected == show:
             return
         self.show_disconnected = show
-        self.update()
-
-    def _showEmptyGroups(self, show_str):
-        self.showEmptyGroups(C.bool(show_str))
+        self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile)
 
     def showEmptyGroups(self, show):
         assert isinstance(show, bool)
         if self.show_empty_groups == show:
             return
         self.show_empty_groups = show
-        self.update()
+        self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile)
 
     def showResources(self, show):
         assert isinstance(show, bool)
         if self.show_resources == show:
             return
         self.show_resources = show
+        self.update(profile=self.profile)
+
+    def plug(self):
+        handler.addProfile(self.profile)
+
+    def unplug(self):
+        handler.removeProfile(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(u"QuickContactListHandler must be instanciated only once")
+        handler = self
+        self._clist = {} # key: profile, value: ProfileContactList
+        self._widgets = set()
+
+    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.itervalues():
+            if entity in contact_list:
+                return True
+        return False
+
+    @property
+    def roster_entities(self):
+        """Return all the bare JIDs of the roster entities.
+
+        @return (set[jid.JID])
+        """
+        entities = set()
+        for contact_list in self._clist.itervalues():
+            entities.update(contact_list.roster_entities)
+        return entities
+
+    @property
+    def roster_entities_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.itervalues():
+            entities.update(contact_list.roster_entities_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.itervalues():
+            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.itervalues():
+            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.itervalues():
+            entities.update(contact_list.selected)
+        return entities
+
+    @property
+    def items(self):
+        """Return item representation for all visible entities in cache
+
+        items are unordered
+        key: bare jid, value: data
+        """
+        items = {}
+        for profile, contact_list in self._clist.iteritems():
+            for bare_jid, cache in contact_list.items.iteritems():
+                data = cache.copy()
+                items[bare_jid] = data
+                data[C.CONTACT_PROFILE] = profile
+            items.update(contact_list.items)
+        return items
+
+    @property
+    def items_sorted(self):
+        """Return item representation for all 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 (we 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 addProfiles(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 addProfile(self, profile):
+        return self.addProfiles([profile])[0]
+
+    def removeProfiles(self, profiles):
+        """Remove given unplugged profiles from contact list
+
+        @param profile(iterable[unicode]): unplugged profiles
+        """
+        for profile in profiles:
+            del self._clist[profile]
+
+    def removeProfile(self, profile):
+        self.removeProfiles([profile])
+
+    def getSpecialExtras(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.itervalues():
+            entities.update(contact_list.getSpecialExtras(special_type))
+        return entities
+
+    def _contactsFilled(self, profile):
+        self._to_fill.remove(profile)
+        if not self._to_fill:
+            del self._to_fill
+            self.update()
+
+    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()
+
+        # if 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(self._clist.items())
+
+        remaining = to_fill.difference(filled)
+        if remaining != to_fill:
+            log.debug(u"Not re-filling already filled contact list(s) for {}".format(u', '.join(to_fill.intersection(filled))))
+        for profile in remaining:
+            self._clist[profile]._fill()
+
+    def clearContacts(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.itervalues():
+            contact_list.clearContacts(keep_cache)
         self.update()
 
+    def select(self, entity):
+        for contact_list in self._clist.itervalues():
+            contact_list.select(entity)
+
+    def unselect(self, entity):
+        for contact_list in self._clist.itervalues():
+            contact_list.select(entity)
+
+    def update(self, entities=None, type_=None, profile=None):
+        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
+    PROFILES_ALLOW_NONE=True # Can be linked to no profile (e.g. at the early forntend start)
+
+    def __init__(self, host, profiles):
+        super(QuickContactList, self).__init__(host, None, profiles)
+        handler.register(self)
+
+        # 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
+
+    @property
+    def items(self):
+        return handler.items
+
+    @property
+    def items_sorted(self):
+        return handler.items
+
+    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
+            or None for undefined update
+        @param profile(unicode, None): profile concerned with the update
+            None if unknown
+        """
+        raise NotImplementedError
+
     def onDelete(self):
         QuickWidget.onDelete(self)
-        self.host.removeListener('presence', self.presenceListener)
+        handler.unregister(self)
--- a/frontends/src/quick_frontend/quick_widgets.py	Sun Apr 17 18:07:55 2016 +0200
+++ b/frontends/src/quick_frontend/quick_widgets.py	Mon Apr 18 18:31:13 2016 +0200
@@ -93,16 +93,20 @@
         else:
             return widgets_map.itervalues()
 
-    def getWidget(self, class_, target, profile):
+    def getWidget(self, class_, target=None, profiles=None):
         """Get a widget without creating it if it doesn't exist.
 
         @param class_(class): class of the widget to create
         @param target: target depending of the widget, usually a JID instance
-        @param profile (unicode): %(doc_profile)s
+        @param profiles (unicode, iterable[unicode], None): profile(s) to use (may or may not be
+            used, depending of the widget class)
         @return: a class_ instance or None if the widget doesn't exist
         """
+        assert (target is not None) or (profiles is not None)
+        if profiles is not None and isinstance(profiles, unicode):
+            profiles = [profiles]
         class_ = self.getRealClass(class_)
-        hash_ = class_.getWidgetHash(target, profile)
+        hash_ = class_.getWidgetHash(target, profiles)
         try:
             return self._widgets[class_.__name__][hash_]
         except KeyError:
@@ -140,7 +144,7 @@
         if 'profiles' in _kwargs and 'profile' in _kwargs:
             raise ValueError("You can't have 'profile' and 'profiles' keys at the same time")
         try:
-            _kwargs['profiles'] = _kwargs.pop('profile')
+            _kwargs['profiles'] = [_kwargs.pop('profile')]
         except KeyError:
             if not 'profiles' in _kwargs:
                 _kwargs['profiles'] = None
@@ -266,7 +270,7 @@
             if not self.PROFILES_ALLOW_NONE:
                 raise ValueError("profiles can't have a value of None")
         else:
-            if not self.PROFILES_MULTIPLE:
+            if not self.PROFILES_MULTIPLE and len(profiles) != 1:
                 raise ValueError("multiple profiles are not allowed")
             for profile in profiles:
                 self.addProfile(profile)