diff libervia/web/pages/chat/_browser/__init__.py @ 1584:eab815e48795

browser (chat): message edition + extra menu: - handle extra menu - implement `quote` action - last message correction - show popup with last editions history when "editer" pencil icon is hovered - up arrow let quickly edit last message - implement input modes, `normal` being the default, `edit` or `quote` are new ones. - [ESC] erase input and returns to `normal` mode - fix size and set focus on message input when page is loaded - fix identity retrieval on new messages
author Goffi <goffi@goffi.org>
date Tue, 28 Nov 2023 17:59:11 +0100
parents 9ba532041a8e
children 9fc4120888be
line wrap: on
line diff
--- a/libervia/web/pages/chat/_browser/__init__.py	Tue Nov 28 17:54:30 2023 +0100
+++ b/libervia/web/pages/chat/_browser/__init__.py	Tue Nov 28 17:59:11 2023 +0100
@@ -2,13 +2,14 @@
 import re
 
 from bridge import AsyncBridge as Bridge
-from browser import DOMNode, aio, bind, console as log, document, window
+from browser import DOMNode, aio, bind, console as log, document, timer, window
 from cache import cache, identities
 import dialog
 from file_uploader import FileUploader
 import jid
 from js_modules import emoji_picker_element
 from js_modules.tippy_js import tippy
+import popup
 from template import Template, safe
 from tools import is_touch_device
 
@@ -22,15 +23,22 @@
 # Sensible value to consider that user is at the bottom
 SCROLL_SENSITIVITY = 200
 
+INPUT_MODES = {"normal", "edit", "quote"}
+MODE_CLASS = "mode_{}"
+
 
 class LiberviaWebChat:
     def __init__(self):
+        self._input_mode = "normal"
+        self.input_data = {}
         self.message_tpl = Template("chat/message.html")
+        self.extra_menu_tpl = Template("chat/extra_menu.html")
         self.reactions_tpl = Template("chat/reactions.html")
         self.reactions_details_tpl = Template("chat/reactions_details.html")
         self.url_preview_control_tpl = Template("components/url_preview_control.html")
         self.url_preview_tpl = Template("components/url_preview.html")
         self.new_messages_marker_elt = Template("chat/new_messages_marker.html").get_elt()
+        self.editions_tpl = Template("chat/editions.html")
 
         self.messages_elt = document["messages"]
 
@@ -52,6 +60,31 @@
         # we want the message scroll to be initially at the bottom
         self.messages_elt.scrollTop = self.messages_elt.scrollHeight
 
+        # listeners/dynamic updates
+        self.add_message_event_listeners()
+        self.handle_url_previews()
+        self.add_reactions_listeners()
+
+        # input
+        self.auto_resize_message_input()
+        self.message_input.focus()
+
+    @property
+    def input_mode(self) -> str:
+        return self._input_mode
+
+    @input_mode.setter
+    def input_mode(self, new_mode: str) -> None:
+        if new_mode == self.input_mode:
+            return
+        if new_mode not in INPUT_MODES:
+            raise ValueError(f"Invalid input mode: {new_mode!r}")
+        target_elt = self.message_input
+        target_elt.classList.remove(MODE_CLASS.format(self._input_mode))
+        self._input_mode = new_mode
+        target_elt.classList.add(MODE_CLASS.format(new_mode))
+        self.input_data.clear()
+
     @property
     def is_at_bottom(self):
         return (
@@ -61,7 +94,7 @@
             <= SCROLL_SENSITIVITY
         )
 
