Mercurial > libervia-web
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()