comparison libervia/web/pages/chat/_browser/__init__.py @ 1577:9ba532041a8e

browser (chat): implement message reactions.
author Goffi <goffi@goffi.org>
date Wed, 22 Nov 2023 16:31:36 +0100
parents fb31d3dba0c3
children eab815e48795
comparison
equal deleted inserted replaced
1576:c7d15ded4cbb 1577:9ba532041a8e
1 import json 1 import json
2 import re 2 import re
3 3
4 from bridge import AsyncBridge as Bridge 4 from bridge import AsyncBridge as Bridge
5 from browser import DOMNode, aio, bind, console as log, document, window, html 5 from browser import DOMNode, aio, bind, console as log, document, window
6 from cache import cache, identities 6 from cache import cache, identities
7 import dialog 7 import dialog
8 from file_uploader import FileUploader 8 from file_uploader import FileUploader
9 import jid 9 import jid
10 from js_modules import emoji_picker_element
11 from js_modules.tippy_js import tippy
10 from template import Template, safe 12 from template import Template, safe
13 from tools import is_touch_device
11 14
12 log.warning = log.warn 15 log.warning = log.warn
13 profile = window.profile or "" 16 profile = window.profile or ""
14 own_jid = jid.JID(window.own_jid) 17 own_jid = jid.JID(window.own_jid)
15 target_jid = jid.JID(window.target_jid) 18 target_jid = jid.JID(window.target_jid)
19 chat_type = window.chat_type
16 bridge = Bridge() 20 bridge = Bridge()
17 21
18 # Sensible value to consider that user is at the bottom 22 # Sensible value to consider that user is at the bottom
19 SCROLL_SENSITIVITY = 200 23 SCROLL_SENSITIVITY = 200
20 24
21 25
22 class LiberviaWebChat: 26 class LiberviaWebChat:
23 def __init__(self): 27 def __init__(self):
24 self.message_tpl = Template("chat/message.html") 28 self.message_tpl = Template("chat/message.html")
29 self.reactions_tpl = Template("chat/reactions.html")
30 self.reactions_details_tpl = Template("chat/reactions_details.html")
25 self.url_preview_control_tpl = Template("components/url_preview_control.html") 31 self.url_preview_control_tpl = Template("components/url_preview_control.html")
26 self.url_preview_tpl = Template("components/url_preview.html") 32 self.url_preview_tpl = Template("components/url_preview.html")
27 self.new_messages_marker_elt = Template("chat/new_messages_marker.html").get_elt() 33 self.new_messages_marker_elt = Template("chat/new_messages_marker.html").get_elt()
28 34
29 self.messages_elt = document["messages"] 35 self.messages_elt = document["messages"]
32 self.file_uploader = FileUploader( 38 self.file_uploader = FileUploader(
33 "", "chat/attachment_preview.html", on_delete_cb=self.on_attachment_delete 39 "", "chat/attachment_preview.html", on_delete_cb=self.on_attachment_delete
34 ) 40 )
35 self.attachments_elt = document["attachments"] 41 self.attachments_elt = document["attachments"]
36 self.message_input = document["message_input"] 42 self.message_input = document["message_input"]
43
44 close_button = document.select_one(".modal-close")
45 close_button.bind("click", self.close_modal)
37 46
38 # hide/show attachments 47 # hide/show attachments
39 MutationObserver = window.MutationObserver 48 MutationObserver = window.MutationObserver
40 observer = MutationObserver.new(lambda *__: self.update_attachments_visibility()) 49 observer = MutationObserver.new(lambda *__: self.update_attachments_visibility())
41 observer.observe(self.attachments_elt, {"childList": True}) 50 observer.observe(self.attachments_elt, {"childList": True})
74 83
75 # now we send the message 84 # now we send the message
76 try: 85 try:
77 aio.run( 86 aio.run(
78 bridge.message_send( 87 bridge.message_send(
79 str(target_jid), {"": message}, {}, "auto", json.dumps(extra) 88 str(target_jid), {"": message}, {}, "auto", json.dumps(extra, ensure_ascii=False)
80 ) 89 )
81 ) 90 )
82 except Exception as e: 91 except Exception as e:
83 dialog.notification.show(f"Can't send message: {e}", "error") 92 dialog.notification.show(f"Can't send message: {e}", "error")
84 else: 93 else:
87 96
88 def _on_message_new( 97 def _on_message_new(
89 self, 98 self,
90 uid: str, 99 uid: str,
91 timestamp: float, 100 timestamp: float,
92 from_jid: str, 101 from_jid_s: str,
93 to_jid: str, 102 to_jid_s: str,
94 message: dict, 103 message: dict,
95 subject: dict, 104 subject: dict,
96 mess_type: str, 105 mess_type: str,
97 extra_s: str, 106 extra_s: str,
98 profile: str, 107 profile: str,
99 ) -> None: 108 ) -> None:
109 from_jid = jid.JID(from_jid_s)
110 to_jid = jid.JID(to_jid_s)
100 if ( 111 if (
101 jid.JID(from_jid).bare == window.target_jid 112 from_jid.bare == window.target_jid
102 or jid.JID(to_jid).bare == window.target_jid 113 or to_jid.bare == window.target_jid
103 ): 114 ):
104 aio.run( 115 aio.run(
105 self.on_message_new( 116 self.on_message_new(
106 uid, 117 uid,
107 timestamp, 118 timestamp,
113 json.loads(extra_s), 124 json.loads(extra_s),
114 profile, 125 profile,
115 ) 126 )
116 ) 127 )
117 128
129 def _on_message_update(
130 self, uid: str, type_: str, update_data_s: str, profile: str
131 ) -> None:
132 aio.run(self.on_message_update(uid, type_, update_data_s, profile))
133
134 async def on_message_update(
135 self, uid: str, type_: str, update_data_s: str, profile: str
136 ) -> None:
137 update_data = json.loads(update_data_s)
138 is_at_bottom = self.is_at_bottom
139 if type_ == "REACTION":
140 reactions = update_data["reactions"]
141 log.debug(f"new reactions: {reactions}")
142 try:
143 reactions_wrapper_elt = document[f"msg_reactions_{uid}"]
144 except KeyError:
145 log.debug(f"Message {uid} not found, no reactions to update")
146 else:
147 log.debug(f"Message {uid} found, new reactions: {reactions}")
148 reactions_elt = self.reactions_tpl.get_elt({"reactions": reactions})
149 reactions_wrapper_elt.clear()
150 reactions_wrapper_elt <= reactions_elt
151 self.add_reactions_listeners(reactions_elt)
152 else:
153 log.warning(f"Unsupported update type: {type_!r}")
154
155 # If user was at the bottom, keep the scroll at the bottom
156 if is_at_bottom:
157 self.messages_elt.scrollTop = self.messages_elt.scrollHeight
158
118 async def on_message_new( 159 async def on_message_new(
119 self, 160 self,
120 uid: str, 161 uid: str,
121 timestamp: float, 162 timestamp: float,
122 from_jid: str, 163 from_jid: jid.JID,
123 to_jid: str, 164 to_jid: jid.JID,
124 message_data: dict, 165 message_data: dict,
125 subject_data: dict, 166 subject_data: dict,
126 mess_type: str, 167 mess_type: str,
127 extra: dict, 168 extra: dict,
128 profile: str, 169 profile: str,
129 ) -> None: 170 ) -> None:
130 # FIXME: visibilityState doesn't detect OS events such as `Alt + Tab`, using focus 171 # FIXME: visibilityState doesn't detect OS events such as `Alt + Tab`, using focus
131 # event may help to get those use cases, but it gives false positives. 172 # event may help to get those use cases, but it gives false positives.
132 if document.visibilityState == "hidden" and self.new_messages_marker_elt.parent is None: 173 if (
174 document.visibilityState == "hidden"
175 and self.new_messages_marker_elt.parent is None
176 ):
133 # the page is not visible, and we have no new messages marker yet, so we add 177 # the page is not visible, and we have no new messages marker yet, so we add
134 # it 178 # it
135 self.messages_elt <= self.new_messages_marker_elt 179 self.messages_elt <= self.new_messages_marker_elt
136 xhtml_data = extra.get("xhtml") 180 xhtml_data = extra.get("xhtml")
137 if not xhtml_data: 181 if not xhtml_data:
140 try: 184 try:
141 xhtml = xhtml_data[""] 185 xhtml = xhtml_data[""]
142 except KeyError: 186 except KeyError:
143 xhtml = next(iter(xhtml_data.values())) 187 xhtml = next(iter(xhtml_data.values()))
144 188
145 await cache.fill_identities([from_jid]) 189 if chat_type == "group":
190 await cache.fill_identities([str(from_jid)])
191 else:
192 await cache.fill_identities([str(jid.JID(from_jid).bare)])
146 193
147 msg_data = { 194 msg_data = {
148 "id": uid, 195 "id": uid,
149 "timestamp": timestamp, 196 "timestamp": timestamp,
150 "type": mess_type, 197 "type": mess_type,
151 "from_": from_jid, 198 "from_": str(from_jid),
152 "text": message_data.get("") or next(iter(message_data.values()), ""), 199 "text": message_data.get("") or next(iter(message_data.values()), ""),
153 "subject": subject_data.get("") or next(iter(subject_data.values()), ""), 200 "subject": subject_data.get("") or next(iter(subject_data.values()), ""),
154 "type": mess_type, 201 "type": mess_type,
155 "thread": extra.get("thread"), 202 "thread": extra.get("thread"),
156 "thread_parent": extra.get("thread_parent"), 203 "thread_parent": extra.get("thread_parent"),
173 220
174 # Check if user is viewing older messages or is at the bottom 221 # Check if user is viewing older messages or is at the bottom
175 is_at_bottom = self.is_at_bottom 222 is_at_bottom = self.is_at_bottom
176 223
177 self.messages_elt <= message_elt 224 self.messages_elt <= message_elt
178 self.make_attachments_dynamic(message_elt) 225 self.add_message_event_listeners(message_elt)
179 # we add preview in parallel on purpose, as they can be slow to get 226 # we add preview in parallel on purpose, as they can be slow to get
180 self.handle_url_previews(message_elt) 227 self.handle_url_previews(message_elt)
181 228
182 # If user was at the bottom, keep the scroll at the bottom 229 # If user was at the bottom, keep the scroll at the bottom
183 if is_at_bottom: 230 if is_at_bottom:
233 item_elt.remove() 280 item_elt.remove()
234 281
235 def on_attach_button_click(self, evt): 282 def on_attach_button_click(self, evt):
236 document["file_input"].click() 283 document["file_input"].click()
237 284
285 def on_extra_btn_click(self, evt):
286 print("extra bouton clicked!")
287
288 def on_reaction_click(self, evt, message_elt):
289 window.evt = evt
290 aio.run(
291 bridge.message_reactions_set(
292 message_elt["id"], [evt.detail["unicode"]], "toggle"
293 )
294 )
295
238 @bind(document["attachments"], "wheel") 296 @bind(document["attachments"], "wheel")
239 def wheel_event(evt): 297 def wheel_event(evt):
240 """Make the mouse wheel to act on horizontal scrolling for attachments 298 """Make the mouse wheel to act on horizontal scrolling for attachments
241 299
242 Attachments don't have vertical scrolling, thus is makes sense to use the wheel 300 Attachments don't have vertical scrolling, thus is makes sense to use the wheel
244 """ 302 """
245 if evt.deltaY != 0: 303 if evt.deltaY != 0:
246 document["attachments"].scrollLeft += evt.deltaY * 0.8 304 document["attachments"].scrollLeft += evt.deltaY * 0.8
247 evt.preventDefault() 305 evt.preventDefault()
248 306
249 def make_attachments_dynamic(self, parent_elt=None): 307 def get_reaction_panel(self, source_elt):
250 """Make attachments dynamically clickable""" 308 emoji_picker_elt = document.createElement("emoji-picker")
309 message_elt = source_elt.closest("div.is-chat-message")
310 emoji_picker_elt.bind(
311 "emoji-click", lambda evt: self.on_reaction_click(evt, message_elt)
312 )
313
314 return emoji_picker_elt
315
316 def add_message_event_listeners(self, parent_elt=None):
317 """Prepare a message to be dynamic
318
319 - make attachments dynamically clickable
320 - make the extra button clickable
321 """
322
323 ## attachments
251 # FIXME: only handle images for now, and display them in a modal 324 # FIXME: only handle images for now, and display them in a modal
252 if parent_elt is None: 325 if parent_elt is None:
253 parent_elt = document 326 parent_elt = document
254 img_elts = parent_elt.select(".message-attachment img") 327 img_elts = parent_elt.select(".message-attachment img")
255 for img_elt in img_elts: 328 for img_elt in img_elts:
256 img_elt.bind("click", self.open_modal) 329 img_elt.bind("click", self.open_modal)
257 img_elt.style.cursor = "pointer" 330 img_elt.style.cursor = "pointer"
258 331
259 close_button = document.select_one(".modal-close") 332 ## reaction button
260 close_button.bind("click", self.close_modal) 333 for reaction_btn in parent_elt.select(".reaction-button"):
334 message_elt = reaction_btn.closest("div.is-chat-message")
335 tippy(
336 reaction_btn,
337 {
338 "trigger": "click",
339 "content": self.get_reaction_panel,
340 "appendTo": document.body,
341 "placement": "bottom",
342 "interactive": True,
343 "theme": "light",
344 "onShow": lambda __, message_elt=message_elt: (
345 message_elt.classList.add("chat-message-highlight")
346 ),
347 "onHide": lambda __, message_elt=message_elt: (
348 message_elt.classList.remove("chat-message-highlight")
349 ),
350 },
351 )
352
353 ## extra button
354 for extra_btn in parent_elt.select(".extra-button"):
355 extra_btn.bind("click", self.on_extra_btn_click)
356
357 def add_reactions_listeners(self, parent_elt=None) -> None:
358 """Add listener on reactions to handle details and reaction toggle"""
359 if parent_elt is None:
360 parent_elt = document
361
362 is_touch = is_touch_device()
363
364 for reaction_elt in parent_elt.select(".reaction"):
365 # Reaction details
366 dataset = reaction_elt.dataset.to_dict()
367 reacting_jids = sorted(json.loads(dataset.get("jids", "[]")))
368 reaction_details_elt = self.reactions_details_tpl.get_elt(
369 {"reacting_jids": reacting_jids, "identities": identities}
370 )
371
372 # Configure tippy based on device type
373 tippy_config = {
374 "content": reaction_details_elt,
375 "placement": "bottom",
376 "theme": "light",
377 "touch": ["hold", 500] if is_touch else True,
378 "trigger": "click" if is_touch else "mouseenter focus",
379 "delay": [0, 800] if is_touch else 0,
380 }
381 tippy(reaction_elt, tippy_config)
382
383 # Toggle reaction when clicked/touched
384 emoji_elt = reaction_elt.select_one(".emoji")
385 emoji = emoji_elt.html.strip()
386 message_elt = reaction_elt.closest("div.is-chat-message")
387 msg_id = message_elt["id"]
388
389 def toggle_reaction(event, msg_id=msg_id, emoji=emoji):
390 # Prevent default if it's a touch device to distinguish from long press
391 if is_touch:
392 event.preventDefault()
393 aio.run(bridge.message_reactions_set(msg_id, [emoji], "toggle"))
394
395 reaction_elt.bind("click", toggle_reaction)
261 396
262 def find_links(self, message_elt): 397 def find_links(self, message_elt):
263 """Find all http and https links within the body of a message.""" 398 """Find all http and https links within the body of a message."""
264 msg_body_elt = message_elt.select_one(".msg_body") 399 msg_body_elt = message_elt.select_one(".msg_body")
265 if not msg_body_elt: 400 if not msg_body_elt:
302 437
303 url_preview_elt = self.url_preview_tpl.get_elt( 438 url_preview_elt = self.url_preview_tpl.get_elt(
304 {"url_preview": url_preview_data} 439 {"url_preview": url_preview_data}
305 ) 440 )
306 url_previews_elt <= url_preview_elt 441 url_previews_elt <= url_preview_elt
307
308 442
309 def handle_url_previews(self, parent_elt=None): 443 def handle_url_previews(self, parent_elt=None):
310 """Check if URL are presents in a message and show appropriate element 444 """Check if URL are presents in a message and show appropriate element
311 445
312 According to settings, either a preview control panel will be shown to wait for 446 According to settings, either a preview control panel will be shown to wait for
322 urls = self.find_links(message_elt) 456 urls = self.find_links(message_elt)
323 if urls: 457 if urls:
324 url_previews_elt = message_elt.select_one(".url-previews") 458 url_previews_elt = message_elt.select_one(".url-previews")
325 url_previews_elt.classList.remove("is-hidden") 459 url_previews_elt.classList.remove("is-hidden")
326 preview_control_elt = self.url_preview_control_tpl.get_elt() 460 preview_control_elt = self.url_preview_control_tpl.get_elt()
327 fetch_preview_btn = preview_control_elt.select_one(".action_fetch_preview") 461 fetch_preview_btn = preview_control_elt.select_one(
462 ".action_fetch_preview"
463 )
328 fetch_preview_btn.bind( 464 fetch_preview_btn.bind(
329 "click", 465 "click",
330 lambda __, previews_elt=url_previews_elt, preview_urls=urls: aio.run( 466 lambda __, previews_elt=url_previews_elt, preview_urls=urls: aio.run(
331 self.add_url_previews(previews_elt, preview_urls) 467 self.add_url_previews(previews_elt, preview_urls)
332 ) 468 ),
333 ) 469 )
334 url_previews_elt <= preview_control_elt 470 url_previews_elt <= preview_control_elt
335 471
336 def open_modal(self, evt): 472 def open_modal(self, evt):
337 modal_image = document.select_one("#modal-image") 473 modal_image = document.select_one("#modal-image")
341 modal.classList.add("is-active") 477 modal.classList.add("is-active")
342 478
343 def close_modal(self, evt): 479 def close_modal(self, evt):
344 modal = document.select_one("#modal") 480 modal = document.select_one("#modal")
345 modal.classList.remove("is-active") 481 modal.classList.remove("is-active")
346
347 482
348 def handle_visibility_change(self, evt): 483 def handle_visibility_change(self, evt):
349 if ( 484 if (
350 document.visibilityState == "hidden" 485 document.visibilityState == "hidden"
351 and self.new_messages_marker_elt.parent is not None 486 and self.new_messages_marker_elt.parent is not None
365 document["file_input"].bind("change", libervia_web_chat.on_file_selected) 500 document["file_input"].bind("change", libervia_web_chat.on_file_selected)
366 501
367 document.bind("visibilitychange", libervia_web_chat.handle_visibility_change) 502 document.bind("visibilitychange", libervia_web_chat.handle_visibility_change)
368 503
369 bridge.register_signal("message_new", libervia_web_chat._on_message_new) 504 bridge.register_signal("message_new", libervia_web_chat._on_message_new)
370 505 bridge.register_signal("message_update", libervia_web_chat._on_message_update)
371 libervia_web_chat.make_attachments_dynamic() 506
507 libervia_web_chat.add_message_event_listeners()
372 libervia_web_chat.handle_url_previews() 508 libervia_web_chat.handle_url_previews()
509 libervia_web_chat.add_reactions_listeners()