changeset 1536:dc81403a5b2f

browser: chat page: since the move to Brython, the chat was really basic and not really usable. Now that dynamism has been re-implemented correctly in the new frontend, a real advanced chat page can be done. This is the first draft in this direction.
author Goffi <goffi@goffi.org>
date Wed, 28 Jun 2023 10:05:13 +0200
parents de09d4d25194
children c2569408fa35
files libervia/web/pages/chat/_browser/__init__.py libervia/web/pages/chat/page_meta.py
diffstat 2 files changed, 298 insertions(+), 13 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/chat/_browser/__init__.py	Wed Jun 28 10:05:13 2023 +0200
@@ -0,0 +1,275 @@
+import json
+
+import dialog
+import jid
+from bridge import AsyncBridge as Bridge
+from browser import aio, console as log, document, DOMNode, window, bind
+from template import Template, safe
+from file_uploader import FileUploader
+from cache import cache, identities
+
+log.warning = log.warn
+profile = window.profile or ""
+own_jid = jid.JID(window.own_jid)
+target_jid = jid.JID(window.target_jid)
+bridge = Bridge()
+
+# Sensible value to consider that user is at the bottom
+SCROLL_SENSITIVITY = 200
+
+
+class LiberviaWebChat:
+
+    def __init__(self):
+        self.message_tpl = Template("chat/message.html")
+        self.messages_elt = document["messages"]
+
+        # 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"]
+
+        # hide/show attachments
+        MutationObserver = window.MutationObserver
+        observer = MutationObserver.new(lambda *__: self.update_attachments_visibility())
+        observer.observe(self.attachments_elt, {"childList": True})
+
+        # we want the message scroll to be initially at the bottom
+        self.messages_elt.scrollTop = self.messages_elt.scrollHeight
+
+    @property
+    def is_at_bottom(self):
+        return (
+            self.messages_elt.scrollHeight
+            - self.messages_elt.scrollTop
+            - self.messages_elt.clientHeight
+            <= SCROLL_SENSITIVITY
+        )
+
+
+    def send_message(self):
+        """Send message currently in input area
+
+        The message and corresponding attachment will be sent
+        """
+        message = self.message_input.value.rstrip()
+        log.info(f"{message=}")
+
+        # attachments
+        attachments = []
+        for attachment_elt in self.attachments_elt.children:
+            file_data = json.loads(attachment_elt.getAttribute("data-file"))
+            attachments.append(file_data)
+
+        if message or attachments:
+            extra = {}
+
+            if attachments:
+                extra["attachments"] = attachments
+
+            # now we send the message
+            try:
+                aio.run(
+                    bridge.message_send(
+                        str(target_jid), {"": message}, {}, "auto", json.dumps(extra)
+                    )
+                )
+            except Exception as e:
+                dialog.notification.show(f"Can't send message: {e}", "error")
+            else:
+                self.message_input.value = ""
+                self.attachments_elt.clear()
+
+    def _on_message_new(
+        self,
+        uid: str,
+        timestamp: float,
+        from_jid: str,
+        to_jid: str,
+        message: dict,
+        subject: dict,
+        mess_type: str,
+        extra_s: str,
+        profile: str,
+    ) -> None:
+        if (
+            jid.JID(from_jid).bare == window.target_jid
+            or jid.JID(to_jid).bare == window.target_jid
+        ):
+            aio.run(
+                self.on_message_new(
+                    uid,
+                    timestamp,
+                    from_jid,
+                    to_jid,
+                    message,
+                    subject,
+                    mess_type,
+                    json.loads(extra_s),
+                    profile,
+                )
+            )
+
+    async def on_message_new(
+        self,
+        uid: str,
+        timestamp: float,
+        from_jid: str,
+        to_jid: str,
+        message_data: dict,
+        subject_data: dict,
+        mess_type: str,
+        extra: dict,
+        profile: str,
+    ) -> None:
+        log.info(f"on_message_new: [{from_jid} -> {to_jid}] {message_data}, {extra=}")
+        xhtml_data = extra.get("xhtml")
+        if not xhtml_data:
+            xhtml = None
+        else:
+            try:
+                xhtml = xhtml_data[""]
+            except KeyError:
+                xhtml = next(iter(xhtml_data.values()))
+
+        await cache.fill_identities([from_jid])
+
+        msg_data = {
+            "id": uid,
+            "timestamp": timestamp,
+            "type": mess_type,
+            "from_": 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,
+            "thread": extra.get("thread"),
+            "thread_parent": extra.get("thread_parent"),
+            "reeceived": extra.get("received_timestamp") or timestamp,
+            "delay_sender": extra.get("delay_sender"),
+            "info_type": extra.get("info_type"),
+            "html": safe(xhtml) if xhtml else None,
+            "encrypted": extra.get("encrypted", False),
+            "received": extra.get("received", False),
+            "edited": extra.get("edited", False),
+            "attachments": extra.get("attachments", []),
+        }
+        message_elt = self.message_tpl.get_elt(
+            {
+                "own_jid": own_jid,
+                "msg": msg_data,
+                "identities": identities,
+            }
+        )
+
+        # Check if user is viewing older messages or is at the bottom
+        is_at_bottom = self.is_at_bottom
+
+        self.messages_elt <= message_elt
+        self.make_attachments_dynamic(message_elt)
+
+        # If user was at the bottom, keep the scroll at the bottom
+        if is_at_bottom:
+            self.messages_elt.scrollTop = self.messages_elt.scrollHeight
+
+    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
+        # the previous content.
+        self.message_input.style.height = "auto"
+
+        # Then the height is set to the scrollHeight of the textarea (which is the height
+        # of the content), plus the vertical border, resulting in a textarea that grows as
+        # more lines of text are added.
+        self.message_input.style.height = f"{self.message_input.scrollHeight + 2}px"
+
+        if is_at_bottom:
+            # we want the message are to still display the last message
+            self.messages_elt.scrollTop = self.messages_elt.scrollHeight
+
+    def on_message_keydown(self, evt):
+        """Handle the 'keydown' event of the message input field
+
+        @param evt: The event object. 'target' refers to the textarea element.
+        """
+        if evt.keyCode == 13:  # <Enter> key
+            if not window.navigator.maxTouchPoints:
+                # we have a non touch device, we send message on <Enter>
+                if not evt.shiftKey:
+                    evt.preventDefault()  # Prevents line break
+                    self.send_message()
+
+    def update_attachments_visibility(self):
+        if len(self.attachments_elt.children):
+            self.attachments_elt.classList.remove("is-contracted")
+        else:
+            self.attachments_elt.classList.add("is-contracted")
+
+    def on_file_selected(self, evt):
+        """Handle file selection"""
+        log.info("file selected")
+        files = evt.currentTarget.files
+        self.file_uploader.upload_files(files, self.attachments_elt)
+        self.message_input.focus()
+
+    def on_attachment_delete(self, evt):
+        evt.stopPropagation()
+        target = evt.currentTarget
+        item_elt = DOMNode(target.closest('.attachment-preview'))
+        item_elt.remove()
+
+    def on_attach_button_click(self, evt):
+        document["file_input"].click()
+
+    @bind(document["attachments"], 'wheel')
+    def wheel_event(evt):
+        """Make the mouse wheel to act on horizontal scrolling for attachments
+
+        Attachments don't have vertical scrolling, thus is makes sense to use the wheel
+        for horizontal scrolling
+        """
+        if evt.deltaY != 0:
+            document['attachments'].scrollLeft += evt.deltaY * 0.8
+            evt.preventDefault()
+
+    def make_attachments_dynamic(self, parent_elt = None):
+        """Make attachments dynamically clickable"""
+        # FIXME: only handle images for now, and display them in a modal
+        if parent_elt is None:
+            parent_elt = document
+        img_elts = parent_elt.select('.message-attachment img')
+        for img_elt in img_elts:
+            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)
+
+    def open_modal(self, evt):
+        modal_image = document.select_one('#modal-image')
+        modal_image.src = evt.target.src
+        modal_image.alt = evt.target.alt
+        modal = document.select_one('#modal')
+        modal.classList.add('is-active')
+
+    def close_modal(self, evt):
+        modal = document.select_one('#modal')
+        modal.classList.remove('is-active')
+
+
+libervia_web_chat = LiberviaWebChat()
+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 __: 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)
+bridge.register_signal("message_new", libervia_web_chat._on_message_new)
+libervia_web_chat.make_attachments_dynamic()
--- a/libervia/web/pages/chat/page_meta.py	Thu Jun 22 16:36:18 2023 +0200
+++ b/libervia/web/pages/chat/page_meta.py	Wed Jun 28 10:05:13 2023 +0200
@@ -5,7 +5,7 @@
 from libervia.backend.core.log import getLogger
 from libervia.backend.tools.common import data_objects
 from libervia.backend.tools.common import data_format
