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(