comparison libervia/tui/chat.py @ 4076:b620a8e882e1

refactoring: rename `libervia.frontends.primitivus` to `libervia.tui`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 16:25:25 +0200
parents libervia/frontends/primitivus/chat.py@26b7ed2817da
children
comparison
equal deleted inserted replaced
4075:47401850dec6 4076:b620a8e882e1
1 #!/usr/bin/env python3
2
3
4 # Libervia TUI
5 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 from functools import total_ordering
21 from pathlib import Path
22 import bisect
23 import urwid
24 from urwid_satext import sat_widgets
25 from libervia.backend.core.i18n import _
26 from libervia.backend.core import log as logging
27 from libervia.frontends.quick_frontend import quick_widgets
28 from libervia.frontends.quick_frontend import quick_chat
29 from libervia.frontends.quick_frontend import quick_games
30 from libervia.tui import game_tarot
31 from libervia.tui.constants import Const as C
32 from libervia.tui.keys import action_key_map as a_key
33 from libervia.tui.widget import LiberviaTUIWidget
34 from libervia.tui.contact_list import ContactList
35
36
37 log = logging.getLogger(__name__)
38
39
40 OCCUPANTS_FOOTER = _("{} occupants")
41
42
43 class MessageWidget(urwid.WidgetWrap, quick_chat.MessageWidget):
44 def __init__(self, mess_data):
45 """
46 @param mess_data(quick_chat.Message, None): message data
47 None: used only for non text widgets (e.g.: focus separator)
48 """
49 self.mess_data = mess_data
50 mess_data.widgets.add(self)
51 super(MessageWidget, self).__init__(urwid.Text(self.markup))
52
53 @property
54 def markup(self):
55 return (
56 self._generate_info_markup()
57 if self.mess_data.type == C.MESS_TYPE_INFO
58 else self._generate_markup()
59 )
60
61 @property
62 def info_type(self):
63 return self.mess_data.info_type
64
65 @property
66 def parent(self):
67 return self.mess_data.parent
68
69 @property
70 def message(self):
71 """Return currently displayed message"""
72 return self.mess_data.main_message
73
74 @message.setter
75 def message(self, value):
76 self.mess_data.message = {"": value}
77 self.redraw()
78
79 @property
80 def type(self):
81 try:
82 return self.mess_data.type
83 except AttributeError:
84 return C.MESS_TYPE_INFO
85
86 def redraw(self):
87 self._w.set_text(self.markup)
88 self.mess_data.parent.host.redraw() # FIXME: should not be necessary
89
90 def selectable(self):
91 return True
92
93 def keypress(self, size, key):
94 return key
95
96 def get_cursor_coords(self, size):
97 return 0, 0
98
99 def render(self, size, focus=False):
100 # Text widget doesn't render cursor, but we want one
101 # so we add it here
102 canvas = urwid.CompositeCanvas(self._w.render(size, focus))
103 if focus:
104 canvas.set_cursor(self.get_cursor_coords(size))
105 return canvas
106
107 def _generate_info_markup(self):
108 return ("info_msg", self.message)
109
110 def _generate_markup(self):
111 """Generate text markup according to message data and Widget options"""
112 markup = []
113 d = self.mess_data
114 mention = d.mention
115
116 # message status
117 if d.status is None:
118 markup.append(" ")
119 elif d.status == "delivered":
120 markup.append(("msg_status_received", "✔"))
121 else:
122 log.warning("Unknown status: {}".format(d.status))
123
124 # timestamp
125 if self.parent.show_timestamp:
126 attr = "msg_mention" if mention else "date"
127 markup.append((attr, "[{}]".format(d.time_text)))
128 else:
129 if mention:
130 markup.append(("msg_mention", "[*]"))
131
132 # nickname
133 if self.parent.show_short_nick:
134 markup.append(
135 ("my_nick" if d.own_mess else "other_nick", "**" if d.own_mess else "*")
136 )
137 else:
138 markup.append(
139 ("my_nick" if d.own_mess else "other_nick", "[{}] ".format(d.nick or ""))
140 )
141
142 msg = self.message # needed to generate self.selected_lang
143
144 if d.selected_lang:
145 markup.append(("msg_lang", "[{}] ".format(d.selected_lang)))
146
147 # message body
148 markup.append(msg)
149
150 return markup
151
152 # events
153 def update(self, update_dict=None):
154 """update all the linked message widgets
155
156 @param update_dict(dict, None): key=attribute updated value=new_value
157 """
158 self.redraw()
159
160
161 @total_ordering
162 class OccupantWidget(urwid.WidgetWrap):
163 def __init__(self, occupant_data):
164 self.occupant_data = occupant_data
165 occupant_data.widgets.add(self)
166 markup = self._generate_markup()
167 text = sat_widgets.ClickableText(markup)
168 urwid.connect_signal(
169 text,
170 "click",
171 self.occupant_data.parent._occupants_clicked,
172 user_args=[self.occupant_data],
173 )
174 super(OccupantWidget, self).__init__(text)
175
176 def __hash__(self):
177 return id(self)
178
179 def __eq__(self, other):
180 if other is None:
181 return False
182 return self.occupant_data.nick == other.occupant_data.nick
183
184 def __lt__(self, other):
185 return self.occupant_data.nick.lower() < other.occupant_data.nick.lower()
186
187 @property
188 def markup(self):
189 return self._generate_markup()
190
191 @property
192 def parent(self):
193 return self.mess_data.parent
194
195 @property
196 def nick(self):
197 return self.occupant_data.nick
198
199 def redraw(self):
200 self._w.set_text(self.markup)
201 self.occupant_data.parent.host.redraw() # FIXME: should not be necessary
202
203 def selectable(self):
204 return True
205
206 def keypress(self, size, key):
207 return key
208
209 def get_cursor_coords(self, size):
210 return 0, 0
211
212 def render(self, size, focus=False):
213 # Text widget doesn't render cursor, but we want one
214 # so we add it here
215 canvas = urwid.CompositeCanvas(self._w.render(size, focus))
216 if focus:
217 canvas.set_cursor(self.get_cursor_coords(size))
218 return canvas
219
220 def _generate_markup(self):
221 # TODO: role and affiliation are shown in a Q&D way
222 # should be more intuitive and themable
223 o = self.occupant_data
224 markup = []
225 markup.append(
226 ("info_msg", "{}{} ".format(o.role[0].upper(), o.affiliation[0].upper()))
227 )
228 markup.append(o.nick)
229 if o.state is not None:
230 markup.append(" {}".format(C.CHAT_STATE_ICON[o.state]))
231 return markup
232
233 # events
234 def update(self, update_dict=None):
235 self.redraw()
236
237
238 class OccupantsWidget(urwid.WidgetWrap):
239 def __init__(self, parent):
240 self.parent = parent
241 self.occupants_walker = urwid.SimpleListWalker([])
242 self.occupants_footer = urwid.Text("", align="center")
243 self.update_footer()
244 occupants_widget = urwid.Frame(
245 urwid.ListBox(self.occupants_walker), footer=self.occupants_footer
246 )
247 super(OccupantsWidget, self).__init__(occupants_widget)
248 occupants_list = sorted(list(self.parent.occupants.keys()), key=lambda o: o.lower())
249 for occupant in occupants_list:
250 occupant_data = self.parent.occupants[occupant]
251 self.occupants_walker.append(OccupantWidget(occupant_data))
252
253 def clear(self):
254 del self.occupants_walker[:]
255
256 def update_footer(self):
257 """update footer widget"""
258 txt = OCCUPANTS_FOOTER.format(len(self.parent.occupants))
259 self.occupants_footer.set_text(txt)
260
261 def get_nicks(self, start=""):
262 """Return nicks of all occupants
263
264 @param start(unicode): only return nicknames which start with this text
265 """
266 return [
267 w.nick
268 for w in self.occupants_walker
269 if isinstance(w, OccupantWidget) and w.nick.startswith(start)
270 ]
271
272 def addUser(self, occupant_data):
273 """add a user to the list"""
274 bisect.insort(self.occupants_walker, OccupantWidget(occupant_data))
275 self.update_footer()
276 self.parent.host.redraw() # FIXME: should not be necessary
277
278 def removeUser(self, occupant_data):
279 """remove a user from the list"""
280 for widget in occupant_data.widgets:
281 self.occupants_walker.remove(widget)
282 self.update_footer()
283 self.parent.host.redraw() # FIXME: should not be necessary
284
285
286 class Chat(LiberviaTUIWidget, quick_chat.QuickChat):
287 def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None,
288 subject=None, statuses=None, profiles=None):
289 self.filters = [] # list of filter callbacks to apply
290 self.mess_walker = urwid.SimpleListWalker([])
291 self.mess_widgets = urwid.ListBox(self.mess_walker)
292 self.chat_widget = urwid.Frame(self.mess_widgets)
293 self.chat_colums = urwid.Columns([("weight", 8, self.chat_widget)])
294 self.pile = urwid.Pile([self.chat_colums])
295 LiberviaTUIWidget.__init__(self, self.pile, target)
296 quick_chat.QuickChat.__init__(
297 self, host, target, type_, nick, occupants, subject, statuses,
298 profiles=profiles
299 )
300
301 # we must adapt the behaviour with the type
302 if type_ == C.CHAT_GROUP:
303 if len(self.chat_colums.contents) == 1:
304 self.occupants_widget = OccupantsWidget(self)
305 self.occupants_panel = sat_widgets.VerticalSeparator(
306 self.occupants_widget
307 )
308 self._append_occupants_panel()
309 self.host.addListener("presence", self.presence_listener, [profiles])
310
311 # focus marker is a separator indicated last visible message before focus was lost
312 self.focus_marker = None # link to current marker
313 self.focus_marker_set = None # True if a new marker has been inserted
314 self.show_timestamp = True
315 self.show_short_nick = False
316 self.show_title = 1 # 0: clip title; 1: full title; 2: no title
317 self.post_init()
318
319 @property
320 def message_widgets_rev(self):
321 return reversed(self.mess_walker)
322
323 def keypress(self, size, key):
324 if key == a_key["OCCUPANTS_HIDE"]: # user wants to (un)hide the occupants panel
325 if self.type == C.CHAT_GROUP:
326 widgets = [widget for (widget, options) in self.chat_colums.contents]
327 if self.occupants_panel in widgets:
328 self._remove_occupants_panel()
329 else:
330 self._append_occupants_panel()
331 elif key == a_key["TIMESTAMP_HIDE"]: # user wants to (un)hide timestamp
332 self.show_timestamp = not self.show_timestamp
333 self.redraw()
334 elif key == a_key["SHORT_NICKNAME"]: # user wants to (not) use short nick
335 self.show_short_nick = not self.show_short_nick
336 self.redraw()
337 elif (key == a_key["SUBJECT_SWITCH"]):
338 # user wants to (un)hide group's subject or change its apperance
339 if self.subject:
340 self.show_title = (self.show_title + 1) % 3
341 if self.show_title == 0:
342 self.set_subject(self.subject, "clip")
343 elif self.show_title == 1:
344 self.set_subject(self.subject, "space")
345 elif self.show_title == 2:
346 self.chat_widget.header = None
347 self._invalidate()
348 elif key == a_key["GOTO_BOTTOM"]: # user wants to focus last message
349 self.mess_widgets.focus_position = len(self.mess_walker) - 1
350
351 return super(Chat, self).keypress(size, key)
352
353 def completion(self, text, completion_data):
354 """Completion method which complete nicknames in group chat
355
356 for params, see [sat_widgets.AdvancedEdit]
357 """
358 if self.type != C.CHAT_GROUP:
359 return text
360
361 space = text.rfind(" ")
362 start = text[space + 1 :]
363 words = self.occupants_widget.get_nicks(start)
364 if not words:
365 return text
366 try:
367 word_idx = words.index(completion_data["last_word"]) + 1
368 except (KeyError, ValueError):
369 word_idx = 0
370 else:
371 if word_idx == len(words):
372 word_idx = 0
373 word = completion_data["last_word"] = words[word_idx]
374 return "{}{}{}".format(text[: space + 1], word, ": " if space < 0 else "")
375
376 def get_menu(self):
377 """Return Menu bar"""
378 menu = sat_widgets.Menu(self.host.loop)
379 if self.type == C.CHAT_GROUP:
380 self.host.add_menus(menu, C.MENU_ROOM, {"room_jid": self.target.bare})
381 game = _("Game")
382 menu.add_menu(game, "Tarot", self.on_tarot_request)
383 elif self.type == C.CHAT_ONE2ONE:
384 # FIXME: self.target is a bare jid, we need to check that
385 contact_list = self.host.contact_lists[self.profile]
386 if not self.target.resource:
387 full_jid = contact_list.get_full_jid(self.target)
388 else:
389 full_jid = self.target
390 self.host.add_menus(menu, C.MENU_SINGLE, {"jid": full_jid})
391 return menu
392
393 def set_filter(self, args):
394 """set filtering of messages
395
396 @param args(list[unicode]): filters following syntax "[filter]=[value]"
397 empty list to clear all filters
398 only lang=XX is handled for now
399 """
400 del self.filters[:]
401 if args:
402 if args[0].startswith("lang="):
403 lang = args[0][5:].strip()
404 self.filters.append(lambda mess_data: lang in mess_data.message)
405
406 self.print_messages()
407
408 def presence_listener(self, entity, show, priority, statuses, profile):
409 """Update entity's presence status
410
411 @param entity (jid.JID): entity updated
412 @param show: availability
413 @param priority: resource's priority
414 @param statuses: dict of statuses
415 @param profile: %(doc_profile)s
416 """
417 # FIXME: disable for refactoring, need to be checked and re-enabled
418 return
419 # assert self.type == C.CHAT_GROUP
420 # if entity.bare != self.target:
421 # return
422 # self.update(entity)
423
424 def create_message(self, message):
425 self.appendMessage(message)
426
427 def _scrollDown(self):
428 """scroll down message only if we are already at the bottom (minus 1)"""
429 current_focus = self.mess_widgets.focus_position
430 bottom = len(self.mess_walker) - 1
431 if current_focus == bottom - 1:
432 self.mess_widgets.focus_position = bottom # scroll down
433 self.host.redraw() # FIXME: should not be necessary
434
435 def appendMessage(self, message, minor_notifs=True):
436 """Create a MessageWidget and append it
437
438 Can merge info messages together if desirable (e.g.: multiple joined/leave)
439 @param message(quick_chat.Message): message to add
440 @param minor_notifs(boolean): if True, basic notifications are allowed
441 If False, notification are not shown except if we have an important one
442 (like a mention).
443 False is generally used when printing history, when we don't want every
444 message to be notified.
445 """
446 if message.attachments:
447 # FIXME: Q&D way to see attachments in LiberviaTUI
448 # it should be done in a more user friendly way
449 for lang, body in message.message.items():
450 for attachment in message.attachments:
451 if 'url' in attachment:
452 body+=f"\n{attachment['url']}"
453 elif 'path' in attachment:
454 path = Path(attachment['path'])
455 body+=f"\n{path.as_uri()}"
456 else:
457 log.warning(f'No "url" nor "path" in attachment: {attachment}')
458 message.message[lang] = body
459
460 if self.filters:
461 if not all([f(message) for f in self.filters]):
462 return
463
464 if self.handle_user_moved(message):
465 return
466
467 if ((self.host.selected_widget != self or not self.host.x_notify.has_focus())
468 and self.focus_marker_set is not None):
469 if not self.focus_marker_set and not self._locked and self.mess_walker:
470 if self.focus_marker is not None:
471 try:
472 self.mess_walker.remove(self.focus_marker)
473 except ValueError:
474 # self.focus_marker may not be in mess_walker anymore if
475 # mess_walker has been cleared, e.g. when showing search
476 # result or using :history command
477 pass
478 self.focus_marker = urwid.Divider("—")
479 self.mess_walker.append(self.focus_marker)
480 self.focus_marker_set = True
481 self._scrollDown()
482 else:
483 if self.focus_marker_set:
484 self.focus_marker_set = False
485
486 wid = MessageWidget(message)
487 self.mess_walker.append(wid)
488 self._scrollDown()
489 if self.is_user_moved(message):
490 return # no notification for moved messages
491
492 # notifications
493
494 if self._locked:
495 # we don't want notifications when locked
496 # because that's history messages
497 return
498
499 if wid.mess_data.mention:
500 from_jid = wid.mess_data.from_jid
501 msg = _(
502 "You have been mentioned by {nick} in {room}".format(
503 nick=wid.mess_data.nick, room=self.target
504 )
505 )
506 self.host.notify(
507 C.NOTIFY_MENTION, from_jid, msg, widget=self, profile=self.profile
508 )
509 elif not minor_notifs:
510 return
511 elif self.type == C.CHAT_ONE2ONE:
512 from_jid = wid.mess_data.from_jid
513 msg = _("{entity} is talking to you".format(entity=from_jid))
514 self.host.notify(
515 C.NOTIFY_MESSAGE, from_jid, msg, widget=self, profile=self.profile
516 )
517 else:
518 self.host.notify(
519 C.NOTIFY_MESSAGE, self.target, widget=self, profile=self.profile
520 )
521
522 def addUser(self, nick):
523 occupant = super(Chat, self).addUser(nick)
524 self.occupants_widget.addUser(occupant)
525
526 def removeUser(self, occupant_data):
527 occupant = super(Chat, self).removeUser(occupant_data)
528 if occupant is not None:
529 self.occupants_widget.removeUser(occupant)
530
531 def occupants_clear(self):
532 super(Chat, self).occupants_clear()
533 self.occupants_widget.clear()
534
535 def _occupants_clicked(self, occupant, clicked_wid):
536 assert self.type == C.CHAT_GROUP
537 contact_list = self.host.contact_lists[self.profile]
538
539 # we have a click on a nick, we need to create the widget if it doesn't exists
540 self.get_or_create_private_widget(occupant.jid)
541
542 # now we select the new window
543 for contact_list in self.host.widgets.get_widgets(
544 ContactList, profiles=(self.profile,)
545 ):
546 contact_list.set_focus(occupant.jid, True)
547
548 def _append_occupants_panel(self):
549 self.chat_colums.contents.append((self.occupants_panel, ("weight", 2, False)))
550
551 def _remove_occupants_panel(self):
552 for widget, options in self.chat_colums.contents:
553 if widget is self.occupants_panel:
554 self.chat_colums.contents.remove((widget, options))
555 break
556
557 def add_game_panel(self, widget):
558 """Insert a game panel to this Chat dialog.
559
560 @param widget (Widget): the game panel
561 """
562 assert len(self.pile.contents) == 1
563 self.pile.contents.insert(0, (widget, ("weight", 1)))
564 self.pile.contents.insert(1, (urwid.Filler(urwid.Divider("-"), ("fixed", 1))))
565 self.host.redraw()
566
567 def remove_game_panel(self, widget):
568 """Remove the game panel from this Chat dialog.
569
570 @param widget (Widget): the game panel
571 """
572 assert len(self.pile.contents) == 3
573 del self.pile.contents[0]
574 self.host.redraw()
575
576 def set_subject(self, subject, wrap="space"):
577 """Set title for a group chat"""
578 quick_chat.QuickChat.set_subject(self, subject)
579 self.subj_wid = urwid.Text(
580 str(subject.replace("\n", "|") if wrap == "clip" else subject),
581 align="left" if wrap == "clip" else "center",
582 wrap=wrap,
583 )
584 self.chat_widget.header = urwid.AttrMap(self.subj_wid, "title")
585 self.host.redraw()
586
587 ## Messages
588
589 def print_messages(self, clear=True):
590 """generate message widgets
591
592 @param clear(bool): clear message before printing if true
593 """
594 if clear:
595 del self.mess_walker[:]
596 for message in self.messages.values():
597 self.appendMessage(message, minor_notifs=False)
598
599 def redraw(self):
600 """redraw all messages"""
601 for w in self.mess_walker:
602 try:
603 w.redraw()
604 except AttributeError:
605 pass
606
607 def update_history(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, profile="@NONE@"):
608 del self.mess_walker[:]
609 if filters and "search" in filters:
610 self.mess_walker.append(
611 urwid.Text(
612 _("Results for searching the globbing pattern: {}").format(
613 filters["search"]
614 )
615 )
616 )
617 self.mess_walker.append(
618 urwid.Text(_("Type ':history <lines>' to reset the chat history"))
619 )
620 super(Chat, self).update_history(size, filters, profile)
621
622 def _on_history_printed(self):
623 """Refresh or scroll down the focus after the history is printed"""
624 self.print_messages(clear=False)
625 super(Chat, self)._on_history_printed()
626
627 def on_private_created(self, widget):
628 self.host.contact_lists[widget.profile].set_special(
629 widget.target, C.CONTACT_SPECIAL_GROUP
630 )
631
632 def on_selected(self):
633 self.focus_marker_set = False
634
635 def notify(self, contact="somebody", msg=""):
636 """Notify the user of a new message if Libervia TUI doesn't have the focus.
637
638 @param contact (unicode): contact who wrote to the users
639 @param msg (unicode): the message that has been received
640 """
641 # FIXME: not called anymore after refactoring
642 if msg == "":
643 return
644 if self.mess_widgets.get_focus()[1] == len(self.mess_walker) - 2:
645 # we don't change focus if user is not at the bottom
646 # as that mean that he is probably watching discussion history
647 self.mess_widgets.focus_position = len(self.mess_walker) - 1
648 self.host.redraw()
649 if not self.host.x_notify.has_focus():
650 if self.type == C.CHAT_ONE2ONE:
651 self.host.x_notify.send_notification(
652 _("LiberviaTUI: %s is talking to you") % contact
653 )
654 elif self.nick is not None and self.nick.lower() in msg.lower():
655 self.host.x_notify.send_notification(
656 _("LiberviaTUI: %(user)s mentioned you in room '%(room)s'")
657 % {"user": contact, "room": self.target}
658 )
659
660 # MENU EVENTS #
661 def on_tarot_request(self, menu):
662 # TODO: move this to plugin_misc_tarot with dynamic menu
663 if len(self.occupants) != 4:
664 self.host.show_pop_up(
665 sat_widgets.Alert(
666 _("Can't start game"),
667 _(
668 "You need to be exactly 4 peoples in the room to start a Tarot game"
669 ),
670 ok_cb=self.host.remove_pop_up,
671 )
672 )
673 else:
674 self.host.bridge.tarot_game_create(
675 self.target, list(self.occupants), self.profile
676 )
677
678 # MISC EVENTS #
679
680 def on_delete(self):
681 # FIXME: to be checked after refactoring
682 super(Chat, self).on_delete()
683 if self.type == C.CHAT_GROUP:
684 self.host.removeListener("presence", self.presence_listener)
685
686 def on_chat_state(self, from_jid, state, profile):
687 super(Chat, self).on_chat_state(from_jid, state, profile)
688 if self.type == C.CHAT_ONE2ONE:
689 self.title_dynamic = C.CHAT_STATE_ICON[state]
690 self.host.redraw() # FIXME: should not be necessary
691
692 def _on_subject_dialog_cb(self, button, dialog):
693 self.change_subject(dialog.text)
694 self.host.remove_pop_up(dialog)
695
696 def on_subject_dialog(self, new_subject=None):
697 dialog = sat_widgets.InputDialog(
698 _("Change title"),
699 _("Enter the new title"),
700 default_txt=new_subject if new_subject is not None else self.subject,
701 )
702 dialog.set_callback("ok", self._on_subject_dialog_cb, dialog)
703 dialog.set_callback("cancel", lambda __: self.host.remove_pop_up(dialog))
704 self.host.show_pop_up(dialog)
705
706
707 quick_widgets.register(quick_chat.QuickChat, Chat)
708 quick_widgets.register(quick_games.Tarot, game_tarot.TarotGame)