-from twisted.words.protocols.jabber import jid
+from libervia.frontends.tools import jid
 from libervia.web.server.constants import Const as C
 from libervia.web.server import session_iface
 
@@ -26,22 +26,22 @@
     except IndexError:
         # not chat jid, we redirect to jid selection page
         self.page_redirect("chat_select", request)
+        return
 
     try:
         target_jid = jid.JID(target_jid_s)
-        if not target_jid.user:
+        if not target_jid.local:
             raise ValueError(_("invalid jid for chat (no local part)"))
     except Exception as e:
         log.warning(
-            _("bad chat jid entered: {jid} ({msg})").format(jid=target_jid, msg=e)
+            _("bad chat jid entered: {jid} ({msg})").format(jid=target_jid_s, msg=e)
         )
         self.page_error(request, C.HTTP_BAD_REQUEST)
     else:
         rdata["target"] = target_jid
 
 
-@defer.inlineCallbacks
-def prepare_render(self, request):
+async def prepare_render(self, request):
     #  FIXME: bug on room filtering (currently display messages from all rooms)
     session = self.host.get_session_data(request, session_iface.IWebSession)
     template_data = request.template_data
@@ -50,11 +50,13 @@
     profile = session.profile
     profile_jid = session.jid
 
-    disco = yield self.host.bridge_call("disco_infos", target_jid.host, "", True, profile)
+    disco = await self.host.bridge_call(
+        "disco_infos", target_jid.domain, "", True, profile
+    )
     if "conference" in [i[0] for i in disco[1]]:
         chat_type = C.CHAT_GROUP
