diff libervia/web/pages/chat/_browser/__init__.py @ 1620:3a60bf3762ef

browser: threads and replies implementation: rel 457
author Goffi <goffi@goffi.org>
date Tue, 06 May 2025 00:40:07 +0200
parents a2cd4222c702
children
line wrap: on
line diff
--- a/libervia/web/pages/chat/_browser/__init__.py	Sat Apr 12 00:21:45 2025 +0200
+++ b/libervia/web/pages/chat/_browser/__init__.py	Tue May 06 00:40:07 2025 +0200
@@ -14,7 +14,7 @@
 from js_modules.tippy_js import tippy as tippy_ori
 import popup
 from template import Template, safe
-from tools import is_touch_device
+from tools import is_touch_device, remove_ids
 from loading import remove_loading_screen
 from jid_search import JidSearch
 from interpreter import Inspector
@@ -34,6 +34,7 @@
 
 INPUT_MODES = {"normal", "edit", "quote"}
 MODE_CLASS = "mode_{}"
+SELECTED_THREAD_CLS = "has-background-info-light"
 
 
 # FIXME: workaround for https://github.com/brython-dev/brython/issues/2542
@@ -287,6 +288,19 @@
         self.attachments_elt = document["attachments"]
         self.message_input = document["message_input_area"]
 
+        # reply to/thread
+        self.thread_panel_tpl = Template("chat/thread_panel.html")
+        # current thread panel, if any.
+        self.thread_panel = None
+        self._reply_to = None
+        self.thread_id = None
+        document["cancel_reply_btn"].bind(
+            "click",
+            lambda __: setattr(self, "reply_to", None)
+        )
+        # use `thead` property to modify.
+        self._show_thread = None
+
         # close_button = document.select_one(".modal-close")
         # close_button.bind("click", self.close_modal)
 
@@ -356,6 +370,58 @@
             <= SCROLL_SENSITIVITY
         )
 
+    @property
+    def reply_to(self) -> dict|None:
+        return self._reply_to
+
+    @reply_to.setter
+    def reply_to(self, reply_to: dict|None) -> None:
+        if reply_to == self._reply_to:
+            return
+        self._reply_to = reply_to
+        document["reply-to_message"].clear()
+        if reply_to is None:
+            document["reply-to"].classList.add("is-hidden")
+        else:
+            document["reply-to"].classList.remove("is-hidden")
+            parent_message_elt = document[reply_to["id"]]
+            cloned_parent_elt = parent_message_elt.cloneNode(True)
+            remove_ids(cloned_parent_elt)
+            message_actions_elt = cloned_parent_elt.select_one(".message-actions")
+            if message_actions_elt is not None:
+                message_actions_elt.remove()
+            document["reply-to_message"] <= cloned_parent_elt
+
+    @property
+    def show_thread(self) -> str|None:
+        """Indicate the thread to highlight"""
+        return self._show_thread
+
+    @show_thread.setter
+    def show_thread(self, thread_id: str|None) -> None:
+        """Set the thread to highlight, or None to clear view."""
+        if self._show_thread == thread_id:
+            return
+        if self._show_thread is not None:
+            # If we have a previously selected thread, we clean the view.
+            for message_core_elt in self.messages_elt.select(".message-core"):
+                message_core_elt.classList.remove(SELECTED_THREAD_CLS)
+        self._show_thread = thread_id
+        if thread_id is not None:
+            for message_elt in self.messages_elt.select(".chat-message"):
+                try:
+                    message_thread_id = message_elt.dataset["thread"]
+                except KeyError:
+                    continue
+                if message_thread_id == thread_id:
+                    message_core_elt = message_elt.select_one(".message-core")
+                    if not message_core_elt:
+                        log.debug(
+                            f"Not message core found for message {message_elt['id']!r}."
+                        )
+                    else:
+                        message_core_elt.classList.add(SELECTED_THREAD_CLS)
+
     def open_chat(self, entity_jid: str) -> None:
         """Change the current chat for the given one."""
         # For now we keep it simple and just load the new location.
@@ -385,6 +451,12 @@
             if attachments:
                 extra["attachments"] = attachments
 
+            if self.reply_to:
+                extra["reply"] = self.reply_to
+
+            if self.thread_id:
+                extra["thread"] = self.thread_id
+
             # now we send the message
             try:
                 if self.input_mode == "edit":
@@ -405,6 +477,13 @@
                 dialog.notification.show(f"Can't send message: {e}", "error")
             else:
                 self.message_input.value = ""
+                # 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
+                #   some point, with a slide-in panel for thread, as thread_id would then
+                #   be used for the thread panel's message input, but not if the main area
+                #   message input is used.
+                self.reply_to = None
                 self.attachments_elt.clear()
                 self.auto_resize_message_input()
         self.input_mode = "normal"
@@ -605,6 +684,37 @@
             template_data
         )
 
+
+        if "reply" in extra:
+            parent_id = extra["reply"]["id"]
+            try:
+                parent_message_elt = document[parent_id]
+            except KeyError:
+                log.info(
+                    "Parent message of reply not found in current history: "
+                    f"{parent_id!r}"
+                )
+            else:
+                thread_id = extra.get("thread", parent_id)
+                if "thread" not in parent_message_elt.dataset:
+                    parent_message_elt.dataset["thread"] = thread_id
+                    # TODO: Regenerate parent message, so thread icon will appear.
+
+        if (
+            "thread" in extra
+            and self.thread_panel
+            and self.thread_id
+            and extra["thread"] == self.thread_id
+        ):
+            # FIXME: This is quite fragile, IDs are removed, meaning that some listener
+            #   may not be working correctly, and it's using #input-panel as reference,
+            #   but in the future, it may not moved from main area the thread panel like
+            #   that anymore.
+            cloned_message_elt = message_elt.cloneNode(True)
+            remove_ids(cloned_message_elt)
+            thread_messages_elt = document["thread-messages"]
+            thread_messages_elt.insertBefore(cloned_message_elt, document["input-panel"])
+
         # Check if user is viewing older messages or is at the bottom
         is_at_bottom = self.is_at_bottom
 
