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