comparison libervia/web/pages/chat/_browser/__init__.py @ 1536:dc81403a5b2f

browser: chat page: since the move to Brython, the chat was really basic and not really usable. Now that dynamism has been re-implemented correctly in the new frontend, a real advanced chat page can be done. This is the first draft in this direction.
author Goffi <goffi@goffi.org>
date Wed, 28 Jun 2023 10:05:13 +0200
parents
children b4342176fa0a
comparison
equal deleted inserted replaced
1535:de09d4d25194 1536:dc81403a5b2f
1 import json
2
3 import dialog
4 import jid
5 from bridge import AsyncBridge as Bridge
6 from browser import aio, console as log, document, DOMNode, window, bind
7 from template import Template, safe
8 from file_uploader import FileUploader
9 from cache import cache, identities
10
11 log.warning = log.warn
12 profile = window.profile or ""
13 own_jid = jid.JID(window.own_jid)
14 target_jid = jid.JID(window.target_jid)
15 bridge = Bridge()
16
17 # Sensible value to consider that user is at the bottom
18 SCROLL_SENSITIVITY = 200
19
20
21 class LiberviaWebChat:
22
23 def __init__(self):
24 self.message_tpl = Template("chat/message.html")
25 self.messages_elt = document["messages"]
26
27 # attachments
28 self.file_uploader = FileUploader(
29 "", "chat/attachment_preview.html", on_delete_cb=self.on_attachment_delete
30 )
31 self.attachments_elt = document["attachments"]
32 self.message_input = document["message_input"]
33
34 # hide/show attachments
35 MutationObserver = window.MutationObserver
36 observer = MutationObserver.new(lambda *__: self.update_attachments_visibility())
37 observer.observe(self.attachments_elt, {"childList": True})
38
39 # we want the message scroll to be initially at the bottom
40 self.messages_elt.scrollTop = self.messages_elt.scrollHeight
41
42 @property
43 def is_at_bottom(self):
44 return (
45 self.messages_elt.scrollHeight
46 - self.messages_elt.scrollTop
47 - self.messages_elt.clientHeight
48 <= SCROLL_SENSITIVITY
49 )
50
51
52 def send_message(self):
53 """Send message currently in input area
54
55 The message and corresponding attachment will be sent
56 """
57 message = self.message_input.value.rstrip()
58 log.info(f"{message=}")
59
60 # attachments
61 attachments = []
62 for attachment_elt in self.attachments_elt.children:
63 file_data = json.loads(attachment_elt.getAttribute("data-file"))
64 attachments.append(file_data)
65
66 if message or attachments:
67 extra = {}
68
69 if attachments:
70 extra["attachments"] = attachments
71
72 # now we send the message
73 try:
74 aio.run(
75 bridge.message_send(
76 str(target_jid), {"": message}, {}, "auto", json.dumps(extra)
77 )
78 )
79 except Exception as e:
80 dialog.notification.show(f"Can't send message: {e}", "error")
81 else:
82 self.message_input.value = ""
83 self.attachments_elt.clear()
84
85 def _on_message_new(
86 self,
87 uid: str,
88 timestamp: float,
89 from_jid: str,
90 to_jid: str,
91 message: dict,
92 subject: dict,
93 mess_type: str,
94 extra_s: str,
95 profile: str,
96 ) -> None:
97 if (
98 jid.JID(from_jid).bare == window.target_jid
99 or jid.JID(to_jid).bare == window.target_jid
100 ):
101 aio.run(
102 self.on_message_new(
103 uid,
104 timestamp,
105 from_jid,
106 to_jid,
107 message,
108 subject,
109 mess_type,
110 json.loads(extra_s),
111 profile,
112 )
113 )
114
115 async def on_message_new(
116 self,
117 uid: str,
118 timestamp: float,
119 from_jid: str,
120 to_jid: str,
121 message_data: dict,
122 subject_data: dict,
123 mess_type: str,
124 extra: dict,
125 profile: str,
126 ) -> None:
127 log.info(f"on_message_new: [{from_jid} -> {to_jid}] {message_data}, {extra=}")
128 xhtml_data = extra.get("xhtml")
129 if not xhtml_data:
130 xhtml = None
131 else:
132 try:
133 xhtml = xhtml_data[""]
134 except KeyError:
135 xhtml = next(iter(xhtml_data.values()))
136
137 await cache.fill_identities([from_jid])
138
139 msg_data = {
140 "id": uid,
141 "timestamp": timestamp,
142 "type": mess_type,
143 "from_": from_jid,
144 "text": message_data.get("") or next(iter(message_data.values()), ""),
145 "subject": subject_data.get("") or next(iter(subject_data.values()), ""),
146 "type": mess_type,
147 "thread": extra.get("thread"),
148 "thread_parent": extra.get("thread_parent"),
149 "reeceived": extra.get("received_timestamp") or timestamp,
150 "delay_sender": extra.get("delay_sender"),
151 "info_type": extra.get("info_type"),
152 "html": safe(xhtml) if xhtml else None,
153 "encrypted": extra.get("encrypted", False),
154 "received": extra.get("received", False),
155 "edited": extra.get("edited", False),
156 "attachments": extra.get("attachments", []),
157 }
158 message_elt = self.message_tpl.get_elt(
159 {
160 "own_jid": own_jid,
161 "msg": msg_data,
162 "identities": identities,
163 }
164 )
165
166 # Check if user is viewing older messages or is at the bottom
167 is_at_bottom = self.is_at_bottom
168
169 self.messages_elt <= message_elt
170 self.make_attachments_dynamic(message_elt)
171
172 # If user was at the bottom, keep the scroll at the bottom
173 if is_at_bottom:
174 self.messages_elt.scrollTop = self.messages_elt.scrollHeight
175
176 def auto_resize_message_input(self):
177 """Resize the message input field according to content."""
178
179 is_at_bottom = self.is_at_bottom
180
181 # The textarea's height is first reset to 'auto' to ensure it's not influenced by
182 # the previous content.
183 self.message_input.style.height = "auto"
184
185 # Then the height is set to the scrollHeight of the textarea (which is the height
186 # of the content), plus the vertical border, resulting in a textarea that grows as
187 # more lines of text are added.
188 self.message_input.style.height = f"{self.message_input.scrollHeight + 2}px"
189
190 if is_at_bottom:
191 # we want the message are to still display the last message
192 self.messages_elt.scrollTop = self.messages_elt.scrollHeight
193
194 def on_message_keydown(self, evt):
195 """Handle the 'keydown' event of the message input field
196
197 @param evt: The event object. 'target' refers to the textarea element.
198 """
199 if evt.keyCode == 13: # <Enter> key
200 if not window.navigator.maxTouchPoints:
201 # we have a non touch device, we send message on <Enter>
202 if not evt.shiftKey:
203 evt.preventDefault() # Prevents line break
204 self.send_message()
205
206 def update_attachments_visibility(self):
207 if len(self.attachments_elt.children):
208 self.attachments_elt.classList.remove("is-contracted")
209 else:
210 self.attachments_elt.classList.add("is-contracted")
211
212 def on_file_selected(self, evt):
213 """Handle file selection"""
214 log.info("file selected")
215 files = evt.currentTarget.files
216 self.file_uploader.upload_files(files, self.attachments_elt)
217 self.message_input.focus()
218
219 def on_attachment_delete(self, evt):
220 evt.stopPropagation()
221 target = evt.currentTarget
222 item_elt = DOMNode(target.closest('.attachment-preview'))
223 item_elt.remove()
224
225 def on_attach_button_click(self, evt):
226 document["file_input"].click()
227
228 @bind(document["attachments"], 'wheel')
229 def wheel_event(evt):
230 """Make the mouse wheel to act on horizontal scrolling for attachments
231
232 Attachments don't have vertical scrolling, thus is makes sense to use the wheel
233 for horizontal scrolling
234 """
235 if evt.deltaY != 0:
236 document['attachments'].scrollLeft += evt.deltaY * 0.8
237 evt.preventDefault()
238
239 def make_attachments_dynamic(self, parent_elt = None):
240 """Make attachments dynamically clickable"""
241 # FIXME: only handle images for now, and display them in a modal
242 if parent_elt is None:
243 parent_elt = document
244 img_elts = parent_elt.select('.message-attachment img')
245 for img_elt in img_elts:
246 img_elt.bind('click', self.open_modal)
247 img_elt.style.cursor = 'pointer'
248
249 close_button = document.select_one('.modal-close')
250 close_button.bind('click', self.close_modal)
251
252 def open_modal(self, evt):
253 modal_image = document.select_one('#modal-image')
254 modal_image.src = evt.target.src
255 modal_image.alt = evt.target.alt
256 modal = document.select_one('#modal')
257 modal.classList.add('is-active')
258
259 def close_modal(self, evt):
260 modal = document.select_one('#modal')
261 modal.classList.remove('is-active')
262
263
264 libervia_web_chat = LiberviaWebChat()
265 document["message_input"].bind(
266 "input", lambda __: libervia_web_chat.auto_resize_message_input()
267 )
268 document["message_input"].bind("keydown", libervia_web_chat.on_message_keydown)
269 document["send_button"].bind("click", lambda __: libervia_web_chat.send_message())
270 document["attach_button"].bind(
271 "click", libervia_web_chat.on_attach_button_click
272 )
273 document["file_input"].bind("change", libervia_web_chat.on_file_selected)
274 bridge.register_signal("message_new", libervia_web_chat._on_message_new)
275 libervia_web_chat.make_attachments_dynamic()