comparison libervia/web/pages/chat/_browser/__init__.py @ 1584:eab815e48795

browser (chat): message edition + extra menu: - handle extra menu - implement `quote` action - last message correction - show popup with last editions history when "editer" pencil icon is hovered - up arrow let quickly edit last message - implement input modes, `normal` being the default, `edit` or `quote` are new ones. - [ESC] erase input and returns to `normal` mode - fix size and set focus on message input when page is loaded - fix identity retrieval on new messages
author Goffi <goffi@goffi.org>
date Tue, 28 Nov 2023 17:59:11 +0100
parents 9ba532041a8e
children 9fc4120888be
comparison
equal deleted inserted replaced
1583:9865013da86c 1584:eab815e48795
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 5 from browser import DOMNode, aio, bind, console as log, document, timer, 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 10 from js_modules import emoji_picker_element
11 from js_modules.tippy_js import tippy 11 from js_modules.tippy_js import tippy
12 import popup
12 from template import Template, safe 13 from template import Template, safe
13 from tools import is_touch_device 14 from tools import is_touch_device
14 15
15 log.warning = log.warn 16 log.warning = log.warn
16 profile = window.profile or "" 17 profile = window.profile or ""
20 bridge = Bridge() 21 bridge = Bridge()
21 22
22 # Sensible value to consider that user is at the bottom 23 # Sensible value to consider that user is at the bottom
23 SCROLL_SENSITIVITY = 200 24 SCROLL_SENSITIVITY = 200
24 25
26 INPUT_MODES = {"normal", "edit", "quote"}
27 MODE_CLASS = "mode_{}"
28
25 29
26 class LiberviaWebChat: 30 class LiberviaWebChat:
27 def __init__(self): 31 def __init__(self):
32 self._input_mode = "normal"
33 self.input_data = {}
28 self.message_tpl = Template("chat/message.html") 34 self.message_tpl = Template("chat/message.html")
35 self.extra_menu_tpl = Template("chat/extra_menu.html")
29 self.reactions_tpl = Template("chat/reactions.html") 36 self.reactions_tpl = Template("chat/reactions.html")
30 self.reactions_details_tpl = Template("chat/reactions_details.html") 37 self.reactions_details_tpl = Template("chat/reactions_details.html")
31 self.url_preview_control_tpl = Template("components/url_preview_control.html") 38 self.url_preview_control_tpl = Template("components/url_preview_control.html")
32 self.url_preview_tpl = Template("components/url_preview.html") 39 self.url_preview_tpl = Template("components/url_preview.html")
33 self.new_messages_marker_elt = Template("chat/new_messages_marker.html").get_elt() 40 self.new_messages_marker_elt = Template("chat/new_messages_marker.html").get_elt()
41 self.editions_tpl = Template("chat/editions.html")
34 42
35 self.messages_elt = document["messages"] 43 self.messages_elt = document["messages"]
36 44
37 # attachments 45 # attachments
38 self.file_uploader = FileUploader( 46 self.file_uploader = FileUploader(
49 observer = MutationObserver.new(lambda *__: self.update_attachments_visibility()) 57 observer = MutationObserver.new(lambda *__: self.update_attachments_visibility())
50 observer.observe(self.attachments_elt, {"childList": True}) 58 observer.observe(self.attachments_elt, {"childList": True})
51 59
52 # we want the message scroll to be initially at the bottom 60 # we want the message scroll to be initially at the bottom
53 self.messages_elt.scrollTop = self.messages_elt.scrollHeight 61 self.messages_elt.scrollTop = self.messages_elt.scrollHeight
62
63 # listeners/dynamic updates
64 self.add_message_event_listeners()
65 self.handle_url_previews()
66 self.add_reactions_listeners()
67
68 # input
69 self.auto_resize_message_input()
70 self.message_input.focus()
71
72 @property
73 def input_mode(self) -> str:
74 return self._input_mode
75
76 @input_mode.setter
77 def input_mode(self, new_mode: str) -> None:
78 if new_mode == self.input_mode:
79 return
80 if new_mode not in INPUT_MODES:
81 raise ValueError(f"Invalid input mode: {new_mode!r}")
82 target_elt = self.message_input
83 target_elt.classList.remove(MODE_CLASS.format(self._input_mode))
84 self._input_mode = new_mode
85 target_elt.classList.add(MODE_CLASS.format(new_mode))
86 self.input_data.clear()
54 87
55 @property 88 @property
56 def is_at_bottom(self): 89 def is_at_bottom(self):
57 return ( 90 return (
58 self.messages_elt.scrollHeight 91 self.messages_elt.scrollHeight
59 - self.messages_elt.scrollTop 92 - self.messages_elt.scrollTop
60 - self.messages_elt.clientHeight 93 - self.messages_elt.clientHeight
61 <= SCROLL_SENSITIVITY 94 <= SCROLL_SENSITIVITY
62 ) 95 )
63 96
64 def send_message(self): 97 async def send_message(self):
65 """Send message currently in input area 98 """Send message currently in input area
66 99
67 The message and corresponding attachment will be sent 100 The message and corresponding attachment will be sent
68 """ 101 """
69 message = self.message_input.value.rstrip() 102 message = self.message_input.value.rstrip()
81 if attachments: 114 if attachments:
82 extra["attachments"] = attachments 115 extra["attachments"] = attachments
83 116
84 # now we send the message 117 # now we send the message
85 try: 118 try:
86 aio.run( 119 if self.input_mode == "edit":
87 bridge.message_send( 120 message_id = self.input_data["id"]
88 str(target_jid), {"": message}, {}, "auto", json.dumps(extra, ensure_ascii=False) 121 edit_data = {
122 "message": {"": message},
123 "extra": extra
124 }
125 await bridge.message_edit(
126 message_id, json.dumps(edit_data, ensure_ascii=False)
127 )
128 else:
129 await bridge.message_send(
130 str(target_jid),
131 {"": message}, {}, "auto", json.dumps(extra, ensure_ascii=False)
89 ) 132 )
90 )
91 except Exception as e: 133 except Exception as e:
92 dialog.notification.show(f"Can't send message: {e}", "error") 134 dialog.notification.show(f"Can't send message: {e}", "error")
93 else: 135 else:
94 self.message_input.value = "" 136 self.message_input.value = ""
95 self.attachments_elt.clear() 137 self.attachments_elt.clear()
138 self.input_mode = "normal"
96 139
97 def _on_message_new( 140 def _on_message_new(
98 self, 141 self,
99 uid: str, 142 uid: str,
100 timestamp: float, 143 timestamp: float,
147 log.debug(f"Message {uid} found, new reactions: {reactions}") 190 log.debug(f"Message {uid} found, new reactions: {reactions}")
148 reactions_elt = self.reactions_tpl.get_elt({"reactions": reactions}) 191 reactions_elt = self.reactions_tpl.get_elt({"reactions": reactions})
149 reactions_wrapper_elt.clear() 192 reactions_wrapper_elt.clear()
150 reactions_wrapper_elt <= reactions_elt 193 reactions_wrapper_elt <= reactions_elt
151 self.add_reactions_listeners(reactions_elt) 194 self.add_reactions_listeners(reactions_elt)
195 elif type_ == "EDIT":
196 try:
197 old_message_elt = document[uid]
198 except KeyError:
199 log.debug(f"Message {uid} not found, no edition to apply")
200 else:
201 template_data = await self.message_to_template_data(
202 uid,
203 update_data["timestamp"],
204 jid.JID(update_data["from"]),
205 jid.JID(update_data["to"]),
206 update_data["message"],
207 update_data["subject"],
208 update_data["type"],
209 update_data["extra"]
210 )
211
212 new_message_elt = self.message_tpl.get_elt(
213 template_data
214 )
215 old_message_elt.replaceWith(new_message_elt)
216 self.add_message_event_listeners(new_message_elt)
217 self.handle_url_previews(new_message_elt)
152 else: 218 else:
153 log.warning(f"Unsupported update type: {type_!r}") 219 log.warning(f"Unsupported update type: {type_!r}")
154 220
155 # If user was at the bottom, keep the scroll at the bottom 221 # If user was at the bottom, keep the scroll at the bottom
156 if is_at_bottom: 222 if is_at_bottom:
157 self.messages_elt.scrollTop = self.messages_elt.scrollHeight 223 self.messages_elt.scrollTop = self.messages_elt.scrollHeight
224
225 async def message_to_template_data(
226 self,
227 uid: str,
228 timestamp: float,
229 from_jid: jid.JID,
230 to_jid: jid.JID,
231 message_data: dict,
232 subject_data: dict,
233 mess_type: str,
234 extra: dict,
235 ) -> dict:
236 """Generate template data to use with [message_tpl]
237
238 @return: template data
239 """
240 xhtml_data = extra.get("xhtml")
241 if not xhtml_data:
242 xhtml = None
243 else:
244 try:
245 xhtml = xhtml_data[""]
246 except KeyError:
247 xhtml = next(iter(xhtml_data.values()))
248
249 if chat_type == "group":
250 await cache.fill_identities([str(from_jid)])
251 else:
252 await cache.fill_identities([str(jid.JID(from_jid).bare)])
253 from_jid = from_jid.bare
254
255 return {
256 "own_jid": own_jid,
257 "msg": {
258 "id": uid,
259 "timestamp": extra.get("updated", timestamp),
260 "type": mess_type,
261 "from_": str(from_jid),
262 "text": message_data.get("") or next(iter(message_data.values()), ""),
263 "subject": subject_data.get("") or next(iter(subject_data.values()), ""),
264 "type": mess_type,
265 "thread": extra.get("thread"),
266 "thread_parent": extra.get("thread_parent"),
267 "reeceived": extra.get("received_timestamp") or timestamp,
268 "delay_sender": extra.get("delay_sender"),
269 "info_type": extra.get("info_type"),
270 "html": safe(xhtml) if xhtml else None,
271 "encrypted": extra.get("encrypted", False),
272 "received": extra.get("received", False),
273 "attachments": extra.get("attachments", []),
274 "extra": extra
275 },
276 "identities": identities,
277 }
158 278
159 async def on_message_new( 279 async def on_message_new(
160 self, 280 self,
161 uid: str, 281 uid: str,
162 timestamp: float, 282 timestamp: float,
175 and self.new_messages_marker_elt.parent is None 295 and self.new_messages_marker_elt.parent is None
176 ): 296 ):
177 # the page is not visible, and we have no new messages marker yet, so we add 297 # the page is not visible, and we have no new messages marker yet, so we add
178 # it 298 # it
179 self.messages_elt <= self.new_messages_marker_elt 299 self.messages_elt <= self.new_messages_marker_elt
180 xhtml_data = extra.get("xhtml") 300
181 if not xhtml_data: 301 template_data = await self.message_to_template_data(
182 xhtml = None 302 uid,
183 else: 303 timestamp,
184 try: 304 from_jid,
185 xhtml = xhtml_data[""] 305 to_jid,
186 except KeyError: 306 message_data,
187 xhtml = next(iter(xhtml_data.values())) 307 subject_data,
188 308 mess_type,
189 if chat_type == "group": 309 extra
190 await cache.fill_identities([str(from_jid)]) 310 )
191 else: 311
192 await cache.fill_identities([str(jid.JID(from_jid).bare)])
193
194 msg_data = {
195 "id": uid,
196 "timestamp": timestamp,
197 "type": mess_type,
198 "from_": str(from_jid),
199 "text": message_data.get("") or next(iter(message_data.values()), ""),
200 "subject": subject_data.get("") or next(iter(subject_data.values()), ""),
201 "type": mess_type,
202 "thread": extra.get("thread"),
203 "thread_parent": extra.get("thread_parent"),
204 "reeceived": extra.get("received_timestamp") or timestamp,
205 "delay_sender": extra.get("delay_sender"),
206 "info_type": extra.get("info_type"),
207 "html": safe(xhtml) if xhtml else None,
208 "encrypted": extra.get("encrypted", False),
209 "received": extra.get("received", False),
210 "edited": extra.get("edited", False),
211 "attachments": extra.get("attachments", []),
212 }
213 message_elt = self.message_tpl.get_elt( 312 message_elt = self.message_tpl.get_elt(
214 { 313 template_data
215 "own_jid": own_jid,
216 "msg": msg_data,
217 "identities": identities,
218 }
219 ) 314 )
220 315
221 # Check if user is viewing older messages or is at the bottom 316 # Check if user is viewing older messages or is at the bottom
222 is_at_bottom = self.is_at_bottom 317 is_at_bottom = self.is_at_bottom
223 318
256 if evt.keyCode == 13: # <Enter> key 351 if evt.keyCode == 13: # <Enter> key
257 if not window.navigator.maxTouchPoints: 352 if not window.navigator.maxTouchPoints:
258 # we have a non touch device, we send message on <Enter> 353 # we have a non touch device, we send message on <Enter>
259 if not evt.shiftKey: 354 if not evt.shiftKey:
260 evt.preventDefault() # Prevents line break 355 evt.preventDefault() # Prevents line break
261 self.send_message() 356 aio.run(self.send_message())
357 elif evt.keyCode == 27: # <ESC> key
358 evt.preventDefault()
359 self.message_input.value = ''
360 self.input_mode = 'normal'
361 self.auto_resize_message_input()
362 elif evt.keyCode == 38: # <Up> arrow key
363 if self.input_mode == "normal" and self.message_input.value.strip() == "":
364 evt.preventDefault()
365 own_msgs = document.getElementsByClassName('own_msg')
366 if own_msgs.length > 0:
367 last_msg = own_msgs[own_msgs.length - 1]
368 aio.run(self.on_action_edit(None, last_msg))
262 369
263 def update_attachments_visibility(self): 370 def update_attachments_visibility(self):
264 if len(self.attachments_elt.children): 371 if len(self.attachments_elt.children):
265 self.attachments_elt.classList.remove("is-contracted") 372 self.attachments_elt.classList.remove("is-contracted")
266 else: 373 else:
281 388
282 def on_attach_button_click(self, evt): 389 def on_attach_button_click(self, evt):
283 document["file_input"].click() 390 document["file_input"].click()
284 391
285 def on_extra_btn_click(self, evt): 392 def on_extra_btn_click(self, evt):
286 print("extra bouton clicked!") 393 message_elt = evt.target.closest("div.is-chat-message")
394 is_own = message_elt.classList.contains("own_msg")
395 if is_own:
396 own_messages = document.select('.own_msg')
397 # with XMPP, we can currently only edit our last message
398 can_edit = own_messages and message_elt is own_messages[-1]
399 else:
400 can_edit = False
401
402 content_elt = self.extra_menu_tpl.get_elt({
403 "edit": can_edit,
404 })
405 extra_popup = popup.create_popup(evt.target, content_elt, focus_elt=message_elt)
406
407 def on_action_click(evt, callback):
408 extra_popup.hide()
409 aio.run(
410 callback(evt, message_elt)
411 )
412
413 for cls_name, callback in (
414 ("action_quote", self.on_action_quote),
415 ("action_edit", self.on_action_edit)
416 ):
417 for elt in content_elt.select(f".{cls_name}"):
418 elt.bind("click", lambda evt, callback=callback: on_action_click(
419 evt, callback
420 ))
287 421
288 def on_reaction_click(self, evt, message_elt): 422 def on_reaction_click(self, evt, message_elt):
289 window.evt = evt 423 window.evt = evt
290 aio.run( 424 aio.run(
291 bridge.message_reactions_set( 425 bridge.message_reactions_set(
292 message_elt["id"], [evt.detail["unicode"]], "toggle" 426 message_elt["id"], [evt.detail["unicode"]], "toggle"
293 ) 427 )
294 ) 428 )
295
296 @bind(document["attachments"], "wheel")
297 def wheel_event(evt):
298 """Make the mouse wheel to act on horizontal scrolling for attachments
299
300 Attachments don't have vertical scrolling, thus is makes sense to use the wheel
301 for horizontal scrolling
302 """
303 if evt.deltaY != 0: 429 if evt.deltaY != 0:
304 document["attachments"].scrollLeft += evt.deltaY * 0.8 430 document["attachments"].scrollLeft += evt.deltaY * 0.8
305 evt.preventDefault() 431 evt.preventDefault()
432
433 async def get_message_tuple(self, message_elt) -> tuple|None:
434 """Retrieve message tuple from as sent by [message_new]
435
436 If not corresponding message data is found, an error will shown, and None is
437 returned.
438 @param message_elt: message element, it's "id" attribute will be use to retrieve
439 message data
440 @return: message data as a tuple, or None if not message with this ID is found.
441 """
442 message_id = message_elt['id']
443 history_data = await bridge.history_get(
444 "", "", -2, True, {"id": message_elt['id']}
445 )
446 if not history_data:
447 dialog.notification.show(f"Can't find message {message_id}", "error")
448 return None
449 return history_data[0]
450
451 async def on_action_quote(self, __, message_elt) -> None:
452 message_data = await self.get_message_tuple(message_elt)
453 if message_data is not None:
454 messages = message_data[4]
455 body = next(iter(messages.values()), "")
456 quote = "\n".join(f"> {l}" for l in body.split("\n"))
457 self.message_input.value = f"{quote}\n{self.message_input.value}"
458 self.input_mode = "quote"
459 self.input_data["id"] = message_elt["id"]
460 self.auto_resize_message_input()
461 self.message_input.focus()
462
463 async def on_action_edit(self, __, message_elt) -> None:
464 message_data = await self.get_message_tuple(message_elt)
465 if message_data is not None:
466 messages = message_data[4]
467 body = next(iter(messages.values()), "")
468 if not body:
469 dialog.notification.show("No content found in message, nothing to edit")
470 return
471
472 self.message_input.value = body
473 self.input_mode = "edit"
474 self.input_data["id"] = message_elt["id"]
475 self.auto_resize_message_input()
476 self.message_input.focus()
306 477
307 def get_reaction_panel(self, source_elt): 478 def get_reaction_panel(self, source_elt):
308 emoji_picker_elt = document.createElement("emoji-picker") 479 emoji_picker_elt = document.createElement("emoji-picker")
309 message_elt = source_elt.closest("div.is-chat-message") 480 message_elt = source_elt.closest("div.is-chat-message")
310 emoji_picker_elt.bind( 481 emoji_picker_elt.bind(
317 """Prepare a message to be dynamic 488 """Prepare a message to be dynamic
318 489
319 - make attachments dynamically clickable 490 - make attachments dynamically clickable
320 - make the extra button clickable 491 - make the extra button clickable
321 """ 492 """
322
323 ## attachments 493 ## attachments
324 # FIXME: only handle images for now, and display them in a modal 494 # FIXME: only handle images for now, and display them in a modal
325 if parent_elt is None: 495 if parent_elt is None:
326 parent_elt = document 496 parent_elt = document
327 img_elts = parent_elt.select(".message-attachment img") 497 img_elts = parent_elt.select(".message-attachment img")
340 "appendTo": document.body, 510 "appendTo": document.body,
341 "placement": "bottom", 511 "placement": "bottom",
342 "interactive": True, 512 "interactive": True,
343 "theme": "light", 513 "theme": "light",
344 "onShow": lambda __, message_elt=message_elt: ( 514 "onShow": lambda __, message_elt=message_elt: (
345 message_elt.classList.add("chat-message-highlight") 515 message_elt.classList.add("has-popup-focus")
346 ), 516 ),
347 "onHide": lambda __, message_elt=message_elt: ( 517 "onHide": lambda __, message_elt=message_elt: (
348 message_elt.classList.remove("chat-message-highlight") 518 message_elt.classList.remove("has-popup-focus")
349 ), 519 ),
350 }, 520 },
351 ) 521 )
352 522
353 ## extra button 523 ## extra button
354 for extra_btn in parent_elt.select(".extra-button"): 524 for extra_btn in parent_elt.select(".extra-button"):
355 extra_btn.bind("click", self.on_extra_btn_click) 525 extra_btn.bind("click", self.on_extra_btn_click)
526
527 ## editions
528 for edition_icon_elt in parent_elt.select(".message-editions"):
529 message_elt = edition_icon_elt.closest("div.is-chat-message")
530 dataset = message_elt.dataset.to_dict()
531 try:
532 editions = json.loads(dataset["editions"])
533 except (ValueError, KeyError):
534 log.error(
535 f"Internal Error: invalid or missing editions data: {message_elt['id']}"
536 )
537 else:
538 for edition in editions:
539 edition["text"] = (
540 edition["message"].get("")
541 or next(iter(edition["message"].values()), "")
542 )
543 editions_elt = self.editions_tpl.get_elt({"editions": editions})
544 tippy(
545 edition_icon_elt,
546 {
547 "content": editions_elt,
548 "theme": "light",
549 "appendTo": document.body
550 }
551
552 )
356 553
357 def add_reactions_listeners(self, parent_elt=None) -> None: 554 def add_reactions_listeners(self, parent_elt=None) -> None:
358 """Add listener on reactions to handle details and reaction toggle""" 555 """Add listener on reactions to handle details and reaction toggle"""
359 if parent_elt is None: 556 if parent_elt is None:
360 parent_elt = document 557 parent_elt = document
493 690
494 document["message_input"].bind( 691 document["message_input"].bind(
495 "input", lambda __: libervia_web_chat.auto_resize_message_input() 692 "input", lambda __: libervia_web_chat.auto_resize_message_input()
496 ) 693 )
497 document["message_input"].bind("keydown", libervia_web_chat.on_message_keydown) 694 document["message_input"].bind("keydown", libervia_web_chat.on_message_keydown)
498 document["send_button"].bind("click", lambda __: libervia_web_chat.send_message()) 695 document["send_button"].bind(
696 "click",
697 lambda __: aio.run(libervia_web_chat.send_message())
698 )
499 document["attach_button"].bind("click", libervia_web_chat.on_attach_button_click) 699 document["attach_button"].bind("click", libervia_web_chat.on_attach_button_click)
500 document["file_input"].bind("change", libervia_web_chat.on_file_selected) 700 document["file_input"].bind("change", libervia_web_chat.on_file_selected)
501 701
502 document.bind("visibilitychange", libervia_web_chat.handle_visibility_change) 702 document.bind("visibilitychange", libervia_web_chat.handle_visibility_change)
503 703
504 bridge.register_signal("message_new", libervia_web_chat._on_message_new) 704 bridge.register_signal("message_new", libervia_web_chat._on_message_new)
505 bridge.register_signal("message_update", libervia_web_chat._on_message_update) 705 bridge.register_signal("message_update", libervia_web_chat._on_message_update)
506
507 libervia_web_chat.add_message_event_listeners()
508 libervia_web_chat.handle_url_previews()
509 libervia_web_chat.add_reactions_listeners()