1536
|
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() |