changeset 1619:a2cd4222c702

browser: Updates for new design: This patch add code to handle the new design for chat. New bridge method are used to invite users to MUC or get list of occupants. A new modules is used for components, with a first one for collapsible cards. rel 457
author Goffi <goffi@goffi.org>
date Sat, 12 Apr 2025 00:21:45 +0200
parents 5d9889f14012
children 3a60bf3762ef
files libervia/web/pages/_browser/cache.py libervia/web/pages/_browser/components.py libervia/web/pages/_browser/dialog.py libervia/web/pages/_browser/errors.py libervia/web/pages/_browser/jid_search.py libervia/web/pages/_browser/popup.py libervia/web/pages/_browser/template.py libervia/web/pages/chat/_browser/__init__.py libervia/web/pages/chat/page_meta.py libervia/web/server/restricted_bridge.py
diffstat 10 files changed, 499 insertions(+), 43 deletions(-) [+]
line wrap: on
line diff
--- a/libervia/web/pages/_browser/cache.py	Sat Oct 26 23:07:01 2024 +0200
+++ b/libervia/web/pages/_browser/cache.py	Sat Apr 12 00:21:45 2025 +0200
@@ -2,6 +2,7 @@
 from browser.local_storage import storage
 from dialog import notification
 from bridge import Bridge, AsyncBridge
+import javascript
 import json
 
 log.warning = log.warn
@@ -42,7 +43,9 @@
 
     def update(self):
         log.debug(f"updating: {self._cache}")
-        storage['libervia_cache'] = json.dumps(self._cache)
+        # FIXME: workaround in Brython 3.13.0
+        # storage['libervia_cache'] = json.dumps(self._cache)
+        storage['libervia_cache'] = javascript.JSON.stringify(self._cache)
         log.debug("cache stored")
 
     def _store_if_complete(self):
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/_browser/components.py	Sat Apr 12 00:21:45 2025 +0200
@@ -0,0 +1,31 @@
+from browser import document
+
+def init_collapsible_cards(parent_elt=None) -> None:
+    """Initialize cards which can be collapsed."""
+    parent = parent_elt or document
+    cards = parent.select('.collapsible-card')
+
+    for card in cards:
+        header = card.select_one('.collapsible-header')
+        content = card.select_one('.collapsible-content')
+
+        content.style.maxHeight = 'none'
+        natural_height = content.scrollHeight
+        content.dataset.natural_height = natural_height
+
+        header.bind('click', lambda ev: _toggle_card(ev))
+
+def _toggle_card(event):
+    """Collapse/expand the card."""
+    header = event.currentTarget
+    card = header.closest('.collapsible-card')
+    content = card.select_one('.collapsible-content')
+
+    if content.style.maxHeight == '0px':
+        # Expand
+        content.style.maxHeight = f"{content.dataset.natural_height}px"
+        header.classList.remove('collapsed')
+    else:
+        # Collapse
+        content.style.maxHeight = '0px'
+        header.classList.add('collapsed')
--- a/libervia/web/pages/_browser/dialog.py	Sat Oct 26 23:07:01 2024 +0200
+++ b/libervia/web/pages/_browser/dialog.py	Sat Apr 12 00:21:45 2025 +0200
@@ -181,6 +181,62 @@
             elt.bind('click', lambda __: self.retry(notif_elt))
 
 