-    def send_message(self):
+    async def send_message(self):
         """Send message currently in input area
 
         The message and corresponding attachment will be sent
@@ -83,16 +116,26 @@
 
             # now we send the message
             try:
-                aio.run(
-                    bridge.message_send(
-                        str(target_jid), {"": message}, {}, "auto", json.dumps(extra, ensure_ascii=False)
+                if self.input_mode == "edit":
+                    message_id = self.input_data["id"]
+                    edit_data = {
+                        "message": {"": message},
+                        "extra": extra
+                    }
+                    await bridge.message_edit(
+                            message_id, json.dumps(edit_data, ensure_ascii=False)
+                        )
+                else:
+                    await bridge.message_send(
+                        str(target_jid),
+                        {"": message}, {}, "auto", json.dumps(extra, ensure_ascii=False)
                     )
-                )
             except Exception as e:
                 dialog.notification.show(f"Can't send message: {e}", "error")
             else:
                 self.message_input.value = ""
                 self.attachments_elt.clear()
+        self.input_mode = "normal"
 
     def _on_message_new(
         self,
@@ -149,6 +192,29 @@
                 reactions_wrapper_elt.clear()
                 reactions_wrapper_elt <= reactions_elt
                 self.add_reactions_listeners(reactions_elt)
+        elif type_ == "EDIT":
+            try:
+                old_message_elt = document[uid]
+            except KeyError:
+                log.debug(f"Message {uid} not found, no edition to apply")
+            else:
+                template_data = await self.message_to_template_data(
+                    uid,
+                    update_data["timestamp"],
+                    jid.JID(update_data["from"]),
+                    jid.JID(update_data["to"]),
+                    update_data["message"],
+                    update_data["subject"],
+                    update_data["type"],
+                    update_data["extra"]
+                )
+
+                new_message_elt = self.message_tpl.get_elt(
+                    template_data
+                )
+                old_message_elt.replaceWith(new_message_elt)
+                self.add_message_event_listeners(new_message_elt)
+                self.handle_url_previews(new_message_elt)
         else:
             log.warning(f"Unsupported update type: {type_!r}")
 
@@ -156,6 +222,60 @@
         if is_at_bottom:
             self.messages_elt.scrollTop = self.messages_elt.scrollHeight
 
+    async def message_to_template_data(
+        self,
+        uid: str,
+        timestamp: float,
+        from_jid: jid.JID,
+        to_jid: jid.JID,
+        message_data: dict,
+        subject_data: dict,
+        mess_type: str,
+        extra: dict,
+    ) -> dict:
+        """Generate template data to use with [message_tpl]
+
+            @return: template data
+        """
+        xhtml_data = extra.get("xhtml")
+        if not xhtml_data:
+            xhtml = None
+        else:
+            try:
+                xhtml = xhtml_data[""]
+            except KeyError:
+                xhtml = next(iter(xhtml_data.values()))
+
+        if chat_type == "group":
+            await cache.fill_identities([str(from_jid)])
+        else:
+            await cache.fill_identities([str(jid.JID(from_jid).bare)])
+            from_jid = from_jid.bare
+
+        return {
+            "own_jid": own_jid,
+            "msg": {
+                "id": uid,
+                "timestamp": extra.get("updated", timestamp),
+                "type": mess_type,
+                "from_": str(from_jid),
+                "text": message_data.get("") or next(iter(message_data.values()), ""),
+                "subject": subject_data.get("") or next(iter(subject_data.values()), ""),
+                "type": mess_type,
+                "thread": extra.get("thread"),
+                "thread_parent": extra.get("thread_parent"),
+                "reeceived": extra.get("received_timestamp") or timestamp,
+                "delay_sender": extra.get("delay_sender"),
+                "info_type": extra.get("info_type"),
+                "html": safe(xhtml) if xhtml else None,
+                "encrypted": extra.get("encrypted", False),
+                "received": extra.get("received", False),
+                "attachments": extra.get("attachments", []),
+                "extra": extra
+            },
+            "identities": identities,
+        }
+
     async def on_message_new(
         self,
         uid: str,
@@ -177,45 +297,20 @@
             # the page is not visible, and we have no new messages marker yet, so we add
             # it
             self.messages_elt <= self.new_messages_marker_elt
-        xhtml_data = extra.get("xhtml")
-        if not xhtml_data:
-            xhtml = None
-        else:
-            try:
-                xhtml = xhtml_data[""]
-            except KeyError:
-                xhtml = next(iter(xhtml_data.values()))
-
-        if chat_type == "group":
-            await cache.fill_identities([str(from_jid)])
-        else:
-            await cache.fill_identities([str(jid.JID(from_jid).bare)])
 
-        msg_data = {
-            "id": uid,
-            "timestamp": timestamp,
-            "type": mess_type,
-            "from_": str(from_jid),
-            "text": message_data.get("") or next(iter(message_data.values()), ""),
-            "subject": subject_data.get("") or next(iter(subject_data.values()), ""),
-            "type": mess_type,
-            "thread": extra.get("thread"),
-            "thread_parent": extra.get("thread_parent"),
-            "reeceived": extra.get("received_timestamp") or timestamp,
-            "delay_sender": extra.get("delay_sender"),
-            "info_type": extra.get("info_type"),
-            "html": safe(xhtml) if xhtml else None,
-            "encrypted": extra.get("encrypted", False),
-            "received": extra.get("received", False),
-            "edited": extra.get("edited", False),
-            "attachments": extra.get("attachments", []),
-        }
+        template_data = await self.message_to_template_data(
+            uid,
+            timestamp,
+            from_jid,
+            to_jid,
+            message_data,
+            subject_data,
+            mess_type,
+            extra
+        )
+
         message_elt = self.message_tpl.get_elt(
-            {
-                "own_jid": own_jid,
-                "msg": msg_data,
-                "identities": identities,
-            }
+            template_data
         )
 
         # Check if user is viewing older messages or is at the bottom
@@ -258,7 +353,19 @@
                 # we have a non touch device, we send message on <Enter>
                 if not evt.shiftKey:
                     evt.preventDefault()  # Prevents line break
-                    self.send_message()
+                    aio.run(self.send_message())
+        elif evt.keyCode == 27:  # <ESC> key
+            evt.preventDefault()
+            self.message_input.value = ''
+            self.input_mode = 'normal'
+            self.auto_resize_message_input()
+        elif evt.keyCode == 38:  # <Up> arrow key
+            if self.input_mode == "normal" and self.message_input.value.strip() == "":
+                evt.preventDefault()
+                own_msgs = document.getElementsByClassName('own_msg')
+                if own_msgs.length > 0:
+                    last_msg = own_msgs[own_msgs.length - 1]
+                    aio.run(self.on_action_edit(None, last_msg))
 
     def update_attachments_visibility(self):
         if len(self.attachments_elt.children):
@@ -283,7 +390,34 @@
         document["file_input"].click()
 
     def on_extra_btn_click(self, evt):
-        print("extra bouton clicked!")
+        message_elt = evt.target.closest("div.is-chat-message")
+        is_own = message_elt.classList.contains("own_msg")
+        if is_own:
+            own_messages = document.select('.own_msg')
+            # with XMPP, we can currently only edit our last message
+            can_edit = own_messages and message_elt is own_messages[-1]
+        else:
+            can_edit = False
+
+        content_elt = self.extra_menu_tpl.get_elt({
+            "edit": can_edit,
+        })
+        extra_popup = popup.create_popup(evt.target, content_elt, focus_elt=message_elt)
+
+        def on_action_click(evt, callback):
+            extra_popup.hide()
+            aio.run(
+                callback(evt, message_elt)
+            )
+
+        for cls_name, callback in (
+            ("action_quote", self.on_action_quote),
+            ("action_edit", self.on_action_edit)
+        ):
+            for elt in content_elt.select(f".{cls_name}"):
+                elt.bind("click", lambda evt, callback=callback: on_action_click(
+                    evt, callback
+                ))
 
     def on_reaction_click(self, evt, message_elt):
         window.evt = evt
@@ -292,18 +426,55 @@
                 message_elt["id"], [evt.detail["unicode"]], "toggle"
             )
         )
-
-    @bind(document["attachments"], "wheel")
-    def wheel_event(evt):
-        """Make the mouse wheel to act on horizontal scrolling for attachments
-
-        Attachments don't have vertical scrolling, thus is makes sense to use the wheel
-        for horizontal scrolling
-        """
         if evt.deltaY != 0:
             document["attachments"].scrollLeft += evt.deltaY * 0.8
             evt.preventDefault()
 
+    async def get_message_tuple(self, message_elt) -> tuple|None:
+        """Retrieve message tuple from as sent by [message_new]
+
+        If not corresponding message data is found, an error will shown, and None is
+        returned.
+        @param message_elt: message element, it's "id" attribute will be use to retrieve
+            message data
+        @return: message data as a tuple, or None if not message with this ID is found.
+        """
+        message_id = message_elt['id']
+        history_data = await bridge.history_get(
+            "", "", -2, True, {"id": message_elt['id']}
+        )
+        if not history_data:
+            dialog.notification.show(f"Can't find message {message_id}", "error")
+            return None
+        return history_data[0]
+
+    async def on_action_quote(self, __, message_elt) -> None:
+        message_data = await self.get_message_tuple(message_elt)
+        if message_data is not None:
+            messages = message_data[4]
+            body = next(iter(messages.values()), "")
+            quote = "\n".join(f"> {l}" for l in body.split("\n"))
+            self.message_input.value = f"{quote}\n{self.message_input.value}"
+            self.input_mode = "quote"
+            self.input_data["id"] = message_elt["id"]
+            self.auto_resize_message_input()
+            self.message_input.focus()
+
+    async def on_action_edit(self, __, message_elt) -> None:
+        message_data = await self.get_message_tuple(message_elt)
+        if message_data is not None:
+            messages = message_data[4]
+            body = next(iter(messages.values()), "")
+            if not body:
+                dialog.notification.show("No content found in message, nothing to edit")
+                return
+
+            self.message_input.value = body
+            self.input_mode = "edit"
+            self.input_data["id"] = message_elt["id"]
+            self.auto_resize_message_input()
+            self.message_input.focus()
+
     def get_reaction_panel(self, source_elt):
         emoji_picker_elt = document.createElement("emoji-picker")
         message_elt = source_elt.closest("div.is-chat-message")
@@ -319,7 +490,6 @@
         - make attachments dynamically clickable
         - make the extra button clickable
         """
