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

refactoring: rename `sat_frontends` to `libervia.frontends`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 14:12:38 +0200
parents sat_frontends/quick_frontend/quick_contact_list.py@4b842c1fb686
children b47f21f2b8fa
comparison
equal deleted inserted replaced
4073:7c5654c54fed 4074:26b7ed2817da
1 #!/usr/bin/env python3
2
3 # helper class for making a SàT frontend contact lists
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 """Contact List handling multi profiles at once,
20 should replace quick_contact_list module in the future"""
21
22 from libervia.backend.core.i18n import _
23 from libervia.backend.core.log import getLogger
24 from libervia.backend.core import exceptions
25 from libervia.frontends.quick_frontend.quick_widgets import QuickWidget
26 from libervia.frontends.quick_frontend.constants import Const as C
27 from libervia.frontends.tools import jid
28 from collections import OrderedDict
29
30 log = getLogger(__name__)
31
32 try:
33 # FIXME: to be removed when an acceptable solution is here
34 str("") # XXX: unicode doesn't exist in pyjamas
35 except (TypeError, AttributeError): # Error raised is not the same depending on
36 # pyjsbuild options
37 # XXX: pyjamas' max doesn't support key argument, so we implement it ourself
38 pyjamas_max = max
39
40 def max(iterable, key):
41 iter_cpy = list(iterable)
42 iter_cpy.sort(key=key)
43 return pyjamas_max(iter_cpy)
44
45 # next doesn't exist in pyjamas
46 def next(iterable, *args):
47 try:
48 return iterable.__next__()
49 except StopIteration as e:
50 if args:
51 return args[0]
52 raise e
53
54
55 handler = None
56
57
58 class ProfileContactList(object):
59 """Contact list data for a single profile"""
60
61 def __init__(self, profile):
62 self.host = handler.host
63 self.profile = profile
64 # contain all jids in roster or not,
65 # bare jids as keys, resources are used in data
66 # XXX: we don't mutualise cache, as values may differ
67 # for different profiles (e.g. directed presence)
68 self._cache = {}
69
70 # special entities (groupchat, gateways, etc)
71 # may be bare or full jid
72 self._specials = set()
73
74 # group data contain jids in groups and misc frontend data
75 # None key is used for jids with no group
76 self._groups = {} # groups to group data map
77
78 # contacts in roster (bare jids)
79 self._roster = set()
80
81 # selected entities, full jid
82 self._selected = set()
83
84 # options
85 self.show_disconnected = False
86 self._show_empty_groups = True
87 self.show_resources = False
88 self.show_status = False
89 # do we show entities with notifications?
90 # if True, entities will be show even if they normally would not
91 # (e.g. not in contact list) if they have notifications attached
92 self.show_entities_with_notifs = True
93
94 self.host.bridge.param_get_a_async(
95 C.SHOW_EMPTY_GROUPS,
96 "General",
97 profile_key=profile,
98 callback=self._show_empty_groups_cb,
99 )
100
101 self.host.bridge.param_get_a_async(
102 C.SHOW_OFFLINE_CONTACTS,
103 "General",
104 profile_key=profile,
105 callback=self._show_offline_contacts,
106 )
107
108 self.host.addListener("presence", self.on_presence_update, [self.profile])
109 self.host.addListener("nicknames", self.on_nicknames_update, [self.profile])
110 self.host.addListener("notification", self.on_notification, [self.profile])
111 # on_notification only updates the entity, so we can re-use it
112 self.host.addListener("notificationsClear", self.on_notification, [self.profile])
113
114 @property
115 def whoami(self):
116 return self.host.profiles[self.profile].whoami
117
118 def _show_empty_groups_cb(self, show_str):
119 # Called only by __init__
120 # self.update is not wanted here, as it is done by
121 # handler when all profiles are ready
122 self.show_empty_groups(C.bool(show_str))
123
124 def _show_offline_contacts(self, show_str):
125 # same comments as for _show_empty_groups
126 self.show_offline_contacts(C.bool(show_str))
127
128 def __contains__(self, entity):
129 """Check if entity is in contact list
130
131 An entity can be in contact list even if not in roster
132 use is_in_roster to check if entity is in roster.
133 @param entity (jid.JID): jid of the entity (resource is not ignored,
134 use bare jid if needed)
135 """
136 if entity.resource:
137 try:
138 return entity.resource in self.getCache(entity.bare, C.CONTACT_RESOURCES)
139 except exceptions.NotFound:
140 return False
141 return entity in self._cache
142
143 @property
144 def roster(self):
145 """Return all the bare JIDs of the roster entities.
146
147 @return (set[jid.JID])
148 """
149 return self._roster
150
151 @property
152 def roster_connected(self):
153 """Return all the bare JIDs of the roster entities that are connected.
154
155 @return (set[jid.JID])
156 """
157 return set(
158 [
159 entity
160 for entity in self._roster
161 if self.getCache(entity, C.PRESENCE_SHOW, default=None) is not None
162 ]
163 )
164
165 @property
166 def roster_entities_by_group(self):
167 """Return a dictionary binding the roster groups to their entities bare JIDs.
168
169 This also includes the empty group (None key).
170 @return (dict[unicode,set(jid.JID)])
171 """
172 return {group: self._groups[group]["jids"] for group in self._groups}
173
174 @property
175 def roster_groups_by_entities(self):
176 """Return a dictionary binding the entities bare JIDs to their roster groups
177
178 @return (dict[jid.JID, set(unicode)])
179 """
180 result = {}
181 for group, data in self._groups.items():
182 for entity in data["jids"]:
183 result.setdefault(entity, set()).add(group)
184 return result
185
186 @property
187 def selected(self):
188 """Return contacts currently selected
189
190 @return (set): set of selected entities
191 """
192 return self._selected
193
194 @property
195 def all_iter(self):
196 """return all know entities in cache as an iterator of tuples
197
198 entities are not sorted
199 """
200 return iter(self._cache.items())
201
202 @property
203 def items(self):
204 """Return item representation for all visible entities in cache
205
206 entities are not sorted
207 key: bare jid, value: data
208 """
209 return {
210 jid_: cache
211 for jid_, cache in self._cache.items()
212 if self.entity_visible(jid_)
213 }
214
215 def get_item(self, entity):
216 """Return item representation of requested entity
217
218 @param entity(jid.JID): bare jid of entity
219 @raise (KeyError): entity is unknown
220 """
221 return self._cache[entity]
222
223 def _got_contacts(self, contacts):
224 """Add contacts and notice parent that contacts are filled
225
226 Called during initial contact list filling
227 @param contacts(tuple): all contacts
228 """
229 for contact in contacts:
230 entity = jid.JID(contact[0])
231 if entity.resource:
232 # we use entity's bare jid to cache data, so a resource here
233 # will cause troubles
234 log.warning(
235 "Roster entities with resources are not managed, ignoring {entity}"
236 .format(entity=entity))
237 continue
238 self.host.contact_new_handler(*contact, profile=self.profile)
239 handler._contacts_filled(self.profile)
240
241 def _fill(self):
242 """Get all contacts from backend
243
244 Contacts will be cleared before refilling them
245 """
246 self.clear_contacts(keep_cache=True)
247 self.host.bridge.contacts_get(self.profile, callback=self._got_contacts)
248
249 def fill(self):
250 handler.fill(self.profile)
251
252 def getCache(
253 self, entity, name=None, bare_default=True, create_if_not_found=False,
254 default=Exception):
255 """Return a cache value for a contact
256
257 @param entity(jid.JID): entity of the contact from who we want data
258 (resource is used if given)
259 if a resource specific information is requested:
260 - if no resource is given (bare jid), the main resource is used,
261 according to priority
262 - if resource is given, it is used
263 @param name(unicode): name the data to get, or None to get everything
264 @param bare_default(bool, None): if True and entity is a full jid,
265 the value of bare jid will be returned if not value is found for
266 the requested resource.
267 If False, None is returned if no value is found for the requested resource.
268 If None, bare_default will be set to False if entity is in a room, True else
269 @param create_if_not_found(bool): if True, create contact if it's not found
270 in cache
271 @param default(object): value to return when name is not found in cache
272 if Exception is used, a KeyError will be returned
273 otherwise, the given value will be used
274 @return: full cache if no name is given, or value of "name", or None
275 @raise NotFound: entity not found in cache
276 @raise KeyError: name not found in cache
277 """
278 # FIXME: resource handling need to be reworked
279 # FIXME: bare_default work for requesting full jid to get bare jid,
280 # but not the other way
281 # e.g.: if we have set an avatar for user@server.tld/resource
282 # and we request user@server.tld
283 # we won't get the avatar set in the resource
284 try:
285 cache = self._cache[entity.bare]
286 except KeyError:
287 if create_if_not_found:
288 self.set_contact(entity)
289 cache = self._cache[entity.bare]
290 else:
291 raise exceptions.NotFound
292
293 if name is None:
294 if default is not Exception:
295 raise exceptions.InternalError(
296 "default value can only Exception when name is not specified"
297 )
298 # full cache is requested
299 return cache
300
301 if name in ("status", C.PRESENCE_STATUSES, C.PRESENCE_PRIORITY, C.PRESENCE_SHOW):
302 # these data are related to the resource
303 if not entity.resource:
304 main_resource = cache[C.CONTACT_MAIN_RESOURCE]
305 if main_resource is None:
306 # we ignore presence info if we don't have any resource in cache
307 # FIXME: to be checked
308 return
309 cache = cache[C.CONTACT_RESOURCES].setdefault(main_resource, {})
310 else:
311 cache = cache[C.CONTACT_RESOURCES].setdefault(entity.resource, {})
312
313 if name == "status": # XXX: we get the first status for 'status' key
314 # TODO: manage main language for statuses
315 return cache[C.PRESENCE_STATUSES].get(C.PRESENCE_STATUSES_DEFAULT, "")
316
317 elif entity.resource:
318 try:
319 return cache[C.CONTACT_RESOURCES][entity.resource][name]
320 except KeyError as e:
321 if bare_default is None:
322 bare_default = not self.is_room(entity.bare)
323 if not bare_default:
324 if default is Exception:
325 raise e
326 else:
327 return default
328
329 try:
330 return cache[name]
331 except KeyError as e:
332 if default is Exception:
333 raise e
334 else:
335 return default
336
337 def set_cache(self, entity, name, value):
338 """Set or update value for one data in cache
339
340 @param entity(JID): entity to update
341 @param name(str): value to set or update
342 """
343 self.set_contact(entity, attributes={name: value})
344
345 def get_full_jid(self, entity):
346 """Get full jid from a bare jid
347
348 @param entity(jid.JID): must be a bare jid
349 @return (jid.JID): bare jid + main resource
350 @raise ValueError: the entity is not bare
351 """
352 if entity.resource:
353 raise ValueError("get_full_jid must be used with a bare jid")
354 main_resource = self.getCache(entity, C.CONTACT_MAIN_RESOURCE)
355 return jid.JID("{}/{}".format(entity, main_resource))
356
357 def set_group_data(self, group, name, value):
358 """Register a data for a group
359
360 @param group: a valid (existing) group name
361 @param name: name of the data (can't be "jids")
362 @param value: value to set
363 """
364 assert name != "jids"
365 self._groups[group][name] = value
366
367 def get_group_data(self, group, name=None):
368 """Return value associated to group data
369
370 @param group: a valid (existing) group name
371 @param name: name of the data or None to get the whole dict
372 @return: registered value
373 """
374 if name is None:
375 return self._groups[group]
376 return self._groups[group][name]
377
378 def is_in_roster(self, entity):
379 """Tell if an entity is in roster
380
381 @param entity(jid.JID): jid of the entity
382 the bare jid will be used
383 """
384 return entity.bare in self._roster
385
386 def is_room(self, entity):
387 """Helper method to know if entity is a MUC room
388
389 @param entity(jid.JID): jid of the entity
390 hint: use bare jid here, as room can't be full jid with MUC
391 @return (bool): True if entity is a room
392 """
393 assert entity.resource is None # FIXME: this may change when MIX will be handled
394 return self.is_special(entity, C.CONTACT_SPECIAL_GROUP)
395
396 def is_special(self, entity, special_type):
397 """Tell if an entity is of a specialy _type
398
399 @param entity(jid.JID): jid of the special entity
400 if the jid is full, will be added to special extras
401 @param special_type: one of special type (e.g. C.CONTACT_SPECIAL_GROUP)
402 @return (bool): True if entity is from this special type
403 """
404 return self.getCache(entity, C.CONTACT_SPECIAL, default=None) == special_type
405
406 def set_special(self, entity, special_type):
407 """Set special flag on an entity
408
409 @param entity(jid.JID): jid of the special entity
410 if the jid is full, will be added to special extras
411 @param special_type: one of special type (e.g. C.CONTACT_SPECIAL_GROUP)
412 or None to remove special flag
413 """
414 assert special_type in C.CONTACT_SPECIAL_ALLOWED + (None,)
415 self.set_cache(entity, C.CONTACT_SPECIAL, special_type)
416
417 def get_specials(self, special_type=None, bare=False):
418 """Return all the bare JIDs of the special roster entities of with given type.
419
420 @param special_type(unicode, None): if not None, filter by special type
421 (e.g. C.CONTACT_SPECIAL_GROUP)
422 @param bare(bool): return only bare jids if True
423 @return (iter[jid.JID]): found special entities
424 """
425 for entity in self._specials:
426 if bare and entity.resource:
427 continue
428 if (
429 special_type is not None
430 and self.getCache(entity, C.CONTACT_SPECIAL, default=None) != special_type
431 ):
432 continue
433 yield entity
434
435 def disconnect(self):
436 # for now we just clear contacts on disconnect
437 self.clear_contacts()
438
439 def clear_contacts(self, keep_cache=False):
440 """Clear all the contact list
441
442 @param keep_cache: if True, don't reset the cache
443 """
444 self.select(None)
445 if not keep_cache:
446 self._cache.clear()
447 self._groups.clear()
448 self._specials.clear()
449 self._roster.clear()
450 self.update()
451
452 def set_contact(self, entity, groups=None, attributes=None, in_roster=False):
453 """Add a contact to the list if it doesn't exist, else update it.
454
455 This method can be called with groups=None for the purpose of updating
456 the contact's attributes (e.g. nicknames). In that case, the groups
457 attribute must not be set to the default group but ignored. If not,
458 you may move your contact from its actual group(s) to the default one.
459
460 None value for 'groups' has a different meaning than [None]
461 which is for the default group.
462
463 @param entity (jid.JID): entity to add or replace
464 if entity is a full jid, attributes will be cached in for the full jid only
465 @param groups (list): list of groups or None to ignore the groups membership.
466 @param attributes (dict): attibutes of the added jid or to update
467 @param in_roster (bool): True if contact is from roster
468 """
469 if attributes is None:
470 attributes = {}
471
472 entity_bare = entity.bare
473 # we check if the entity is visible before changing anything
474 # this way we know if we need to do an UPDATE_ADD, UPDATE_MODIFY
475 # or an UPDATE_DELETE
476 was_visible = self.entity_visible(entity_bare)
477
478 if in_roster:
479 self._roster.add(entity_bare)
480
481 cache = self._cache.setdefault(
482 entity_bare,
483 {
484 C.CONTACT_RESOURCES: {},
485 C.CONTACT_MAIN_RESOURCE: None,
486 C.CONTACT_SELECTED: set(),
487 },
488 )
489
490 # we don't want forbidden data in attributes
491 assert not C.CONTACT_DATA_FORBIDDEN.intersection(attributes)
492
493 # we set groups and fill self._groups accordingly
494 if groups is not None:
495 if not groups:
496 groups = [None] # [None] is the default group
497 if C.CONTACT_GROUPS in cache:
498 # XXX: don't use set(cache[C.CONTACT_GROUPS]).difference(groups) because
499 # it won't work in Pyjamas if None is in cache[C.CONTACT_GROUPS]
500 for group in [
501 group for group in cache[C.CONTACT_GROUPS] if group not in groups
502 ]:
503 self._groups[group]["jids"].remove(entity_bare)
504 cache[C.CONTACT_GROUPS] = groups
505 for group in groups:
506 self._groups.setdefault(group, {}).setdefault("jids", set()).add(
507 entity_bare
508 )
509
510 # special entities management
511 if C.CONTACT_SPECIAL in attributes:
512 if attributes[C.CONTACT_SPECIAL] is None:
513 del attributes[C.CONTACT_SPECIAL]
514 self._specials.remove(entity)
515 else:
516 self._specials.add(entity)
517 cache[C.CONTACT_MAIN_RESOURCE] = None
518 if 'nicknames' in cache:
519 del cache['nicknames']
520
521 # now the attributes we keep in cache
522 # XXX: if entity is a full jid, we store the value for the resource only
523 cache_attr = (
524 cache[C.CONTACT_RESOURCES].setdefault(entity.resource, {})
525 if entity.resource
526 else cache
527 )
528 for attribute, value in attributes.items():
529 if attribute == "nicknames" and self.is_special(
530 entity, C.CONTACT_SPECIAL_GROUP
531 ):
532 # we don't want to keep nicknames for MUC rooms
533 # FIXME: this is here as plugin XEP-0054 can link resource's nick
534 # with bare jid which in the case of MUC
535 # set the nick for the whole MUC
536 # resulting in bad name displayed in some frontends
537 # FIXME: with plugin XEP-0054 + plugin identity refactoring, this
538 # may not be needed anymore…
539 continue
540 cache_attr[attribute] = value
541
542 # we can update the display if needed
543 if self.entity_visible(entity_bare):
544 # if the contact was not visible, we need to add a widget
545 # else we just update id
546 update_type = C.UPDATE_MODIFY if was_visible else C.UPDATE_ADD
547 self.update([entity], update_type, self.profile)
548 elif was_visible:
549 # the entity was visible and is not anymore, we remove it
550 self.update([entity], C.UPDATE_DELETE, self.profile)
551
552 def entity_visible(self, entity, check_resource=False):
553 """Tell if the contact should be showed or hidden.
554
555 @param entity (jid.JID): jid of the contact
556 @param check_resource (bool): True if resource must be significant
557 @return (bool): True if that contact should be showed in the list
558 """
559 try:
560 show = self.getCache(entity, C.PRESENCE_SHOW)
561 except (exceptions.NotFound, KeyError):
562 return False
563
564 if check_resource:
565 selected = self._selected
566 else:
567 selected = {selected.bare for selected in self._selected}
568 return (
569 (show is not None and show != C.PRESENCE_UNAVAILABLE)
570 or self.show_disconnected
571 or entity in selected
572 or (
573 self.show_entities_with_notifs
574 and next(self.host.get_notifs(entity.bare, profile=self.profile), None)
575 )
576 or entity.resource is None and self.is_room(entity.bare)
577 )
578
579 def any_entity_visible(self, entities, check_resources=False):
580 """Tell if in a list of entities, at least one should be shown
581
582 @param entities (list[jid.JID]): list of jids
583 @param check_resources (bool): True if resources must be significant
584 @return (bool): True if a least one entity need to be shown
585 """
586 # FIXME: looks inefficient, really needed?
587 for entity in entities:
588 if self.entity_visible(entity, check_resources):
589 return True
590 return False
591
592 def is_entity_in_group(self, entity, group):
593 """Tell if an entity is in a roster group
594
595 @param entity(jid.JID): jid of the entity
596 @param group(unicode): group to check
597 @return (bool): True if the entity is in the group
598 """
599 return entity in self.get_group_data(group, "jids")
600
601 def remove_contact(self, entity):
602 """remove a contact from the list
603
604 @param entity(jid.JID): jid of the entity to remove (bare jid is used)
605 """
606 entity_bare = entity.bare
607 was_visible = self.entity_visible(entity_bare)
608 try:
609 groups = self._cache[entity_bare].get(C.CONTACT_GROUPS, set())
610 except KeyError:
611 log.error(_("Trying to delete an unknow entity [{}]").format(entity))
612 try:
613 self._roster.remove(entity_bare)
614 except KeyError:
615 pass
616 del self._cache[entity_bare]
617 for group in groups:
618 self._groups[group]["jids"].remove(entity_bare)
619 if not self._groups[group]["jids"]:
620 # FIXME: we use pop because of pyjamas:
621 # http://wiki.goffi.org/wiki/Issues_with_Pyjamas/en
622 self._groups.pop(group)
623 for iterable in (self._selected, self._specials):
624 to_remove = set()
625 for set_entity in iterable:
626 if set_entity.bare == entity.bare:
627 to_remove.add(set_entity)
628 iterable.difference_update(to_remove)
629 if was_visible:
630 self.update([entity], C.UPDATE_DELETE, self.profile)
631
632 def on_presence_update(self, entity, show, priority, statuses, profile):
633 """Update entity's presence status
634
635 @param entity(jid.JID): entity updated
636 @param show: availability
637 @parap priority: resource's priority
638 @param statuses: dict of statuses
639 @param profile: %(doc_profile)s
640 """
641 # FIXME: cache modification should be done with set_contact
642 # the resources/presence handling logic should be moved there
643 was_visible = self.entity_visible(entity.bare)
644 cache = self.getCache(entity, create_if_not_found=True)
645 if show == C.PRESENCE_UNAVAILABLE:
646 if not entity.resource:
647 cache[C.CONTACT_RESOURCES].clear()
648 cache[C.CONTACT_MAIN_RESOURCE] = None
649 else:
650 try:
651 del cache[C.CONTACT_RESOURCES][entity.resource]
652 except KeyError:
653 log.error(
654 "Presence unavailable received "
655 "for an unknown resource [{}]".format(entity)
656 )
657 if not cache[C.CONTACT_RESOURCES]:
658 cache[C.CONTACT_MAIN_RESOURCE] = None
659 else:
660 if not entity.resource:
661 log.warning(
662 _(
663 "received presence from entity "
664 "without resource: {}".format(entity)
665 )
666 )
667 resources_data = cache[C.CONTACT_RESOURCES]
668 resource_data = resources_data.setdefault(entity.resource, {})
669 resource_data[C.PRESENCE_SHOW] = show
670 resource_data[C.PRESENCE_PRIORITY] = int(priority)
671 resource_data[C.PRESENCE_STATUSES] = statuses
672
673 if entity.bare not in self._specials:
674 # we may have resources with no priority
675 # (when a cached value is added for a not connected resource)
676 priority_resource = max(
677 resources_data,
678 key=lambda res: resources_data[res].get(
679 C.PRESENCE_PRIORITY, -2 ** 32
680 ),
681 )
682 cache[C.CONTACT_MAIN_RESOURCE] = priority_resource
683 if self.entity_visible(entity.bare):
684 update_type = C.UPDATE_MODIFY if was_visible else C.UPDATE_ADD
685 self.update([entity], update_type, self.profile)
686 elif was_visible:
687 self.update([entity], C.UPDATE_DELETE, self.profile)
688
689 def on_nicknames_update(self, entity, nicknames, profile):
690 """Update entity's nicknames
691
692 @param entity(jid.JID): entity updated
693 @param nicknames(list[unicode]): nicknames of the entity
694 @param profile: %(doc_profile)s
695 """
696 assert profile == self.profile
697 self.set_cache(entity, "nicknames", nicknames)
698
699 def on_notification(self, entity, notif, profile):
700 """Update entity with notification
701
702 @param entity(jid.JID): entity updated
703 @param notif(dict): notification data
704 @param profile: %(doc_profile)s
705 """
706 assert profile == self.profile
707 if entity is not None and self.entity_visible(entity):
708 self.update([entity], C.UPDATE_MODIFY, profile)
709
710 def unselect(self, entity):
711 """Unselect an entity
712
713 @param entity(jid.JID): entity to unselect
714 """
715 try:
716 cache = self._cache[entity.bare]
717 except:
718 log.error("Try to unselect an entity not in cache")
719 else:
720 try:
721 cache[C.CONTACT_SELECTED].remove(entity.resource)
722 except KeyError:
723 log.error("Try to unselect a not selected entity")
724 else:
725 self._selected.remove(entity)
726 self.update([entity], C.UPDATE_SELECTION)
727
728 def select(self, entity):
729 """Select an entity
730
731 @param entity(jid.JID, None): entity to select (resource is significant)
732 None to unselect all entities
733 """
734 if entity is None:
735 self._selected.clear()
736 for cache in self._cache.values():
737 cache[C.CONTACT_SELECTED].clear()
738 self.update(type_=C.UPDATE_SELECTION, profile=self.profile)
739 else:
740 log.debug("select %s" % entity)
741 try:
742 cache = self._cache[entity.bare]
743 except:
744 log.error("Try to select an entity not in cache")
745 else:
746 cache[C.CONTACT_SELECTED].add(entity.resource)
747 self._selected.add(entity)
748 self.update([entity], C.UPDATE_SELECTION, profile=self.profile)
749
750 def show_offline_contacts(self, show):
751 """Tell if offline contacts should be shown
752
753 @param show(bool): True if offline contacts should be shown
754 """
755 assert isinstance(show, bool)
756 if self.show_disconnected == show:
757 return
758 self.show_disconnected = show
759 self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile)
760
761 def show_empty_groups(self, show):
762 assert isinstance(show, bool)
763 if self._show_empty_groups == show:
764 return
765 self._show_empty_groups = show
766 self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile)
767
768 def show_resources(self, show):
769 assert isinstance(show, bool)
770 if self.show_resources == show:
771 return
772 self.show_resources = show
773 self.update(type_=C.UPDATE_STRUCTURE, profile=self.profile)
774
775 def plug(self):
776 handler.add_profile(self.profile)
777
778 def unplug(self):
779 handler.remove_profile(self.profile)
780
781 def update(self, entities=None, type_=None, profile=None):
782 handler.update(entities, type_, profile)
783
784
785 class QuickContactListHandler(object):
786 def __init__(self, host):
787 super(QuickContactListHandler, self).__init__()
788 self.host = host
789 global handler
790 if handler is not None:
791 raise exceptions.InternalError(
792 "QuickContactListHandler must be instanciated only once"
793 )
794 handler = self
795 self._clist = {} # key: profile, value: ProfileContactList
796 self._widgets = set()
797 self._update_locked = False # se to True to ignore updates
798
799 def __getitem__(self, profile):
800 """Return ProfileContactList instance for the requested profile"""
801 return self._clist[profile]
802
803 def __contains__(self, entity):
804 """Check if entity is in contact list
805
806 @param entity (jid.JID): jid of the entity (resource is not ignored,
807 use bare jid if needed)
808 """
809 for contact_list in self._clist.values():
810 if entity in contact_list:
811 return True
812 return False
813
814 @property
815 def roster(self):
816 """Return all the bare JIDs of the roster entities.
817
818 @return (set[jid.JID])
819 """
820 entities = set()
821 for contact_list in self._clist.values():
822 entities.update(contact_list.roster)
823 return entities
824
825 @property
826 def roster_connected(self):
827 """Return all the bare JIDs of the roster entities that are connected.
828
829 @return (set[jid.JID])
830 """
831 entities = set()
832 for contact_list in self._clist.values():
833 entities.update(contact_list.roster_connected)
834 return entities
835
836 @property
837 def roster_entities_by_group(self):
838 """Return a dictionary binding the roster groups to their entities bare
839 JIDs. This also includes the empty group (None key).
840
841 @return (dict[unicode,set(jid.JID)])
842 """
843 groups = {}
844 for contact_list in self._clist.values():
845 groups.update(contact_list.roster_entities_by_group)
846 return groups
847
848 @property
849 def roster_groups_by_entities(self):
850 """Return a dictionary binding the entities bare JIDs to their roster
851 groups.
852
853 @return (dict[jid.JID, set(unicode)])
854 """
855 entities = {}
856 for contact_list in self._clist.values():
857 entities.update(contact_list.roster_groups_by_entities)
858 return entities
859
860 @property
861 def selected(self):
862 """Return contacts currently selected
863
864 @return (set): set of selected entities
865 """
866 entities = set()
867 for contact_list in self._clist.values():
868 entities.update(contact_list.selected)
869 return entities
870
871 @property
872 def all_iter(self):
873 """Return item representation for all entities in cache
874
875 items are unordered
876 """
877 for profile, contact_list in self._clist.items():
878 for bare_jid, cache in contact_list.all_iter:
879 data = cache.copy()
880 data[C.CONTACT_PROFILE] = profile
881 yield bare_jid, data
882
883 @property
884 def items(self):
885 """Return item representation for visible entities in cache
886
887 items are unordered
888 key: bare jid, value: data
889 """
890 items = {}
891 for profile, contact_list in self._clist.items():
892 for bare_jid, cache in contact_list.items.items():
893 data = cache.copy()
894 items[bare_jid] = data
895 data[C.CONTACT_PROFILE] = profile
896 return items
897
898 @property
899 def items_sorted(self):
900 """Return item representation for visible entities in cache
901
902 items are ordered using self.items_sort
903 key: bare jid, value: data
904 """
905 return self.items_sort(self.items)
906
907 def items_sort(self, items):
908 """sort items
909
910 @param items(dict): items to sort (will be emptied !)
911 @return (OrderedDict): sorted items
912 """
913 ordered_items = OrderedDict()
914 bare_jids = sorted(items.keys())
915 for jid_ in bare_jids:
916 ordered_items[jid_] = items.pop(jid_)
917 return ordered_items
918
919 def register(self, widget):
920 """Register a QuickContactList widget
921
922 This method should only be used in QuickContactList
923 """
924 self._widgets.add(widget)
925
926 def unregister(self, widget):
927 """Unregister a QuickContactList widget
928
929 This method should only be used in QuickContactList
930 """
931 self._widgets.remove(widget)
932
933 def add_profiles(self, profiles):
934 """Add a contact list for plugged profiles
935
936 @param profile(iterable[unicode]): plugged profiles
937 """
938 for profile in profiles:
939 if profile not in self._clist:
940 self._clist[profile] = ProfileContactList(profile)
941 return [self._clist[profile] for profile in profiles]
942
943 def add_profile(self, profile):
944 return self.add_profiles([profile])[0]
945
946 def remove_profiles(self, profiles):
947 """Remove given unplugged profiles from contact list
948
949 @param profile(iterable[unicode]): unplugged profiles
950 """
951 for profile in profiles:
952 del self._clist[profile]
953
954 def remove_profile(self, profile):
955 self.remove_profiles([profile])
956
957 def get_special_extras(self, special_type=None):
958 """Return special extras with given type
959
960 If special_type is None, return all special extras.
961
962 @param special_type(unicode, None): one of special type
963 (e.g. C.CONTACT_SPECIAL_GROUP)
964 None to return all special extras.
965 @return (set[jid.JID])
966 """
967 entities = set()
968 for contact_list in self._clist.values():
969 entities.update(contact_list.get_special_extras(special_type))
970 return entities
971
972 def _contacts_filled(self, profile):
973 self._to_fill.remove(profile)
974 if not self._to_fill:
975 del self._to_fill
976 # we need a full update when all contacts are filled
977 self.update()
978 self.host.call_listeners("contactsFilled", profile=profile)
979
980 def fill(self, profile=None):
981 """Get all contacts from backend, and fill the widget
982
983 Contacts will be cleared before refilling them
984 @param profile(unicode, None): profile to fill
985 None to fill all profiles
986 """
987 try:
988 to_fill = self._to_fill
989 except AttributeError:
990 to_fill = self._to_fill = set()
991
992 # we check if profiles have already been filled
993 # to void filling them several times
994 filled = to_fill.copy()
995
996 if profile is not None:
997 assert profile in self._clist
998 to_fill.add(profile)
999 else:
1000 to_fill.update(list(self._clist.keys()))
1001
1002 remaining = to_fill.difference(filled)
1003 if remaining != to_fill:
1004 log.debug(
1005 "Not re-filling already filled contact list(s) for {}".format(
1006 ", ".join(to_fill.intersection(filled))
1007 )
1008 )
1009 for profile in remaining:
1010 self._clist[profile]._fill()
1011
1012 def clear_contacts(self, keep_cache=False):
1013 """Clear all the contact list
1014
1015 @param keep_cache: if True, don't reset the cache
1016 """
1017 for contact_list in self._clist.values():
1018 contact_list.clear_contacts(keep_cache)
1019 # we need a full update
1020 self.update()
1021
1022 def select(self, entity):
1023 for contact_list in self._clist.values():
1024 contact_list.select(entity)
1025
1026 def unselect(self, entity):
1027 for contact_list in self._clist.values():
1028 contact_list.select(entity)
1029
1030 def lock_update(self, locked=True, do_update=True):
1031 """Forbid contact list updates
1032
1033 Used mainly while profiles are plugged, as many updates can occurs, causing
1034 an impact on performances
1035 @param locked(bool): updates are forbidden if True
1036 @param do_update(bool): if True, a full update is done after unlocking
1037 if set to False, widget state can be inconsistent, be sure to know
1038 what youa re doing!
1039 """
1040 log.debug(
1041 "Contact lists updates are now {}".format(
1042 "LOCKED" if locked else "UNLOCKED"
1043 )
1044 )
1045 self._update_locked = locked
1046 if not locked and do_update:
1047 self.update()
1048
1049 def update(self, entities=None, type_=None, profile=None):
1050 if not self._update_locked:
1051 for widget in self._widgets:
1052 widget.update(entities, type_, profile)
1053
1054
1055 class QuickContactList(QuickWidget):
1056 """This class manage the visual representation of contacts"""
1057
1058 SINGLE = False
1059 PROFILES_MULTIPLE = True
1060 # Can be linked to no profile (e.g. at the early frontend start)
1061 PROFILES_ALLOW_NONE = True
1062
1063 def __init__(self, host, profiles):
1064 super(QuickContactList, self).__init__(host, None, profiles)
1065
1066 # options
1067 # for next values, None means use indivual value per profile
1068 # True or False mean override these values for all profiles
1069 self.show_disconnected = None # TODO
1070 self._show_empty_groups = None # TODO
1071 self.show_resources = None # TODO
1072 self.show_status = None # TODO
1073
1074 def post_init(self):
1075 """Method to be called by frontend after widget is initialised"""
1076 handler.register(self)
1077
1078 @property
1079 def all_iter(self):
1080 return handler.all_iter
1081
1082 @property
1083 def items(self):
1084 return handler.items
1085
1086 @property
1087 def items_sorted(self):
1088 return handler.items_sorted
1089
1090 def update(self, entities=None, type_=None, profile=None):
1091 """Update the display when something changed
1092
1093 @param entities(iterable[jid.JID], None): updated entities,
1094 None to update the whole contact list
1095 @param type_(unicode, None): update type, may be:
1096 - C.UPDATE_DELETE: entity deleted
1097 - C.UPDATE_MODIFY: entity updated
1098 - C.UPDATE_ADD: entity added
1099 - C.UPDATE_SELECTION: selection modified
1100 - C.UPDATE_STRUCTURE: organisation of items is modified (not items
1101 themselves)
1102 or None for undefined update
1103 Note that events correspond to addition, modification and deletion
1104 of items on the whole contact list. If the contact is visible or not
1105 has no influence on the type_.
1106 @param profile(unicode, None): profile concerned with the update
1107 None if all profiles need to be updated
1108 """
1109 raise NotImplementedError
1110
1111 def on_delete(self):
1112 QuickWidget.on_delete(self)
1113 handler.unregister(self)