Mercurial > libervia-web
comparison libervia/web/pages/chat/_browser/__init__.py @ 1635:332822ceae85
browser (chat): Add rich editor, forward and extra recipients:
A new "extra" menu is now available next to input field, allowing to toggle to rich
editor. Rich editors allows message styling using bold, italic, underline, (un)numbered
list and link. Other features will probably follow with time.
An extra menu item allows to add recipients, with `to`, `cc` or `bcc` flag like for
emails.
Messages can now be forwarded to any entity with a new item in the 3 dots menu.
rel 461
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 04 Jul 2025 17:47:37 +0200 |
parents | 8400d3b58515 |
children |
comparison
equal
deleted
inserted
replaced
1634:6c6ab1a96b34 | 1635:332822ceae85 |
---|---|
2 import re | 2 import re |
3 from typing import Callable | 3 from typing import Callable |
4 import errors | 4 import errors |
5 | 5 |
6 from bridge import AsyncBridge as Bridge | 6 from bridge import AsyncBridge as Bridge |
7 from browser import DOMNode, aio, console as log, document, window | 7 from browser import DOMNode, aio, console as log, document, html, window |
8 from cache import cache, identities, roster | 8 from cache import cache, identities, roster |
9 import dialog | 9 import dialog |
10 from file_uploader import FileUploader | 10 from file_uploader import FileUploader |
11 import jid | 11 import jid |
12 from javascript import pyobj2jsobj | 12 from javascript import pyobj2jsobj, NULL |
13 from js_modules import emoji_picker_element | 13 from js_modules import emoji_picker_element |
14 from js_modules.tippy_js import tippy as tippy_ori | 14 from js_modules.tippy_js import tippy as tippy_ori |
15 from js_modules.quill import Quill | |
15 import popup | 16 import popup |
16 from template import Template, safe | 17 from template import Template, safe |
17 from tools import is_touch_device, remove_ids, make_placeholder | 18 from tools import is_touch_device, remove_ids, make_placeholder |
18 from loading import remove_loading_screen | 19 from loading import remove_loading_screen |
19 from jid_search import JidSearch | 20 from jid_search import JidSearch |
252 | 253 |
253 | 254 |
254 class LiberviaWebChat: | 255 class LiberviaWebChat: |
255 def __init__(self): | 256 def __init__(self): |
256 self._input_mode = "normal" | 257 self._input_mode = "normal" |
258 self._rich_edit = False | |
257 self.input_data = {} | 259 self.input_data = {} |
258 self.direct_messages_tpl = Template("chat/direct_messages.html") | 260 self.direct_messages_tpl = Template("chat/direct_messages.html") |
259 self.message_tpl = Template("chat/message.html") | 261 self.message_tpl = Template("chat/message.html") |
260 self.extra_menu_tpl = Template("chat/extra_menu.html") | 262 self.extra_menu_tpl = Template("chat/extra_menu.html") |
261 self.reactions_tpl = Template("components/reactions.html") | 263 self.reactions_tpl = Template("components/reactions.html") |
263 self.url_preview_control_tpl = Template("components/url_preview_control.html") | 265 self.url_preview_control_tpl = Template("components/url_preview_control.html") |
264 self.url_preview_tpl = Template("components/url_preview.html") | 266 self.url_preview_tpl = Template("components/url_preview.html") |
265 self.new_messages_marker_elt = Template("chat/new_messages_marker.html").get_elt() | 267 self.new_messages_marker_elt = Template("chat/new_messages_marker.html").get_elt() |
266 self.editions_tpl = Template("chat/editions.html") | 268 self.editions_tpl = Template("chat/editions.html") |
267 self.occupant_item_tpl = Template("chat/occupant_item.html") | 269 self.occupant_item_tpl = Template("chat/occupant_item.html") |
270 self.input_extra_menu_tpl = Template("chat/input_extra_menu.html") | |
271 self.extra_recipient_field_tpl = Template("chat/extra_recipient_field.html") | |
268 | 272 |
269 # panels and their toggle buttons | 273 # panels and their toggle buttons |
270 | 274 |
271 self.left_panel = document["left_panel"] | 275 self.left_panel = document["left_panel"] |
272 self.left_toggle = document["left_panel-toggle"] | 276 self.left_toggle = document["left_panel-toggle"] |
320 self.add_message_event_listeners() | 324 self.add_message_event_listeners() |
321 self.handle_url_previews() | 325 self.handle_url_previews() |
322 self.add_reactions_listeners() | 326 self.add_reactions_listeners() |
323 | 327 |
324 # input | 328 # input |
329 document["input-extra-button"].bind("click", self.on_input_extra_btn) | |
330 self.rich_editor = Quill.new("#message_input_area_rich") | |
331 for rich_btn_elt in document.select(".rich-editor-btn"): | |
332 rich_btn_elt.bind("click", self.on_rich_action) | |
325 self.auto_resize_message_input() | 333 self.auto_resize_message_input() |
326 self.message_input.focus() | 334 self.message_input.focus() |
327 | 335 |
328 # direct messages | 336 # direct messages |
329 direct_messages_elt = self.direct_messages_tpl.get_elt( | 337 direct_messages_elt = self.direct_messages_tpl.get_elt( |
375 def input_mode(self) -> str: | 383 def input_mode(self) -> str: |
376 return self._input_mode | 384 return self._input_mode |
377 | 385 |
378 @input_mode.setter | 386 @input_mode.setter |
379 def input_mode(self, new_mode: str) -> None: | 387 def input_mode(self, new_mode: str) -> None: |
380 if new_mode == self.input_mode: | 388 if new_mode == self._input_mode: |
381 return | 389 return |
382 if new_mode not in INPUT_MODES: | 390 if new_mode not in INPUT_MODES: |
383 raise ValueError(f"Invalid input mode: {new_mode!r}") | 391 raise ValueError(f"Invalid input mode: {new_mode!r}") |
384 target_elt = self.message_input | 392 target_elt = self.message_input |
385 target_elt.classList.remove(MODE_CLASS.format(self._input_mode)) | 393 target_elt.classList.remove(MODE_CLASS.format(self._input_mode)) |
386 self._input_mode = new_mode | 394 self._input_mode = new_mode |
387 target_elt.classList.add(MODE_CLASS.format(new_mode)) | 395 target_elt.classList.add(MODE_CLASS.format(new_mode)) |
388 self.input_data.clear() | 396 self.input_data.clear() |
397 | |
398 @property | |
399 def rich_edit(self) -> bool: | |
400 return self._rich_edit | |
401 | |
402 @rich_edit.setter | |
403 def rich_edit(self, rich_edit_activated: bool) -> None: | |
404 if rich_edit_activated == self._rich_edit: | |
405 return | |
406 self._rich_edit = rich_edit_activated | |
407 if rich_edit_activated: | |
408 document["rich-edit-toolbar"].classList.remove("is-hidden") | |
409 document["message_input_area"].classList.add("is-hidden") | |
410 document["message_input_area_rich"].classList.remove("is-hidden") | |
411 self.rich_editor.setText(document["message_input_area"].value) | |
412 else: | |
413 document["rich-edit-toolbar"].classList.add("is-hidden") | |
414 document["message_input_area_rich"].classList.add("is-hidden") | |
415 document["message_input_area"].classList.remove("is-hidden") | |
416 document["message_input_area"].value = self.rich_editor.getText().strip() | |
389 | 417 |
390 @property | 418 @property |
391 def is_at_bottom(self): | 419 def is_at_bottom(self): |
392 return self.is_elt_at_bottom(self.messages_elt) | 420 return self.is_elt_at_bottom(self.messages_elt) |
393 | 421 |
464 async def send_message(self): | 492 async def send_message(self): |
465 """Send message currently in input area | 493 """Send message currently in input area |
466 | 494 |
467 The message and corresponding attachment will be sent | 495 The message and corresponding attachment will be sent |
468 """ | 496 """ |
469 message = self.message_input.value.rstrip() | 497 extra = {} |
470 log.info(f"{message=}") | 498 if not self.rich_edit: |
499 message = self.message_input.value.rstrip() | |
500 else: | |
501 message = self.rich_editor.getText().strip() | |
502 if self.has_rich_formatting(): | |
503 extra["xhtml"] = self.rich_editor.getSemanticHTML() | |
471 | 504 |
472 # attachments | 505 # attachments |
473 attachments = [] | 506 attachments = [] |
474 for attachment_elt in self.attachments_elt.children: | 507 for attachment_elt in self.attachments_elt.children: |
475 file_data = json.loads(attachment_elt.getAttribute("data-file")) | 508 file_data = json.loads(attachment_elt.getAttribute("data-file")) |
476 attachments.append(file_data) | 509 attachments.append(file_data) |
477 | 510 |
478 if message or attachments: | 511 if message or attachments: |
479 extra = {} | |
480 | 512 |
481 if attachments: | 513 if attachments: |
482 extra["attachments"] = attachments | 514 extra["attachments"] = attachments |
483 | 515 |
484 if self.reply_to: | 516 if self.reply_to: |
487 if self.thread_id: | 519 if self.thread_id: |
488 extra["thread"] = self.thread_id | 520 extra["thread"] = self.thread_id |
489 | 521 |
490 if self.keyword: | 522 if self.keyword: |
491 extra.setdefault("keywords", []).append(self.keyword) | 523 extra.setdefault("keywords", []).append(self.keyword) |
524 | |
525 input_panel = document["input-panel"] | |
526 recipient_field_elts = list(input_panel.select("div.recipient-field")) | |
527 if recipient_field_elts: | |
528 addresses = {} | |
529 for recipient_field_elt in recipient_field_elts: | |
530 input_elt = recipient_field_elt.select_one("input") | |
531 assert input_elt is not None | |
532 recipient_jid = input_elt.value.strip() | |
533 if not recipient_jid: | |
534 continue | |
535 select_elt = recipient_field_elt.select_one("select") | |
536 assert select_elt is not None | |
537 recipient_type = select_elt.value | |
538 if recipient_type not in ("to", "cc", "bcc"): | |
539 dialog.notification.show( | |
540 f"Unexpected recipient type: {recipient_type:r}.", | |
541 "error" | |
542 ) | |
543 recipient_type = "to" | |
544 addresses.setdefault(recipient_type, []).append({"jid": recipient_jid}) | |
545 | |
546 if addresses: | |
547 extra["addresses"] = addresses | |
492 | 548 |
493 # now we send the message | 549 # now we send the message |
494 try: | 550 try: |
495 if self.input_mode == "edit": | 551 if self.input_mode == "edit": |
496 message_id = self.input_data["id"] | 552 message_id = self.input_data["id"] |
508 ) | 564 ) |
509 except Exception as e: | 565 except Exception as e: |
510 dialog.notification.show(f"Can't send message: {e}", "error") | 566 dialog.notification.show(f"Can't send message: {e}", "error") |
511 else: | 567 else: |
512 self.message_input.value = "" | 568 self.message_input.value = "" |
569 self.rich_editor.setText("") | |
513 # We must not reset self.thread_id here, it is when the thread panel is | 570 # We must not reset self.thread_id here, it is when the thread panel is |
514 # closed. | 571 # closed. |
515 # FIXME: Another mechanism must be used if the input panel is cloned at | 572 # FIXME: Another mechanism must be used if the input panel is cloned at |
516 # some point, with a slide-in panel for thread, as thread_id would then | 573 # some point, with a slide-in panel for thread, as thread_id would then |
517 # be used for the thread panel's message input, but not if the main area | 574 # be used for the thread panel's message input, but not if the main area |
518 # message input is used. | 575 # message input is used. |
519 self.reply_to = None | 576 self.reply_to = None |
520 self.attachments_elt.clear() | 577 self.attachments_elt.clear() |
578 if recipient_field_elts: | |
579 for recipient_field_elt in recipient_field_elts: | |
580 recipient_field_elt.remove() | |
521 self.auto_resize_message_input() | 581 self.auto_resize_message_input() |
522 self.input_mode = "normal" | 582 self.input_mode = "normal" |
583 | |
584 def has_rich_formatting(self): | |
585 """Indicates if there is formatting in the Rich Editor. | |
586 | |
587 It works by looking for attributes inserted in Quill contents. | |
588 """ | |
589 delta = self.rich_editor.getContents() | |
590 | |
591 ops = getattr(delta, 'ops', []) | |
592 | |
593 for op in ops: | |
594 op = dict(op) | |
595 if "insert" in op and op.get("attributes"): | |
596 return True | |
597 return False | |
598 | |
599 def on_rich_action(self, evt): | |
600 btn_elt = evt.currentTarget | |
601 action = btn_elt.dataset["action"] | |
602 text_range = self.rich_editor.getSelection() | |
603 if not text_range: | |
604 return | |
605 format_data = dict(self.rich_editor.getFormat(text_range.index, 1)) | |
606 | |
607 try: | |
608 match action: | |
609 case "bold": | |
610 is_bold = format_data.get("bold", False) | |
611 self.rich_editor.format("bold", not is_bold) | |
612 case "italic": | |
613 is_italic = format_data.get("italic", False) | |
614 self.rich_editor.format("italic", not is_italic) | |
615 case "underline": | |
616 is_underline = format_data.get("underline", False) | |
617 self.rich_editor.format("underline", not is_underline) | |
618 case list_type if list_type.startswith("list-"): | |
619 list_type = list_type[5:] | |
620 current_list = format_data.get("list") | |
621 if current_list == list_type: | |
622 self.rich_editor.format("list", False) | |
623 else: | |
624 self.rich_editor.format("list", list_type) | |
625 case "link": | |
626 current_url = format_data.get("link") | |
627 if current_url: | |
628 self.rich_editor.format("link", False) | |
629 else: | |
630 url = window.prompt("Enter URL:", "https://") | |
631 if url: | |
632 self.rich_editor.format("link", url) | |
633 case _: | |
634 dialog.notification.show(f"Unknown action {action!r}", "error") | |
635 except Exception as e: | |
636 dialog.notification.show(f"Can't apply action: {e}", "error") | |
637 raise e | |
523 | 638 |
524 def _on_message_new( | 639 def _on_message_new( |
525 self, | 640 self, |
526 uid: str, | 641 uid: str, |
527 timestamp: float, | 642 timestamp: float, |
625 ) -> dict: | 740 ) -> dict: |
626 """Generate template data to use with [message_tpl] | 741 """Generate template data to use with [message_tpl] |
627 | 742 |
628 @return: template data | 743 @return: template data |
629 """ | 744 """ |
630 xhtml_data = extra.get("xhtml") | 745 # FIXME: We do this deconstruction because of the historical way XEP-0071 build |
746 # the `xhtml` keys (i.e., `xhtml_<lang>`, because `extra` was a string to string | |
747 # mapping). This will be refactored when we'll fully move messages to Pydantic | |
748 # models, then a proper mapping from language to data will be used. | |
749 xhtml_data = { | |
750 key.partition("_")[2]: xhtml | |
751 for key, xhtml in extra.items() | |
752 if key .startswith("xhtml") | |
753 } | |
631 if not xhtml_data: | 754 if not xhtml_data: |
632 xhtml = None | 755 xhtml = None |
633 else: | 756 else: |
634 try: | 757 try: |
635 xhtml = xhtml_data[""] | 758 xhtml = xhtml_data[""] |
670 value = extra.get(key) | 793 value = extra.get(key) |
671 if value is not None: | 794 if value is not None: |
672 msg_data[key] = value | 795 msg_data[key] = value |
673 | 796 |
674 if xhtml: | 797 if xhtml: |
798 # XHTML is guaranteed to be safe by the backend. | |
675 msg_data["html"] = safe(xhtml) | 799 msg_data["html"] = safe(xhtml) |
676 | 800 |
677 return { | 801 return { |
678 "own_local_jid": str(own_local_jid), | 802 "own_local_jid": str(own_local_jid), |
679 "chat_type": chat_type, | 803 "chat_type": chat_type, |
823 log.info("file selected") | 947 log.info("file selected") |
824 files = evt.currentTarget.files | 948 files = evt.currentTarget.files |
825 self.file_uploader.upload_files(files, self.attachments_elt) | 949 self.file_uploader.upload_files(files, self.attachments_elt) |
826 self.message_input.focus() | 950 self.message_input.focus() |
827 | 951 |
952 def on_input_extra_btn(self, evt) -> None: | |
953 evt.stopPropagation() | |
954 input_extra_menu_elt = self.input_extra_menu_tpl.get_elt({ | |
955 "rich_edit": self.rich_edit, | |
956 }) | |
957 input_extra_popup = popup.create_popup( | |
958 evt.currentTarget, input_extra_menu_elt, placement="top" | |
959 ) | |
960 def on_action_click(evt, callback): | |
961 input_extra_popup.hide() | |
962 aio.run( | |
963 callback(evt) | |
964 ) | |
965 | |
966 for cls_name, callback in ( | |
967 ("action_toggle_rich_editor", self.on_action_rich_editor), | |
968 ("action_toggle_extra_recipients", self.on_action_extra_recipients), | |
969 ): | |
970 for elt in input_extra_menu_elt.select(f".{cls_name}"): | |
971 elt.bind("click", lambda evt, callback=callback: on_action_click( | |
972 evt, callback | |
973 )) | |
974 | |
828 def on_attachment_delete(self, evt): | 975 def on_attachment_delete(self, evt): |
829 evt.stopPropagation() | 976 evt.stopPropagation() |
830 target = evt.currentTarget | 977 target = evt.currentTarget |
831 item_elt = DOMNode(target.closest(".attachment-preview")) | 978 item_elt = DOMNode(target.closest(".attachment-preview")) |
832 item_elt.remove() | 979 item_elt.remove() |
868 | 1015 |
869 for cls_name, callback in ( | 1016 for cls_name, callback in ( |
870 ("action_quote", self.on_action_quote), | 1017 ("action_quote", self.on_action_quote), |
871 ("action_edit", self.on_action_edit), | 1018 ("action_edit", self.on_action_edit), |
872 ("action_retract", self.on_action_retract), | 1019 ("action_retract", self.on_action_retract), |
1020 ("action_forward", self.on_action_forward), | |
873 ): | 1021 ): |
874 for elt in content_elt.select(f".{cls_name}"): | 1022 for elt in content_elt.select(f".{cls_name}"): |
875 elt.bind("click", lambda evt, callback=callback: on_action_click( | 1023 elt.bind("click", lambda evt, callback=callback: on_action_click( |
876 evt, callback | 1024 evt, callback |
877 )) | 1025 )) |
1048 )).ashow() | 1196 )).ashow() |
1049 if confirmed: | 1197 if confirmed: |
1050 await bridge.message_retract(message_elt["id"]) | 1198 await bridge.message_retract(message_elt["id"]) |
1051 else: | 1199 else: |
1052 log.info(f"Retraction of message {message_elt['id']} cancelled by user.") | 1200 log.info(f"Retraction of message {message_elt['id']} cancelled by user.") |
1201 | |
1202 async def on_action_forward(self, __, message_elt) -> None: | |
1203 # TODO: Use a real dialog here. | |
1204 recipient_jid = window.prompt("Enter JID to forward this message to:") | |
1205 if recipient_jid is NULL: | |
1206 return | |
1207 recipient_jid = recipient_jid.strip() | |
1208 if recipient_jid: | |
1209 message_id = message_elt["id"] | |
1210 await bridge.message_forward(message_id, recipient_jid) | |
1211 dialog.notification.show(f"Message forwarded to {recipient_jid}.") | |
1212 | |
1213 | |
1214 async def on_action_rich_editor(self, evt) -> None: | |
1215 if self.rich_edit and self.has_rich_formatting(): | |
1216 confirmed = await dialog.Confirm( | |
1217 "Switching to simple edit will lose all formatting, are you sure?" | |
1218 ).ashow() | |
1219 if not confirmed: | |
1220 return | |
1221 | |
1222 self.rich_edit = not self.rich_edit | |
1223 | |
1224 async def on_action_extra_recipients(self, __=None, selected: str = "to") -> None: | |
1225 elt = self.extra_recipient_field_tpl.get_elt({"selected": selected}) | |
1226 elt.select_one("button.delete-action").bind( | |
1227 "click", | |
1228 lambda __: elt.remove() | |
1229 ) | |
1230 # We add a new recipient field on [enter]. | |
1231 def on_keypress(evt): | |
1232 if evt.key == "Enter": | |
1233 evt.preventDefault() | |
1234 evt.stopPropagation() | |
1235 selected = elt.select_one("select").value | |
1236 aio.run(self.on_action_extra_recipients(selected=selected)) | |
1237 input_elt = elt.select_one("input") | |
1238 if input_elt is None: | |
1239 dialog.notification.show( | |
1240 "Internal error: can't find recipient field's input", | |
1241 "error" | |
1242 ) | |
1243 return | |
1244 input_elt.bind("keydown", on_keypress) | |
1245 | |
1246 toolbar_elt = document["rich-edit-toolbar"] | |
1247 toolbar_elt.parent.insertBefore(elt, toolbar_elt) | |
1248 input_elt.focus() | |
1053 | 1249 |
1054 def get_reaction_panel(self, source_elt): | 1250 def get_reaction_panel(self, source_elt): |
1055 emoji_picker_elt = document.createElement("emoji-picker") | 1251 emoji_picker_elt = document.createElement("emoji-picker") |
1056 message_elt = source_elt.closest("div.chat-message") | 1252 message_elt = source_elt.closest("div.chat-message") |
1057 emoji_picker_elt.bind( | 1253 emoji_picker_elt.bind( |