changeset 1577:9ba532041a8e

browser (chat): implement message reactions.
author Goffi <goffi@goffi.org>
date Wed, 22 Nov 2023 16:31:36 +0100
parents c7d15ded4cbb
children c57133362fb7
files libervia/web/pages/calls/_browser/webrtc.py libervia/web/pages/chat/_browser/__init__.py
diffstat 2 files changed, 158 insertions(+), 22 deletions(-) [+]
line wrap: on
line diff
--- 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
--- 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()