comparison libervia/web/pages/chat/_browser/__init__.py @ 1625:698eaabfca0e

browser (chat): side/panel and keyword handling: - `dialog.Modal` can now be used with a `position` argument. The default `center` keeps the old behaviour of modal in the middle of the screen, while using one of the direction makes the modal appear from top/bottom/right/left with an animation. The closing cross has been removed in favor of clicking/touching any part outside of the modal. - A method to create mockup clones of elements is now used to make placeholders. It is used for the input panel in the main area when it is moved to a sub-panel modal, this way there is not element disappearing. - Clicking on a keyword now shows a sub-messages panel with all messages with this keyword, in a similar way as for threads. Writing a messages there will add the keyword automatically. - Sub-messages panel will auto-scroll when messages are added. rel 458
author Goffi <goffi@goffi.org>
date Fri, 06 Jun 2025 11:08:05 +0200
parents fdb5689fb826
children 84e287565fab
comparison
equal deleted inserted replaced
1624:fd421f1be8f5 1625:698eaabfca0e
1 from html import escape
1 import json 2 import json
2 import re 3 import re
3 from typing import Callable 4 from typing import Callable
4 import errors 5 import errors
5 6
12 from javascript import pyobj2jsobj 13 from javascript import pyobj2jsobj
13 from js_modules import emoji_picker_element 14 from js_modules import emoji_picker_element
14 from js_modules.tippy_js import tippy as tippy_ori 15 from js_modules.tippy_js import tippy as tippy_ori
15 import popup 16 import popup
16 from template import Template, safe 17 from template import Template, safe
17 from tools import is_touch_device, remove_ids 18 from tools import is_touch_device, remove_ids, make_placeholder
18 from loading import remove_loading_screen 19 from loading import remove_loading_screen
19 from jid_search import JidSearch 20 from jid_search import JidSearch
20 from interpreter import Inspector 21 from interpreter import Inspector
21 from components import init_collapsible_cards 22 from components import init_collapsible_cards
22 23
287 ) 288 )
288 self.attachments_elt = document["attachments"] 289 self.attachments_elt = document["attachments"]
289 self.message_input = document["message_input_area"] 290 self.message_input = document["message_input_area"]
290 291
291 # reply to/thread 292 # reply to/thread
292 self.thread_panel_tpl = Template("chat/thread_panel.html") 293 self.sub_messages_panel_tpl = Template("chat/sub_messages_panel.html")
293 # current thread panel, if any. 294 # current thread panel, if any.
294 self.thread_panel = None 295 self.sub_messages_panel = None
295 self._reply_to = None 296 # Method to call to check if a new message must be displayer in sub-messages
296 self.thread_id = None 297 # panel.
298 self.sub_messages_check_cb: Callable|None = None
299 self._reply_to: str|None = None
300 self.thread_id: str|None = None
301 self.keyword: str|None = None
297 document["cancel_reply_btn"].bind( 302 document["cancel_reply_btn"].bind(
298 "click", 303 "click",
299 lambda __: setattr(self, "reply_to", None) 304 lambda __: setattr(self, "reply_to", None)
300 ) 305 )
301 # use `thead` property to modify. 306 # use `thead` property to modify.
361 target_elt.classList.add(MODE_CLASS.format(new_mode)) 366 target_elt.classList.add(MODE_CLASS.format(new_mode))
362 self.input_data.clear() 367 self.input_data.clear()
363 368
364 @property 369 @property
365 def is_at_bottom(self): 370 def is_at_bottom(self):
366 return ( 371 return self.is_elt_at_bottom(self.messages_elt)
367 self.messages_elt.scrollHeight
368 - self.messages_elt.scrollTop
369 - self.messages_elt.clientHeight
370 <= SCROLL_SENSITIVITY
371 )
372 372
373 @property 373 @property
374 def reply_to(self) -> dict|None: 374 def reply_to(self) -> dict|None:
375 return self._reply_to 375 return self._reply_to
376 376
420 f"Not message core found for message {message_elt['id']!r}." 420 f"Not message core found for message {message_elt['id']!r}."
421 ) 421 )
422 else: 422 else:
423 message_core_elt.classList.add(SELECTED_THREAD_CLS) 423 message_core_elt.classList.add(SELECTED_THREAD_CLS)
424 424
425 def is_elt_at_bottom(self, elt) -> bool:
426 """Tell is a scrollable elemement is scrolled down.
427
428 There is a margin to check it set with SCROLL_SENSITIVITY.
429 @param element: Scrollable element to check.
430 @return: True if the element is as its bottom.
431 """
432 return elt.scrollHeight - elt.scrollTop - elt.clientHeight <= SCROLL_SENSITIVITY
433
425 def open_chat(self, entity_jid: str) -> None: 434 def open_chat(self, entity_jid: str) -> None:
426 """Change the current chat for the given one.""" 435 """Change the current chat for the given one."""
427 # For now we keep it simple and just load the new location. 436 # For now we keep it simple and just load the new location.
428 window.location = f"{chat_url}/{entity_jid}" 437 window.location = f"{chat_url}/{entity_jid}"
429 438
454 if self.reply_to: 463 if self.reply_to:
455 extra["reply"] = self.reply_to 464 extra["reply"] = self.reply_to
456 465
457 if self.thread_id: 466 if self.thread_id:
458 extra["thread"] = self.thread_id 467 extra["thread"] = self.thread_id
468
469 if self.keyword:
470 extra.setdefault("keywords", []).append(self.keyword)
459 471
460 # now we send the message 472 # now we send the message
461 try: 473 try:
462 if self.input_mode == "edit": 474 if self.input_mode == "edit":
463 message_id = self.input_data["id"] 475 message_id = self.input_data["id"]
700 if "thread" not in parent_message_elt.dataset: 712 if "thread" not in parent_message_elt.dataset:
701 parent_message_elt.dataset["thread"] = thread_id 713 parent_message_elt.dataset["thread"] = thread_id
702 # TODO: Regenerate parent message, so thread icon will appear. 714 # TODO: Regenerate parent message, so thread icon will appear.
703 715
704 if ( 716 if (
705 "thread" in extra 717 self.sub_messages_check_cb is not None
706 and self.thread_panel 718 and self.sub_messages_panel is not None
707 and self.thread_id 719 and self.sub_messages_check_cb(extra)
708 and extra["thread"] == self.thread_id
709 ): 720 ):
710 # FIXME: This is quite fragile, IDs are removed, meaning that some listener 721 # FIXME: This is quite fragile, IDs are removed, meaning that some listener
711 # may not be working correctly, and it's using #input-panel as reference, 722 # may not be working correctly,
712 # but in the future, it may not moved from main area the thread panel like
713 # that anymore.
714 cloned_message_elt = message_elt.cloneNode(True) 723 cloned_message_elt = message_elt.cloneNode(True)
715 remove_ids(cloned_message_elt) 724 remove_ids(cloned_message_elt)
716 thread_messages_elt = document["thread-messages"] 725 sub_messages_elt = document["sub-messages"]
717 thread_messages_elt.insertBefore(cloned_message_elt, document["input-panel"]) 726 is_at_bottom = self.is_elt_at_bottom(sub_messages_elt)
727 sub_messages_elt.appendChild(cloned_message_elt)
728 if is_at_bottom:
729 sub_messages_elt.scrollTop = sub_messages_elt.scrollHeight
718 730
719 # Check if user is viewing older messages or is at the bottom 731 # Check if user is viewing older messages or is at the bottom
720 is_at_bottom = self.is_at_bottom 732 is_at_bottom = self.is_at_bottom
721 733
722 self.messages_elt <= message_elt 734 self.messages_elt <= message_elt
852 ) 864 )
853 # if evt.deltaY != 0: 865 # if evt.deltaY != 0:
854 # document["attachments"].scrollLeft += evt.deltaY * 0.8 866 # document["attachments"].scrollLeft += evt.deltaY * 0.8
855 # evt.preventDefault() 867 # evt.preventDefault()
856 868
857 async def on_show_thread(self, thread_id: str) -> None: 869 async def show_filtered_messages(
858 assert thread_id 870 self,
859 self.thread_id = thread_id 871 filters: dict,
860 872 sub_messages_panel_args: dict|None = None,
861 thread_panel_elt = self.thread_panel_tpl.get_elt({ 873 sub_messages_check_cb: Callable[[dict], bool]|None = None
862 "messages": [] 874 ) -> None:
863 }) 875 """Show message matching a filter in a sub-messages panel.
864 self.thread_panel = thread_panel_elt 876
865 thread_messages_elt = thread_panel_elt.select_one("#thread-messages") 877 A panel will slide-in from the right to show filtered messages.
866 assert thread_messages_elt is not None 878 @param filters: Filters to use in ``bridge.history_get``.
879 @param sub_messages_panel_args: Args to use with sub_messages_panel template.
880 @param sub_messages_check_cb: Method to call to check if a new message must go to
881 the sub-messages panel. The method must return True if the message is to be
882 displayed in the panel.
883 """
884 if sub_messages_panel_args is None:
885 sub_messages_panel_args = {}
886 self.sub_messages_check_cb = sub_messages_check_cb
887 sub_messages_panel_elt = self.sub_messages_panel_tpl.get_elt(
888 sub_messages_panel_args
889 )
890 self.sub_messages_panel = sub_messages_panel_elt
891 sub_messages_elt = sub_messages_panel_elt.select_one("#sub-messages")
892 sub_messages_input_elt = sub_messages_panel_elt.select_one("#sub-messages-input")
893 assert sub_messages_elt is not None
867 history_data = await bridge.history_get( 894 history_data = await bridge.history_get(
868 "", "", -2, True, {"thread_id": thread_id} 895 "", "", -2, True, filters
869 ) 896 )
870 for message_data in history_data: 897 for message_data in history_data:
871 uid, timestamp, from_jid_s, to_jid_s, message_data, subject_data, mess_type, extra_s = message_data 898 uid, timestamp, from_jid_s, to_jid_s, message_data, subject_data, mess_type, extra_s = message_data
872 template_data = await self.message_to_template_data( 899 template_data = await self.message_to_template_data(
873 uid, 900 uid,
878 subject_data=subject_data, 905 subject_data=subject_data,
879 mess_type=mess_type, 906 mess_type=mess_type,
880 extra=json.loads(extra_s) 907 extra=json.loads(extra_s)
881 ) 908 )
882 message_elt = self.message_tpl.get_elt(template_data) 909 message_elt = self.message_tpl.get_elt(template_data)
883 thread_messages_elt <= message_elt 910 sub_messages_elt <= message_elt
884 # FIXME: The whole input-panel is currently moved to the thread panel so listeners 911 # FIXME: The whole input-panel is currently moved to the filtered messages panel
885 # don't have to be moved. At some point, it may be better to make a clone 912 # so listeners don't have to be moved. At some point, it may be better to make a
886 # (without the IDs) and to clone listeners too, this way a slide-in panel could 913 # clone (without the IDs) and to clone listeners too, this way a non-modal panel
887 # be used instead of a modal. 914 # could be used.
888 input_panel_elt = document["input-panel"] 915 input_panel_elt = document["input-panel"]
889 thread_messages_elt <= input_panel_elt 916 placeholder = make_placeholder(input_panel_elt)
890 917 sub_messages_input_elt <= input_panel_elt
891 def on_thread_panel_close(): 918 document["input-panel-area"].appendChild(placeholder)
892 document["input-panel-area"].appendChild(input_panel_elt) 919
920 def on_sub_messages_panel_close():
921 placeholder.replaceWith(input_panel_elt)
893 self.thread_id = None 922 self.thread_id = None
894 self.thread_panel = None 923 self.keyword = None
895 924 self.sub_messages_panel = None
896 thread_modal = dialog.Modal( 925 self.sub_messages_check_cb = None
897 thread_panel_elt, 926
927 sub_messages_modal = dialog.Modal(
928 sub_messages_panel_elt,
898 closable=True, 929 closable=True,
899 close_cb=on_thread_panel_close 930 close_cb=on_sub_messages_panel_close,
900 ) 931 position="right"
901 thread_modal.show() 932 )
933 sub_messages_modal.show()
934
935 async def on_show_thread(self, thread_id: str) -> None:
936 assert thread_id
937 self.thread_id = thread_id
938 def sub_messages_check_cb(extra: dict) -> bool:
939 return bool(
940 self.thread_id
941 and "thread" in extra
942 and extra["thread"] == self.thread_id
943 )
944
945 await self.show_filtered_messages(
946 {"thread_id": thread_id},
947 {"title": "Thread view"},
948 sub_messages_check_cb
949 )
950
951 async def _on_keyword_click(self, keyword_elt) -> None:
952 self.keyword = keyword_elt.text
953 def sub_messages_check_cb(extra: dict) -> bool:
954 return self.keyword in extra.get("keywords", [])
955 await self.show_filtered_messages(
956 {"keyword": self.keyword},
957 {"title": f"🏷 Label view: {self.keyword}."},
958 sub_messages_check_cb
959 )
902 960
903 961
904 async def get_message_tuple(self, message_elt) -> tuple|None: 962 async def get_message_tuple(self, message_elt) -> tuple|None:
905 """Retrieve message tuple from as sent by [message_new] 963 """Retrieve message tuple from as sent by [message_new]
906 964
1058 thread_icon_elt.bind( 1116 thread_icon_elt.bind(
1059 "click", 1117 "click",
1060 lambda __, thread_id=thread_id: aio.run(self.on_show_thread(thread_id)) 1118 lambda __, thread_id=thread_id: aio.run(self.on_show_thread(thread_id))
1061 ) 1119 )
1062 1120
1121 ## keywords
1122 for keyword_elt in parent_elt.select(".message-keyword"):
1123 keyword_elt.bind(
1124 "click",
1125 lambda __, keyword_elt=keyword_elt: aio.run(
1126 self._on_keyword_click(keyword_elt)
1127 )
1128 )
1129
1130
1063 def add_reactions_listeners(self, parent_elt=None) -> None: 1131 def add_reactions_listeners(self, parent_elt=None) -> None:
1064 """Add listener on reactions to handle details and reaction toggle""" 1132 """Add listener on reactions to handle details and reaction toggle"""
1065 if parent_elt is None: 1133 if parent_elt is None:
1066 parent_elt = document 1134 parent_elt = document
1067 1135