Mercurial > libervia-backend
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) |