@@ -687,12 +797,21 @@
         item_elt = DOMNode(target.closest(".attachment-preview"))
         item_elt.remove()
 
-    def on_attach_button_click(self, evt):
+    def on_attach_btn_click(self, evt):
         document["file-input"].click()
 
+    def on_reply_btn_click(self, evt) -> None:
+        chat_message_elt = evt.currentTarget.closest("div.chat-message")
+        self.reply_to = {
+            "id": chat_message_elt["id"],
+            "to": chat_message_elt.dataset["from"]
+        }
+        log.debug(f'"reply_to" set to {self.reply_to!r}')
+
+
     def on_extra_btn_click(self, evt):
-        message_elt = evt.target.closest("div.chat-message")
-        message_core_elt = evt.target.closest("div.message-core")
+        message_elt = evt.currentTarget.closest("div.chat-message")
+        message_core_elt = evt.currentTarget.closest("div.message-core")
         is_own = message_elt.classList.contains("own_msg")
         if is_own:
             own_messages = document.select('.own_msg')
@@ -734,6 +853,53 @@
         #     document["attachments"].scrollLeft += evt.deltaY * 0.8
         #     evt.preventDefault()
 
+    async def on_show_thread(self, thread_id: str) -> None:
+        assert thread_id
+        self.thread_id = thread_id
+
+        thread_panel_elt = self.thread_panel_tpl.get_elt({
+            "messages": []
+        })
+        self.thread_panel = thread_panel_elt
+        thread_messages_elt = thread_panel_elt.select_one("#thread-messages")
+        assert thread_messages_elt is not None
+        history_data = await bridge.history_get(
+            "", "", -2, True, {"thread_id": thread_id}
+        )
+        for message_data in history_data:
+            uid, timestamp, from_jid, to_jid, message_data, subject_data, mess_type, extra = message_data
+            template_data = await self.message_to_template_data(
+                uid,
+                timestamp=timestamp,
+                from_jid=from_jid,
+                to_jid=to_jid,
+                message_data=message_data,
+                subject_data=subject_data,
+                mess_type=mess_type,
+                extra=json.loads(extra)
+            )
+            message_elt = self.message_tpl.get_elt(template_data)
+            thread_messages_elt <= message_elt
+        # FIXME: The whole input-panel is currently moved to the thread panel so listeners
+        #   don't have to be moved. At some point, it may be better to make a clone
+        #   (without the IDs) and to clone listeners too, this way a slide-in panel could
+        #   be used instead of a modal.
+        input_panel_elt = document["input-panel"]
+        thread_messages_elt <= input_panel_elt
+
+        def on_thread_panel_close():
+            document["input-panel-area"].appendChild(input_panel_elt)
+            self.thread_id = None
+            self.thread_panel = None
+
+        thread_modal = dialog.Modal(
+            thread_panel_elt,
+            closable=True,
+            close_cb=on_thread_panel_close
+        )
+        thread_modal.show()
+
+
     async def get_message_tuple(self, message_elt) -> tuple|None:
         """Retrieve message tuple from as sent by [message_new]
 
@@ -818,6 +984,10 @@
             img_elt.bind("click", self.open_modal)
             img_elt.style.cursor = "pointer"
 
+        ## reply button
+        for reply_btn in parent_elt.select(".reply-button"):
+            reply_btn.bind("click", self.on_reply_btn_click)
+
         ## reaction button
         i = 0
         for reaction_btn in parent_elt.select(".reaction-button"):
@@ -872,6 +1042,23 @@
 
                 )
 
+        ## thread
+        for thread_icon_elt in parent_elt.select(".message-thread"):
+            message_elt = thread_icon_elt.closest("div.chat-message")
+            thread_id = message_elt.dataset["thread"]
+            thread_icon_elt.bind(
+                "mouseenter",
+                lambda __, thread_id=thread_id: setattr(self, "show_thread", thread_id)
+            )
+            thread_icon_elt.bind(
+                "mouseleave",
+                lambda __, thread_id=thread_id: setattr(self, "show_thread", None)
+            )
+            thread_icon_elt.bind(
+                "click",
+                lambda __, thread_id=thread_id: aio.run(self.on_show_thread(thread_id))
+            )
+
     def add_reactions_listeners(self, parent_elt=None) -> None:
         """Add listener on reactions to handle details and reaction toggle"""
         if parent_elt is None:
@@ -989,8 +1176,8 @@
 
     def open_modal(self, evt):
         modal_image = document.select_one("#modal-image")
-        modal_image.src = evt.target.src
-        modal_image.alt = evt.target.alt
+        modal_image.src = evt.currentTarget.src
+        modal_image.alt = evt.currentTarget.alt
         modal = document.select_one("#modal")
         modal.classList.add("is-active")
 
@@ -1017,11 +1204,11 @@
     "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 __: aio.run(libervia_web_chat.send_message())
-# )
-document["attach-button"].bind("click", libervia_web_chat.on_attach_button_click)
+document["send_button"].bind(
+    "click",
+    lambda __: aio.run(libervia_web_chat.send_message())
+)
+document["attach-button"].bind("click", libervia_web_chat.on_attach_btn_click)
 document["file-input"].bind("change", libervia_web_chat.on_file_selected)
 
 document.bind("visibilitychange", libervia_web_chat.handle_visibility_change)