Mercurial > libervia-backend
view frontends/src/quick_frontend/quick_contact_list.py @ 2019:c0ff84243650
quick_frontend(chat): better handling of cached signals when initialising widget
author | Goffi <goffi@goffi.org> |
---|---|
date | Sun, 24 Jul 2016 18:02:34 +0200 |
parents | 7aa58b7a47e2 |
children | d941fa9954f4 |
line wrap: on
line source
#!/usr/bin/env python2 # -*- coding: utf-8 -*- # helper class for making a SAT frontend # Copyright (C) 2009-2016 Jérôme Poisson (goffi@goffi.org) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. """Contact List handling multi profiles at once, should replace quick_contact_list module in the future""" from 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: # FIXME: to be removed when an acceptable solution is here unicode('') # XXX: unicode doesn't exist in pyjamas except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options # XXX: pyjamas' max doesn't support key argument, so we implement it ourself pyjamas_max = max def max(iterable, key): iter_cpy = list(iterable) iter_cpy.sort(key=key) return pyjamas_max(iter_cpy) handler = None class ProfileContactList(object): """Contact list data for a single profile""" def __init__(self, profile): self.host = handler.host self.profile = profile # contain all jids in roster or not, # bare jids as keys, resources are used in data # XXX: we don't mutualise cache, as values may differ # for different profiles (e.g. directed presence) self._cache = {} # special entities (groupchat, gateways, etc) # may be bare or full jid self._specials = set() # group data contain jids in groups and misc frontend data # None key is used for jids with not group self._groups = {} # groups to group data map # contacts in roster (bare jids) self._roster = set() # selected entities, full jid self._selected = set() # we keep our own jid 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 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, [self.profile]) self.nickListener = self.onNickUpdate self.host.addListener('nick', self.nickListener, [self.profile]) self.notifListener = self.onNotification self.host.addListener('notification', self.notifListener, [self.profile]) # notifListener only update the entity, so we can re-use it self.host.addListener('notificationsClear', self.notifListener, [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: try: return entity.resource in self.getCache(entity.bare, C.CONTACT_RESOURCES) except KeyError: return False return entity in self._cache @property def roster(self): """Return all the bare JIDs of the roster entities. @return (set[jid.JID]) """ return self._roster @property def roster_connected(self): """Return all the bare JIDs of the roster entities that are connected. @return (set[jid.JID]) """ return set([entity for entity in self._roster if self.getCache(entity, C.PRESENCE_SHOW) is not None]) @property def roster_entities_by_group(self): """Return a dictionary binding the roster groups to their entities bare JIDs. This also includes the empty group (None key). @return (dict[unicode,set(jid.JID)]) """ return {group: self._groups[group]['jids'] for group in self._groups} @property def roster_groups_by_entities(self): """Return a dictionary binding the entities bare JIDs to their roster groups @return (dict[jid.JID, set(unicode)]) """ result = {} for group, data in self._groups.iteritems(): 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 _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 Contacts will be cleared before refilling them """ self.clearContacts(keep_cache=True) self.host.bridge.getContacts(self.profile, callback=self._gotContacts) def fill(self): handler.fill(self.profile) def getCache(self, entity, name=None): """Return a cache value for a contact @param entity(entity.entity): entity of the contact from who we want data (resource is used if given) if a resource specific information is requested: - if no resource is given (bare jid), the main resource is used, according to priority - if resource is given, it is used @param name(unicode): name the data to get, or None to get everything @return: full cache if no name is given, or value of "name", or None """ try: cache = self._cache[entity.bare] except KeyError: self.setContact(entity) cache = self._cache[entity.bare] if name is None: return cache try: if name in ('status', C.PRESENCE_STATUSES, C.PRESENCE_PRIORITY, C.PRESENCE_SHOW): # these data are related to the resource if not entity.resource: main_resource = cache[C.CONTACT_MAIN_RESOURCE] cache = cache[C.CONTACT_RESOURCES][main_resource] else: cache = cache[C.CONTACT_RESOURCES][entity.resource] if name == 'status': # XXX: we get the first status for 'status' key # TODO: manage main language for statuses return cache[C.PRESENCE_STATUSES].get(C.PRESENCE_STATUSES_DEFAULT, '') return cache[name] except KeyError: return None def setCache(self, entity, name, value): """Set or update value for one data in cache @param entity(JID): entity to update @param name(unicode): value to set or update """ self.setContact(entity, attributes={name: value}) def getFullJid(self, entity): """Get full jid from a bare jid @param entity(jid.JID): must be a bare jid @return (jid.JID): bare jid + main resource @raise ValueError: the entity is not bare """ if entity.resource: raise ValueError(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)) def setGroupData(self, group, name, value): """Register a data for a group @param group: a valid (existing) group name @param name: name of the data (can't be "jids") @param value: value to set """ assert name is not 'jids' self._groups[group][name] = value def getGroupData(self, group, name=None): """Return value associated to group data @param group: a valid (existing) group name @param name: name of the data or None to get the whole dict @return: registered value """ if name is None: return self._groups[group] return self._groups[group][name] def setSpecial(self, entity, special_type): """Set special flag on an entity @param entity(jid.JID): jid of the special entity if the jid is full, will be added to special extras @param special_type: one of special type (e.g. C.CONTACT_SPECIAL_GROUP) or None to remove special flag """ assert special_type in C.CONTACT_SPECIAL_ALLOWED + (None,) self.setCache(entity, C.CONTACT_SPECIAL, special_type) def getSpecials(self, special_type=None, bare=False): """Return all the bare JIDs of the special roster entities of with given type. @param special_type(unicode, None): if not None, filter by special type (e.g. C.CONTACT_SPECIAL_GROUP) @param bare(bool): return only bare jids if True @return (iter[jid.JID]): found special entities """ for entity in self._specials: if bare and entity.resource: continue if special_type is not None and self.getCache(entity, C.CONTACT_SPECIAL) != special_type: continue yield entity 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.select(None) if not keep_cache: self._cache.clear() self._groups.clear() self._specials.clear() self._roster.clear() self.update() def setContact(self, entity, groups=None, attributes=None, in_roster=False): """Add a contact to the list if doesn't exist, else update it. This method can be called with groups=None for the purpose of updating the contact's attributes (e.g. nickname). In that case, the groups attribute must not be set to the default group but ignored. If not, you may move your contact from its actual group(s) to the default one. None value for 'groups' has a different meaning than [None] which is for the default group. @param entity (jid.JID): entity to add or replace @param groups (list): list of groups or None to ignore the groups membership. @param attributes (dict): attibutes of the added jid or to update @param in_roster (bool): True if contact is from roster """ if attributes is None: attributes = {} entity_bare = entity.bare 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: {}, C.CONTACT_SELECTED: set()}) assert not C.CONTACT_DATA_FORBIDDEN.intersection(attributes) # we don't want forbidden data in attributes # we set groups and fill self._groups accordingly if groups is not None: if not groups: groups = [None] # [None] is the default group if C.CONTACT_GROUPS in cache: # XXX: don't use set(cache[C.CONTACT_GROUPS]).difference(groups) because it won't work in Pyjamas if None is in cache[C.CONTACT_GROUPS] for group in [group for group in cache[C.CONTACT_GROUPS] if group not in groups]: self._groups[group]['jids'].remove(entity_bare) cache[C.CONTACT_GROUPS] = groups for group in groups: self._groups.setdefault(group, {}).setdefault('jids', set()).add(entity_bare) # special entities management if C.CONTACT_SPECIAL in attributes: if attributes[C.CONTACT_SPECIAL] is None: del attributes[C.CONTACT_SPECIAL] self._specials.remove(entity) else: self._specials.add(entity) cache[C.CONTACT_MAIN_RESOURCE] = None # now the attributes we keep in cache for attribute, value in attributes.iteritems(): cache[attribute] = value # we can update the display self.update([entity], update_type, self.profile) def entityToShow(self, entity, check_resource=False): """Tell if the contact should be showed or hidden. @param entity (jid.JID): jid of the contact @param check_resource (bool): True if resource must be significant @return (bool): True if that contact should be showed in the list """ show = self.getCache(entity, C.PRESENCE_SHOW) if check_resource: selected = self._selected else: selected = {selected.bare for selected in self._selected} return ((show is not None and show != C.PRESENCE_UNAVAILABLE) or self.show_disconnected or entity in selected or next(self.host.getNotifs(entity.bare, profile=self.profile), None) ) def anyEntityToShow(self, entities, check_resources=False): """Tell if in a list of entities, at least one should be shown @param entities (list[jid.JID]): list of jids @param check_resources (bool): True if resources must be significant @return (bool): True if a least one entity need to be shown """ # FIXME: looks inefficient, really needed? for entity in entities: if self.entityToShow(entity, check_resources): return True return False def isEntityInGroup(self, entity, group): """Tell if an entity is in a roster group @param entity(jid.JID): jid of the entity @param group(unicode): group to check @return (bool): True if the entity is in the group """ return entity in self.getGroupData(group, "jids") def removeContact(self, entity): """remove a contact from the list @param entity(jid.JID): jid of the entity to remove (bare jid is used) """ entity_bare = entity.bare try: groups = self._cache[entity_bare].get(C.CONTACT_GROUPS, set()) except KeyError: 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) # FIXME: we use pop because of pyjamas: http://wiki.goffi.org/wiki/Issues_with_Pyjamas/en for iterable in (self._selected, self._specials): to_remove = set() for set_entity in iterable: if set_entity.bare == entity.bare: to_remove.add(set_entity) iterable.difference_update(to_remove) self.update([entity], C.UPDATE_DELETE, self.profile) def onPresenceUpdate(self, entity, show, priority, statuses, profile): """Update entity's presence status @param entity(jid.JID): entity updated @param show: availability @parap priority: resource's priority @param statuses: dict of statuses @param profile: %(doc_profile)s """ cache = self.getCache(entity) if show == C.PRESENCE_UNAVAILABLE: if not entity.resource: cache[C.CONTACT_RESOURCES].clear() cache[C.CONTACT_MAIN_RESOURCE] = None else: try: del cache[C.CONTACT_RESOURCES][entity.resource] except KeyError: log.error(u"Presence unavailable received for an unknown resource [{}]".format(entity)) if not cache[C.CONTACT_RESOURCES]: cache[C.CONTACT_MAIN_RESOURCE] = None else: if not entity.resource: log.warning(_(u"received presence from entity without resource: {}".format(entity))) resources_data = cache[C.CONTACT_RESOURCES] resource_data = resources_data.setdefault(entity.resource, {}) resource_data[C.PRESENCE_SHOW] = show resource_data[C.PRESENCE_PRIORITY] = int(priority) resource_data[C.PRESENCE_STATUSES] = statuses if entity.bare not in self._specials: priority_resource = max(resources_data, key=lambda res: resources_data[res][C.PRESENCE_PRIORITY]) cache[C.CONTACT_MAIN_RESOURCE] = priority_resource self.update([entity], C.UPDATE_MODIFY, self.profile) def onNickUpdate(self, entity, new_nick, profile): """Update entity's nick @param entity(jid.JID): entity updated @param new_nick(unicode): new nick of the entity @param profile: %(doc_profile)s """ assert profile == self.profile self.setCache(entity, 'nick', new_nick) self.update([entity], C.UPDATE_MODIFY, profile) def onNotification(self, entity, notif, profile): """Update entity with notification @param entity(jid.JID): entity updated @param notif(dict): notification data @param profile: %(doc_profile)s """ assert profile == self.profile if entity is not None: self.update([entity], C.UPDATE_MODIFY, profile) def unselect(self, entity): """Unselect an entity @param entity(jid.JID): entity to unselect """ try: cache = self._cache[entity.bare] except: log.error(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, None): entity to select (resource is significant) None to unselect all entities """ 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 showOfflineContacts(self, show): """Tell if offline contacts should shown @param show(bool): True if offline contacts should be shown """ assert isinstance(show, bool) if self.show_disconnected == show: return self.show_disconnected = show self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile) def showEmptyGroups(self, show): assert isinstance(show, bool) if self.show_empty_groups == show: return self.show_empty_groups = show self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile) def 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() self._update_locked = False # se to True to ignore updates def __getitem__(self, profile): """Return ProfileContactList instance for the requested profile""" return self._clist[profile] def __contains__(self, entity): """Check if entity is in contact list @param entity (jid.JID): jid of the entity (resource is not ignored, use bare jid if needed) """ for contact_list in self._clist.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 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 visible entities in cache items are ordered using self.items_sort key: bare jid, value: data """ return self.items_sort(self.items) def items_sort(self, items): """sort items @param items(dict): items to sort (will be emptied !) @return (OrderedDict): sorted items """ ordered_items = OrderedDict() bare_jids = sorted(items.keys()) for jid_ in bare_jids: ordered_items[jid_] = items.pop(jid_) return ordered_items def register(self, widget): """Register a QuickContactList widget This method should only be used in QuickContactList """ self._widgets.add(widget) def unregister(self, widget): """Unregister a QuickContactList widget This method should only be used in QuickContactList """ self._widgets.remove(widget) def 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 lockUpdate(self, locked=True, do_update=True): """Forbid contact list updates Used mainly while profiles are plugged, as many updates can occurs, causing an impact on performances @param locked(bool): updates are forbidden if True @param do_update(bool): if True, a full update is done after unlocking if set to False, widget state can be inconsistent, be sure to know what youa re doing! """ log.debug(u"Contact lists updates are now {}".format(u"LOCKED" if locked else u"UNLOCKED")) self._update_locked = locked if not locked and do_update: self.update() def update(self, entities=None, type_=None, profile=None): if not self._update_locked: for widget in self._widgets: widget.update(entities, type_, profile) class QuickContactList(QuickWidget): """This class manage the visual representation of contacts""" SINGLE=False PROFILES_MULTIPLE=True 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) # options # for next values, None means use indivual value per profile # True or False mean override these values for all profiles self.show_disconnected = None # TODO self.show_empty_groups = None # TODO self.show_resources = None # TODO self.show_status = None # TODO def postInit(self): """Method to be called by frontend after widget is initialised""" handler.register(self) @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) handler.unregister(self)