# HG changeset patch # User Goffi # Date 1687939513 -7200 # Node ID dc81403a5b2f2219b789cdb83cab3d111933fef6 # Parent de09d4d25194d4f26fa347d92a86da6f2bf6cc41 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. diff -r de09d4d25194 -r dc81403a5b2f libervia/web/pages/chat/_browser/__init__.py --- /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: # key + if not window.navigator.maxTouchPoints: + # we have a non touch device, we send message on + 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() diff -r de09d4d25194 -r dc81403a5b2f libervia/web/pages/chat/page_meta.py --- 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):