changeset 1625:698eaabfca0e

browser (chat): side/panel and keyword handling: - `dialog.Modal` can now be used with a `position` argument. The default `center` keeps the old behaviour of modal in the middle of the screen, while using one of the direction makes the modal appear from top/bottom/right/left with an animation. The closing cross has been removed in favor of clicking/touching any part outside of the modal. - A method to create mockup clones of elements is now used to make placeholders. It is used for the input panel in the main area when it is moved to a sub-panel modal, this way there is not element disappearing. - Clicking on a keyword now shows a sub-messages panel with all messages with this keyword, in a similar way as for threads. Writing a messages there will add the keyword automatically. - Sub-messages panel will auto-scroll when messages are added. rel 458
author Goffi <goffi@goffi.org>
date Fri, 06 Jun 2025 11:08:05 +0200
parents fd421f1be8f5
children 00abe08875f5
files libervia/web/pages/_browser/dialog.py libervia/web/pages/_browser/tools.py libervia/web/pages/chat/_browser/__init__.py
diffstat 3 files changed, 191 insertions(+), 82 deletions(-) [+]
line wrap: on
line diff
--- a/libervia/web/pages/_browser/dialog.py	Wed May 21 15:58:56 2025 +0200
+++ b/libervia/web/pages/_browser/dialog.py	Fri Jun 06 11:08:05 2025 +0200
@@ -1,5 +1,6 @@
 """manage common dialogs"""
 
+from typing import Callable, Literal
 from browser import document, window, timer, console as log
 from template import Template
 
@@ -183,63 +184,86 @@
 
 class Modal:
 
-    def __init__(self, content_elt, is_card=False, closable=False, close_cb=None):
-        """Init a Modal instance.
-
-        @param content_elt: Content of the modal.
-        @param is_card: If True, a Modal card will be used. The ``content_elt`` must be a
-            <div> with the "modal-card" class.
-        @param closable: if True, add a close cross at the top right of the modal.
-        """
+    def __init__(
+        self,
+        content_elt,
+        is_card: bool = False,
+        closable: bool = False,
+        close_cb: Callable|None = None,
+        position: Literal["center", "left", "right", "top", "bottom"] = "center"
+    ) -> None:
+        self.position = position
         self.is_card = is_card
-        if is_card:
+        if is_card and position == 'center':
             if not content_elt.classList.contains("modal-card"):
                 raise ValueError(
-                    'Element must have a "modal-card" class when `is_card` is used'
+                    'Element must have a "modal-card" class when `is_card` is used for center modal'
                 )
         self.closable = closable
         self._close_cb = close_cb
         self._tpl = Template("dialogs/modal.html")
         self.content_elt = content_elt
         self._modal_elt = None
-        self.reset()
+        self._closing = False
+        self._is_panel = position != 'center'
 
-    def reset(self):
-        """Reset values of callbacks and notif element"""
+    def _cleanup(self):
         if self._modal_elt is not None:
             self._modal_elt.remove()
             self._modal_elt = None
-
-    def _default_cancel_cb(self, evt, notif_elt):
-        notif_elt.remove()
+        self._closing = False
 
     def close(self):
-        """Close the dialog."""
-        if self._modal_elt is None:
-            log.warning("Calling close on an unshown dialog.")
-        self.reset()
+        if self._modal_elt is None or self._closing:
+            return
+
+        self._closing = True
+
+        self._modal_elt.classList.remove('is-active')
+
+        if self._is_panel:
+            self._modal_elt.classList.add('closing')
 
-    def on_close_click(self, evt) -> None:
-        evt.preventDefault()
-        evt.stopPropagation()
-        if self._close_cb is not None:
-            self._close_cb()
-        self.close()
+        def on_close_finished():
+            if self._close_cb:
+                self._close_cb()
+            self._cleanup()
+
+        timer.set_timeout(on_close_finished, 300)
+
+    def on_background_click(self, evt):
+        if self.closable and not self._closing:
+            evt.preventDefault()
+            evt.stopPropagation()
+            self.close()
 
     def show(self) -> None:
