# HG changeset patch # User Goffi # Date 1700667096 -3600 # Node ID 9ba532041a8e837fcb07bbeef36fe00fcb985443 # Parent c7d15ded4cbb5b8d2e9e86725c218317790c6ff0 browser (chat): implement message reactions. diff -r c7d15ded4cbb -r 9ba532041a8e libervia/web/pages/calls/_browser/webrtc.py --- a/libervia/web/pages/calls/_browser/webrtc.py Wed Nov 22 16:31:36 2023 +0100 +++ b/libervia/web/pages/calls/_browser/webrtc.py Wed Nov 22 16:31:36 2023 +0100 @@ -234,7 +234,6 @@ """ log.debug(f"on ice candidate {event.candidate=}") if event.candidate and event.candidate.candidate: - window.last_event = event parsed_candidate = self.parse_ice_candidate(event.candidate.candidate) if parsed_candidate is None: return diff -r c7d15ded4cbb -r 9ba532041a8e libervia/web/pages/chat/_browser/__init__.py --- a/libervia/web/pages/chat/_browser/__init__.py Wed Nov 22 16:31:36 2023 +0100 +++ b/libervia/web/pages/chat/_browser/__init__.py Wed Nov 22 16:31:36 2023 +0100 @@ -2,17 +2,21 @@ import re from bridge import AsyncBridge as Bridge -from browser import DOMNode, aio, bind, console as log, document, window, html +from browser import DOMNode, aio, bind, console as log, document, window from cache import cache, identities import dialog from file_uploader import FileUploader import jid +from js_modules import emoji_picker_element +from js_modules.tippy_js import tippy from template import Template, safe +from tools import is_touch_device log.warning = log.warn profile = window.profile or "" own_jid = jid.JID(window.own_jid) target_jid = jid.JID(window.target_jid) +chat_type = window.chat_type bridge = Bridge() # Sensible value to consider that user is at the bottom @@ -22,6 +26,8 @@ class LiberviaWebChat: def __init__(self): self.message_tpl = Template("chat/message.html") + self.reactions_tpl = Template("chat/reactions.html") + self.reactions_details_tpl = Template("chat/reactions_details.html") self.url_preview_control_tpl = Template("components/url_preview_control.html") self.url_preview_tpl = Template("components/url_preview.html") self.new_messages_marker_elt = Template("chat/new_messages_marker.html").get_elt() @@ -35,6 +41,9 @@ self.attachments_elt = document["attachments"] self.message_input = document["message_input"] + close_button = document.select_one(".modal-close") + close_button.bind("click", self.close_modal) + # hide/show attachments MutationObserver = window.MutationObserver observer = MutationObserver.new(lambda *__: self.update_attachments_visibility()) @@ -76,7 +85,7 @@ try: aio.run( bridge.message_send( - str(target_jid), {"": message}, {}, "auto", json.dumps(extra) + str(target_jid), {"": message}, {}, "auto", json.dumps(extra, ensure_ascii=False) ) ) except Exception as e: @@ -89,17 +98,19 @@ self, uid: str, timestamp: float, - from_jid: str, - to_jid: str, + from_jid_s: str, + to_jid_s: str, message: dict, subject: dict, mess_type: str, extra_s: str, profile: str, ) -> None: + from_jid = jid.JID(from_jid_s) + to_jid = jid.JID(to_jid_s) if ( - jid.JID(from_jid).bare == window.target_jid - or jid.JID(to_jid).bare == window.target_jid + from_jid.bare == window.target_jid + or to_jid.bare == window.target_jid ): aio.run( self.on_message_new( @@ -115,12 +126,42 @@ ) ) + def _on_message_update( + self, uid: str, type_: str, update_data_s: str, profile: str + ) -> None: + aio.run(self.on_message_update(uid, type_, update_data_s, profile)) + + async def on_message_update( + self, uid: str, type_: str, update_data_s: str, profile: str + ) -> None: + update_data = json.loads(update_data_s) + is_at_bottom = self.is_at_bottom + if type_ == "REACTION": + reactions = update_data["reactions"] + log.debug(f"new reactions: {reactions}") + try: + reactions_wrapper_elt = document[f"msg_reactions_{uid}"] + except KeyError: + log.debug(f"Message {uid} not found, no reactions to update") + else: + log.debug(f"Message {uid} found, new reactions: {reactions}") + reactions_elt = self.reactions_tpl.get_elt({"reactions": reactions}) + reactions_wrapper_elt.clear() + reactions_wrapper_elt <= reactions_elt + self.add_reactions_listeners(reactions_elt) + else: + log.warning(f"Unsupported update type: {type_!r}") + + # If user was at the bottom, keep the scroll at the bottom + if is_at_bottom: + self.messages_elt.scrollTop = self.messages_elt.scrollHeight + async def on_message_new( self, uid: str, timestamp: float, - from_jid: str, - to_jid: str, + from_jid: jid.JID, + to_jid: jid.JID, message_data: dict, subject_data: dict, mess_type: str, @@ -129,7 +170,10 @@ ) -> None: # FIXME: visibilityState doesn't detect OS events such as `Alt + Tab`, using focus # event may help to get those use cases, but it gives false positives. - if document.visibilityState == "hidden" and self.new_messages_marker_elt.parent is None: + if ( + document.visibilityState == "hidden" + and self.new_messages_marker_elt.parent is None + ): # the page is not visible, and we have no new messages marker yet, so we add # it self.messages_elt <= self.new_messages_marker_elt @@ -142,13 +186,16 @@ except KeyError: xhtml = next(iter(xhtml_data.values())) - await cache.fill_identities([from_jid]) + if chat_type == "group": + await cache.fill_identities([str(from_jid)]) + else: + await cache.fill_identities([str(jid.JID(from_jid).bare)]) msg_data = { "id": uid, "timestamp": timestamp, "type": mess_type, - "from_": from_jid, + "from_": str(from_jid), "text": message_data.get("") or next(iter(message_data.values()), ""), "subject": subject_data.get("") or next(iter(subject_data.values()), ""), "type": mess_type, @@ -175,7 +222,7 @@ is_at_bottom = self.is_at_bottom self.messages_elt <= message_elt - self.make_attachments_dynamic(message_elt) + self.add_message_event_listeners(message_elt) # we add preview in parallel on purpose, as they can be slow to get self.handle_url_previews(message_elt) @@ -235,6 +282,17 @@ def on_attach_button_click(self, evt): document["file_input"].click() + def on_extra_btn_click(self, evt): + print("extra bouton clicked!") + + def on_reaction_click(self, evt, message_elt): + window.evt = evt + aio.run( + bridge.message_reactions_set( + message_elt["id"], [evt.detail["unicode"]], "toggle" + ) + ) + @bind(document["attachments"], "wheel") def wheel_event(evt): """Make the mouse wheel to act on horizontal scrolling for attachments @@ -246,8 +304,23 @@ document["attachments"].scrollLeft += evt.deltaY * 0.8 evt.preventDefault() - def make_attachments_dynamic(self, parent_elt=None): - """Make attachments dynamically clickable""" + def get_reaction_panel(self, source_elt): + emoji_picker_elt = document.createElement("emoji-picker") + message_elt = source_elt.closest("div.is-chat-message") + emoji_picker_elt.bind( + "emoji-click", lambda evt: self.on_reaction_click(evt, message_elt) + ) + + return emoji_picker_elt + + def add_message_event_listeners(self, parent_elt=None): + """Prepare a message to be dynamic + + - make attachments dynamically clickable + - make the extra button clickable + """ + + ## attachments # FIXME: only handle images for now, and display them in a modal if parent_elt is None: parent_elt = document @@ -256,8 +329,70 @@ img_elt.bind("click", self.open_modal) img_elt.style.cursor = "pointer" - close_button = document.select_one(".modal-close") - close_button.bind("click", self.close_modal) + ## reaction button + for reaction_btn in parent_elt.select(".reaction-button"): + message_elt = reaction_btn.closest("div.is-chat-message") + tippy( + reaction_btn, + { + "trigger": "click", + "content": self.get_reaction_panel, + "appendTo": document.body, + "placement": "bottom", + "interactive": True, + "theme": "light", + "onShow": lambda __, message_elt=message_elt: ( + message_elt.classList.add("chat-message-highlight") + ), + "onHide": lambda __, message_elt=message_elt: ( + message_elt.classList.remove("chat-message-highlight") + ), + }, + ) + + ## extra button + for extra_btn in parent_elt.select(".extra-button"): + extra_btn.bind("click", self.on_extra_btn_click) + + def add_reactions_listeners(self, parent_elt=None) -> None: + """Add listener on reactions to handle details and reaction toggle""" + if parent_elt is None: + parent_elt = document + + is_touch = is_touch_device() + + for reaction_elt in parent_elt.select(".reaction"): + # Reaction details + dataset = reaction_elt.dataset.to_dict() + reacting_jids = sorted(json.loads(dataset.get("jids", "[]"))) + reaction_details_elt = self.reactions_details_tpl.get_elt( + {"reacting_jids": reacting_jids, "identities": identities} + ) + + # Configure tippy based on device type + tippy_config = { + "content": reaction_details_elt, + "placement": "bottom", + "theme": "light", + "touch": ["hold", 500] if is_touch else True, + "trigger": "click" if is_touch else "mouseenter focus", + "delay": [0, 800] if is_touch else 0, + } + tippy(reaction_elt, tippy_config) + + # 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") + msg_id = message_elt["id"] + + def toggle_reaction(event, msg_id=msg_id, emoji=emoji): + # Prevent default if it's a touch device to distinguish from long press + if is_touch: + event.preventDefault() + aio.run(bridge.message_reactions_set(msg_id, [emoji], "toggle")) + + reaction_elt.bind("click", toggle_reaction) def find_links(self, message_elt): """Find all http and https links within the body of a message.""" @@ -305,7 +440,6 @@ ) url_previews_elt <= url_preview_elt - def handle_url_previews(self, parent_elt=None): """Check if URL are presents in a message and show appropriate element @@ -324,12 +458,14 @@ url_previews_elt = message_elt.select_one(".url-previews") url_previews_elt.classList.remove("is-hidden") preview_control_elt = self.url_preview_control_tpl.get_elt() - fetch_preview_btn = preview_control_elt.select_one(".action_fetch_preview") + fetch_preview_btn = preview_control_elt.select_one( + ".action_fetch_preview" + ) fetch_preview_btn.bind( "click", lambda __, previews_elt=url_previews_elt, preview_urls=urls: aio.run( self.add_url_previews(previews_elt, preview_urls) - ) + ), ) url_previews_elt <= preview_control_elt @@ -344,7 +480,6 @@ modal = document.select_one("#modal") modal.classList.remove("is-active") - def handle_visibility_change(self, evt): if ( document.visibilityState == "hidden" @@ -367,6 +502,8 @@ 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) -libervia_web_chat.make_attachments_dynamic() +libervia_web_chat.add_message_event_listeners() libervia_web_chat.handle_url_previews() +libervia_web_chat.add_reactions_listeners()