-        join_ret = yield self.host.bridge_call(
-            "muc_join", target_jid.userhost(), "", "", profile
+        join_ret = await self.host.bridge_call(
+            "muc_join", target_jid.bare, "", "", profile
         )
         (already_joined,
          room_jid_s,
@@ -66,27 +68,28 @@
         template_data["subject"] = room_subject
         template_data["room_statuses"] = room_statuses
         own_jid = jid.JID(room_jid_s)
-        own_jid.resource = user_nick
+        own_jid = own_jid.change_resource(user_nick)
     else:
+        room_subject = None
         chat_type = C.CHAT_ONE2ONE
         own_jid = profile_jid
     rdata["chat_type"] = chat_type
     template_data["own_jid"] = own_jid
 
-    self.register_signal(request, "message_new")
-    history = yield self.host.bridge_call(
+    history = await self.host.bridge_call(
         "history_get",
         profile_jid.userhost(),
-        target_jid.userhost(),
+        target_jid.bare,
         20,
         True,
         {},
         profile,
     )
+
     authors = {m[2] for m in history}
     identities = session.identities
     for author in authors:
-        id_raw = yield self.host.bridge_call(
+        id_raw = await self.host.bridge_call(
             "identity_get", author, [], True, profile)
         identities[author] = data_format.deserialise(id_raw)
 
@@ -94,6 +97,13 @@
     rdata['identities'] = identities
     template_data["target_jid"] = target_jid
     template_data["chat_type"] = chat_type
+    self.expose_to_scripts(
+        request,
+        room_subject=room_subject,
+        own_jid=own_jid,
+        target_jid=target_jid,
+        chat_type=chat_type,
+    )
 
 
 def on_data(self, request, data):