# HG changeset patch # User Goffi # Date 1701190751 -3600 # Node ID eab815e48795fae97933191b9be652ca7680d99b # Parent 9865013da86cab8465b83af1610d89661eb86b2a browser (chat): message edition + extra menu: - handle extra menu - implement `quote` action - last message correction - show popup with last editions history when "editer" pencil icon is hovered - up arrow let quickly edit last message - implement input modes, `normal` being the default, `edit` or `quote` are new ones. - [ESC] erase input and returns to `normal` mode - fix size and set focus on message input when page is loaded - fix identity retrieval on new messages diff -r 9865013da86c -r eab815e48795 libervia/web/pages/chat/_browser/__init__.py --- a/libervia/web/pages/chat/_browser/__init__.py Tue Nov 28 17:54:30 2023 +0100 +++ b/libervia/web/pages/chat/_browser/__init__.py Tue Nov 28 17:59:11 2023 +0100 @@ -2,13 +2,14 @@ import re from bridge import AsyncBridge as Bridge -from browser import DOMNode, aio, bind, console as log, document, window +from browser import DOMNode, aio, bind, console as log, document, timer, 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 +import popup from template import Template, safe from tools import is_touch_device @@ -22,15 +23,22 @@ # Sensible value to consider that user is at the bottom SCROLL_SENSITIVITY = 200 +INPUT_MODES = {"normal", "edit", "quote"} +MODE_CLASS = "mode_{}" + class LiberviaWebChat: def __init__(self): + self._input_mode = "normal" + self.input_data = {} self.message_tpl = Template("chat/message.html") + self.extra_menu_tpl = Template("chat/extra_menu.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() + self.editions_tpl = Template("chat/editions.html") self.messages_elt = document["messages"] @@ -52,6 +60,31 @@ # we want the message scroll to be initially at the bottom self.messages_elt.scrollTop = self.messages_elt.scrollHeight + # listeners/dynamic updates + self.add_message_event_listeners() + self.handle_url_previews() + self.add_reactions_listeners() + + # input + self.auto_resize_message_input() + self.message_input.focus() + + @property + def input_mode(self) -> str: + return self._input_mode + + @input_mode.setter + def input_mode(self, new_mode: str) -> None: + if new_mode == self.input_mode: + return + if new_mode not in INPUT_MODES: + raise ValueError(f"Invalid input mode: {new_mode!r}") + target_elt = self.message_input + target_elt.classList.remove(MODE_CLASS.format(self._input_mode)) + self._input_mode = new_mode + target_elt.classList.add(MODE_CLASS.format(new_mode)) + self.input_data.clear() + @property def is_at_bottom(self): return ( @@ -61,7 +94,7 @@ <= SCROLL_SENSITIVITY ) - def send_message(self): + async def send_message(self): """Send message currently in input area The message and corresponding attachment will be sent @@ -83,16 +116,26 @@ # now we send the message try: - aio.run( - bridge.message_send( - str(target_jid), {"": message}, {}, "auto", json.dumps(extra, ensure_ascii=False) + if self.input_mode == "edit": + message_id = self.input_data["id"] + edit_data = { + "message": {"": message}, + "extra": extra + } + await bridge.message_edit( + message_id, json.dumps(edit_data, ensure_ascii=False) + ) + else: + await bridge.message_send( + str(target_jid), + {"": message}, {}, "auto", json.dumps(extra, ensure_ascii=False) ) - ) except Exception as e: dialog.notification.show(f"Can't send message: {e}", "error") else: self.message_input.value = "" self.attachments_elt.clear() + self.input_mode = "normal" def _on_message_new( self, @@ -149,6 +192,29 @@ reactions_wrapper_elt.clear() reactions_wrapper_elt <= reactions_elt self.add_reactions_listeners(reactions_elt) + elif type_ == "EDIT": + try: + old_message_elt = document[uid] + except KeyError: + log.debug(f"Message {uid} not found, no edition to apply") + else: + template_data = await self.message_to_template_data( + uid, + update_data["timestamp"], + jid.JID(update_data["from"]), + jid.JID(update_data["to"]), + update_data["message"], + update_data["subject"], + update_data["type"], + update_data["extra"] + ) + + new_message_elt = self.message_tpl.get_elt( + template_data + ) + old_message_elt.replaceWith(new_message_elt) + self.add_message_event_listeners(new_message_elt) + self.handle_url_previews(new_message_elt) else: log.warning(f"Unsupported update type: {type_!r}") @@ -156,6 +222,60 @@ if is_at_bottom: self.messages_elt.scrollTop = self.messages_elt.scrollHeight + async def message_to_template_data( + self, + uid: str, + timestamp: float, + from_jid: jid.JID, + to_jid: jid.JID, + message_data: dict, + subject_data: dict, + mess_type: str, + extra: dict, + ) -> dict: + """Generate template data to use with [message_tpl] + + @return: template data + """ + xhtml_data = extra.get("xhtml") + if not xhtml_data: + xhtml = None + else: + try: + xhtml = xhtml_data[""] + except KeyError: + xhtml = next(iter(xhtml_data.values())) + + if chat_type == "group": + await cache.fill_identities([str(from_jid)]) + else: + await cache.fill_identities([str(jid.JID(from_jid).bare)]) + from_jid = from_jid.bare + + return { + "own_jid": own_jid, + "msg": { + "id": uid, + "timestamp": extra.get("updated", timestamp), + "type": mess_type, + "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, + "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), + "attachments": extra.get("attachments", []), + "extra": extra + }, + "identities": identities, + } + async def on_message_new( self, uid: str, @@ -177,45 +297,20 @@ # 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 - xhtml_data = extra.get("xhtml") - if not xhtml_data: - xhtml = None - else: - try: - xhtml = xhtml_data[""] - except KeyError: - xhtml = next(iter(xhtml_data.values())) - - 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_": 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, - "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", []), - } + template_data = await self.message_to_template_data( + uid, + timestamp, + from_jid, + to_jid, + message_data, + subject_data, + mess_type, + extra + ) + message_elt = self.message_tpl.get_elt( - { - "own_jid": own_jid, - "msg": msg_data, - "identities": identities, - } + template_data ) # Check if user is viewing older messages or is at the bottom @@ -258,7 +353,19 @@ # we have a non touch device, we send message on if not evt.shiftKey: evt.preventDefault() # Prevents line break - self.send_message() + aio.run(self.send_message()) + elif evt.keyCode == 27: # key + evt.preventDefault() + self.message_input.value = '' + self.input_mode = 'normal' + self.auto_resize_message_input() + elif evt.keyCode == 38: # arrow key + if self.input_mode == "normal" and self.message_input.value.strip() == "": + evt.preventDefault() + own_msgs = document.getElementsByClassName('own_msg') + if own_msgs.length > 0: + last_msg = own_msgs[own_msgs.length - 1] + aio.run(self.on_action_edit(None, last_msg)) def update_attachments_visibility(self): if len(self.attachments_elt.children): @@ -283,7 +390,34 @@ document["file_input"].click() def on_extra_btn_click(self, evt): - print("extra bouton clicked!") + message_elt = evt.target.closest("div.is-chat-message") + is_own = message_elt.classList.contains("own_msg") + if is_own: + own_messages = document.select('.own_msg') + # with XMPP, we can currently only edit our last message + can_edit = own_messages and message_elt is own_messages[-1] + else: + can_edit = False + + content_elt = self.extra_menu_tpl.get_elt({ + "edit": can_edit, + }) + extra_popup = popup.create_popup(evt.target, content_elt, focus_elt=message_elt) + + def on_action_click(evt, callback): + extra_popup.hide() + aio.run( + callback(evt, message_elt) + ) + + for cls_name, callback in ( + ("action_quote", self.on_action_quote), + ("action_edit", self.on_action_edit) + ): + for elt in content_elt.select(f".{cls_name}"): + elt.bind("click", lambda evt, callback=callback: on_action_click( + evt, callback + )) def on_reaction_click(self, evt, message_elt): window.evt = evt @@ -292,18 +426,55 @@ 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 - - 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() + async def get_message_tuple(self, message_elt) -> tuple|None: + """Retrieve message tuple from as sent by [message_new] + + If not corresponding message data is found, an error will shown, and None is + returned. + @param message_elt: message element, it's "id" attribute will be use to retrieve + message data + @return: message data as a tuple, or None if not message with this ID is found. + """ + message_id = message_elt['id'] + history_data = await bridge.history_get( + "", "", -2, True, {"id": message_elt['id']} + ) + if not history_data: + dialog.notification.show(f"Can't find message {message_id}", "error") + return None + return history_data[0] + + async def on_action_quote(self, __, message_elt) -> None: + message_data = await self.get_message_tuple(message_elt) + if message_data is not None: + messages = message_data[4] + body = next(iter(messages.values()), "") + quote = "\n".join(f"> {l}" for l in body.split("\n")) + self.message_input.value = f"{quote}\n{self.message_input.value}" + self.input_mode = "quote" + self.input_data["id"] = message_elt["id"] + self.auto_resize_message_input() + self.message_input.focus() + + async def on_action_edit(self, __, message_elt) -> None: + message_data = await self.get_message_tuple(message_elt) + if message_data is not None: + messages = message_data[4] + body = next(iter(messages.values()), "") + if not body: + dialog.notification.show("No content found in message, nothing to edit") + return + + self.message_input.value = body + self.input_mode = "edit" + self.input_data["id"] = message_elt["id"] + self.auto_resize_message_input() + self.message_input.focus() + def get_reaction_panel(self, source_elt): emoji_picker_elt = document.createElement("emoji-picker") message_elt = source_elt.closest("div.is-chat-message") @@ -319,7 +490,6 @@ - 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: @@ -342,10 +512,10 @@ "interactive": True, "theme": "light", "onShow": lambda __, message_elt=message_elt: ( - message_elt.classList.add("chat-message-highlight") + message_elt.classList.add("has-popup-focus") ), "onHide": lambda __, message_elt=message_elt: ( - message_elt.classList.remove("chat-message-highlight") + message_elt.classList.remove("has-popup-focus") ), }, ) @@ -354,6 +524,33 @@ for extra_btn in parent_elt.select(".extra-button"): extra_btn.bind("click", self.on_extra_btn_click) + ## editions + for edition_icon_elt in parent_elt.select(".message-editions"): + message_elt = edition_icon_elt.closest("div.is-chat-message") + dataset = message_elt.dataset.to_dict() + try: + editions = json.loads(dataset["editions"]) + except (ValueError, KeyError): + log.error( + f"Internal Error: invalid or missing editions data: {message_elt['id']}" + ) + else: + for edition in editions: + edition["text"] = ( + edition["message"].get("") + or next(iter(edition["message"].values()), "") + ) + editions_elt = self.editions_tpl.get_elt({"editions": editions}) + tippy( + edition_icon_elt, + { + "content": editions_elt, + "theme": "light", + "appendTo": document.body + } + + ) + def add_reactions_listeners(self, parent_elt=None) -> None: """Add listener on reactions to handle details and reaction toggle""" if parent_elt is None: @@ -495,7 +692,10 @@ "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["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) @@ -503,7 +703,3 @@ 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.add_message_event_listeners() -libervia_web_chat.handle_url_previews() -libervia_web_chat.add_reactions_listeners()