-        modal_elt = self._tpl.get_elt({
+        if self._modal_elt:
+            self._cleanup()
+
+        self._modal_elt = self._tpl.get_elt({
             "closable": self.closable,
+            "position": self.position,
+            "is_panel": self._is_panel
         })
-        self._modal_elt = modal_elt
-        if self.is_card:
-            container_elt = modal_elt
+
+        if self.position == 'center':
+            container_elt = self._modal_elt.select_one(".modal-content")
+            container_elt <= self.content_elt
         else:
-            container_elt = modal_elt.select_one(".modal-content")
-        container_elt <= self.content_elt
+            container_elt = self._modal_elt.select_one(".modal-panel-container")
+            container_elt <= self.content_elt
+            container_elt.classList.add(f"is-{self.position}")
+
+        document['notifs_area'] <= self._modal_elt
 
-        document['notifs_area'] <= modal_elt
-        for ok_elt in modal_elt.select(".modal-close"):
-            ok_elt.bind("click", self.on_close_click)
+        if self.closable:
+            bg = self._modal_elt.select_one(".modal-background, .modal-panel-background")
+            if bg:
+                bg.bind("click", self.on_background_click)
+
+        # Add active class after a small delay to trigger animation
+        timer.set_timeout(lambda: self._modal_elt.classList.add('is-active'), 10)
 
 
 notification = Notification()
--- a/libervia/web/pages/_browser/tools.py	Wed May 21 15:58:56 2025 +0200
+++ b/libervia/web/pages/_browser/tools.py	Fri Jun 06 11:08:05 2025 +0200
@@ -1,15 +1,32 @@
 """Common useful tools"""
-from browser import window
+from browser import window, html
 
 
 def is_touch_device():
     return hasattr(window, 'ontouchstart')
 
 
-
 def remove_ids(element):
     """Recursively remove "id" attribute of element and all its descendants."""
     element.removeAttribute('id')
 
     for child in element.children:
         remove_ids(child)
+
+
+def make_placeholder(original_elt, placeholder_class="visual-placeholder"):
+    """Create a visual placeholder that matches an element's appearance.
+
+    The placeholder will be a clone of the original element but without any ID attributes
+    to prevent DOM conflicts.
+
+    @param original_elt: Element to create placeholder for.
+    @param placeholder_class: CSS class to add to placeholder element.
+    @return: Placeholder element.
+    """
+    placeholder = original_elt.cloneNode(True)
+    remove_ids(placeholder)
+    placeholder.classList.add(placeholder_class)
+    placeholder.attrs["aria-hidden"] = "true"
+    placeholder.attrs["data-placeholder-for"] = original_elt.id or ""
+    return placeholder
--- a/libervia/web/pages/chat/_browser/__init__.py	Wed May 21 15:58:56 2025 +0200
+++ b/libervia/web/pages/chat/_browser/__init__.py	Fri Jun 06 11:08:05 2025 +0200
@@ -1,3 +1,4 @@
+from html import escape
 import json
 import re
 from typing import Callable
@@ -14,7 +15,7 @@
 from js_modules.tippy_js import tippy as tippy_ori
 import popup
 from template import Template, safe
-from tools import is_touch_device, remove_ids
+from tools import is_touch_device, remove_ids, make_placeholder
 from loading import remove_loading_screen
 from jid_search import JidSearch
 from interpreter import Inspector
@@ -289,11 +290,15 @@
         self.message_input = document["message_input_area"]
 
         # reply to/thread
-        self.thread_panel_tpl = Template("chat/thread_panel.html")
+        self.sub_messages_panel_tpl = Template("chat/sub_messages_panel.html")
         # current thread panel, if any.
-        self.thread_panel = None
-        self._reply_to = None
-        self.thread_id = None
+        self.sub_messages_panel = None
+        # Method to call to check if a new message must be displayer in sub-messages
+        # panel.
+        self.sub_messages_check_cb: Callable|None = None
+        self._reply_to: str|None = None
+        self.thread_id: str|None = None
+        self.keyword: str|None = None
         document["cancel_reply_btn"].bind(
             "click",
             lambda __: setattr(self, "reply_to", None)
@@ -363,12 +368,7 @@
 
     @property
     def is_at_bottom(self):
-        return (
-            self.messages_elt.scrollHeight
-            - self.messages_elt.scrollTop
-            - self.messages_elt.clientHeight
-            <= SCROLL_SENSITIVITY
-        )
+        return self.is_elt_at_bottom(self.messages_elt)
 
     @property
     def reply_to(self) -> dict|None:
@@ -422,6 +422,15 @@
                     else:
                         message_core_elt.classList.add(SELECTED_THREAD_CLS)
 
+    def is_elt_at_bottom(self, elt) -> bool:
+        """Tell is a scrollable elemement is scrolled down.
+
+        There is a margin to check it set with SCROLL_SENSITIVITY.
+        @param element: Scrollable element to check.
+        @return: True if the element is as its bottom.
+        """
+        return elt.scrollHeight - elt.scrollTop - elt.clientHeight <= SCROLL_SENSITIVITY
+
     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.
@@ -457,6 +466,9 @@
             if self.thread_id:
                 extra["thread"] = self.thread_id
 
+            if self.keyword:
+                extra.setdefault("keywords", []).append(self.keyword)
+
             # now we send the message
             try:
                 if self.input_mode == "edit":
@@ -702,19 +714,19 @@
                     # 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
+            self.sub_messages_check_cb is not None
+            and self.sub_messages_panel is not None
+            and self.sub_messages_check_cb(extra)
         ):
             # 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.
+            #   may not be working correctly,
             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"])
+            sub_messages_elt = document["sub-messages"]
+            is_at_bottom = self.is_elt_at_bottom(sub_messages_elt)
+            sub_messages_elt.appendChild(cloned_message_elt)
+            if is_at_bottom:
+                sub_messages_elt.scrollTop = sub_messages_elt.scrollHeight
 
         # Check if user is viewing older messages or is at the bottom
         is_at_bottom = self.is_at_bottom
@@ -854,18 +866,33 @@
         #     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
+    async def show_filtered_messages(
+        self,
+        filters: dict,
+        sub_messages_panel_args: dict|None = None,
+        sub_messages_check_cb: Callable[[dict], bool]|None = None
+    ) -> None:
+        """Show message matching a filter in a sub-messages panel.
 
-        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
+        A panel will slide-in from the right to show filtered messages.
+        @param filters: Filters to use in ``bridge.history_get``.
+        @param sub_messages_panel_args: Args to use with sub_messages_panel template.
+        @param sub_messages_check_cb: Method to call to check if a new message must go to
+            the sub-messages panel. The method must return True if the message is to be
+            displayed in the panel.
+        """
+        if sub_messages_panel_args is None:
+            sub_messages_panel_args = {}
+        self.sub_messages_check_cb = sub_messages_check_cb
+        sub_messages_panel_elt = self.sub_messages_panel_tpl.get_elt(
+            sub_messages_panel_args
+        )
+        self.sub_messages_panel = sub_messages_panel_elt
+        sub_messages_elt = sub_messages_panel_elt.select_one("#sub-messages")
+        sub_messages_input_elt = sub_messages_panel_elt.select_one("#sub-messages-input")
+        assert sub_messages_elt is not None
         history_data = await bridge.history_get(
-            "", "", -2, True, {"thread_id": thread_id}
+            "", "", -2, True, filters
         )
         for message_data in history_data:
             uid, timestamp, from_jid_s, to_jid_s, message_data, subject_data, mess_type, extra_s = message_data
@@ -880,25 +907,56 @@
                 extra=json.loads(extra_s)
             )
             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.
+            sub_messages_elt <= message_elt
+        # FIXME: The whole input-panel is currently moved to the filtered messages 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 non-modal panel
+        #   could be used.
         input_panel_elt = document["input-panel"]
-        thread_messages_elt <= input_panel_elt
+        placeholder = make_placeholder(input_panel_elt)
+        sub_messages_input_elt <= input_panel_elt
+        document["input-panel-area"].appendChild(placeholder)
+
+        def on_sub_messages_panel_close():
+            placeholder.replaceWith(input_panel_elt)
+            self.thread_id = None
+            self.keyword = None
+            self.sub_messages_panel = None
+            self.sub_messages_check_cb = None
+
+        sub_messages_modal = dialog.Modal(
+            sub_messages_panel_elt,
+            closable=True,
+            close_cb=on_sub_messages_panel_close,
+            position="right"
+        )
+        sub_messages_modal.show()
 
-        def on_thread_panel_close():
-            document["input-panel-area"].appendChild(input_panel_elt)
-            self.thread_id = None
-            self.thread_panel = None
+    async def on_show_thread(self, thread_id: str) -> None:
+        assert thread_id
+        self.thread_id = thread_id
+        def sub_messages_check_cb(extra: dict) -> bool:
+            return bool(
+                self.thread_id
+                and "thread" in extra
+                and extra["thread"] == self.thread_id
+            )
 
-        thread_modal = dialog.Modal(
-            thread_panel_elt,
-            closable=True,
-            close_cb=on_thread_panel_close
+        await self.show_filtered_messages(
+            {"thread_id": thread_id},
+            {"title": "Thread view"},
+            sub_messages_check_cb
         )
-        thread_modal.show()
+
+    async def _on_keyword_click(self, keyword_elt) -> None:
+        self.keyword = keyword_elt.text
+        def sub_messages_check_cb(extra: dict) -> bool:
+            return self.keyword in extra.get("keywords", [])
+        await self.show_filtered_messages(
+            {"keyword": self.keyword},
+            {"title": f"🏷 Label view: {self.keyword}."},
+            sub_messages_check_cb
+        )
 
 
     async def get_message_tuple(self, message_elt) -> tuple|None:
@@ -1060,6 +1118,16 @@
                 lambda __, thread_id=thread_id: aio.run(self.on_show_thread(thread_id))
             )
 
+        ## keywords
+        for keyword_elt in parent_elt.select(".message-keyword"):
+            keyword_elt.bind(
+                "click",
+                lambda __, keyword_elt=keyword_elt: aio.run(
+                    self._on_keyword_click(keyword_elt)
+                )
+            )
+
+
     def add_reactions_listeners(self, parent_elt=None) -> None:
         """Add listener on reactions to handle details and reaction toggle"""
         if parent_elt is None: