Mercurial > libervia-desktop-kivy
comparison libervia/desktop_kivy/plugins/plugin_wid_chat.py @ 493:b3cedbee561d
refactoring: rename `cagou` to `libervia.desktop_kivy` + update imports and names following backend changes
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 18:26:16 +0200 |
parents | cagou/plugins/plugin_wid_chat.py@203755bbe0fe |
children | 196483685a63 |
comparison
equal
deleted
inserted
replaced
492:5114bbb5daa3 | 493:b3cedbee561d |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 #Libervia Desktop-Kivy | |
4 # Copyright (C) 2016-2021 Jérôme Poisson (goffi@goffi.org) | |
5 | |
6 # This program is free software: you can redistribute it and/or modify | |
7 # it under the terms of the GNU Affero General Public License as published by | |
8 # the Free Software Foundation, either version 3 of the License, or | |
9 # (at your option) any later version. | |
10 | |
11 # This program is distributed in the hope that it will be useful, | |
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
14 # GNU Affero General Public License for more details. | |
15 | |
16 # You should have received a copy of the GNU Affero General Public License | |
17 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
18 | |
19 | |
20 from functools import partial | |
21 from pathlib import Path | |
22 import sys | |
23 import uuid | |
24 import mimetypes | |
25 from urllib.parse import urlparse | |
26 from kivy.uix.boxlayout import BoxLayout | |
27 from kivy.uix.gridlayout import GridLayout | |
28 from kivy.uix.screenmanager import Screen, NoTransition | |
29 from kivy.uix.textinput import TextInput | |
30 from kivy.uix.label import Label | |
31 from kivy.uix import screenmanager | |
32 from kivy.uix.behaviors import ButtonBehavior | |
33 from kivy.metrics import sp, dp | |
34 from kivy.clock import Clock | |
35 from kivy import properties | |
36 from kivy.uix.stacklayout import StackLayout | |
37 from kivy.uix.dropdown import DropDown | |
38 from kivy.core.window import Window | |
39 from libervia.backend.core import log as logging | |
40 from libervia.backend.core.i18n import _ | |
41 from libervia.backend.core import exceptions | |
42 from libervia.backend.tools.common import data_format | |
43 from libervia.frontends.quick_frontend import quick_widgets | |
44 from libervia.frontends.quick_frontend import quick_chat | |
45 from libervia.frontends.tools import jid | |
46 from libervia.desktop_kivy import G | |
47 from ..core.constants import Const as C | |
48 from ..core import cagou_widget | |
49 from ..core import xmlui | |
50 from ..core.image import Image, AsyncImage | |
51 from ..core.common import Symbol, SymbolButton, JidButton, ContactButton | |
52 from ..core.behaviors import FilterBehavior | |
53 from ..core import menu | |
54 from ..core.common_widgets import ImagesGallery | |
55 | |
56 log = logging.getLogger(__name__) | |
57 | |
58 PLUGIN_INFO = { | |
59 "name": _("chat"), | |
60 "main": "Chat", | |
61 "description": _("instant messaging with one person or a group"), | |
62 "icon_symbol": "chat", | |
63 } | |
64 | |
65 # FIXME: OTR specific code is legacy, and only used nowadays for lock color | |
66 # we can probably get rid of them. | |
67 OTR_STATE_UNTRUSTED = 'untrusted' | |
68 OTR_STATE_TRUSTED = 'trusted' | |
69 OTR_STATE_TRUST = (OTR_STATE_UNTRUSTED, OTR_STATE_TRUSTED) | |
70 OTR_STATE_UNENCRYPTED = 'unencrypted' | |
71 OTR_STATE_ENCRYPTED = 'encrypted' | |
72 OTR_STATE_ENCRYPTION = (OTR_STATE_UNENCRYPTED, OTR_STATE_ENCRYPTED) | |
73 | |
74 SYMBOL_UNENCRYPTED = 'lock-open' | |
75 SYMBOL_ENCRYPTED = 'lock' | |
76 SYMBOL_ENCRYPTED_TRUSTED = 'lock-filled' | |
77 COLOR_UNENCRYPTED = (0.4, 0.4, 0.4, 1) | |
78 COLOR_ENCRYPTED = (0.4, 0.4, 0.4, 1) | |
79 COLOR_ENCRYPTED_TRUSTED = (0.29,0.87,0.0,1) | |
80 | |
81 # below this limit, new messages will be prepended | |
82 INFINITE_SCROLL_LIMIT = dp(600) | |
83 | |
84 # File sending progress | |
85 PROGRESS_UPDATE = 0.2 # number of seconds before next progress update | |
86 | |
87 | |
88 # FIXME: a ScrollLayout was supposed to be used here, but due | |
89 # to https://github.com/kivy/kivy/issues/6745, a StackLayout is used for now | |
90 class AttachmentsLayout(StackLayout): | |
91 """Layout for attachments in a received message""" | |
92 padding = properties.VariableListProperty([dp(5), dp(5), 0, dp(5)]) | |
93 attachments = properties.ObjectProperty() | |
94 | |
95 | |
96 class AttachmentsToSend(BoxLayout): | |
97 """Layout for attachments to be sent with current message""" | |
98 attachments = properties.ObjectProperty() | |
99 reduce_checkbox = properties.ObjectProperty() | |
100 show_resize = properties.BooleanProperty(False) | |
101 | |
102 def on_kv_post(self, __): | |
103 self.attachments.bind(children=self.on_attachment) | |
104 | |
105 def on_attachment(self, __, attachments): | |
106 if len(attachments) == 0: | |
107 self.show_resize = False | |
108 | |
109 | |
110 class BaseAttachmentItem(BoxLayout): | |
111 data = properties.DictProperty() | |
112 progress = properties.NumericProperty(0) | |
113 | |
114 | |
115 class AttachmentItem(BaseAttachmentItem): | |
116 | |
117 def get_symbol(self, data): | |
118 media_type = data.get(C.KEY_ATTACHMENTS_MEDIA_TYPE, '') | |
119 main_type = media_type.split('/', 1)[0] | |
120 if main_type == 'image': | |
121 return "file-image" | |
122 elif main_type == 'video': | |
123 return "file-video" | |
124 elif main_type == 'audio': | |
125 return "file-audio" | |
126 else: | |
127 return "doc" | |
128 | |
129 def on_press(self): | |
130 url = self.data.get('url') | |
131 if url: | |
132 G.local_platform.open_url(url, self) | |
133 else: | |
134 log.warning(f"can't find URL in {self.data}") | |
135 | |
136 | |
137 class AttachmentImageItem(ButtonBehavior, BaseAttachmentItem): | |
138 image = properties.ObjectProperty() | |
139 | |
140 def on_press(self): | |
141 full_size_source = self.data.get('path', self.data.get('url')) | |
142 gallery = ImagesGallery(sources=[full_size_source]) | |
143 G.host.show_extra_ui(gallery) | |
144 | |
145 def on_kv_post(self, __): | |
146 self.on_data(None, self.data) | |
147 | |
148 def on_data(self, __, data): | |
149 if self.image is None: | |
150 return | |
151 source = data.get('preview') or data.get('path') or data.get('url') | |
152 if source: | |
153 self.image.source = source | |
154 | |
155 | |
156 class AttachmentImagesCollectionItem(ButtonBehavior, GridLayout): | |
157 attachments = properties.ListProperty([]) | |
158 chat = properties.ObjectProperty() | |
159 mess_data = properties.ObjectProperty() | |
160 | |
161 def _set_preview(self, attachment, wid, preview_path): | |
162 attachment['preview'] = preview_path | |
163 wid.source = preview_path | |
164 | |
165 def _set_path(self, attachment, wid, path): | |
166 attachment['path'] = path | |
167 if wid is not None: | |
168 # we also need a preview for the widget | |
169 if 'preview' in attachment: | |
170 wid.source = attachment['preview'] | |
171 else: | |
172 G.host.bridge.image_generate_preview( | |
173 path, | |
174 self.chat.profile, | |
175 callback=partial(self._set_preview, attachment, wid), | |
176 ) | |
177 | |
178 def on_kv_post(self, __): | |
179 attachments = self.attachments | |
180 self.clear_widgets() | |
181 for idx, attachment in enumerate(attachments): | |
182 try: | |
183 url = attachment['url'] | |
184 except KeyError: | |
185 url = None | |
186 to_download = False | |
187 else: | |
188 if url.startswith("aesgcm:"): | |
189 del attachment['url'] | |
190 # if the file is encrypted, we need to download it for decryption | |
191 to_download = True | |
192 else: | |
193 to_download = False | |
194 | |
195 if idx < 3 or len(attachments) <= 4: | |
196 if ((self.mess_data.own_mess | |
197 or self.chat.contact_list.is_in_roster(self.mess_data.from_jid))): | |
198 wid = AsyncImage(size_hint=(1, 1), source="data/images/image-loading.gif") | |
199 if 'preview' in attachment: | |
200 wid.source = attachment["preview"] | |
201 elif 'path' in attachment: | |
202 G.host.bridge.image_generate_preview( | |
203 attachment['path'], | |
204 self.chat.profile, | |
205 callback=partial(self._set_preview, attachment, wid), | |
206 ) | |
207 elif url is None: | |
208 log.warning(f"Can't find any source for {attachment}") | |
209 else: | |
210 # we'll download the file, the preview will then be generated | |
211 to_download = True | |
212 else: | |
213 # we don't download automatically the image if the contact is not | |
214 # in roster, to avoid leaking the ip | |
215 wid = Symbol(symbol="file-image") | |
216 self.add_widget(wid) | |
217 else: | |
218 wid = None | |
219 | |
220 if to_download: | |
221 # the file needs to be downloaded, the widget source, | |
222 # attachment path, and preview will then be completed | |
223 G.host.download_url( | |
224 url, | |
225 callback=partial(self._set_path, attachment, wid), | |
226 dest=C.FILE_DEST_CACHE, | |
227 profile=self.chat.profile, | |
228 ) | |
229 | |
230 if len(attachments) > 4: | |
231 counter = Label( | |
232 bold=True, | |
233 text=f"+{len(attachments) - 3}", | |
234 ) | |
235 self.add_widget(counter) | |
236 | |
237 def on_press(self): | |
238 sources = [] | |
239 for attachment in self.attachments: | |
240 source = attachment.get('path') or attachment.get('url') | |
241 if not source: | |
242 log.warning(f"no source for {attachment}") | |
243 else: | |
244 sources.append(source) | |
245 gallery = ImagesGallery(sources=sources) | |
246 G.host.show_extra_ui(gallery) | |
247 | |
248 | |
249 class AttachmentToSendItem(AttachmentItem): | |
250 # True when the item is being sent | |
251 sending = properties.BooleanProperty(False) | |
252 | |
253 | |
254 class MessAvatar(ButtonBehavior, Image): | |
255 pass | |
256 | |
257 | |
258 class MessageWidget(quick_chat.MessageWidget, BoxLayout): | |
259 mess_data = properties.ObjectProperty() | |
260 mess_xhtml = properties.ObjectProperty() | |
261 mess_padding = (dp(5), dp(5)) | |
262 avatar = properties.ObjectProperty() | |
263 delivery = properties.ObjectProperty() | |
264 font_size = properties.NumericProperty(sp(12)) | |
265 right_part = properties.ObjectProperty() | |
266 header_box = properties.ObjectProperty() | |
267 | |
268 def on_kv_post(self, __): | |
269 if not self.mess_data: | |
270 raise exceptions.InternalError( | |
271 "mess_data must always be set in MessageWidget") | |
272 | |
273 self.mess_data.widgets.add(self) | |
274 self.add_attachments() | |
275 | |
276 @property | |
277 def chat(self): | |
278 """return parent Chat instance""" | |
279 return self.mess_data.parent | |
280 | |
281 def _get_from_mess_data(self, name, default): | |
282 if self.mess_data is None: | |
283 return default | |
284 return getattr(self.mess_data, name) | |
285 | |
286 def _get_message(self): | |
287 """Return currently displayed message""" | |
288 if self.mess_data is None: | |
289 return "" | |
290 return self.mess_data.main_message | |
291 | |
292 def _set_message(self, message): | |
293 if self.mess_data is None: | |
294 return False | |
295 if message == self.mess_data.message.get(""): | |
296 return False | |
297 self.mess_data.message = {"": message} | |
298 return True | |
299 | |
300 message = properties.AliasProperty( | |
301 partial(_get_from_mess_data, name="main_message", default=""), | |
302 _set_message, | |
303 bind=['mess_data'], | |
304 ) | |
305 message_xhtml = properties.AliasProperty( | |
306 partial(_get_from_mess_data, name="main_message_xhtml", default=""), | |
307 bind=['mess_data']) | |
308 mess_type = properties.AliasProperty( | |
309 partial(_get_from_mess_data, name="type", default=""), bind=['mess_data']) | |
310 own_mess = properties.AliasProperty( | |
311 partial(_get_from_mess_data, name="own_mess", default=False), bind=['mess_data']) | |
312 nick = properties.AliasProperty( | |
313 partial(_get_from_mess_data, name="nick", default=""), bind=['mess_data']) | |
314 time_text = properties.AliasProperty( | |
315 partial(_get_from_mess_data, name="time_text", default=""), bind=['mess_data']) | |
316 | |
317 @property | |
318 def info_type(self): | |
319 return self.mess_data.info_type | |
320 | |
321 def update(self, update_dict): | |
322 if 'avatar' in update_dict: | |
323 avatar_data = update_dict['avatar'] | |
324 if avatar_data is None: | |
325 source = G.host.get_default_avatar() | |
326 else: | |
327 source = avatar_data['path'] | |
328 self.avatar.source = source | |
329 if 'status' in update_dict: | |
330 status = update_dict['status'] | |
331 self.delivery.text = '\u2714' if status == 'delivered' else '' | |
332 | |
333 def _set_path(self, data, path): | |
334 """Set path of decrypted file to an item""" | |
335 data['path'] = path | |
336 | |
337 def add_attachments(self): | |
338 """Add attachments layout + attachments item""" | |
339 attachments = self.mess_data.attachments | |
340 if not attachments: | |
341 return | |
342 root_layout = AttachmentsLayout() | |
343 self.right_part.add_widget(root_layout) | |
344 layout = root_layout.attachments | |
345 | |
346 image_attachments = [] | |
347 other_attachments = [] | |
348 # we first separate images and other attachments, so we know if we need | |
349 # to use an image collection | |
350 for attachment in attachments: | |
351 media_type = attachment.get(C.KEY_ATTACHMENTS_MEDIA_TYPE, '') | |
352 main_type = media_type.split('/', 1)[0] | |
353 # GIF images are really badly handled by Kivy, the memory | |
354 # consumption explode, and the images frequencies are not handled | |
355 # correctly, thus we can't display them and we consider them as | |
356 # other attachment, so user can open the item with appropriate | |
357 # software. | |
358 if main_type == 'image' and media_type != "image/gif": | |
359 image_attachments.append(attachment) | |
360 else: | |
361 other_attachments.append(attachment) | |
362 | |
363 if len(image_attachments) > 1: | |
364 collection = AttachmentImagesCollectionItem( | |
365 attachments=image_attachments, | |
366 chat=self.chat, | |
367 mess_data=self.mess_data, | |
368 ) | |
369 layout.add_widget(collection) | |
370 elif image_attachments: | |
371 attachment = image_attachments[0] | |
372 # to avoid leaking IP address, we only display image if the contact is in | |
373 # roster | |
374 if ((self.mess_data.own_mess | |
375 or self.chat.contact_list.is_in_roster(self.mess_data.from_jid))): | |
376 try: | |
377 url = urlparse(attachment['url']) | |
378 except KeyError: | |
379 item = AttachmentImageItem(data=attachment) | |
380 else: | |
381 if url.scheme == "aesgcm": | |
382 # we remove the URL now, we'll replace it by | |
383 # the local decrypted version | |
384 del attachment['url'] | |
385 item = AttachmentImageItem(data=attachment) | |
386 G.host.download_url( | |
387 url.geturl(), | |
388 callback=partial(self._set_path, item.data), | |
389 dest=C.FILE_DEST_CACHE, | |
390 profile=self.chat.profile, | |
391 ) | |
392 else: | |
393 item = AttachmentImageItem(data=attachment) | |
394 else: | |
395 item = AttachmentItem(data=attachment) | |
396 | |
397 layout.add_widget(item) | |
398 | |
399 for attachment in other_attachments: | |
400 item = AttachmentItem(data=attachment) | |
401 layout.add_widget(item) | |
402 | |
403 | |
404 class MessageInputBox(BoxLayout): | |
405 message_input = properties.ObjectProperty() | |
406 | |
407 def send_text(self): | |
408 self.message_input.send_text() | |
409 | |
410 | |
411 class MessageInputWidget(TextInput): | |
412 | |
413 def keyboard_on_key_down(self, window, keycode, text, modifiers): | |
414 # We don't send text when shift is pressed to be able to add line feeds | |
415 # (i.e. multi-lines messages). We don't send on Android either as the | |
416 # send button appears on this platform. | |
417 if (keycode[-1] == "enter" | |
418 and "shift" not in modifiers | |
419 and sys.platform != 'android'): | |
420 self.send_text() | |
421 else: | |
422 return super(MessageInputWidget, self).keyboard_on_key_down( | |
423 window, keycode, text, modifiers) | |
424 | |
425 def send_text(self): | |
426 self.dispatch('on_text_validate') | |
427 | |
428 | |
429 class TransferButton(SymbolButton): | |
430 chat = properties.ObjectProperty() | |
431 | |
432 def on_release(self, *args): | |
433 menu.TransferMenu( | |
434 encrypted=self.chat.encrypted, | |
435 callback=self.chat.transfer_file, | |
436 ).show(self) | |
437 | |
438 | |
439 class ExtraMenu(DropDown): | |
440 chat = properties.ObjectProperty() | |
441 | |
442 def on_select(self, menu): | |
443 if menu == 'bookmark': | |
444 G.host.bridge.menu_launch(C.MENU_GLOBAL, ("groups", "bookmarks"), | |
445 {}, C.NO_SECURITY_LIMIT, self.chat.profile, | |
446 callback=partial( | |
447 G.host.action_manager, profile=self.chat.profile), | |
448 errback=G.host.errback) | |
449 elif menu == 'close': | |
450 if self.chat.type == C.CHAT_GROUP: | |
451 # for MUC, we have to indicate the backend that we've left | |
452 G.host.bridge.muc_leave(self.chat.target, self.chat.profile) | |
453 else: | |
454 # for one2one, backend doesn't keep any state, so we just delete the | |
455 # widget here in the frontend | |
456 G.host.widgets.delete_widget( | |
457 self.chat, all_instances=True, explicit_close=True) | |
458 else: | |
459 raise exceptions.InternalError("Unknown menu: {}".format(menu)) | |
460 | |
461 | |
462 class ExtraButton(SymbolButton): | |
463 chat = properties.ObjectProperty() | |
464 | |
465 | |
466 class EncryptionMainButton(SymbolButton): | |
467 | |
468 def __init__(self, chat, **kwargs): | |
469 """ | |
470 @param chat(Chat): Chat instance | |
471 """ | |
472 self.chat = chat | |
473 self.encryption_menu = EncryptionMenu(chat) | |
474 super(EncryptionMainButton, self).__init__(**kwargs) | |
475 self.bind(on_release=self.encryption_menu.open) | |
476 | |
477 def select_algo(self, name): | |
478 """Mark an encryption algorithm as selected. | |
479 | |
480 This will also deselect all other button | |
481 @param name(unicode, None): encryption plugin name | |
482 None for plain text | |
483 """ | |
484 buttons = self.encryption_menu.container.children | |
485 buttons[-1].selected = name is None | |
486 for button in buttons[:-1]: | |
487 button.selected = button.text == name | |
488 | |
489 def get_color(self): | |
490 if self.chat.otr_state_encryption == OTR_STATE_UNENCRYPTED: | |
491 return (0.4, 0.4, 0.4, 1) | |
492 elif self.chat.otr_state_trust == OTR_STATE_TRUSTED: | |
493 return (0.29,0.87,0.0,1) | |
494 else: | |
495 return (0.4, 0.4, 0.4, 1) | |
496 | |
497 def get_symbol(self): | |
498 if self.chat.otr_state_encryption == OTR_STATE_UNENCRYPTED: | |
499 return 'lock-open' | |
500 elif self.chat.otr_state_trust == OTR_STATE_TRUSTED: | |
501 return 'lock-filled' | |
502 else: | |
503 return 'lock' | |
504 | |
505 | |
506 class TrustManagementButton(SymbolButton): | |
507 pass | |
508 | |
509 | |
510 class EncryptionButton(BoxLayout): | |
511 selected = properties.BooleanProperty(False) | |
512 text = properties.StringProperty() | |
513 trust_button = properties.BooleanProperty(False) | |
514 best_width = properties.NumericProperty(0) | |
515 bold = properties.BooleanProperty(True) | |
516 | |
517 def __init__(self, **kwargs): | |
518 super(EncryptionButton, self).__init__(**kwargs) | |
519 self.register_event_type('on_release') | |
520 self.register_event_type('on_trust_release') | |
521 if self.trust_button: | |
522 self.add_widget(TrustManagementButton()) | |
523 | |
524 def on_release(self): | |
525 pass | |
526 | |
527 def on_trust_release(self): | |
528 pass | |
529 | |
530 | |
531 class EncryptionMenu(DropDown): | |
532 # best with to display all algorithms buttons + trust buttons | |
533 best_width = properties.NumericProperty(0) | |
534 | |
535 def __init__(self, chat, **kwargs): | |
536 """ | |
537 @param chat(Chat): Chat instance | |
538 """ | |
539 self.chat = chat | |
540 super(EncryptionMenu, self).__init__(**kwargs) | |
541 btn = EncryptionButton( | |
542 text=_("unencrypted (plain text)"), | |
543 on_release=self.unencrypted, | |
544 selected=True, | |
545 bold=False, | |
546 ) | |
547 btn.bind( | |
548 on_release=self.unencrypted, | |
549 ) | |
550 self.add_widget(btn) | |
551 for plugin in G.host.encryption_plugins: | |
552 if chat.type == C.CHAT_GROUP and plugin["directed"]: | |
553 # directed plugins can't work with group chat | |
554 continue | |
555 btn = EncryptionButton( | |
556 text=plugin['name'], | |
557 trust_button=True, | |
558 ) | |
559 btn.bind( | |
560 on_release=partial(self.start_encryption, plugin=plugin), | |
561 on_trust_release=partial(self.get_trust_ui, plugin=plugin), | |
562 ) | |
563 self.add_widget(btn) | |
564 log.info("added encryption: {}".format(plugin['name'])) | |
565 | |
566 def message_encryption_stop_cb(self): | |
567 log.info(_("Session with {destinee} is now in plain text").format( | |
568 destinee = self.chat.target)) | |
569 | |
570 def message_encryption_stop_eb(self, failure_): | |
571 msg = _("Error while stopping encryption with {destinee}: {reason}").format( | |
572 destinee = self.chat.target, | |
573 reason = failure_) | |
574 log.warning(msg) | |
575 G.host.add_note(_("encryption problem"), msg, C.XMLUI_DATA_LVL_ERROR) | |
576 | |
577 def unencrypted(self, button): | |
578 self.dismiss() | |
579 G.host.bridge.message_encryption_stop( | |
580 str(self.chat.target), | |
581 self.chat.profile, | |
582 callback=self.message_encryption_stop_cb, | |
583 errback=self.message_encryption_stop_eb) | |
584 | |
585 def message_encryption_start_cb(self, plugin): | |
586 log.info(_("Session with {destinee} is now encrypted with {encr_name}").format( | |
587 destinee = self.chat.target, | |
588 encr_name = plugin['name'])) | |
589 | |
590 def message_encryption_start_eb(self, failure_): | |
591 msg = _("Session can't be encrypted with {destinee}: {reason}").format( | |
592 destinee = self.chat.target, | |
593 reason = failure_) | |
594 log.warning(msg) | |
595 G.host.add_note(_("encryption problem"), msg, C.XMLUI_DATA_LVL_ERROR) | |
596 | |
597 def start_encryption(self, button, plugin): | |
598 """Request encryption with given plugin for this session | |
599 | |
600 @param button(EncryptionButton): button which has been pressed | |
601 @param plugin(dict): plugin data | |
602 """ | |
603 self.dismiss() | |
604 G.host.bridge.message_encryption_start( | |
605 str(self.chat.target), | |
606 plugin['namespace'], | |
607 True, | |
608 self.chat.profile, | |
609 callback=partial(self.message_encryption_start_cb, plugin=plugin), | |
610 errback=self.message_encryption_start_eb) | |
611 | |
612 def encryption_trust_ui_get_cb(self, xmlui_raw): | |
613 xml_ui = xmlui.create( | |
614 G.host, xmlui_raw, profile=self.chat.profile) | |
615 xml_ui.show() | |
616 | |
617 def encryption_trust_ui_get_eb(self, failure_): | |
618 msg = _("Trust manager interface can't be retrieved: {reason}").format( | |
619 reason = failure_) | |
620 log.warning(msg) | |
621 G.host.add_note(_("encryption trust management problem"), msg, | |
622 C.XMLUI_DATA_LVL_ERROR) | |
623 | |
624 def get_trust_ui(self, button, plugin): | |
625 """Request and display trust management UI | |
626 | |
627 @param button(EncryptionButton): button which has been pressed | |
628 @param plugin(dict): plugin data | |
629 """ | |
630 self.dismiss() | |
631 G.host.bridge.encryption_trust_ui_get( | |
632 str(self.chat.target), | |
633 plugin['namespace'], | |
634 self.chat.profile, | |
635 callback=self.encryption_trust_ui_get_cb, | |
636 errback=self.encryption_trust_ui_get_eb) | |
637 | |
638 | |
639 class Chat(quick_chat.QuickChat, cagou_widget.LiberviaDesktopKivyWidget): | |
640 message_input = properties.ObjectProperty() | |
641 messages_widget = properties.ObjectProperty() | |
642 history_scroll = properties.ObjectProperty() | |
643 attachments_to_send = properties.ObjectProperty() | |
644 send_button_visible = properties.BooleanProperty() | |
645 use_header_input = True | |
646 global_screen_manager = True | |
647 collection_carousel = True | |
648 | |
649 def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None, | |
650 subject=None, statuses=None, profiles=None): | |
651 self.show_chat_selector = False | |
652 if statuses is None: | |
653 statuses = {} | |
654 quick_chat.QuickChat.__init__( | |
655 self, host, target, type_, nick, occupants, subject, statuses, | |
656 profiles=profiles) | |
657 self.otr_state_encryption = OTR_STATE_UNENCRYPTED | |
658 self.otr_state_trust = OTR_STATE_UNTRUSTED | |
659 # completion attributes | |
660 self._hi_comp_data = None | |
661 self._hi_comp_last = None | |
662 self._hi_comp_dropdown = DropDown() | |
663 self._hi_comp_allowed = True | |
664 cagou_widget.LiberviaDesktopKivyWidget.__init__(self) | |
665 transfer_btn = TransferButton(chat=self) | |
666 self.header_input_add_extra(transfer_btn) | |
667 if (type_ == C.CHAT_ONE2ONE or "REALJID_PUBLIC" in statuses): | |
668 self.encryption_btn = EncryptionMainButton(self) | |
669 self.header_input_add_extra(self.encryption_btn) | |
670 self.extra_menu = ExtraMenu(chat=self) | |
671 extra_btn = ExtraButton(chat=self) | |
672 self.header_input_add_extra(extra_btn) | |
673 self.header_input.hint_text = target | |
674 self._history_prepend_lock = False | |
675 self.history_count = 0 | |
676 | |
677 def on_kv_post(self, __): | |
678 self.post_init() | |
679 | |
680 def screen_manager_init(self, screen_manager): | |
681 screen_manager.transition = screenmanager.SlideTransition(direction='down') | |
682 sel_screen = Screen(name='chat_selector') | |
683 chat_selector = ChatSelector(profile=self.profile) | |
684 sel_screen.add_widget(chat_selector) | |
685 screen_manager.add_widget(sel_screen) | |
686 if self.show_chat_selector: | |
687 transition = screen_manager.transition | |
688 screen_manager.transition = NoTransition() | |
689 screen_manager.current = 'chat_selector' | |
690 screen_manager.transition = transition | |
691 | |
692 def __str__(self): | |
693 return "Chat({})".format(self.target) | |
694 | |
695 def __repr__(self): | |
696 return self.__str__() | |
697 | |
698 @classmethod | |
699 def factory(cls, plugin_info, target, profiles): | |
700 profiles = list(profiles) | |
701 if len(profiles) > 1: | |
702 raise NotImplementedError("Multi-profiles is not available yet for chat") | |
703 if target is None: | |
704 show_chat_selector = True | |
705 target = G.host.profiles[profiles[0]].whoami | |
706 else: | |
707 show_chat_selector = False | |
708 wid = G.host.widgets.get_or_create_widget(cls, target, on_new_widget=None, | |
709 on_existing_widget=G.host.get_or_clone, | |
710 profiles=profiles) | |
711 wid.show_chat_selector = show_chat_selector | |
712 return wid | |
713 | |
714 @property | |
715 def message_widgets_rev(self): | |
716 return self.messages_widget.children | |
717 | |
718 ## keyboard ## | |
719 | |
720 def key_input(self, window, key, scancode, codepoint, modifier): | |
721 if key == 27: | |
722 screen_manager = self.screen_manager | |
723 screen_manager.transition.direction = 'down' | |
724 screen_manager.current = 'chat_selector' | |
725 return True | |
726 | |
727 ## drop ## | |
728 | |
729 def on_drop_file(self, path): | |
730 self.add_attachment(path) | |
731 | |
732 ## header ## | |
733 | |
734 def change_widget(self, jid_): | |
735 """change current widget for a new one with given jid | |
736 | |
737 @param jid_(jid.JID): jid of the widget to create | |
738 """ | |
739 plugin_info = G.host.get_plugin_info(main=Chat) | |
740 factory = plugin_info['factory'] | |
741 G.host.switch_widget(self, factory(plugin_info, jid_, profiles=[self.profile])) | |
742 self.header_input.text = '' | |
743 | |
744 def on_header_wid_input(self): | |
745 text = self.header_input.text.strip() | |
746 try: | |
747 if text.count('@') != 1 or text.count(' '): | |
748 raise ValueError | |
749 jid_ = jid.JID(text) | |
750 except ValueError: | |
751 log.info("entered text is not a jid") | |
752 return | |
753 | |
754 def disco_cb(disco): | |
755 # TODO: check if plugin XEP-0045 is activated | |
756 if "conference" in [i[0] for i in disco[1]]: | |
757 G.host.bridge.muc_join(str(jid_), "", "", self.profile, | |
758 callback=self._muc_join_cb, errback=self._muc_join_eb) | |
759 else: | |
760 self.change_widget(jid_) | |
761 | |
762 def disco_eb(failure): | |
763 log.warning("Disco failure, ignore this text: {}".format(failure)) | |
764 | |
765 G.host.bridge.disco_infos( | |
766 jid_.domain, | |
767 profile_key=self.profile, | |
768 callback=disco_cb, | |
769 errback=disco_eb) | |
770 | |
771 def on_header_wid_input_completed(self, input_wid, completed_text): | |
772 self._hi_comp_allowed = False | |
773 input_wid.text = completed_text | |
774 self._hi_comp_allowed = True | |
775 self._hi_comp_dropdown.dismiss() | |
776 self.on_header_wid_input() | |
777 | |
778 def on_header_wid_input_complete(self, wid, text): | |
779 if not self._hi_comp_allowed: | |
780 return | |
781 text = text.lstrip() | |
782 if not text: | |
783 self._hi_comp_data = None | |
784 self._hi_comp_last = None | |
785 self._hi_comp_dropdown.dismiss() | |
786 return | |
787 | |
788 profile = list(self.profiles)[0] | |
789 | |
790 if self._hi_comp_data is None: | |
791 # first completion, we build the initial list | |
792 comp_data = self._hi_comp_data = [] | |
793 self._hi_comp_last = '' | |
794 for jid_, jid_data in G.host.contact_lists[profile].all_iter: | |
795 comp_data.append((jid_, jid_data)) | |
796 comp_data.sort(key=lambda datum: datum[0]) | |
797 else: | |
798 comp_data = self._hi_comp_data | |
799 | |
800 # XXX: dropdown is rebuilt each time backspace is pressed or if the text is changed, | |
801 # it works OK, but some optimisation may be done here | |
802 dropdown = self._hi_comp_dropdown | |
803 | |
804 if not text.startswith(self._hi_comp_last) or not self._hi_comp_last: | |
805 # text has changed or backspace has been pressed, we restart | |
806 dropdown.clear_widgets() | |
807 | |
808 for jid_, jid_data in comp_data: | |
809 nick = jid_data.get('nick', '') | |
810 if text in jid_.bare or text in nick.lower(): | |
811 btn = JidButton( | |
812 jid = jid_.bare, | |
813 profile = profile, | |
814 size_hint = (0.5, None), | |
815 nick = nick, | |
816 on_release=lambda __, txt=jid_.bare: self.on_header_wid_input_completed(wid, txt) | |
817 ) | |
818 dropdown.add_widget(btn) | |
819 else: | |
820 # more chars, we continue completion by removing unwanted widgets | |
821 to_remove = [] | |
822 for c in dropdown.children[0].children: | |
823 if text not in c.jid and text not in (c.nick or ''): | |
824 to_remove.append(c) | |
825 for c in to_remove: | |
826 dropdown.remove_widget(c) | |
827 if dropdown.attach_to is None: | |
828 dropdown.open(wid) | |
829 self._hi_comp_last = text | |
830 | |
831 def message_data_converter(self, idx, mess_id): | |
832 return {"mess_data": self.messages[mess_id]} | |
833 | |
834 def _on_history_printed(self): | |
835 """Refresh or scroll down the focus after the history is printed""" | |
836 # self.adapter.data = self.messages | |
837 for mess_data in self.messages.values(): | |
838 self.appendMessage(mess_data) | |
839 super(Chat, self)._on_history_printed() | |
840 | |
841 def create_message(self, message): | |
842 self.appendMessage(message) | |
843 # we need to render immediatly next 2 layouts to avoid an unpleasant flickering | |
844 # when sending or receiving a message | |
845 self.messages_widget.dont_delay_next_layouts = 2 | |
846 | |
847 def appendMessage(self, mess_data): | |
848 """Append a message Widget to the history | |
849 | |
850 @param mess_data(quick_chat.Message): message data | |
851 """ | |
852 if self.handle_user_moved(mess_data): | |
853 return | |
854 self.messages_widget.add_widget(MessageWidget(mess_data=mess_data)) | |
855 self.notify(mess_data) | |
856 | |
857 def prepend_message(self, mess_data): | |
858 """Prepend a message Widget to the history | |
859 | |
860 @param mess_data(quick_chat.Message): message data | |
861 """ | |
862 mess_wid = self.messages_widget | |
863 last_idx = len(mess_wid.children) | |
864 mess_wid.add_widget(MessageWidget(mess_data=mess_data), index=last_idx) | |
865 | |
866 def _get_notif_msg(self, mess_data): | |
867 return _("{nick}: {message}").format( | |
868 nick=mess_data.nick, | |
869 message=mess_data.main_message) | |
870 | |
871 def notify(self, mess_data): | |
872 """Notify user when suitable | |
873 | |
874 For one2one chat, notification will happen when window has not focus | |
875 or when one2one chat is not visible. A note is also there when widget | |
876 is not visible. | |
877 For group chat, note will be added on mention, with a desktop notification if | |
878 window has not focus or is not visible. | |
879 """ | |
880 visible_clones = [w for w in G.host.get_visible_list(self.__class__) | |
881 if w.target == self.target] | |
882 if len(visible_clones) > 1 and visible_clones.index(self) > 0: | |
883 # to avoid multiple notifications in case of multiple cloned widgets | |
884 # we only handle first clone | |
885 return | |
886 is_visible = bool(visible_clones) | |
887 if self.type == C.CHAT_ONE2ONE: | |
888 if (not Window.focus or not is_visible) and not mess_data.history: | |
889 notif_msg = self._get_notif_msg(mess_data) | |
890 G.host.notify( | |
891 type_=C.NOTIFY_MESSAGE, | |
892 entity=mess_data.from_jid, | |
893 message=notif_msg, | |
894 subject=_("private message"), | |
895 widget=self, | |
896 profile=self.profile | |
897 ) | |
898 if not is_visible: | |
899 G.host.add_note( | |
900 _("private message"), | |
901 notif_msg, | |
902 symbol = "chat", | |
903 action = { | |
904 "action": 'chat', | |
905 "target": self.target, | |
906 "profiles": self.profiles} | |
907 ) | |
908 else: | |
909 if mess_data.mention: | |
910 notif_msg = self._get_notif_msg(mess_data) | |
911 G.host.add_note( | |
912 _("mention"), | |
913 notif_msg, | |
914 symbol = "chat", | |
915 action = { | |
916 "action": 'chat', | |
917 "target": self.target, | |
918 "profiles": self.profiles} | |
919 ) | |
920 if not is_visible or not Window.focus: | |
921 subject=_("mention ({room_jid})").format(room_jid=self.target) | |
922 G.host.notify( | |
923 type_=C.NOTIFY_MENTION, | |
924 entity=self.target, | |
925 message=notif_msg, | |
926 subject=subject, | |
927 widget=self, | |
928 profile=self.profile | |
929 ) | |
930 | |
931 # message input | |
932 | |
933 def _attachment_progress_cb(self, item, metadata, profile): | |
934 item.parent.remove_widget(item) | |
935 log.info(f"item {item.data.get('path')} uploaded successfully") | |
936 | |
937 def _attachment_progress_eb(self, item, err_msg, profile): | |
938 item.parent.remove_widget(item) | |
939 path = item.data.get('path') | |
940 msg = _("item {path} could not be uploaded: {err_msg}").format( | |
941 path=path, err_msg=err_msg) | |
942 G.host.add_note(_("can't upload file"), msg, C.XMLUI_DATA_LVL_WARNING) | |
943 log.warning(msg) | |
944 | |
945 def _progress_get_cb(self, item, metadata): | |
946 try: | |
947 position = int(metadata["position"]) | |
948 size = int(metadata["size"]) | |
949 except KeyError: | |
950 # we got empty metadata, the progression is either not yet started or | |
951 # finished | |
952 if item.progress: | |
953 # if progress is already started, receiving empty metadata means | |
954 # that progression is finished | |
955 item.progress = 100 | |
956 return | |
957 else: | |
958 item.progress = position/size*100 | |
959 | |
960 if item.parent is not None: | |
961 # the item is not yet fully received, we reschedule an update | |
962 Clock.schedule_once( | |
963 partial(self._attachment_progress_update, item), | |
964 PROGRESS_UPDATE) | |
965 | |
966 def _attachment_progress_update(self, item, __): | |
967 G.host.bridge.progress_get( | |
968 item.data["progress_id"], | |
969 self.profile, | |
970 callback=partial(self._progress_get_cb, item), | |
971 errback=G.host.errback, | |
972 ) | |
973 | |
974 def add_nick(self, nick): | |
975 """Add a nickname to message_input if suitable""" | |
976 if (self.type == C.CHAT_GROUP and not self.message_input.text.startswith(nick)): | |
977 self.message_input.text = f'{nick}: {self.message_input.text}' | |
978 | |
979 def on_send(self, input_widget): | |
980 extra = {} | |
981 for item in self.attachments_to_send.attachments.children: | |
982 if item.sending: | |
983 # the item is already being sent | |
984 continue | |
985 item.sending = True | |
986 progress_id = item.data["progress_id"] = str(uuid.uuid4()) | |
987 attachments = extra.setdefault(C.KEY_ATTACHMENTS, []) | |
988 attachment = { | |
989 "path": str(item.data["path"]), | |
990 "progress_id": progress_id, | |
991 } | |
992 if 'media_type' in item.data: | |
993 attachment[C.KEY_ATTACHMENTS_MEDIA_TYPE] = item.data['media_type'] | |
994 | |
995 if ((self.attachments_to_send.reduce_checkbox.active | |
996 and attachment.get('media_type', '').split('/')[0] == 'image')): | |
997 attachment[C.KEY_ATTACHMENTS_RESIZE] = True | |
998 | |
999 attachments.append(attachment) | |
1000 | |
1001 Clock.schedule_once( | |
1002 partial(self._attachment_progress_update, item), | |
1003 PROGRESS_UPDATE) | |
1004 | |
1005 G.host.register_progress_cbs( | |
1006 progress_id, | |
1007 callback=partial(self._attachment_progress_cb, item), | |
1008 errback=partial(self._attachment_progress_eb, item) | |
1009 ) | |
1010 | |
1011 | |
1012 G.host.message_send( | |
1013 self.target, | |
1014 # TODO: handle language | |
1015 {'': input_widget.text}, | |
1016 # TODO: put this in QuickChat | |
1017 mess_type= | |
1018 C.MESS_TYPE_GROUPCHAT if self.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT, | |
1019 extra=extra, | |
1020 profile_key=self.profile | |
1021 ) | |
1022 input_widget.text = '' | |
1023 | |
1024 def _image_check_cb(self, report_raw): | |
1025 report = data_format.deserialise(report_raw) | |
1026 if report['too_large']: | |
1027 self.attachments_to_send.show_resize=True | |
1028 self.attachments_to_send.reduce_checkbox.active=True | |
1029 | |
1030 def add_attachment(self, file_path, media_type=None): | |
1031 file_path = Path(file_path) | |
1032 if media_type is None: | |
1033 media_type = mimetypes.guess_type(str(file_path), strict=False)[0] | |
1034 if not self.attachments_to_send.show_resize and media_type is not None: | |
1035 # we check if the attachment is an image and if it's too large. | |
1036 # If too large, the reduce size check box will be displayed, and checked by | |
1037 # default. | |
1038 main_type = media_type.split('/')[0] | |
1039 if main_type == "image": | |
1040 G.host.bridge.image_check( | |
1041 str(file_path), | |
1042 callback=self._image_check_cb, | |
1043 errback=partial( | |
1044 G.host.errback, | |
1045 title=_("Can't check image size"), | |
1046 message=_("Can't check image at {path}: {{msg}}").format( | |
1047 path=file_path), | |
1048 ) | |
1049 ) | |
1050 | |
1051 data = { | |
1052 "path": file_path, | |
1053 "name": file_path.name, | |
1054 } | |
1055 | |
1056 if media_type is not None: | |
1057 data['media_type'] = media_type | |
1058 | |
1059 self.attachments_to_send.attachments.add_widget( | |
1060 AttachmentToSendItem(data=data) | |
1061 ) | |
1062 | |
1063 def transfer_file(self, file_path, transfer_type=C.TRANSFER_UPLOAD, cleaning_cb=None): | |
1064 # FIXME: cleaning_cb is not managed | |
1065 if transfer_type == C.TRANSFER_UPLOAD: | |
1066 self.add_attachment(file_path) | |
1067 elif transfer_type == C.TRANSFER_SEND: | |
1068 if self.type == C.CHAT_GROUP: | |
1069 log.warning("P2P transfer is not possible for group chat") | |
1070 # TODO: show an error dialog to user, or better hide the send button for | |
1071 # MUC | |
1072 else: | |
1073 jid_ = self.target | |
1074 if not jid_.resource: | |
1075 jid_ = G.host.contact_lists[self.profile].get_full_jid(jid_) | |
1076 G.host.bridge.file_send(str(jid_), str(file_path), "", "", "", | |
1077 profile=self.profile) | |
1078 # TODO: notification of sending/failing | |
1079 else: | |
1080 raise log.error("transfer of type {} are not handled".format(transfer_type)) | |
1081 | |
1082 def message_encryption_started(self, plugin_data): | |
1083 quick_chat.QuickChat.message_encryption_started(self, plugin_data) | |
1084 self.encryption_btn.symbol = SYMBOL_ENCRYPTED | |
1085 self.encryption_btn.color = COLOR_ENCRYPTED | |
1086 self.encryption_btn.select_algo(plugin_data['name']) | |
1087 | |
1088 def message_encryption_stopped(self, plugin_data): | |
1089 quick_chat.QuickChat.message_encryption_stopped(self, plugin_data) | |
1090 self.encryption_btn.symbol = SYMBOL_UNENCRYPTED | |
1091 self.encryption_btn.color = COLOR_UNENCRYPTED | |
1092 self.encryption_btn.select_algo(None) | |
1093 | |
1094 def _muc_join_cb(self, joined_data): | |
1095 joined, room_jid_s, occupants, user_nick, subject, statuses, profile = joined_data | |
1096 self.host.muc_room_joined_handler(*joined_data[1:]) | |
1097 jid_ = jid.JID(room_jid_s) | |
1098 self.change_widget(jid_) | |
1099 | |
1100 def _muc_join_eb(self, failure): | |
1101 log.warning("Can't join room: {}".format(failure)) | |
1102 | |
1103 def on_otr_state(self, state, dest_jid, profile): | |
1104 assert profile in self.profiles | |
1105 if state in OTR_STATE_ENCRYPTION: | |
1106 self.otr_state_encryption = state | |
1107 elif state in OTR_STATE_TRUST: | |
1108 self.otr_state_trust = state | |
1109 else: | |
1110 log.error(_("Unknown OTR state received: {}".format(state))) | |
1111 return | |
1112 self.encryption_btn.symbol = self.encryption_btn.get_symbol() | |
1113 self.encryption_btn.color = self.encryption_btn.get_color() | |
1114 | |
1115 def on_visible(self): | |
1116 if not self.sync: | |
1117 self.resync() | |
1118 | |
1119 def on_selected(self): | |
1120 G.host.clear_notifs(self.target, profile=self.profile) | |
1121 | |
1122 def on_delete(self, **kwargs): | |
1123 if kwargs.get('explicit_close', False): | |
1124 wrapper = self.whwrapper | |
1125 if wrapper is not None: | |
1126 if len(wrapper.carousel.slides) == 1: | |
1127 # if we delete the last opened chat, we need to show the selector | |
1128 screen_manager = self.screen_manager | |
1129 screen_manager.transition.direction = 'down' | |
1130 screen_manager.current = 'chat_selector' | |
1131 wrapper.carousel.remove_widget(self) | |
1132 return True | |
1133 # we always keep one widget, so it's available when swiping | |
1134 # TODO: delete all widgets when chat is closed | |
1135 nb_instances = sum(1 for _ in self.host.widgets.get_widget_instances(self)) | |
1136 # we want to keep at least one instance of Chat by WHWrapper | |
1137 nb_to_keep = len(G.host.widgets_handler.children) | |
1138 if nb_instances <= nb_to_keep: | |
1139 return False | |
1140 | |
1141 def _history_unlock(self, __): | |
1142 self._history_prepend_lock = False | |
1143 log.debug("history prepend unlocked") | |
1144 # we call manually on_scroll, to check if we are still in the scrolling zone | |
1145 self.on_scroll(self.history_scroll, self.history_scroll.scroll_y) | |
1146 | |
1147 def _history_scroll_adjust(self, __, scroll_start_height): | |
1148 # history scroll position must correspond to where it was before new messages | |
1149 # have been appended | |
1150 self.history_scroll.scroll_y = ( | |
1151 scroll_start_height / self.messages_widget.height | |
1152 ) | |
1153 | |
1154 # we want a small delay before unlocking, to avoid re-fetching history | |
1155 # again | |
1156 Clock.schedule_once(self._history_unlock, 1.5) | |
1157 | |
1158 def _back_history_get_cb_post(self, __, history, scroll_start_height): | |
1159 if len(history) == 0: | |
1160 # we don't unlock self._history_prepend_lock if there is no history, as there | |
1161 # is no sense to try to retrieve more in this case. | |
1162 log.debug(f"we've reached top of history for {self.target.bare} chat") | |
1163 else: | |
1164 # we have to schedule again for _history_scroll_adjust, else messages_widget | |
1165 # is not resized (self.messages_widget.height is not yet updated) | |
1166 # as a result, the scroll_to can't work correctly | |
1167 Clock.schedule_once(partial( | |
1168 self._history_scroll_adjust, | |
1169 scroll_start_height=scroll_start_height)) | |
1170 log.debug( | |
1171 f"{len(history)} messages prepended to history (last: {history[0][0]})") | |
1172 | |
1173 def _back_history_get_cb(self, history): | |
1174 # TODO: factorise with QuickChat._history_get_cb | |
1175 scroll_start_height = self.messages_widget.height * self.history_scroll.scroll_y | |
1176 for data in reversed(history): | |
1177 uid, timestamp, from_jid, to_jid, message, subject, type_, extra_s = data | |
1178 from_jid = jid.JID(from_jid) | |
1179 to_jid = jid.JID(to_jid) | |
1180 extra = data_format.deserialise(extra_s) | |
1181 extra["history"] = True | |
1182 self.messages[uid] = message = quick_chat.Message( | |
1183 self, | |
1184 uid, | |
1185 timestamp, | |
1186 from_jid, | |
1187 to_jid, | |
1188 message, | |
1189 subject, | |
1190 type_, | |
1191 extra, | |
1192 self.profile, | |
1193 ) | |
1194 self.messages.move_to_end(uid, last=False) | |
1195 self.prepend_message(message) | |
1196 Clock.schedule_once(partial( | |
1197 self._back_history_get_cb_post, | |
1198 history=history, | |
1199 scroll_start_height=scroll_start_height)) | |
1200 | |
1201 def _back_history_get_eb(self, failure_): | |
1202 G.host.add_note( | |
1203 _("Problem while getting back history"), | |
1204 _("Can't back history for {target}: {problem}").format( | |
1205 target=self.target, problem=failure_), | |
1206 C.XMLUI_DATA_LVL_ERROR) | |
1207 # we don't unlock self._history_prepend_lock on purpose, no need | |
1208 # to try to get more history if something is wrong | |
1209 | |
1210 def on_scroll(self, scroll_view, scroll_y): | |
1211 if self._history_prepend_lock: | |
1212 return | |
1213 if (1-scroll_y) * self.messages_widget.height < INFINITE_SCROLL_LIMIT: | |
1214 self._history_prepend_lock = True | |
1215 log.debug(f"Retrieving back history for {self} [{self.history_count}]") | |
1216 self.history_count += 1 | |
1217 first_uid = next(iter(self.messages.keys())) | |
1218 filters = self.history_filters.copy() | |
1219 filters['before_uid'] = first_uid | |
1220 self.host.bridge.history_get( | |
1221 str(self.host.profiles[self.profile].whoami.bare), | |
1222 str(self.target), | |
1223 30, | |
1224 True, | |
1225 {k: str(v) for k,v in filters.items()}, | |
1226 self.profile, | |
1227 callback=self._back_history_get_cb, | |
1228 errback=self._back_history_get_eb, | |
1229 ) | |
1230 | |
1231 | |
1232 class ChatSelector(cagou_widget.LiberviaDesktopKivyWidget, FilterBehavior): | |
1233 jid_selector = properties.ObjectProperty() | |
1234 profile = properties.StringProperty() | |
1235 plugin_info_class = Chat | |
1236 use_header_input = True | |
1237 | |
1238 def on_select(self, contact_button): | |
1239 contact_jid = jid.JID(contact_button.jid) | |
1240 plugin_info = G.host.get_plugin_info(main=Chat) | |
1241 factory = plugin_info['factory'] | |
1242 self.screen_manager.transition.direction = 'up' | |
1243 carousel = self.whwrapper.carousel | |
1244 current_slides = {w.target: w for w in carousel.slides} | |
1245 if contact_jid in current_slides: | |
1246 slide = current_slides[contact_jid] | |
1247 idx = carousel.slides.index(slide) | |
1248 carousel.index = idx | |
1249 self.screen_manager.current = '' | |
1250 else: | |
1251 G.host.switch_widget( | |
1252 self, factory(plugin_info, contact_jid, profiles=[self.profile])) | |
1253 | |
1254 | |
1255 def on_header_wid_input(self): | |
1256 text = self.header_input.text.strip() | |
1257 try: | |
1258 if text.count('@') != 1 or text.count(' '): | |
1259 raise ValueError | |
1260 jid_ = jid.JID(text) | |
1261 except ValueError: | |
1262 log.info("entered text is not a jid") | |
1263 return | |
1264 G.host.do_action("chat", jid_, [self.profile]) | |
1265 | |
1266 def on_header_wid_input_complete(self, wid, text, **kwargs): | |
1267 """we filter items when text is entered in input box""" | |
1268 for layout in self.jid_selector.items_layouts: | |
1269 self.do_filter( | |
1270 layout, | |
1271 text, | |
1272 # we append nick to jid to filter on both | |
1273 lambda c: c.jid + c.data.get('nick', ''), | |
1274 width_cb=lambda c: c.base_width, | |
1275 height_cb=lambda c: c.minimum_height, | |
1276 continue_tests=[lambda c: not isinstance(c, ContactButton)]) | |
1277 | |
1278 | |
1279 PLUGIN_INFO["factory"] = Chat.factory | |
1280 quick_widgets.register(quick_chat.QuickChat, Chat) |