comparison libervia/frontends/quick_frontend/quick_chat.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_chat.py@4b842c1fb686
children 0d7bb4df2343
comparison
equal deleted inserted replaced
4073:7c5654c54fed 4074:26b7ed2817da
1 #!/usr/bin/env python3
2
3 # helper class for making a SàT frontend
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 from libervia.backend.core.i18n import _
20 from libervia.backend.core.log import getLogger
21 from libervia.backend.tools.common import data_format
22 from libervia.backend.core import exceptions
23 from libervia.frontends.quick_frontend import quick_widgets
24 from libervia.frontends.quick_frontend.constants import Const as C
25 from collections import OrderedDict
26 from libervia.frontends.tools import jid
27 import time
28
29
30 log = getLogger(__name__)
31
32
33 ROOM_USER_JOINED = "ROOM_USER_JOINED"
34 ROOM_USER_LEFT = "ROOM_USER_LEFT"
35 ROOM_USER_MOVED = (ROOM_USER_JOINED, ROOM_USER_LEFT)
36
37 # from datetime import datetime
38
39 # FIXME: day_format need to be settable (i18n)
40
41
42 class Message:
43 """Message metadata"""
44
45 def __init__(
46 self, parent, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra,
47 profile):
48 self.parent = parent
49 self.profile = profile
50 self.uid = uid
51 self.timestamp = timestamp
52 self.from_jid = from_jid
53 self.to_jid = to_jid
54 self.message = msg
55 self.subject = subject
56 self.type = type_
57 self.extra = extra
58 self.nick = self.get_nick(from_jid)
59 self._status = None
60 # own_mess is True if message was sent by profile's jid
61 self.own_mess = (
62 (from_jid.resource == self.parent.nick)
63 if self.parent.type == C.CHAT_GROUP
64 else (from_jid.bare == self.host.profiles[profile].whoami.bare)
65 )
66 # is user mentioned here ?
67 if self.parent.type == C.CHAT_GROUP and not self.own_mess:
68 for m in msg.values():
69 if self.parent.nick.lower() in m.lower():
70 self._mention = True
71 break
72 self.handle_me()
73 self.widgets = set() # widgets linked to this message
74
75 def __str__(self):
76 return "Message<{mess_type}> [{time}]{nick}> {message}".format(
77 mess_type=self.type,
78 time=self.time_text,
79 nick=self.nick,
80 message=self.main_message)
81
82 def __contains__(self, item):
83 return hasattr(self, item) or item in self.extra
84
85 @property
86 def host(self):
87 return self.parent.host
88
89 @property
90 def info_type(self):
91 return self.extra.get("info_type")
92
93 @property
94 def mention(self):
95 try:
96 return self._mention
97 except AttributeError:
98 return False
99
100 @property
101 def history(self):
102 """True if message come from history"""
103 return self.extra.get("history", False)
104
105 @property
106 def main_message(self):
107 """currently displayed message"""
108 if self.parent.lang in self.message:
109 self.selected_lang = self.parent.lang
110 return self.message[self.parent.lang]
111 try:
112 self.selected_lang = ""
113 return self.message[""]
114 except KeyError:
115 try:
116 lang, mess = next(iter(self.message.items()))
117 self.selected_lang = lang
118 return mess
119 except StopIteration:
120 if not self.attachments:
121 # we may have empty messages if we have attachments
122 log.error("Can't find message for uid {}".format(self.uid))
123 return ""
124
125 @property
126 def main_message_xhtml(self):
127 """rich message"""
128 xhtml = {k: v for k, v in self.extra.items() if "html" in k}
129 if xhtml:
130 # FIXME: we only return first found value for now
131 return next(iter(xhtml.values()))
132
133 @property
134 def time_text(self):
135 """Return timestamp in a nicely formatted way"""
136 # if the message was sent before today, we print the full date
137 timestamp = time.localtime(self.timestamp)
138 time_format = "%c" if timestamp < self.parent.day_change else "%H:%M"
139 return time.strftime(time_format, timestamp)
140
141 @property
142 def avatar(self):
143 """avatar data or None if no avatar is found"""
144 entity = self.from_jid
145 contact_list = self.host.contact_lists[self.profile]
146 try:
147 return contact_list.getCache(entity, "avatar")
148 except (exceptions.NotFound, KeyError):
149 # we don't check the result as the avatar listener will be called
150 self.host.bridge.avatar_get(entity, True, self.profile)
151 return None
152
153 @property
154 def encrypted(self):
155 return self.extra.get("encrypted", False)
156
157 def get_nick(self, entity):
158 """Return nick of an entity when possible"""
159 contact_list = self.host.contact_lists[self.profile]
160 if self.type == C.MESS_TYPE_INFO and self.info_type in ROOM_USER_MOVED:
161 try:
162 return self.extra["user_nick"]
163 except KeyError:
164 log.error("extra data is missing user nick for uid {}".format(self.uid))
165 return ""
166 # FIXME: converted get_specials to list for pyjamas
167 if self.parent.type == C.CHAT_GROUP or entity in list(
168 contact_list.get_specials(C.CONTACT_SPECIAL_GROUP)
169 ):
170 return entity.resource or ""
171 if entity.bare in contact_list:
172
173 try:
174 nicknames = contact_list.getCache(entity, "nicknames")
175 except (exceptions.NotFound, KeyError):
176 # we check result as listener will be called
177 self.host.bridge.identity_get(
178 entity.bare, ["nicknames"], True, self.profile)
179 return entity.node or entity
180
181 if nicknames:
182 return nicknames[0]
183 else:
184 return (
185 contact_list.getCache(entity, "name", default=None)
186 or entity.node
187 or entity
188 )
189
190 return entity.node or entity
191
192 @property
193 def status(self):
194 return self._status
195
196 @status.setter
197 def status(self, status):
198 if status != self._status:
199 self._status = status
200 for w in self.widgets:
201 w.update({"status": status})
202
203 def handle_me(self):
204 """Check if messages starts with "/me " and change them if it is the case
205
206 if several messages (different languages) are presents, they all need to start with "/me "
207 """
208 # TODO: XHTML-IM /me are not handled
209 me = False
210 # we need to check /me for every message
211 for m in self.message.values():
212 if m.startswith("/me "):
213 me = True
214 else:
215 me = False
216 break
217 if me:
218 self.type = C.MESS_TYPE_INFO
219 self.extra["info_type"] = "me"
220 nick = self.nick
221 for lang, mess in self.message.items():
222 self.message[lang] = "* " + nick + mess[3:]
223
224 @property
225 def attachments(self):
226 return self.extra.get(C.KEY_ATTACHMENTS)
227
228
229 class MessageWidget:
230 """Base classe for widgets"""
231 # This class does nothing and is only used to have a common ancestor
232
233 pass
234
235
236 class Occupant:
237 """Occupant metadata"""
238
239 def __init__(self, parent, data, profile):
240 self.parent = parent
241 self.profile = profile
242 self.nick = data["nick"]
243 self._entity = data.get("entity")
244 self.affiliation = data["affiliation"]
245 self.role = data["role"]
246 self.widgets = set() # widgets linked to this occupant
247 self._state = None
248
249 @property
250 def data(self):
251 """reconstruct data dict from attributes"""
252 data = {}
253 data["nick"] = self.nick
254 if self._entity is not None:
255 data["entity"] = self._entity
256 data["affiliation"] = self.affiliation
257 data["role"] = self.role
258 return data
259
260 @property
261 def jid(self):
262 """jid in the room"""
263 return jid.JID("{}/{}".format(self.parent.target.bare, self.nick))
264
265 @property
266 def real_jid(self):
267 """real jid if known else None"""
268 return self._entity
269
270 @property
271 def host(self):
272 return self.parent.host
273
274 @property
275 def state(self):
276 return self._state
277
278 @state.setter
279 def state(self, new_state):
280 if new_state != self._state:
281 self._state = new_state
282 for w in self.widgets:
283 w.update({"state": new_state})
284
285 def update(self, update_dict=None):
286 for w in self.widgets:
287 w.update(update_dict)
288
289
290 class QuickChat(quick_widgets.QuickWidget):
291 visible_states = ["chat_state"] # FIXME: to be removed, used only in quick_games
292
293 def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None,
294 subject=None, statuses=None, profiles=None):
295 """
296 @param type_: can be C.CHAT_ONE2ONE for single conversation or C.CHAT_GROUP for
297 chat à la IRC
298 """
299 self.lang = "" # default language to use for messages
300 quick_widgets.QuickWidget.__init__(self, host, target, profiles=profiles)
301 assert type_ in (C.CHAT_ONE2ONE, C.CHAT_GROUP)
302 self.current_target = target
303 self.type = type_
304 self.encrypted = False # True if this session is currently encrypted
305 self._locked = False
306 # True when resync is in progress, avoid resynchronising twice when resync is called
307 # and history is still being updated. For internal use only
308 self._resync_lock = False
309 self.set_locked()
310 if type_ == C.CHAT_GROUP:
311 if target.resource:
312 raise exceptions.InternalError(
313 "a group chat entity can't have a resource"
314 )
315 if nick is None:
316 raise exceptions.InternalError("nick must not be None for group chat")
317
318 self.nick = nick
319 self.occupants = {}
320 self.set_occupants(occupants)
321 else:
322 if occupants is not None or nick is not None:
323 raise exceptions.InternalError(
324 "only group chat can have occupants or nick"
325 )
326 self.messages = OrderedDict() # key: uid, value: Message instance
327 self.games = {} # key=game name (unicode), value=instance of quick_games.RoomGame
328 self.subject = subject
329 self.statuses = set(statuses or [])
330 lt = time.localtime()
331 self.day_change = (
332 lt.tm_year,
333 lt.tm_mon,
334 lt.tm_mday,
335 0,
336 0,
337 0,
338 lt.tm_wday,
339 lt.tm_yday,
340 lt.tm_isdst,
341 ) # struct_time of day changing time
342 if self.host.AVATARS_HANDLER:
343 self.host.addListener("avatar", self.on_avatar, profiles)
344
345 def set_locked(self):
346 """Set locked flag
347
348 To be set when we are waiting for history/search
349 """
350 # FIXME: we don't use getter/setter here because of pyjamas
351 # TODO: use proper getter/setter once we get rid of pyjamas
352 if self._locked:
353 log.warning("{wid} is already locked!".format(wid=self))
354 return
355 self._locked = True
356 # message_new signals are cached when locked
357 self._cache = OrderedDict()
358 log.debug("{wid} is now locked".format(wid=self))
359
360 def set_unlocked(self):
361 if not self._locked:
362 log.debug("{wid} was already unlocked".format(wid=self))
363 return
364 self._locked = False
365 for uid, data in self._cache.items():
366 if uid not in self.messages:
367 self.message_new(*data)
368 else:
369 log.debug("discarding message already in history: {data}, ".format(data=data))
370 del self._cache
371 log.debug("{wid} is now unlocked".format(wid=self))
372
373 def post_init(self):
374 """Method to be called by frontend after widget is initialised
375
376 handle the display of history and subject
377 """
378 self.history_print(profile=self.profile)
379 if self.subject is not None:
380 self.set_subject(self.subject)
381 if self.host.ENCRYPTION_HANDLERS:
382 self.get_encryption_state()
383
384 def on_delete(self):
385 if self.host.AVATARS_HANDLER:
386 self.host.removeListener("avatar", self.on_avatar)
387
388 @property
389 def contact_list(self):
390 return self.host.contact_lists[self.profile]
391
392 @property
393 def message_widgets_rev(self):
394 """Return the history of MessageWidget in reverse chronological order
395
396 Must be implemented by frontend
397 """
398 raise NotImplementedError
399
400 ## synchornisation handling ##
401
402 @quick_widgets.QuickWidget.sync.setter
403 def sync(self, state):
404 quick_widgets.QuickWidget.sync.fset(self, state)
405 if not state:
406 self.set_locked()
407
408 def _resync_complete(self):
409 self.sync = True
410 self._resync_lock = False
411
412 def occupants_clear(self):
413 """Remove all occupants
414
415 Must be overridden by frontends to clear their own representations of occupants
416 """
417 self.occupants.clear()
418
419 def resync(self):
420 if self._resync_lock:
421 return
422 self._resync_lock = True
423 log.debug("resynchronising {self}".format(self=self))
424 for mess in reversed(list(self.messages.values())):
425 if mess.type == C.MESS_TYPE_INFO:
426 continue
427 last_message = mess
428 break
429 else:
430 # we have no message yet, we can get normal history
431 self.history_print(callback=self._resync_complete, profile=self.profile)
432 return
433 if self.type == C.CHAT_GROUP:
434 self.occupants_clear()
435 self.host.bridge.muc_occupants_get(
436 str(self.target), self.profile, callback=self.update_occupants,
437 errback=log.error)
438 self.history_print(
439 size=C.HISTORY_LIMIT_NONE,
440 filters={'timestamp_start': last_message.timestamp},
441 callback=self._resync_complete,
442 profile=self.profile)
443
444 ## Widget management ##
445
446 def __str__(self):
447 return "Chat Widget [target: {}, type: {}, profile: {}]".format(
448 self.target, self.type, self.profile
449 )
450
451 @staticmethod
452 def get_widget_hash(target, profiles):
453 profile = list(profiles)[0]
454 return profile + "\n" + str(target.bare)
455
456 @staticmethod
457 def get_private_hash(target, profile):
458 """Get unique hash for private conversations
459
460 This method should be used with force_hash to get unique widget for private MUC conversations
461 """
462 return (str(profile), target)
463
464 def add_target(self, target):
465 super(QuickChat, self).add_target(target)
466 if target.resource:
467 self.current_target = (
468 target
469 ) # FIXME: tmp, must use resource priority throught contactList instead
470
471 def recreate_args(self, args, kwargs):
472 """copy important attribute for a new widget"""
473 kwargs["type_"] = self.type
474 if self.type == C.CHAT_GROUP:
475 kwargs["occupants"] = {o.nick: o.data for o in self.occupants.values()}
476 kwargs["subject"] = self.subject
477 try:
478 kwargs["nick"] = self.nick
479 except AttributeError:
480 pass
481
482 def on_private_created(self, widget):
483 """Method called when a new widget for private conversation (MUC) is created"""
484 raise NotImplementedError
485
486 def get_or_create_private_widget(self, entity):
487 """Create a widget for private conversation, or get it if it already exists
488
489 @param entity: full jid of the target
490 """
491 return self.host.widgets.get_or_create_widget(
492 QuickChat,
493 entity,
494 type_=C.CHAT_ONE2ONE,
495 force_hash=self.get_private_hash(self.profile, entity),
496 on_new_widget=self.on_private_created,
497 profile=self.profile,
498 ) # we force hash to have a new widget, not this one again
499
500 @property
501 def target(self):
502 if self.type == C.CHAT_GROUP:
503 return self.current_target.bare
504 return self.current_target
505
506 ## occupants ##
507
508 def set_occupants(self, occupants):
509 """Set the whole list of occupants"""
510 assert len(self.occupants) == 0
511 for nick, data in occupants.items():
512 # XXX: this log is disabled because it's really too verbose
513 # but kept commented as it may be useful for debugging
514 # log.debug(u"adding occupant {nick} to {room}".format(
515 # nick=nick, room=self.target))
516 self.occupants[nick] = Occupant(self, data, self.profile)
517
518 def update_occupants(self, occupants):
519 """Update occupants list
520
521 In opposition to set_occupants, this only add missing occupants and remove
522 occupants who have left
523 """
524 # FIXME: occupants with modified status are not handled
525 local_occupants = set(self.occupants)
526 updated_occupants = set(occupants)
527 left_occupants = local_occupants - updated_occupants
528 joined_occupants = updated_occupants - local_occupants
529 log.debug("updating occupants for {room}:\n"
530 "left: {left_occupants}\n"
531 "joined: {joined_occupants}"
532 .format(room=self.target,
533 left_occupants=", ".join(left_occupants),
534 joined_occupants=", ".join(joined_occupants)))
535 for nick in left_occupants:
536 self.removeUser(occupants[nick])
537 for nick in joined_occupants:
538 self.addUser(occupants[nick])
539
540 def addUser(self, occupant_data):
541 """Add user if it is not in the group list"""
542 occupant = Occupant(self, occupant_data, self.profile)
543 self.occupants[occupant.nick] = occupant
544 return occupant
545
546 def removeUser(self, occupant_data):
547 """Remove a user from the group list"""
548 nick = occupant_data["nick"]
549 try:
550 occupant = self.occupants.pop(nick)
551 except KeyError:
552 log.warning("Trying to remove an unknown occupant: {}".format(nick))
553 else:
554 return occupant
555
556 def set_user_nick(self, nick):
557 """Set the nick of the user, usefull for e.g. change the color of the user"""
558 self.nick = nick
559
560 def change_user_nick(self, old_nick, new_nick):
561 """Change nick of a user in group list"""
562 log.info("{old} is now known as {new} in room {room_jid}".format(
563 old = old_nick,
564 new = new_nick,
565 room_jid = self.target))
566
567 ## Messages ##
568
569 def manage_message(self, entity, mess_type):
570 """Tell if this chat widget manage an entity and message type couple
571
572 @param entity (jid.JID): (full) jid of the sending entity
573 @param mess_type (str): message type as given by message_new
574 @return (bool): True if this Chat Widget manage this couple
575 """
576 if self.type == C.CHAT_GROUP:
577 if (
578 mess_type in (C.MESS_TYPE_GROUPCHAT, C.MESS_TYPE_INFO)
579 and self.target == entity.bare
580 ):
581 return True
582 else:
583 if mess_type != C.MESS_TYPE_GROUPCHAT and entity in self.targets:
584 return True
585 return False
586
587 def update_history(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, profile="@NONE@"):
588 """Called when history need to be recreated
589
590 Remove all message from history then call history_print
591 Must probably be overriden by frontend to clear widget
592 @param size (int): number of messages
593 @param filters (str): patterns to filter the history results
594 @param profile (str): %(doc_profile)s
595 """
596 self.set_locked()
597 self.messages.clear()
598 self.history_print(size, filters, profile=profile)
599
600 def _on_history_printed(self):
601 """Method called when history is printed (or failed)
602
603 unlock the widget, and can be used to refresh or scroll down
604 the focus after the history is printed
605 """
606 self.set_unlocked()
607
608 def history_print(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, callback=None,
609 profile="@NONE@"):
610 """Print the current history
611
612 Note: self.set_unlocked will be called once history is printed
613 @param size (int): number of messages
614 @param search (str): pattern to filter the history results
615 @param callback(callable, None): method to call when history has been printed
616 @param profile (str): %(doc_profile)s
617 """
618 if filters is None:
619 filters = {}
620 if size == 0:
621 log.debug("Empty history requested, skipping")
622 self._on_history_printed()
623 return
624 log_msg = _("now we print the history")
625 if size != C.HISTORY_LIMIT_DEFAULT:
626 log_msg += _(" ({} messages)".format(size))
627 log.debug(log_msg)
628
629 if self.type == C.CHAT_ONE2ONE:
630 special = self.host.contact_lists[self.profile].getCache(
631 self.target, C.CONTACT_SPECIAL, create_if_not_found=True, default=None
632 )
633 if special == C.CONTACT_SPECIAL_GROUP:
634 # we have a private conversation
635 # so we need full jid for the history
636 # (else we would get history from group itself)
637 # and to filter out groupchat message
638 target = self.target
639 filters["not_types"] = C.MESS_TYPE_GROUPCHAT
640 else:
641 target = self.target.bare
642 else:
643 # groupchat
644 target = self.target.bare
645 # FIXME: info not handled correctly
646 filters["types"] = C.MESS_TYPE_GROUPCHAT
647
648 self.history_filters = filters
649
650 def _history_get_cb(history):
651 # day_format = "%A, %d %b %Y" # to display the day change
652 # previous_day = datetime.now().strftime(day_format)
653 # message_day = datetime.fromtimestamp(timestamp).strftime(self.day_format)
654 # if previous_day != message_day:
655 # self.print_day_change(message_day)
656 # previous_day = message_day
657 for data in history:
658 uid, timestamp, from_jid, to_jid, message, subject, type_, extra_s = data
659 from_jid = jid.JID(from_jid)
660 to_jid = jid.JID(to_jid)
661 extra = data_format.deserialise(extra_s)
662 # if ((self.type == C.CHAT_GROUP and type_ != C.MESS_TYPE_GROUPCHAT) or
663 # (self.type == C.CHAT_ONE2ONE and type_ == C.MESS_TYPE_GROUPCHAT)):
664 # continue
665 extra["history"] = True
666 self.messages[uid] = Message(
667 self,
668 uid,
669 timestamp,
670 from_jid,
671 to_jid,
672 message,
673 subject,
674 type_,
675 extra,
676 profile,
677 )
678 self._on_history_printed()
679 if callback is not None:
680 callback()
681
682 def _history_get_eb(err):
683 log.error(_("Can't get history: {}").format(err))
684 self._on_history_printed()
685 if callback is not None:
686 callback()
687
688 self.host.bridge.history_get(
689 str(self.host.profiles[profile].whoami.bare),
690 str(target),
691 size,
692 True,
693 {k: str(v) for k,v in filters.items()},
694 profile,
695 callback=_history_get_cb,
696 errback=_history_get_eb,
697 )
698
699 def message_encryption_get_cb(self, session_data):
700 if session_data:
701 session_data = data_format.deserialise(session_data)
702 self.message_encryption_started(session_data)
703
704 def message_encryption_get_eb(self, failure_):
705 log.error(_("Can't get encryption state: {reason}").format(reason=failure_))
706
707 def get_encryption_state(self):
708 """Retrieve encryption state with current target.
709
710 Once state is retrieved, default message_encryption_started will be called if
711 suitable
712 """
713 if self.type == C.CHAT_GROUP:
714 return
715 self.host.bridge.message_encryption_get(str(self.target.bare), self.profile,
716 callback=self.message_encryption_get_cb,
717 errback=self.message_encryption_get_eb)
718
719
720 def message_new(self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra,
721 profile):
722 if self._locked:
723 self._cache[uid] = (
724 uid,
725 timestamp,
726 from_jid,
727 to_jid,
728 msg,
729 subject,
730 type_,
731 extra,
732 profile,
733 )
734 return
735
736 if ((not msg and not subject and not extra[C.KEY_ATTACHMENTS]
737 and type_ != C.MESS_TYPE_INFO)):
738 log.warning("Received an empty message for uid {}".format(uid))
739 return
740
741 if self.type == C.CHAT_GROUP:
742 if to_jid.resource and type_ != C.MESS_TYPE_GROUPCHAT:
743 # we have a private message, we forward it to a private conversation
744 # widget
745 chat_widget = self.get_or_create_private_widget(to_jid)
746 chat_widget.message_new(
747 uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile
748 )
749 return
750 if type_ == C.MESS_TYPE_INFO:
751 try:
752 info_type = extra["info_type"]
753 except KeyError:
754 pass
755 else:
756 user_data = {
757 k[5:]: v for k, v in extra.items() if k.startswith("user_")
758 }
759 if info_type == ROOM_USER_JOINED:
760 self.addUser(user_data)
761 elif info_type == ROOM_USER_LEFT:
762 self.removeUser(user_data)
763
764 message = Message(
765 self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile
766 )
767 self.messages[uid] = message
768
769 if "received_timestamp" in extra:
770 log.warning("Delayed message received after history, this should not happen")
771 self.create_message(message)
772
773 def message_encryption_started(self, session_data):
774 self.encrypted = True
775 log.debug(_("message encryption started with {target} using {encryption}").format(
776 target=self.target, encryption=session_data['name']))
777
778 def message_encryption_stopped(self, session_data):
779 self.encrypted = False
780 log.debug(_("message encryption stopped with {target} (was using {encryption})")
781 .format(target=self.target, encryption=session_data['name']))
782
783 def create_message(self, message, append=False):
784 """Must be implemented by frontend to create and show a new message widget
785
786 This is only called on message_new, not on history.
787 You need to override history_print to handle the later
788 @param message(Message): message data
789 """
790 raise NotImplementedError
791
792 def is_user_moved(self, message):
793 """Return True if message is a user left/joined message
794
795 @param message(Message): message to check
796 @return (bool): True is message is user moved info message
797 """
798 if message.type != C.MESS_TYPE_INFO:
799 return False
800 try:
801 info_type = message.extra["info_type"]
802 except KeyError:
803 return False
804 else:
805 return info_type in ROOM_USER_MOVED
806
807 def handle_user_moved(self, message):
808 """Check if this message is a UserMoved one, and merge it when possible
809
810 "merge it" means that info message indicating a user joined/left will be
811 grouped if no other non-info messages has been sent since
812 @param message(Message): message to check
813 @return (bool): True if this message has been merged
814 if True, a new MessageWidget must not be created and appended to history
815 """
816 if self.is_user_moved(message):
817 for wid in self.message_widgets_rev:
818 # we merge in/out messages if no message was sent meanwhile
819 if not isinstance(wid, MessageWidget):
820 continue
821 elif wid.mess_data.type != C.MESS_TYPE_INFO:
822 return False
823 elif (
824 wid.info_type in ROOM_USER_MOVED
825 and wid.mess_data.nick == message.nick
826 ):
827 try:
828 count = wid.reentered_count
829 except AttributeError:
830 count = wid.reentered_count = 1
831 nick = wid.mess_data.nick
832 if message.info_type == ROOM_USER_LEFT:
833 wid.message = _("<= {nick} has left the room ({count})").format(
834 nick=nick, count=count
835 )
836 else:
837 wid.message = _(
838 "<=> {nick} re-entered the room ({count})"
839 ).format(nick=nick, count=count)
840 wid.reentered_count += 1
841 return True
842 return False
843
844 def print_day_change(self, day):
845 """Display the day on a new line.
846
847 @param day(unicode): day to display (or not if this method is not overwritten)
848 """
849 # FIXME: not called anymore after refactoring
850 pass
851
852 ## Room ##
853
854 def set_subject(self, subject):
855 """Set title for a group chat"""
856 if self.type != C.CHAT_GROUP:
857 raise exceptions.InternalError(
858 "trying to set subject for a non group chat window"
859 )
860 self.subject = subject
861
862 def change_subject(self, new_subject):
863 """Change the subject of the room
864
865 This change the subject on the room itself (i.e. via XMPP),
866 while set_subject change the subject of this widget
867 """
868 self.host.bridge.muc_subject(str(self.target), new_subject, self.profile)
869
870 def add_game_panel(self, widget):
871 """Insert a game panel to this Chat dialog.
872
873 @param widget (Widget): the game panel
874 """
875 raise NotImplementedError
876
877 def remove_game_panel(self, widget):
878 """Remove the game panel from this Chat dialog.
879
880 @param widget (Widget): the game panel
881 """
882 raise NotImplementedError
883
884 def update(self, entity=None):
885 """Update one or all entities.
886
887 @param entity (jid.JID): entity to update
888 """
889 # FIXME: to remove ?
890 raise NotImplementedError
891
892 ## events ##
893
894 def on_chat_state(self, from_jid, state, profile):
895 """A chat state has been received"""
896 if self.type == C.CHAT_GROUP:
897 nick = from_jid.resource
898 try:
899 self.occupants[nick].state = state
900 except KeyError:
901 log.warning(
902 "{nick} not found in {room}, ignoring new chat state".format(
903 nick=nick, room=self.target.bare
904 )
905 )
906
907 def on_message_state(self, uid, status, profile):
908 try:
909 mess_data = self.messages[uid]
910 except KeyError:
911 pass
912 else:
913 mess_data.status = status
914
915 def on_avatar(self, entity, avatar_data, profile):
916 if self.type == C.CHAT_GROUP:
917 if entity.bare == self.target:
918 try:
919 self.occupants[entity.resource].update({"avatar": avatar_data})
920 except KeyError:
921 # can happen for a message in history where the
922 # entity is not here anymore
923 pass
924
925 for m in list(self.messages.values()):
926 if m.nick == entity.resource:
927 for w in m.widgets:
928 w.update({"avatar": avatar_data})
929 else:
930 if (
931 entity.bare == self.target.bare
932 or entity.bare == self.host.profiles[profile].whoami.bare
933 ):
934 log.info("avatar updated for {}".format(entity))
935 for m in list(self.messages.values()):
936 if m.from_jid.bare == entity.bare:
937 for w in m.widgets:
938 w.update({"avatar": avatar_data})
939
940
941 quick_widgets.register(QuickChat)