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)