+class Modal:
+
+    def __init__(self, content_elt, is_card=False, closable=False):
+        """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.
+        """
+        self.is_card = is_card
+        if is_card:
+            if not content_elt.classList.contains("modal-card"):
+                raise ValueError(
+                    'Element must have a "modal-card" class when `is_card` is used'
+                )
+        self.closable = closable
+        self._tpl = Template("dialogs/modal.html")
+        self.content_elt = content_elt
+        self._modal_elt = None
+        self.reset()
+
+    def reset(self):
+        """Reset values of callbacks and notif element"""
+        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()
+
+    def close(self):
+        """Close the dialog."""
+        if self._modal_elt is None:
+            log.warning("Calling close on an unshown dialog.")
+        self.reset()
+
+    def on_close_click(self, evt) -> None:
+        evt.preventDefault()
+        evt.stopPropagation()
+        self.close()
+
+    def show(self) -> None:
+        modal_elt = self._tpl.get_elt({
+            "closable": self.closable,
+        })
+        self._modal_elt = modal_elt
+        if self.is_card:
+            container_elt = modal_elt
+        else:
+            container_elt = modal_elt.select_one(".modal-content")
+        container_elt <= self.content_elt
+
+        document['notifs_area'] <= modal_elt
+        for ok_elt in modal_elt.select(".modal-close"):
+            ok_elt.bind("click", self.on_close_click)
 
 
 notification = Notification()
--- a/libervia/web/pages/_browser/errors.py	Sat Oct 26 23:07:01 2024 +0200
+++ b/libervia/web/pages/_browser/errors.py	Sat Apr 12 00:21:45 2025 +0200
@@ -1,2 +1,6 @@
 class TimeoutError(Exception):
-    """An action has not been done in time"""
+    """An action has not been done in time."""
+
+
+class InternalError(Exception):
+    """Something went wrong with our code."""
--- a/libervia/web/pages/_browser/jid_search.py	Sat Oct 26 23:07:01 2024 +0200
+++ b/libervia/web/pages/_browser/jid_search.py	Sat Apr 12 00:21:45 2025 +0200
@@ -1,9 +1,11 @@
 import json
+from typing import Callable
 from urllib.parse import quote, urljoin
 
 from bridge import AsyncBridge as Bridge
 from browser import aio, console as log, window
 from cache import cache
+import errors
 from template import Template
 import jid
 
@@ -17,7 +19,7 @@
         self,
         search_elt,
         container_elt=None,
-        filter_cb=None,
+        filter_cb: Callable[[list[str]], None]|None = None,
         empty_cb=None,
         get_url=None,
         click_cb=None,
@@ -39,7 +41,9 @@
             search result
         @param options: extra options. Key can be:
             no_group(bool)
-                True if groups should not be visible
+                True if entity groups should not be visible
+            type (str)
+                Can be "group" or "one2one". Default to one2one.
             extra_cb(dict)
                 a map from CSS selector to callback, the callback will be binded to the
                 "click" event, and will be called with the ``item`` as argument
@@ -52,6 +56,8 @@
         @param selection_state_callback: A callback function to execute when selection
             state changes. Takes one boolean parameter indicating if items are selected.
         """
+        self.selected = set()
+        self.current_items = set()
         self.search_item_tpl = Template(template)
         self.search_elt = search_elt
         self.search_elt.bind("input", self.on_search_input)
@@ -68,6 +74,13 @@
         if options is None:
             options = {}
         self.options = options
+        match options.get("type"):
+            case None | "one2one":
+                self.group = False
+            case "group":
+                self.group = True
+            case _:
+                raise errors.InternalError(f"Invalid type: {options.get('type')!r}.")
 
         if click_cb is not None and get_url is None:
             self.get_url = lambda _: "#"
@@ -194,7 +207,11 @@
         if self.current_query is None:
             log.debug(f"performing search: {query=}")
             self.current_query = query
-            jid_items = json.loads(await bridge.jid_search(query, ""))
+            if self.group:
+                options = {"entities": False, "groupchat": True}
+            else:
+                options = {"entities": True, "groupchat": False}
+            jid_items = json.loads(await bridge.jid_search(query, json.dumps(options)))
             await cache.fill_identities(i["entity"] for i in jid_items)
 
             self.last_query = query
@@ -221,13 +238,16 @@
     async def on_empty_search(self, jid_search):
         """Handle the situation when the search box is empty"""
         assert self.container_elt is not None
-        items = [
-            {
-                "entity": jid_,
-                "groups": data["groups"]
-            }
-            for jid_, data in cache.roster.items()
-        ]
+        if self.group:
+            items = []
+        else:
+            items = [
+                {
+                    "entity": jid_,
+                    "groups": data["groups"]
+                }
+                for jid_, data in cache.roster.items()
+            ]
         self.show_items(items)
 
     def on_checkbox_change(self, evt, item):
--- a/libervia/web/pages/_browser/popup.py	Sat Oct 26 23:07:01 2024 +0200
+++ b/libervia/web/pages/_browser/popup.py	Sat Apr 12 00:21:45 2025 +0200
@@ -1,6 +1,10 @@
 from browser import document, timer
-from js_modules.tippy_js import tippy
+from js_modules.tippy_js import tippy as tippy_ori
+from javascript import pyobj2jsobj
 
+# FIXME: workaround for https://github.com/brython-dev/brython/issues/2542
+def tippy(target, data):
+    return tippy_ori(target, pyobj2jsobj(data))
 
 def create_popup(
     target,
--- a/libervia/web/pages/_browser/template.py	Sat Oct 26 23:07:01 2024 +0200
+++ b/libervia/web/pages/_browser/template.py	Sat Apr 12 00:21:45 2025 +0200
@@ -102,6 +102,20 @@
 env.addFilter("bare_jid", _bare_jid)
 
 
+def _initials(name: str) -> str:
+    """Return the uppercased initials of a name."""
+    name_parts = name.strip().split()
+    if not name_parts:
+        initials = "??"
+    elif len(name_parts) == 1:
+        initials = name_parts[0][:2]
+    else:
+        initials = name_parts[0][0] + name_parts[1][0]
+    return initials.upper()
+
+env.addFilter("initials", _initials)
+
+
 def _next_gidx(value):
     """Use next current global index as suffix"""
     next_ = gidx.next(value)
@@ -162,9 +176,9 @@
         cls = kwargs.get('cls')
     return safe(
         '<svg class="svg-icon{cls}" xmlns="http://www.w3.org/2000/svg" '
-        'viewBox="0 0 100 100">\n'
+        'viewBox="0 0 100 100" fill="currentColor">\n'
         '    <use href="#{name}"/>'
-        '</svg>\n'.format(name=name, cls=(" " + cls) if cls else "")
+        '</svg>\n'.format(name=name.replace(" ", "_"), cls=(" " + cls) if cls else "")
     )
 
 env.addGlobal("icon", _icon_use)
@@ -180,7 +194,9 @@
         auto_new_fmt="relative",
     )
     from js_modules.moment import moment
-    date = moment.unix(timestamp)
+    # FIXME: we use `window.moment` as workaround for
+    #   https://github.com/brython-dev/brython/issues/2542 (see comments)
+    date = window.moment.unix(timestamp)
 
     if fmt == "auto_day":
         fmt, auto_limit, auto_old_fmt, auto_new_fmt = "auto", 0, "short", "HH:mm"
--- a/libervia/web/pages/chat/_browser/__init__.py	Sat Oct 26 23:07:01 2024 +0200
+++ b/libervia/web/pages/chat/_browser/__init__.py	Sat Apr 12 00:21:45 2025 +0200
@@ -1,17 +1,24 @@
 import json
 import re
+from typing import Callable
+import errors
 
 from bridge import AsyncBridge as Bridge
 from browser import DOMNode, aio, console as log, document, window
-from cache import cache, identities
+from cache import cache, identities, roster
 import dialog
 from file_uploader import FileUploader
 import jid
+from javascript import pyobj2jsobj
 from js_modules import emoji_picker_element
-from js_modules.tippy_js import tippy
+from js_modules.tippy_js import tippy as tippy_ori
 import popup
 from template import Template, safe
 from tools import is_touch_device
+from loading import remove_loading_screen
+from jid_search import JidSearch
+from interpreter import Inspector
+from components import init_collapsible_cards
 
 log.warning = log.warn
 profile = window.profile or ""
@@ -19,6 +26,7 @@
 own_local_jid = jid.JID(window.own_local_jid)
 target_jid = jid.JID(window.target_jid)
 chat_type = window.chat_type
+chat_url = window.chat_url
 bridge = Bridge()
 
 # Sensible value to consider that user is at the bottom
@@ -28,10 +36,225 @@
 MODE_CLASS = "mode_{}"
 
 
+# FIXME: workaround for https://github.com/brython-dev/brython/issues/2542
+def tippy(target, data):
+    return tippy_ori(target, pyobj2jsobj(data))
+
+
+class NewChatDialog:
+
+    def __init__(self, on_select: Callable[[str], None]|None = None) -> None:
+        self.on_select = on_select
+        self.new_chat_dialog_tpl = Template("chat/new_chat_dialog.html")
+        self.dialog_elt = self.new_chat_dialog_tpl.get_elt()
+        self.modal = dialog.Modal(self.dialog_elt, is_card=True)
+
+        # direct chat
+        self.direct_search_input_elt = self.dialog_elt.select_one(
+            "div.direct-content input.search-input"
+        )
+        self.direct_items_container = self.dialog_elt.select_one(".direct-items")
+        self.direct_count_elt = self.dialog_elt.select_one(".direct-count")
+        self.start_chat_btn = self.dialog_elt.select_one(".action_ok")
+        assert self.start_chat_btn is not None
+        self.start_chat_btn.bind("click", self.on_start_chat_btn)
+        if not self.direct_count_elt or not self.start_chat_btn:
+            log.error('"direct-count" or "action_ok" element is missing.')
+        self.selected_entities = set()
+        self.jid_search = JidSearch(
+            self.direct_search_input_elt,
+            self.direct_items_container,
+            click_cb = self.on_search_item_click,
+            template = "chat/search_item.html",
+        )
+        for elt in self.dialog_elt.select(".action_close"):
+            elt.bind("click", lambda __: self.close())
+        for elt in self.dialog_elt.select("div.direct-content .action_clear_search"):
+            elt.bind("click", lambda __: self.clear_search_input())
+
+        # groups
+        self.groups_search_input_elt = self.dialog_elt.select_one(
+            "div.groups-content input.search-input"
+        )
+        self.groups_items_container = self.dialog_elt.select_one(".groups-items")
+
+        self.selected_entities = set()
+
+        self.groups_jid_search = JidSearch(
+            self.groups_search_input_elt,
+            self.groups_items_container,
+            click_cb = self.on_group_search_item_click,
+            options={"type": "group"},
+            template = "chat/groups_search_item.html",
+        )
+        for elt in self.dialog_elt.select(".action_close"):
+            elt.bind("click", lambda __: self.close())
+        for elt in self.dialog_elt.select("div.groups-content .action_clear_search"):
+            elt.bind("click", lambda __: self.clear_groups_search_input())
+
+        self.new_room_btn = self.dialog_elt.select_one(".action_new_room")
+        assert self.new_room_btn is not None
+        self.new_room_btn.bind("click", self.on_new_room_btn)
+        self.panel_new_room = self.dialog_elt.select_one(".panel_new_room")
+        assert self.panel_new_room is not None
+        self.create_room_btn = self.dialog_elt.select_one(".action_create_room")
+        assert self.create_room_btn is not None
+        self.create_room_btn.bind("click", self._on_create_room_btn)
+
+        self.error_message_elt = self.dialog_elt.select_one("div.error-message")
+        assert self.error_message_elt is not None
+        self.error_message_elt.select_one("button.delete").bind(
+            "click",
+            lambda __: self.error_message_elt.classList.add("is-hidden")
+        )
+
+        # tabs
+        self.tabs = {}
+        self.selected_tab_elt = None
+        for tab_elt in self.dialog_elt.select('div.tabs>ul>li'):
+            if tab_elt.classList.contains("is-active"):
+                self.selected_tab_elt = tab_elt
+            tab_name = tab_elt.dataset.tab
+            tab_content_elt = self.dialog_elt.select_one(f".{tab_name}-content")
+            assert tab_content_elt is not None
+            self.tabs[tab_elt] = tab_content_elt
+            tab_elt.bind(
+                'click',
+                lambda __, tab_elt=tab_elt: self.set_active_tab(tab_elt)
+            )
+
+    def set_active_tab(self, selected_tab_elt) -> None:
+        """Display a tab."""
+        self.selected_tab_elt = selected_tab_elt
+        for tab_elt, tab_content_elt in self.tabs.items():
+            if tab_elt == selected_tab_elt:
+                tab_elt.classList.add("is-active")
+                tab_content_elt.classList.remove("is-hidden")
+            else:
+                tab_elt.classList.remove("is-active")
+                tab_content_elt.classList.add("is-hidden")
+        self.update()
+
+    def clear_search_input(self) -> None:
+        """Clear search input, and update dialog."""
+        self.direct_search_input_elt.value = ""
+        self.direct_search_input_elt.dispatchEvent(window.Event.new("input"))
+        self.update()
+
+    def clear_groups_search_input(self) -> None:
+        """Clear search input, and update dialog."""
+        self.groups_search_input_elt.value = ""
+        self.groups_search_input_elt.dispatchEvent(window.Event.new("input"))
+        self.groups_update()
+
+    def on_search_item_click(self, event, item) -> None:
+        """A search item has been clicked"""
+        search_item_elt = event.currentTarget
+        search_item_elt.classList.toggle("is-selected")
+        self.update()
+
+    def on_group_search_item_click(self, event, item) -> None:
+        """A search item has been clicked"""
+        for item_elt in self.groups_items_container.select(".search-item"):
+            if item_elt == event.currentTarget:
+                item_elt.classList.add("is-selected")
+            else:
+                item_elt.classList.remove("is-selected")
+        self.update()
+
+    def update(self) -> None:
+        """Update dialog elements (counter, button) when search items change."""
+        assert self.selected_tab_elt is not None
+        current_tab = self.selected_tab_elt.dataset.tab
+        match current_tab:
+            case "direct":
+                self.selected_entities = {
+                    item_elt.dataset.entity for item_elt in
+                    self.direct_items_container.select(".search-item.is-selected")
+                }
+                self.direct_count_elt.text = str(len(self.selected_entities))
+            case "groups":
+                self.selected_entities = {
+                    item_elt.dataset.entity for item_elt in
+                    self.groups_items_container.select(".search-item.is-selected")
+                }
+            case _:
+                raise ValueError(f"Unknown tab: {current_tab!r}.")
+
+        self.start_chat_btn.disabled = not bool(self.selected_entities)
+
+    def groups_update(self) -> None:
+        """Update dialog elements when groups search items change."""
+        self.selected_entities = {
+            item_elt.dataset.entity for item_elt in
+            self.direct_items_container.select(".search-item.is-selected")
+        }
+        self.start_chat_btn.disabled = not bool(self.selected_entities)
+
+    def on_new_room_btn(self, evt) -> None:
+        self.panel_new_room.classList.toggle("is-hidden")
+
+    def _on_create_room_btn(self, evt) -> None:
+        aio.run(self.on_create_room_btn())
+
+    async def on_create_room_btn(self) -> None:
+        assert self.on_select is not None
+        input_elt = self.dialog_elt.select_one(".input-room-name")
+        assert input_elt is not None
+        try:
+            joined_data = await bridge.muc_join(input_elt.value.strip(), "", {})
+        except Exception as e:
+            msg = f"Can't create room: {e}"
+            log.error(msg)
+            self.error_message_elt.select_one("p").text = msg
+            self.error_message_elt.classList.remove("is-hidden")
+            return
+
+        joined, room_jid_s, occupants, user_nick, subject, statuses, profile = joined_data
+        self.on_select(room_jid_s)
+
+    def on_start_chat_btn(self, evt) -> None:
+        evt.stopPropagation()
+        if self.on_select is None:
+            return
+        if not self.selected_entities:
+            raise errors.InternalError(
+                "Start button should never be called when no entity is selected."
+            )
+        if len(self.selected_entities) == 1:
+            selected_entity = next(iter(self.selected_entities))
+            self.on_select(selected_entity)
+        else:
+            aio.run(self.create_room_selected_jids())
+
+    async def create_room_selected_jids(self) -> None:
+        assert self.on_select is not None
+        joined_data = await bridge.muc_join("", "", {})
+        joined, room_jid_s, occupants, user_nick, subject, statuses, profile = joined_data
+        if not self.selected_entities:
+            Inspector()
+        for entity_jid in self.selected_entities:
+            print(f"inviting {entity_jid=}")
+            await bridge.muc_invite(entity_jid, room_jid_s, {})
+        self.on_select(room_jid_s)
+
+
+    def show(self) -> None:
+        """Show the dialog."""
+        # We want ot be sure to have the elements correctly set when dialog is shown
+        self.update()
+        self.modal.show()
+
+    def close(self) -> None:
+        """Close the dialog."""
+        self.modal.close()
+
+
 class LiberviaWebChat:
     def __init__(self):
         self._input_mode = "normal"
         self.input_data = {}
+        self.direct_messages_tpl = Template("chat/direct_messages.html")
         self.message_tpl = Template("chat/message.html")
         self.extra_menu_tpl = Template("chat/extra_menu.html")
         self.reactions_tpl = Template("chat/reactions.html")
@@ -40,18 +263,32 @@
         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.occupant_item_tpl = Template("chat/occupant_item.html")
+
+        # panels and their toggle buttons
+
+        self.left_panel = document["left_panel"]
+        self.left_toggle = document["left_panel-toggle"]
+        self.left_toggle.bind("click", self.on_left_panel_toggle_click)
+        self.main_panel = document["main_panel"]
+        self.right_panel = document["right_panel"]
+        self.right_toggle = document["right_panel-toggle"]
+        self.right_toggle.bind("click", self.on_right_panel_toggle_click)
 
         self.messages_elt = document["messages"]
 
+        # right-panel internal buttons
+        init_collapsible_cards(self.right_panel)
+
         # attachments
         self.file_uploader = FileUploader(
             "", "chat/attachment_preview.html", on_delete_cb=self.on_attachment_delete
         )
         self.attachments_elt = document["attachments"]
-        self.message_input = document["message_input"]
+        self.message_input = document["message_input_area"]
 
-        close_button = document.select_one(".modal-close")
-        close_button.bind("click", self.close_modal)
+        # close_button = document.select_one(".modal-close")
+        # close_button.bind("click", self.close_modal)
 
         # hide/show attachments
         MutationObserver = window.MutationObserver
@@ -70,6 +307,30 @@
         self.auto_resize_message_input()
         self.message_input.focus()
 
+        # direct messages
+        direct_messages_elt = self.direct_messages_tpl.get_elt(
+            {
+                "roster": roster,
+                "identities": identities,
+                "chat_url": chat_url
+            }
+        )
+        document["direct-messages"] <= direct_messages_elt
+
+    async def post_init(self) -> None:
+        if chat_type == "group":
+            occupants = await bridge.muc_occupants_get(
+                str(target_jid)
+            )
+            document["occupants-count"].text = str(len(occupants))
+            for occupant, occupant_data in occupants.items():
+                occupant_elt = self.occupant_item_tpl.get_elt({
+                    "nick": occupant,
+                    "item": occupant_data,
+                    "identities": identities,
+                })
+                document["group-occupants"] <= occupant_elt
+
     @property
     def input_mode(self) -> str:
         return self._input_mode
@@ -95,6 +356,15 @@
             <= 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.
+        window.location = f"{chat_url}/{entity_jid}"
+
+    async def on_new_chat(self) -> None:
+        new_chat_dialog = NewChatDialog(on_select = self.open_chat)
+        new_chat_dialog.show()
+
     async def send_message(self):
         """Send message currently in input area
 
@@ -136,6 +406,7 @@
             else:
                 self.message_input.value = ""
                 self.attachments_elt.clear()
+                self.auto_resize_message_input()
         self.input_mode = "normal"
 
     def _on_message_new(
@@ -348,7 +619,6 @@
 
     def auto_resize_message_input(self):
         """Resize the message input field according to content."""
-
         is_at_bottom = self.is_at_bottom
 
         # The textarea's height is first reset to 'auto' to ensure it's not influenced by
@@ -364,6 +634,16 @@
             # we want the message are to still display the last message
             self.messages_elt.scrollTop = self.messages_elt.scrollHeight
 
+    def on_left_panel_toggle_click(self, evt) -> None:
+        """Show/Hide side bar."""
+        self.left_panel.classList.toggle("is-collapsed")
+        self.main_panel.classList.toggle("is-expanded-left")
+
+    def on_right_panel_toggle_click(self, evt) -> None:
+        """Show/Hide side bar."""
+        self.right_panel.classList.toggle("is-collapsed")
+        self.main_panel.classList.toggle("is-expanded-right")
+
     def on_message_keydown(self, evt):
         """Handle the 'keydown' event of the message input field
 
@@ -408,10 +688,11 @@
         item_elt.remove()
 
     def on_attach_button_click(self, evt):
-        document["file_input"].click()
+        document["file-input"].click()
 
     def on_extra_btn_click(self, evt):
-        message_elt = evt.target.closest("div.is-chat-message")
+        message_elt = evt.target.closest("div.chat-message")
+        message_core_elt = evt.target.closest("div.message-core")
         is_own = message_elt.classList.contains("own_msg")
         if is_own:
             own_messages = document.select('.own_msg')
@@ -424,7 +705,7 @@
             "edit": can_edit,
             "retract": is_own,
         })
-        extra_popup = popup.create_popup(evt.target, content_elt, focus_elt=message_elt)
+        extra_popup = popup.create_popup(evt.target, content_elt, focus_elt=message_core_elt)
 
         def on_action_click(evt, callback):
             extra_popup.hide()
@@ -449,9 +730,9 @@
                 message_elt["id"], [evt.detail["unicode"]], "toggle"
             )
         )
-        if evt.deltaY != 0:
-            document["attachments"].scrollLeft += evt.deltaY * 0.8
-            evt.preventDefault()
+        # 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]
@@ -462,6 +743,7 @@
             message data
         @return: message data as a tuple, or None if not message with this ID is found.
         """
+        print(f"{message_elt=}")
         message_id = message_elt['id']
         history_data = await bridge.history_get(
             "", "", -2, True, {"id": message_elt['id']}
@@ -514,7 +796,7 @@
 
     def get_reaction_panel(self, source_elt):
         emoji_picker_elt = document.createElement("emoji-picker")
-        message_elt = source_elt.closest("div.is-chat-message")
+        message_elt = source_elt.closest("div.chat-message")
         emoji_picker_elt.bind(
             "emoji-click", lambda evt: self.on_reaction_click(evt, message_elt)
         )
@@ -537,8 +819,10 @@
             img_elt.style.cursor = "pointer"
 
         ## reaction button
+        i = 0
         for reaction_btn in parent_elt.select(".reaction-button"):
-            message_elt = reaction_btn.closest("div.is-chat-message")
+            i+=1
+            message_elt = reaction_btn.closest("div.message-core")
             tippy(
                 reaction_btn,
                 {
@@ -554,7 +838,7 @@
                     "onHide": lambda __, message_elt=message_elt: (
                         message_elt.classList.remove("has-popup-focus")
                     ),
-                },
+                }
             )
 
         ## extra button
@@ -563,7 +847,7 @@
 
         ## editions
         for edition_icon_elt in parent_elt.select(".message-editions"):
-            message_elt = edition_icon_elt.closest("div.is-chat-message")
+            message_elt = edition_icon_elt.closest("div.chat-message")
             dataset = message_elt.dataset.to_dict()
             try:
                 editions = json.loads(dataset["editions"])
@@ -617,7 +901,7 @@
             # Toggle reaction when clicked/touched
             emoji_elt = reaction_elt.select_one(".emoji")
             emoji = emoji_elt.html.strip()
-            message_elt = reaction_elt.closest("div.is-chat-message")
+            message_elt = reaction_elt.closest("div.chat-message")
             msg_id = message_elt["id"]
 
             def toggle_reaction(event, msg_id=msg_id, emoji=emoji):
@@ -683,7 +967,7 @@
 
         if parent_elt is None:
             parent_elt = document
-            chat_message_elts = parent_elt.select(".is-chat-message")
+            chat_message_elts = parent_elt.select(".chat-message")
         else:
             chat_message_elts = [parent_elt]
         for message_elt in chat_message_elts:
@@ -725,18 +1009,24 @@
 
 libervia_web_chat = LiberviaWebChat()
 
+document["new_chat_btn"].bind(
+    "click", lambda __: aio.run(libervia_web_chat.on_new_chat())
+)
+
 document["message_input"].bind(
     "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["file_input"].bind("change", libervia_web_chat.on_file_selected)
+# 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)
 
 document.bind("visibilitychange", libervia_web_chat.handle_visibility_change)
 
 bridge.register_signal("message_new", libervia_web_chat._on_message_new)
 bridge.register_signal("message_update", libervia_web_chat._on_message_update)
+aio.run(libervia_web_chat.post_init())
+remove_loading_screen()
--- a/libervia/web/pages/chat/page_meta.py	Sat Oct 26 23:07:01 2024 +0200
+++ b/libervia/web/pages/chat/page_meta.py	Sat Apr 12 00:21:45 2025 +0200
@@ -24,9 +24,10 @@
     try:
         target_jid_s = self.next_path(request)
     except IndexError:
-        # not chat jid, we redirect to jid selection page
-        self.page_redirect("chat_select", request)
-        return
+        target_jid_s = "goffi@tazar3.int"
+        # # not chat jid, we redirect to jid selection page
+        # self.page_redirect("chat_select", request)
+        # return
 
     try:
         target_jid = jid.JID(target_jid_s)
@@ -50,6 +51,10 @@
     profile = session.profile
     profile_jid = session.jid
 
+    bookmarks = data_format.deserialise(
+        await self.host.bridge_call( "bookmarks_list", "", profile)
+    )
+
     disco = await self.host.bridge_call(
         "disco_infos", target_jid.domain, "", True, profile
     )
@@ -97,12 +102,15 @@
     rdata['identities'] = identities
     template_data["target_jid"] = target_jid
     template_data["chat_type"] = chat_type
+    template_data["bookmarks"] = bookmarks
+    template_data["chat_url"] = self.url
     self.expose_to_scripts(
         request,
         room_subject=room_subject,
         own_local_jid=str(own_local_jid),
         target_jid=target_jid,
         chat_type=chat_type,
+        chat_url=self.url
     )
 
 
--- a/libervia/web/server/restricted_bridge.py	Sat Oct 26 23:07:01 2024 +0200
+++ b/libervia/web/server/restricted_bridge.py	Sat Apr 12 00:21:45 2025 +0200
@@ -282,6 +282,30 @@
             "jingle_terminate", session_id, reason, reason_txt, profile
         )
 
+    async def muc_invite(
+        self, guest_jid_s: str, room_jid_s: str, options: dict[str, str], profile: str
+    ):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "muc_invite", guest_jid_s, room_jid_s, options, profile
+        )
+
+    async def muc_occupants_get(
+        self, room_jid_s: str, profile: str
+    ):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "muc_occupants_get", room_jid_s, profile
+        )
+
+    async def muc_join(
+        self, room_jid_s: str, nick: str, options: dict[str, str], profile: str
+    ):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "muc_join", room_jid_s, nick, options, profile
+        )
+
     async def ps_invite(
         self, invitee_jid_s, service_s, node, item_id, name, extra_s, profile
     ):