-
         ## attachments
         # FIXME: only handle images for now, and display them in a modal
         if parent_elt is None:
@@ -342,10 +512,10 @@
                     "interactive": True,
                     "theme": "light",
                     "onShow": lambda __, message_elt=message_elt: (
-                        message_elt.classList.add("chat-message-highlight")
+                        message_elt.classList.add("has-popup-focus")
                     ),
                     "onHide": lambda __, message_elt=message_elt: (
-                        message_elt.classList.remove("chat-message-highlight")
+                        message_elt.classList.remove("has-popup-focus")
                     ),
                 },
             )
@@ -354,6 +524,33 @@
         for extra_btn in parent_elt.select(".extra-button"):
             extra_btn.bind("click", self.on_extra_btn_click)
 
+        ## editions
+        for edition_icon_elt in parent_elt.select(".message-editions"):
+            message_elt = edition_icon_elt.closest("div.is-chat-message")
+            dataset = message_elt.dataset.to_dict()
+            try:
+                editions = json.loads(dataset["editions"])
+            except (ValueError, KeyError):
+                log.error(
+                    f"Internal Error: invalid or missing editions data: {message_elt['id']}"
+                )
+            else:
+                for edition in editions:
+                    edition["text"] = (
+                        edition["message"].get("")
+                        or next(iter(edition["message"].values()), "")
+                    )
+                editions_elt = self.editions_tpl.get_elt({"editions": editions})
+                tippy(
+                    edition_icon_elt,
+                    {
+                        "content": editions_elt,
+                        "theme": "light",
+                        "appendTo": document.body
+                    }
+
+                )
+
     def add_reactions_listeners(self, parent_elt=None) -> None:
         """Add listener on reactions to handle details and reaction toggle"""
         if parent_elt is None:
@@ -495,7 +692,10 @@
     "input", lambda __: libervia_web_chat.auto_resize_message_input()
 )
 document["message_input"].bind("keydown", libervia_web_chat.on_message_keydown)
-document["send_button"].bind("click", lambda __: libervia_web_chat.send_message())
+document["send_button"].bind(
+    "click",
+    lambda __: aio.run(libervia_web_chat.send_message())
+)
 document["attach_button"].bind("click", libervia_web_chat.on_attach_button_click)
 document["file_input"].bind("change", libervia_web_chat.on_file_selected)
 
@@ -503,7 +703,3 @@
 
 bridge.register_signal("message_new", libervia_web_chat._on_message_new)
 bridge.register_signal("message_update", libervia_web_chat._on_message_update)
-
-libervia_web_chat.add_message_event_listeners()
-libervia_web_chat.handle_url_previews()
-libervia_web_chat.add_reactions_listeners()