# HG changeset patch # User Goffi # Date 1749200885 -7200 # Node ID 698eaabfca0e9987ee397f6f2ee99390ca796714 # Parent fd421f1be8f53277c20c44458f0b0b6053b035ec 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 diff -r fd421f1be8f5 -r 698eaabfca0e libervia/web/pages/_browser/dialog.py --- 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 -
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() diff -r fd421f1be8f5 -r 698eaabfca0e libervia/web/pages/_browser/tools.py --- 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 diff -r fd421f1be8f5 -r 698eaabfca0e libervia/web/pages/chat/_browser/__init__.py --- 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: