diff libervia/web/pages/chat/_browser/__init__.py @ 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 c4407befc52a
children 3a60bf3762ef
line wrap: on
line diff
--- 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()