Mercurial > libervia-web
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() |