Mercurial > libervia-web
comparison libervia/web/pages/chat/_browser/__init__.py @ 1620:3a60bf3762ef
browser: threads and replies implementation:
rel 457
author | Goffi <goffi@goffi.org> |
---|---|
date | Tue, 06 May 2025 00:40:07 +0200 |
parents | a2cd4222c702 |
children |
comparison
equal
deleted
inserted
replaced
1619:a2cd4222c702 | 1620:3a60bf3762ef |
---|---|
12 from javascript import pyobj2jsobj | 12 from javascript import pyobj2jsobj |
13 from js_modules import emoji_picker_element | 13 from js_modules import emoji_picker_element |
14 from js_modules.tippy_js import tippy as tippy_ori | 14 from js_modules.tippy_js import tippy as tippy_ori |
15 import popup | 15 import popup |
16 from template import Template, safe | 16 from template import Template, safe |
17 from tools import is_touch_device | 17 from tools import is_touch_device, remove_ids |
18 from loading import remove_loading_screen | 18 from loading import remove_loading_screen |
19 from jid_search import JidSearch | 19 from jid_search import JidSearch |
20 from interpreter import Inspector | 20 from interpreter import Inspector |
21 from components import init_collapsible_cards | 21 from components import init_collapsible_cards |
22 | 22 |
32 # Sensible value to consider that user is at the bottom | 32 # Sensible value to consider that user is at the bottom |
33 SCROLL_SENSITIVITY = 200 | 33 SCROLL_SENSITIVITY = 200 |
34 | 34 |
35 INPUT_MODES = {"normal", "edit", "quote"} | 35 INPUT_MODES = {"normal", "edit", "quote"} |
36 MODE_CLASS = "mode_{}" | 36 MODE_CLASS = "mode_{}" |
37 SELECTED_THREAD_CLS = "has-background-info-light" | |
37 | 38 |
38 | 39 |
39 # FIXME: workaround for https://github.com/brython-dev/brython/issues/2542 | 40 # FIXME: workaround for https://github.com/brython-dev/brython/issues/2542 |
40 def tippy(target, data): | 41 def tippy(target, data): |
41 return tippy_ori(target, pyobj2jsobj(data)) | 42 return tippy_ori(target, pyobj2jsobj(data)) |
285 "", "chat/attachment_preview.html", on_delete_cb=self.on_attachment_delete | 286 "", "chat/attachment_preview.html", on_delete_cb=self.on_attachment_delete |
286 ) | 287 ) |
287 self.attachments_elt = document["attachments"] | 288 self.attachments_elt = document["attachments"] |
288 self.message_input = document["message_input_area"] | 289 self.message_input = document["message_input_area"] |
289 | 290 |
291 # reply to/thread | |
292 self.thread_panel_tpl = Template("chat/thread_panel.html") | |
293 # current thread panel, if any. | |
294 self.thread_panel = None | |
295 self._reply_to = None | |
296 self.thread_id = None | |
297 document["cancel_reply_btn"].bind( | |
298 "click", | |
299 lambda __: setattr(self, "reply_to", None) | |
300 ) | |
301 # use `thead` property to modify. | |
302 self._show_thread = None | |
303 | |
290 # close_button = document.select_one(".modal-close") | 304 # close_button = document.select_one(".modal-close") |
291 # close_button.bind("click", self.close_modal) | 305 # close_button.bind("click", self.close_modal) |
292 | 306 |
293 # hide/show attachments | 307 # hide/show attachments |
294 MutationObserver = window.MutationObserver | 308 MutationObserver = window.MutationObserver |
354 - self.messages_elt.scrollTop | 368 - self.messages_elt.scrollTop |
355 - self.messages_elt.clientHeight | 369 - self.messages_elt.clientHeight |
356 <= SCROLL_SENSITIVITY | 370 <= SCROLL_SENSITIVITY |
357 ) | 371 ) |
358 | 372 |
373 @property | |
374 def reply_to(self) -> dict|None: | |
375 return self._reply_to | |
376 | |
377 @reply_to.setter | |
378 def reply_to(self, reply_to: dict|None) -> None: | |
379 if reply_to == self._reply_to: | |
380 return | |
381 self._reply_to = reply_to | |
382 document["reply-to_message"].clear() | |
383 if reply_to is None: | |
384 document["reply-to"].classList.add("is-hidden") | |
385 else: | |
386 document["reply-to"].classList.remove("is-hidden") | |
387 parent_message_elt = document[reply_to["id"]] | |
388 cloned_parent_elt = parent_message_elt.cloneNode(True) | |
389 remove_ids(cloned_parent_elt) | |
390 message_actions_elt = cloned_parent_elt.select_one(".message-actions") | |
391 if message_actions_elt is not None: | |
392 message_actions_elt.remove() | |
393 document["reply-to_message"] <= cloned_parent_elt | |
394 | |
395 @property | |
396 def show_thread(self) -> str|None: | |
397 """Indicate the thread to highlight""" | |
398 return self._show_thread | |
399 | |
400 @show_thread.setter | |
401 def show_thread(self, thread_id: str|None) -> None: | |
402 """Set the thread to highlight, or None to clear view.""" | |
403 if self._show_thread == thread_id: | |
404 return | |
405 if self._show_thread is not None: | |
406 # If we have a previously selected thread, we clean the view. | |
407 for message_core_elt in self.messages_elt.select(".message-core"): | |
408 message_core_elt.classList.remove(SELECTED_THREAD_CLS) | |
409 self._show_thread = thread_id | |
410 if thread_id is not None: | |
411 for message_elt in self.messages_elt.select(".chat-message"): | |
412 try: | |
413 message_thread_id = message_elt.dataset["thread"] | |
414 except KeyError: | |
415 continue | |
416 if message_thread_id == thread_id: | |
417 message_core_elt = message_elt.select_one(".message-core") | |
418 if not message_core_elt: | |
419 log.debug( | |
420 f"Not message core found for message {message_elt['id']!r}." | |
421 ) | |
422 else: | |
423 message_core_elt.classList.add(SELECTED_THREAD_CLS) | |
424 | |
359 def open_chat(self, entity_jid: str) -> None: | 425 def open_chat(self, entity_jid: str) -> None: |
360 """Change the current chat for the given one.""" | 426 """Change the current chat for the given one.""" |
361 # For now we keep it simple and just load the new location. | 427 # For now we keep it simple and just load the new location. |
362 window.location = f"{chat_url}/{entity_jid}" | 428 window.location = f"{chat_url}/{entity_jid}" |
363 | 429 |
382 if message or attachments: | 448 if message or attachments: |
383 extra = {} | 449 extra = {} |
384 | 450 |
385 if attachments: | 451 if attachments: |
386 extra["attachments"] = attachments | 452 extra["attachments"] = attachments |
453 | |
454 if self.reply_to: | |
455 extra["reply"] = self.reply_to | |
456 | |
457 if self.thread_id: | |
458 extra["thread"] = self.thread_id | |
387 | 459 |
388 # now we send the message | 460 # now we send the message |
389 try: | 461 try: |
390 if self.input_mode == "edit": | 462 if self.input_mode == "edit": |
391 message_id = self.input_data["id"] | 463 message_id = self.input_data["id"] |
403 ) | 475 ) |
404 except Exception as e: | 476 except Exception as e: |
405 dialog.notification.show(f"Can't send message: {e}", "error") | 477 dialog.notification.show(f"Can't send message: {e}", "error") |
406 else: | 478 else: |
407 self.message_input.value = "" | 479 self.message_input.value = "" |
480 # We must not reset self.thread_id here, it is when the thread panel is | |
481 # closed. | |
482 # FIXME: Another mechanism must be used if the input panel is cloned at | |
483 # some point, with a slide-in panel for thread, as thread_id would then | |
484 # be used for the thread panel's message input, but not if the main area | |
485 # message input is used. | |
486 self.reply_to = None | |
408 self.attachments_elt.clear() | 487 self.attachments_elt.clear() |
409 self.auto_resize_message_input() | 488 self.auto_resize_message_input() |
410 self.input_mode = "normal" | 489 self.input_mode = "normal" |
411 | 490 |
412 def _on_message_new( | 491 def _on_message_new( |
603 | 682 |
604 message_elt = self.message_tpl.get_elt( | 683 message_elt = self.message_tpl.get_elt( |
605 template_data | 684 template_data |
606 ) | 685 ) |
607 | 686 |
687 | |
688 if "reply" in extra: | |
689 parent_id = extra["reply"]["id"] | |
690 try: | |
691 parent_message_elt = document[parent_id] | |
692 except KeyError: | |
693 log.info( | |
694 "Parent message of reply not found in current history: " | |
695 f"{parent_id!r}" | |
696 ) | |
697 else: | |
698 thread_id = extra.get("thread", parent_id) | |
699 if "thread" not in parent_message_elt.dataset: | |
700 parent_message_elt.dataset["thread"] = thread_id | |
701 # TODO: Regenerate parent message, so thread icon will appear. | |
702 | |
703 if ( | |
704 "thread" in extra | |
705 and self.thread_panel | |
706 and self.thread_id | |
707 and extra["thread"] == self.thread_id | |
708 ): | |
709 # FIXME: This is quite fragile, IDs are removed, meaning that some listener | |
710 # may not be working correctly, and it's using #input-panel as reference, | |
711 # but in the future, it may not moved from main area the thread panel like | |
712 # that anymore. | |
713 cloned_message_elt = message_elt.cloneNode(True) | |
714 remove_ids(cloned_message_elt) | |
715 thread_messages_elt = document["thread-messages"] | |
716 thread_messages_elt.insertBefore(cloned_message_elt, document["input-panel"]) | |
717 | |
608 # Check if user is viewing older messages or is at the bottom | 718 # Check if user is viewing older messages or is at the bottom |
609 is_at_bottom = self.is_at_bottom | 719 is_at_bottom = self.is_at_bottom |
610 | 720 |
611 self.messages_elt <= message_elt | 721 self.messages_elt <= message_elt |
612 self.add_message_event_listeners(message_elt) | 722 self.add_message_event_listeners(message_elt) |
685 evt.stopPropagation() | 795 evt.stopPropagation() |
686 target = evt.currentTarget | 796 target = evt.currentTarget |
687 item_elt = DOMNode(target.closest(".attachment-preview")) | 797 item_elt = DOMNode(target.closest(".attachment-preview")) |
688 item_elt.remove() | 798 item_elt.remove() |
689 | 799 |
690 def on_attach_button_click(self, evt): | 800 def on_attach_btn_click(self, evt): |
691 document["file-input"].click() | 801 document["file-input"].click() |
692 | 802 |
803 def on_reply_btn_click(self, evt) -> None: | |
804 chat_message_elt = evt.currentTarget.closest("div.chat-message") | |
805 self.reply_to = { | |
806 "id": chat_message_elt["id"], | |
807 "to": chat_message_elt.dataset["from"] | |
808 } | |
809 log.debug(f'"reply_to" set to {self.reply_to!r}') | |
810 | |
811 | |
693 def on_extra_btn_click(self, evt): | 812 def on_extra_btn_click(self, evt): |
694 message_elt = evt.target.closest("div.chat-message") | 813 message_elt = evt.currentTarget.closest("div.chat-message") |
695 message_core_elt = evt.target.closest("div.message-core") | 814 message_core_elt = evt.currentTarget.closest("div.message-core") |
696 is_own = message_elt.classList.contains("own_msg") | 815 is_own = message_elt.classList.contains("own_msg") |
697 if is_own: | 816 if is_own: |
698 own_messages = document.select('.own_msg') | 817 own_messages = document.select('.own_msg') |
699 # with XMPP, we can currently only edit our last message | 818 # with XMPP, we can currently only edit our last message |
700 can_edit = own_messages and message_elt is own_messages[-1] | 819 can_edit = own_messages and message_elt is own_messages[-1] |
731 ) | 850 ) |
732 ) | 851 ) |
733 # if evt.deltaY != 0: | 852 # if evt.deltaY != 0: |
734 # document["attachments"].scrollLeft += evt.deltaY * 0.8 | 853 # document["attachments"].scrollLeft += evt.deltaY * 0.8 |
735 # evt.preventDefault() | 854 # evt.preventDefault() |
855 | |
856 async def on_show_thread(self, thread_id: str) -> None: | |
857 assert thread_id | |
858 self.thread_id = thread_id | |
859 | |
860 thread_panel_elt = self.thread_panel_tpl.get_elt({ | |
861 "messages": [] | |
862 }) | |
863 self.thread_panel = thread_panel_elt | |
864 thread_messages_elt = thread_panel_elt.select_one("#thread-messages") | |
865 assert thread_messages_elt is not None | |
866 history_data = await bridge.history_get( | |
867 "", "", -2, True, {"thread_id": thread_id} | |
868 ) | |
869 for message_data in history_data: | |
870 uid, timestamp, from_jid, to_jid, message_data, subject_data, mess_type, extra = message_data | |
871 template_data = await self.message_to_template_data( | |
872 uid, | |
873 timestamp=timestamp, | |
874 from_jid=from_jid, | |
875 to_jid=to_jid, | |
876 message_data=message_data, | |
877 subject_data=subject_data, | |
878 mess_type=mess_type, | |
879 extra=json.loads(extra) | |
880 ) | |
881 message_elt = self.message_tpl.get_elt(template_data) | |
882 thread_messages_elt <= message_elt | |
883 # FIXME: The whole input-panel is currently moved to the thread panel so listeners | |
884 # don't have to be moved. At some point, it may be better to make a clone | |
885 # (without the IDs) and to clone listeners too, this way a slide-in panel could | |
886 # be used instead of a modal. | |
887 input_panel_elt = document["input-panel"] | |
888 thread_messages_elt <= input_panel_elt | |
889 | |
890 def on_thread_panel_close(): | |
891 document["input-panel-area"].appendChild(input_panel_elt) | |
892 self.thread_id = None | |
893 self.thread_panel = None | |
894 | |
895 thread_modal = dialog.Modal( | |
896 thread_panel_elt, | |
897 closable=True, | |
898 close_cb=on_thread_panel_close | |
899 ) | |
900 thread_modal.show() | |
901 | |
736 | 902 |
737 async def get_message_tuple(self, message_elt) -> tuple|None: | 903 async def get_message_tuple(self, message_elt) -> tuple|None: |
738 """Retrieve message tuple from as sent by [message_new] | 904 """Retrieve message tuple from as sent by [message_new] |
739 | 905 |
740 If not corresponding message data is found, an error will shown, and None is | 906 If not corresponding message data is found, an error will shown, and None is |
815 parent_elt = document | 981 parent_elt = document |
816 img_elts = parent_elt.select(".message-attachment img") | 982 img_elts = parent_elt.select(".message-attachment img") |
817 for img_elt in img_elts: | 983 for img_elt in img_elts: |
818 img_elt.bind("click", self.open_modal) | 984 img_elt.bind("click", self.open_modal) |
819 img_elt.style.cursor = "pointer" | 985 img_elt.style.cursor = "pointer" |
986 | |
987 ## reply button | |
988 for reply_btn in parent_elt.select(".reply-button"): | |
989 reply_btn.bind("click", self.on_reply_btn_click) | |
820 | 990 |
821 ## reaction button | 991 ## reaction button |
822 i = 0 | 992 i = 0 |
823 for reaction_btn in parent_elt.select(".reaction-button"): | 993 for reaction_btn in parent_elt.select(".reaction-button"): |
824 i+=1 | 994 i+=1 |
870 "appendTo": document.body | 1040 "appendTo": document.body |
871 } | 1041 } |
872 | 1042 |
873 ) | 1043 ) |
874 | 1044 |
1045 ## thread | |
1046 for thread_icon_elt in parent_elt.select(".message-thread"): | |
1047 message_elt = thread_icon_elt.closest("div.chat-message") | |
1048 thread_id = message_elt.dataset["thread"] | |
1049 thread_icon_elt.bind( | |
1050 "mouseenter", | |
1051 lambda __, thread_id=thread_id: setattr(self, "show_thread", thread_id) | |
1052 ) | |
1053 thread_icon_elt.bind( | |
1054 "mouseleave", | |
1055 lambda __, thread_id=thread_id: setattr(self, "show_thread", None) | |
1056 ) | |
1057 thread_icon_elt.bind( | |
1058 "click", | |
1059 lambda __, thread_id=thread_id: aio.run(self.on_show_thread(thread_id)) | |
1060 ) | |
1061 | |
875 def add_reactions_listeners(self, parent_elt=None) -> None: | 1062 def add_reactions_listeners(self, parent_elt=None) -> None: |
876 """Add listener on reactions to handle details and reaction toggle""" | 1063 """Add listener on reactions to handle details and reaction toggle""" |
877 if parent_elt is None: | 1064 if parent_elt is None: |
878 parent_elt = document | 1065 parent_elt = document |
879 | 1066 |
987 ) | 1174 ) |
988 url_previews_elt <= preview_control_elt | 1175 url_previews_elt <= preview_control_elt |
989 | 1176 |
990 def open_modal(self, evt): | 1177 def open_modal(self, evt): |
991 modal_image = document.select_one("#modal-image") | 1178 modal_image = document.select_one("#modal-image") |
992 modal_image.src = evt.target.src | 1179 modal_image.src = evt.currentTarget.src |
993 modal_image.alt = evt.target.alt | 1180 modal_image.alt = evt.currentTarget.alt |
994 modal = document.select_one("#modal") | 1181 modal = document.select_one("#modal") |
995 modal.classList.add("is-active") | 1182 modal.classList.add("is-active") |
996 | 1183 |
997 def close_modal(self, evt): | 1184 def close_modal(self, evt): |
998 modal = document.select_one("#modal") | 1185 modal = document.select_one("#modal") |
1015 | 1202 |
1016 document["message_input"].bind( | 1203 document["message_input"].bind( |
1017 "input", lambda __: libervia_web_chat.auto_resize_message_input() | 1204 "input", lambda __: libervia_web_chat.auto_resize_message_input() |
1018 ) | 1205 ) |
1019 document["message_input"].bind("keydown", libervia_web_chat.on_message_keydown) | 1206 document["message_input"].bind("keydown", libervia_web_chat.on_message_keydown) |
1020 # document["send_button"].bind( | 1207 document["send_button"].bind( |
1021 # "click", | 1208 "click", |
1022 # lambda __: aio.run(libervia_web_chat.send_message()) | 1209 lambda __: aio.run(libervia_web_chat.send_message()) |
1023 # ) | 1210 ) |
1024 document["attach-button"].bind("click", libervia_web_chat.on_attach_button_click) | 1211 document["attach-button"].bind("click", libervia_web_chat.on_attach_btn_click) |
1025 document["file-input"].bind("change", libervia_web_chat.on_file_selected) | 1212 document["file-input"].bind("change", libervia_web_chat.on_file_selected) |
1026 | 1213 |
1027 document.bind("visibilitychange", libervia_web_chat.handle_visibility_change) | 1214 document.bind("visibilitychange", libervia_web_chat.handle_visibility_change) |
1028 | 1215 |
1029 bridge.register_signal("message_new", libervia_web_chat._on_message_new) | 1216 bridge.register_signal("message_new", libervia_web_chat._on_message_new) |