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