# HG changeset patch # User Goffi # Date 1460997073 -7200 # Node ID 011eff37e21d6149f8ac8f878d2025ddb0bbaa5d # Parent 14a33c2b1b2a375d0306634be9ac0b7124538249 quick frontend, primitivus: quickContactList refactored to handle several profiles at once diff -r 14a33c2b1b2a -r 011eff37e21d frontends/src/primitivus/contact_list.py --- 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) diff -r 14a33c2b1b2a -r 011eff37e21d frontends/src/primitivus/primitivus --- 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() diff -r 14a33c2b1b2a -r 011eff37e21d frontends/src/quick_frontend/constants.py --- 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'} diff -r 14a33c2b1b2a -r 011eff37e21d frontends/src/quick_frontend/quick_app.py --- 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) diff -r 14a33c2b1b2a -r 011eff37e21d frontends/src/quick_frontend/quick_contact_list.py --- 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 . +"""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) diff -r 14a33c2b1b2a -r 011eff37e21d frontends/src/quick_frontend/quick_widgets.py --- 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)