Mercurial > libervia-web
changeset 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 | 6c6ab1a96b34 |
children | 691f6c8afb31 |
files | libervia/web/pages/_browser/dialog.py libervia/web/pages/_browser/popup.py libervia/web/pages/chat/_browser/__init__.py libervia/web/server/restricted_bridge.py |
diffstat | 4 files changed, 217 insertions(+), 10 deletions(-) [+] |
line wrap: on
line diff
--- a/libervia/web/pages/_browser/dialog.py Fri Jul 04 17:40:18 2025 +0200 +++ b/libervia/web/pages/_browser/dialog.py Fri Jul 04 17:47:37 2025 +0200 @@ -96,7 +96,8 @@ notif_elt.remove() resolve_cb(confirmed) - async def ashow(self): + async def ashow(self) -> bool: + """Return True if the dialog is confirmed.""" return window.Promise.new( lambda resolve_cb, reject_cb: self.show(
--- a/libervia/web/pages/_browser/popup.py Fri Jul 04 17:40:18 2025 +0200 +++ b/libervia/web/pages/_browser/popup.py Fri Jul 04 17:47:37 2025 +0200 @@ -11,15 +11,17 @@ content_elt, focus_class="has-popup-focus", focus_elt=None, + placement="bottom", **kwargs ): - """Create a popup and show a popup below a target + """Create a popup and show a popup next to a target The popup is created and destroyed on each call, this can be used for dynamic content. @param target: element where the popup will appear, or a selector @param content_elt: HTML element to show in the popup @param focus_class: class added to target element when popup is shown @param focus_elt: element where focus_class is added. If None, target will be used. + @param placement: where the popup must be located. @param kwargs: arguments used to override tippy options """ if focus_elt is None: @@ -31,7 +33,7 @@ "trigger": "click", "content": content_elt, "appendTo": document.body, - "placement": "bottom", + "placement": placement, "interactive": True, "trigger": "manual", "theme": "light",
--- a/libervia/web/pages/chat/_browser/__init__.py Fri Jul 04 17:40:18 2025 +0200 +++ b/libervia/web/pages/chat/_browser/__init__.py Fri Jul 04 17:47:37 2025 +0200 @@ -4,14 +4,15 @@ import errors from bridge import AsyncBridge as Bridge -from browser import DOMNode, aio, console as log, document, window +from browser import DOMNode, aio, console as log, document, html, window from cache import cache, identities, roster import dialog from file_uploader import FileUploader import jid -from javascript import pyobj2jsobj +from javascript import pyobj2jsobj, NULL from js_modules import emoji_picker_element from js_modules.tippy_js import tippy as tippy_ori +from js_modules.quill import Quill import popup from template import Template, safe from tools import is_touch_device, remove_ids, make_placeholder @@ -254,6 +255,7 @@ class LiberviaWebChat: def __init__(self): self._input_mode = "normal" + self._rich_edit = False self.input_data = {} self.direct_messages_tpl = Template("chat/direct_messages.html") self.message_tpl = Template("chat/message.html") @@ -265,6 +267,8 @@ self.new_messages_marker_elt = Template("chat/new_messages_marker.html").get_elt() self.editions_tpl = Template("chat/editions.html") self.occupant_item_tpl = Template("chat/occupant_item.html") + self.input_extra_menu_tpl = Template("chat/input_extra_menu.html") + self.extra_recipient_field_tpl = Template("chat/extra_recipient_field.html") # panels and their toggle buttons @@ -322,6 +326,10 @@ self.add_reactions_listeners() # input + document["input-extra-button"].bind("click", self.on_input_extra_btn) + self.rich_editor = Quill.new("#message_input_area_rich") + for rich_btn_elt in document.select(".rich-editor-btn"): + rich_btn_elt.bind("click", self.on_rich_action) self.auto_resize_message_input() self.message_input.focus() @@ -377,7 +385,7 @@ @input_mode.setter def input_mode(self, new_mode: str) -> None: - if new_mode == self.input_mode: + if new_mode == self._input_mode: return if new_mode not in INPUT_MODES: raise ValueError(f"Invalid input mode: {new_mode!r}") @@ -388,6 +396,26 @@ self.input_data.clear() @property + def rich_edit(self) -> bool: + return self._rich_edit + + @rich_edit.setter + def rich_edit(self, rich_edit_activated: bool) -> None: + if rich_edit_activated == self._rich_edit: + return + self._rich_edit = rich_edit_activated + if rich_edit_activated: + document["rich-edit-toolbar"].classList.remove("is-hidden") + document["message_input_area"].classList.add("is-hidden") + document["message_input_area_rich"].classList.remove("is-hidden") + self.rich_editor.setText(document["message_input_area"].value) + else: + document["rich-edit-toolbar"].classList.add("is-hidden") + document["message_input_area_rich"].classList.add("is-hidden") + document["message_input_area"].classList.remove("is-hidden") + document["message_input_area"].value = self.rich_editor.getText().strip() + + @property def is_at_bottom(self): return self.is_elt_at_bottom(self.messages_elt) @@ -466,8 +494,13 @@ The message and corresponding attachment will be sent """ - message = self.message_input.value.rstrip() - log.info(f"{message=}") + extra = {} + if not self.rich_edit: + message = self.message_input.value.rstrip() + else: + message = self.rich_editor.getText().strip() + if self.has_rich_formatting(): + extra["xhtml"] = self.rich_editor.getSemanticHTML() # attachments attachments = [] @@ -476,7 +509,6 @@ attachments.append(file_data) if message or attachments: - extra = {} if attachments: extra["attachments"] = attachments @@ -490,6 +522,30 @@ if self.keyword: extra.setdefault("keywords", []).append(self.keyword) + input_panel = document["input-panel"] + recipient_field_elts = list(input_panel.select("div.recipient-field")) + if recipient_field_elts: + addresses = {} + for recipient_field_elt in recipient_field_elts: + input_elt = recipient_field_elt.select_one("input") + assert input_elt is not None + recipient_jid = input_elt.value.strip() + if not recipient_jid: + continue + select_elt = recipient_field_elt.select_one("select") + assert select_elt is not None + recipient_type = select_elt.value + if recipient_type not in ("to", "cc", "bcc"): + dialog.notification.show( + f"Unexpected recipient type: {recipient_type:r}.", + "error" + ) + recipient_type = "to" + addresses.setdefault(recipient_type, []).append({"jid": recipient_jid}) + + if addresses: + extra["addresses"] = addresses + # now we send the message try: if self.input_mode == "edit": @@ -510,6 +566,7 @@ dialog.notification.show(f"Can't send message: {e}", "error") else: self.message_input.value = "" + self.rich_editor.setText("") # We must not reset self.thread_id here, it is when the thread panel is # closed. # FIXME: Another mechanism must be used if the input panel is cloned at @@ -518,9 +575,67 @@ # message input is used. self.reply_to = None self.attachments_elt.clear() + if recipient_field_elts: + for recipient_field_elt in recipient_field_elts: + recipient_field_elt.remove() self.auto_resize_message_input() self.input_mode = "normal" + def has_rich_formatting(self): + """Indicates if there is formatting in the Rich Editor. + + It works by looking for attributes inserted in Quill contents. + """ + delta = self.rich_editor.getContents() + + ops = getattr(delta, 'ops', []) + + for op in ops: + op = dict(op) + if "insert" in op and op.get("attributes"): + return True + return False + + def on_rich_action(self, evt): + btn_elt = evt.currentTarget + action = btn_elt.dataset["action"] + text_range = self.rich_editor.getSelection() + if not text_range: + return + format_data = dict(self.rich_editor.getFormat(text_range.index, 1)) + + try: + match action: + case "bold": + is_bold = format_data.get("bold", False) + self.rich_editor.format("bold", not is_bold) + case "italic": + is_italic = format_data.get("italic", False) + self.rich_editor.format("italic", not is_italic) + case "underline": + is_underline = format_data.get("underline", False) + self.rich_editor.format("underline", not is_underline) + case list_type if list_type.startswith("list-"): + list_type = list_type[5:] + current_list = format_data.get("list") + if current_list == list_type: + self.rich_editor.format("list", False) + else: + self.rich_editor.format("list", list_type) + case "link": + current_url = format_data.get("link") + if current_url: + self.rich_editor.format("link", False) + else: + url = window.prompt("Enter URL:", "https://") + if url: + self.rich_editor.format("link", url) + case _: + dialog.notification.show(f"Unknown action {action!r}", "error") + except Exception as e: + dialog.notification.show(f"Can't apply action: {e}", "error") + raise e + def _on_message_new( self, uid: str, @@ -627,7 +742,15 @@ @return: template data """ - xhtml_data = extra.get("xhtml") + # FIXME: We do this deconstruction because of the historical way XEP-0071 build + # the `xhtml` keys (i.e., `xhtml_<lang>`, because `extra` was a string to string + # mapping). This will be refactored when we'll fully move messages to Pydantic + # models, then a proper mapping from language to data will be used. + xhtml_data = { + key.partition("_")[2]: xhtml + for key, xhtml in extra.items() + if key .startswith("xhtml") + } if not xhtml_data: xhtml = None else: @@ -672,6 +795,7 @@ msg_data[key] = value if xhtml: + # XHTML is guaranteed to be safe by the backend. msg_data["html"] = safe(xhtml) return { @@ -825,6 +949,29 @@ self.file_uploader.upload_files(files, self.attachments_elt) self.message_input.focus() + def on_input_extra_btn(self, evt) -> None: + evt.stopPropagation() + input_extra_menu_elt = self.input_extra_menu_tpl.get_elt({ + "rich_edit": self.rich_edit, + }) + input_extra_popup = popup.create_popup( + evt.currentTarget, input_extra_menu_elt, placement="top" + ) + def on_action_click(evt, callback): + input_extra_popup.hide() + aio.run( + callback(evt) + ) + + for cls_name, callback in ( + ("action_toggle_rich_editor", self.on_action_rich_editor), + ("action_toggle_extra_recipients", self.on_action_extra_recipients), + ): + for elt in input_extra_menu_elt.select(f".{cls_name}"): + elt.bind("click", lambda evt, callback=callback: on_action_click( + evt, callback + )) + def on_attachment_delete(self, evt): evt.stopPropagation() target = evt.currentTarget @@ -870,6 +1017,7 @@ ("action_quote", self.on_action_quote), ("action_edit", self.on_action_edit), ("action_retract", self.on_action_retract), + ("action_forward", self.on_action_forward), ): for elt in content_elt.select(f".{cls_name}"): elt.bind("click", lambda evt, callback=callback: on_action_click( @@ -1051,6 +1199,54 @@ else: log.info(f"Retraction of message {message_elt['id']} cancelled by user.") + async def on_action_forward(self, __, message_elt) -> None: + # TODO: Use a real dialog here. + recipient_jid = window.prompt("Enter JID to forward this message to:") + if recipient_jid is NULL: + return + recipient_jid = recipient_jid.strip() + if recipient_jid: + message_id = message_elt["id"] + await bridge.message_forward(message_id, recipient_jid) + dialog.notification.show(f"Message forwarded to {recipient_jid}.") + + + async def on_action_rich_editor(self, evt) -> None: + if self.rich_edit and self.has_rich_formatting(): + confirmed = await dialog.Confirm( + "Switching to simple edit will lose all formatting, are you sure?" + ).ashow() + if not confirmed: + return + + self.rich_edit = not self.rich_edit + + async def on_action_extra_recipients(self, __=None, selected: str = "to") -> None: + elt = self.extra_recipient_field_tpl.get_elt({"selected": selected}) + elt.select_one("button.delete-action").bind( + "click", + lambda __: elt.remove() + ) + # We add a new recipient field on [enter]. + def on_keypress(evt): + if evt.key == "Enter": + evt.preventDefault() + evt.stopPropagation() + selected = elt.select_one("select").value + aio.run(self.on_action_extra_recipients(selected=selected)) + input_elt = elt.select_one("input") + if input_elt is None: + dialog.notification.show( + "Internal error: can't find recipient field's input", + "error" + ) + return + input_elt.bind("keydown", on_keypress) + + toolbar_elt = document["rich-edit-toolbar"] + toolbar_elt.parent.insertBefore(elt, toolbar_elt) + input_elt.focus() + def get_reaction_panel(self, source_elt): emoji_picker_elt = document.createElement("emoji-picker") message_elt = source_elt.closest("div.chat-message")
--- a/libervia/web/server/restricted_bridge.py Fri Jul 04 17:40:18 2025 +0200 +++ b/libervia/web/server/restricted_bridge.py Fri Jul 04 17:47:37 2025 +0200 @@ -187,6 +187,14 @@ "message_edit", message_id, edit_data_s, profile ) + async def message_forward( + self, message_id: str, recipient_jid: str, profile: str + ) -> None: + self.no_service_profile(profile) + return await self.host.bridge_call( + "message_forward", message_id, recipient_jid, profile + ) + async def message_reactions_set( self, message_id: str, reactions: list[str], update_type: str, profile: str ) -> None: