comparison cagou/plugins/plugin_wid_chat.py @ 126:cd99f70ea592

global file reorganisation: - follow common convention by puttin cagou in "cagou" instead of "src/cagou" - added VERSION in cagou with current version - updated dates - moved main executable in /bin - moved buildozer files in root directory - temporary moved platform to assets/platform
author Goffi <goffi@goffi.org>
date Thu, 05 Apr 2018 17:11:21 +0200
parents src/cagou/plugins/plugin_wid_chat.py@dcd6fbb3f010
children 091e288838e1
comparison
equal deleted inserted replaced
125:b6e6afb0dc46 126:cd99f70ea592
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3
4 # Cagou: desktop/mobile frontend for Salut à Toi XMPP client
5 # Copyright (C) 2016-2018 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20
21 from sat.core import log as logging
22 log = logging.getLogger(__name__)
23 from sat.core.i18n import _
24 from cagou.core.constants import Const as C
25 from kivy.uix.boxlayout import BoxLayout
26 from kivy.uix.gridlayout import GridLayout
27 from kivy.uix.textinput import TextInput
28 from kivy.metrics import dp
29 from kivy import properties
30 from sat_frontends.quick_frontend import quick_widgets
31 from sat_frontends.quick_frontend import quick_chat
32 from sat_frontends.tools import jid
33 from cagou.core import cagou_widget
34 from cagou.core.image import Image
35 from cagou.core.common import IconButton, JidWidget
36 from kivy.uix.dropdown import DropDown
37 from cagou import G
38 import mimetypes
39
40
41 PLUGIN_INFO = {
42 "name": _(u"chat"),
43 "main": "Chat",
44 "description": _(u"instant messaging with one person or a group"),
45 "icon_small": u"{media}/icons/muchoslava/png/chat_new_32.png",
46 "icon_medium": u"{media}/icons/muchoslava/png/chat_new_44.png"
47 }
48
49 # following const are here temporary, they should move to quick frontend
50 OTR_STATE_UNTRUSTED = 'untrusted'
51 OTR_STATE_TRUSTED = 'trusted'
52 OTR_STATE_TRUST = (OTR_STATE_UNTRUSTED, OTR_STATE_TRUSTED)
53 OTR_STATE_UNENCRYPTED = 'unencrypted'
54 OTR_STATE_ENCRYPTED = 'encrypted'
55 OTR_STATE_ENCRYPTION = (OTR_STATE_UNENCRYPTED, OTR_STATE_ENCRYPTED)
56
57
58 class MessAvatar(Image):
59 pass
60
61
62 class MessageWidget(GridLayout):
63 mess_data = properties.ObjectProperty()
64 mess_xhtml = properties.ObjectProperty()
65 mess_padding = (dp(5), dp(5))
66 avatar = properties.ObjectProperty()
67 delivery = properties.ObjectProperty()
68
69 def __init__(self, **kwargs):
70 # self must be registered in widgets before kv is parsed
71 kwargs['mess_data'].widgets.add(self)
72 super(MessageWidget, self).__init__(**kwargs)
73 avatar_path = self.mess_data.avatar
74 if avatar_path is not None:
75 self.avatar.source = avatar_path
76
77 @property
78 def chat(self):
79 """return parent Chat instance"""
80 return self.mess_data.parent
81
82 @property
83 def message(self):
84 """Return currently displayed message"""
85 return self.mess_data.main_message
86
87 @property
88 def message_xhtml(self):
89 """Return currently displayed message"""
90 return self.mess_data.main_message_xhtml
91
92 def widthAdjust(self):
93 """this widget grows up with its children"""
94 pass
95 # parent = self.mess_xhtml.parent
96 # padding_x = self.mess_padding[0]
97 # text_width, text_height = self.mess_xhtml.texture_size
98 # if text_width > parent.width:
99 # self.mess_xhtml.text_size = (parent.width - padding_x, None)
100 # self.text_max = text_width
101 # elif self.mess_xhtml.text_size[0] is not None and text_width < parent.width - padding_x:
102 # if text_width < self.text_max:
103 # self.mess_xhtml.text_size = (None, None)
104 # else:
105 # self.mess_xhtml.text_size = (parent.width - padding_x, None)
106
107 def update(self, update_dict):
108 if 'avatar' in update_dict:
109 self.avatar.source = update_dict['avatar']
110 if 'status' in update_dict:
111 status = update_dict['status']
112 self.delivery.text = u'\u2714' if status == 'delivered' else u''
113
114
115 class MessageInputBox(BoxLayout):
116 pass
117
118
119 class MessageInputWidget(TextInput):
120
121 def _key_down(self, key, repeat=False):
122 displayed_str, internal_str, internal_action, scale = key
123 if internal_action == 'enter':
124 self.dispatch('on_text_validate')
125 else:
126 super(MessageInputWidget, self)._key_down(key, repeat)
127
128
129 class MessagesWidget(GridLayout):
130 pass
131
132
133 class EncryptionButton(IconButton):
134
135 def __init__(self, chat, **kwargs):
136 """
137 @param chat(Chat): Chat instance
138 """
139 self.chat = chat
140 # for now we do a simple ContextMenu as we have only OTR
141 self.otr_menu = OtrMenu(chat)
142 super(EncryptionButton, self).__init__(**kwargs)
143 self.bind(on_release=self.otr_menu.open)
144
145 def getIconSource(self):
146 """get path of icon"""
147 # TODO: use a more generic method to get icon name
148 if self.chat.otr_state_encryption == OTR_STATE_UNENCRYPTED:
149 icon_name = 'cadenas_ouvert'
150 else:
151 if self.chat.otr_state_trust == OTR_STATE_TRUSTED:
152 icon_name = 'cadenas_ferme'
153 else:
154 icon_name = 'cadenas_ferme_pas_authenthifie'
155
156 return G.host.app.expand("{media}/icons/muchoslava/png/" + icon_name + "_30.png")
157
158
159 class OtrMenu(DropDown):
160
161 def __init__(self, chat, **kwargs):
162 """
163 @param chat(Chat): Chat instance
164 """
165 self.chat = chat
166 super(OtrMenu, self).__init__(**kwargs)
167
168 def otr_start(self):
169 self.dismiss()
170 G.host.launchMenu(
171 C.MENU_SINGLE,
172 (u"otr", u"start/refresh"),
173 {u'jid': unicode(self.chat.target)},
174 None,
175 C.NO_SECURITY_LIMIT,
176 self.chat.profile
177 )
178
179 def otr_end(self):
180 self.dismiss()
181 G.host.launchMenu(
182 C.MENU_SINGLE,
183 (u"otr", u"end session"),
184 {u'jid': unicode(self.chat.target)},
185 None,
186 C.NO_SECURITY_LIMIT,
187 self.chat.profile
188 )
189
190 def otr_authenticate(self):
191 self.dismiss()
192 G.host.launchMenu(
193 C.MENU_SINGLE,
194 (u"otr", u"authenticate"),
195 {u'jid': unicode(self.chat.target)},
196 None,
197 C.NO_SECURITY_LIMIT,
198 self.chat.profile
199 )
200
201
202 class Chat(quick_chat.QuickChat, cagou_widget.CagouWidget):
203 message_input = properties.ObjectProperty()
204 messages_widget = properties.ObjectProperty()
205
206 def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None, subject=None, profiles=None):
207 quick_chat.QuickChat.__init__(self, host, target, type_, nick, occupants, subject, profiles=profiles)
208 self.otr_state_encryption = OTR_STATE_UNENCRYPTED
209 self.otr_state_trust = OTR_STATE_UNTRUSTED
210 cagou_widget.CagouWidget.__init__(self)
211 if type_ == C.CHAT_ONE2ONE:
212 self.encryption_btn = EncryptionButton(self)
213 self.headerInputAddExtra(self.encryption_btn)
214 self.header_input.hint_text = u"{}".format(target)
215 self.host.addListener('progressError', self.onProgressError, profiles)
216 self.host.addListener('progressFinished', self.onProgressFinished, profiles)
217 self._waiting_pids = {} # waiting progress ids
218 self.postInit()
219 # completion attribtues
220 self._hi_comp_data = None
221 self._hi_comp_last = None
222 self._hi_comp_dropdown = DropDown()
223 self._hi_comp_allowed = True
224
225 @classmethod
226 def factory(cls, plugin_info, target, profiles):
227 profiles = list(profiles)
228 if len(profiles) > 1:
229 raise NotImplementedError(u"Multi-profiles is not available yet for chat")
230 if target is None:
231 target = G.host.profiles[profiles[0]].whoami
232 return G.host.widgets.getOrCreateWidget(cls, target, on_new_widget=None, on_existing_widget=C.WIDGET_RECREATE, profiles=profiles)
233
234 ## header ##
235
236 def changeWidget(self, jid_):
237 """change current widget for a new one with given jid
238
239 @param jid_(jid.JID): jid of the widget to create
240 """
241 plugin_info = G.host.getPluginInfo(main=Chat)
242 factory = plugin_info['factory']
243 G.host.switchWidget(self, factory(plugin_info, jid_, profiles=[self.profile]))
244 self.header_input.text = ''
245
246 def onHeaderInput(self):
247 text = self.header_input.text.strip()
248 try:
249 if text.count(u'@') != 1 or text.count(u' '):
250 raise ValueError
251 jid_ = jid.JID(text)
252 except ValueError:
253 log.info(u"entered text is not a jid")
254 return
255
256 def discoCb(disco):
257 # TODO: check if plugin XEP-0045 is activated
258 if "conference" in [i[0] for i in disco[1]]:
259 G.host.bridge.mucJoin(unicode(jid_), "", "", self.profile, callback=self._mucJoinCb, errback=self._mucJoinEb)
260 else:
261 self.changeWidget(jid_)
262
263 def discoEb(failure):
264 log.warning(u"Disco failure, ignore this text: {}".format(failure))
265
266 G.host.bridge.discoInfos(jid_.domain, self.profile, callback=discoCb, errback=discoEb)
267
268 def onHeaderInputCompleted(self, input_wid, completed_text):
269 self._hi_comp_allowed = False
270 input_wid.text = completed_text
271 self._hi_comp_allowed = True
272 self._hi_comp_dropdown.dismiss()
273 self.onHeaderInput()
274
275 def onHeaderInputComplete(self, wid, text):
276 if not self._hi_comp_allowed:
277 return
278 text = text.lstrip()
279 if not text:
280 self._hi_comp_data = None
281 self._hi_comp_last = None
282 return
283
284 profile = list(self.profiles)[0]
285
286 if self._hi_comp_data is None:
287 # first completion, we build the initial list
288 comp_data = self._hi_comp_data = []
289 self._hi_comp_last = ''
290 for jid_, jid_data in G.host.contact_lists[profile].all_iter:
291 comp_data.append((jid_, jid_data))
292 comp_data.sort(key=lambda datum: datum[0])
293 else:
294 comp_data = self._hi_comp_data
295
296 # XXX: dropdown is rebuilt each time backspace is pressed or if the text is changed,
297 # it works OK, but some optimisation may be done here
298 dropdown = self._hi_comp_dropdown
299
300 if not text.startswith(self._hi_comp_last) or not self._hi_comp_last:
301 # text has changed or backspace has been pressed, we restart
302 dropdown.clear_widgets()
303
304 for jid_, jid_data in comp_data:
305 nick = jid_data.get(u'nick', u'')
306 if text in jid_.bare or text in nick.lower():
307 btn = JidWidget(
308 jid = jid_.bare,
309 profile = profile,
310 size_hint = (0.5, None),
311 nick = nick,
312 on_release=lambda dummy, txt=jid_.bare: self.onHeaderInputCompleted(wid, txt)
313 )
314 dropdown.add_widget(btn)
315 else:
316 # more chars, we continue completion by removing unwanted widgets
317 to_remove = []
318 for c in dropdown.children[0].children:
319 if text not in c.jid and text not in (c.nick or ''):
320 to_remove.append(c)
321 for c in to_remove:
322 dropdown.remove_widget(c)
323
324 dropdown.open(wid)
325 self._hi_comp_last = text
326
327 def messageDataConverter(self, idx, mess_id):
328 return {"mess_data": self.messages[mess_id]}
329
330 def _onHistoryPrinted(self):
331 """Refresh or scroll down the focus after the history is printed"""
332 # self.adapter.data = self.messages
333 for mess_data in self.messages.itervalues():
334 self.appendMessage(mess_data)
335 super(Chat, self)._onHistoryPrinted()
336
337 def createMessage(self, message):
338 self.appendMessage(message)
339
340 def appendMessage(self, mess_data):
341 self.messages_widget.add_widget(MessageWidget(mess_data=mess_data))
342
343 def onSend(self, input_widget):
344 G.host.messageSend(
345 self.target,
346 {'': input_widget.text}, # TODO: handle language
347 mess_type = C.MESS_TYPE_GROUPCHAT if self.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT, # TODO: put this in QuickChat
348 profile_key=self.profile
349 )
350 input_widget.text = ''
351
352 def onProgressFinished(self, progress_id, metadata, profile):
353 try:
354 callback, cleaning_cb = self._waiting_pids.pop(progress_id)
355 except KeyError:
356 return
357 if cleaning_cb is not None:
358 cleaning_cb()
359 callback(metadata, profile)
360
361 def onProgressError(self, progress_id, err_msg, profile):
362 try:
363 dummy, cleaning_cb = self._waiting_pids[progress_id]
364 except KeyError:
365 return
366 else:
367 del self._waiting_pids[progress_id]
368 if cleaning_cb is not None:
369 cleaning_cb()
370 # TODO: display message to user
371 log.warning(u"Can't transfer file: {}".format(err_msg))
372
373 def fileTransferDone(self, metadata, profile):
374 log.debug("file transfered: {}".format(metadata))
375 extra = {}
376
377 # FIXME: Q&D way of getting file type, upload plugins shouls give it
378 mime_type = mimetypes.guess_type(metadata['url'])[0]
379 if mime_type is not None:
380 if mime_type.split(u'/')[0] == 'image':
381 # we generate url ourselves, so this formatting is safe
382 extra['xhtml'] = u"<img src='{url}' />".format(**metadata)
383
384 G.host.messageSend(
385 self.target,
386 {'': metadata['url']},
387 mess_type = C.MESS_TYPE_GROUPCHAT if self.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT,
388 extra = extra,
389 profile_key=profile
390 )
391
392 def fileTransferCb(self, progress_data, cleaning_cb):
393 try:
394 progress_id = progress_data['progress']
395 except KeyError:
396 xmlui = progress_data['xmlui']
397 G.host.showUI(xmlui)
398 else:
399 self._waiting_pids[progress_id] = (self.fileTransferDone, cleaning_cb)
400
401 def onTransferOK(self, file_path, cleaning_cb, transfer_type):
402 if transfer_type == C.TRANSFER_UPLOAD:
403 G.host.bridge.fileUpload(
404 file_path,
405 "",
406 "",
407 {"ignore_tls_errors": C.BOOL_TRUE}, # FIXME: should not be the default
408 self.profile,
409 callback = lambda progress_data: self.fileTransferCb(progress_data, cleaning_cb)
410 )
411 elif transfer_type == C.TRANSFER_SEND:
412 if self.type == C.CHAT_GROUP:
413 log.warning(u"P2P transfer is not possible for group chat")
414 # TODO: show an error dialog to user, or better hide the send button for MUC
415 else:
416 jid_ = self.target
417 if not jid_.resource:
418 jid_ = G.host.contact_lists[self.profile].getFullJid(jid_)
419 G.host.bridge.fileSend(unicode(jid_), file_path, "", "", profile=self.profile)
420 # TODO: notification of sending/failing
421 else:
422 raise log.error(u"transfer of type {} are not handled".format(transfer_type))
423
424
425 def _mucJoinCb(self, joined_data):
426 joined, room_jid_s, occupants, user_nick, subject, profile = joined_data
427 self.host.mucRoomJoinedHandler(*joined_data[1:])
428 jid_ = jid.JID(room_jid_s)
429 self.changeWidget(jid_)
430
431 def _mucJoinEb(self, failure):
432 log.warning(u"Can't join room: {}".format(failure))
433
434 def _onDelete(self):
435 self.host.removeListener('progressFinished', self.onProgressFinished)
436 self.host.removeListener('progressError', self.onProgressError)
437 return super(Chat, self).onDelete()
438
439 def onOTRState(self, state, dest_jid, profile):
440 assert profile in self.profiles
441 if state in OTR_STATE_ENCRYPTION:
442 self.otr_state_encryption = state
443 elif state in OTR_STATE_TRUST:
444 self.otr_state_trust = state
445 else:
446 log.error(_(u"Unknown OTR state received: {}".format(state)))
447 return
448 self.encryption_btn.source = self.encryption_btn.getIconSource()
449
450 def onDelete(self, force=False):
451 if force==True:
452 return self._onDelete()
453 if len(list(G.host.widgets.getWidgets(self.__class__, self.target, profiles=self.profiles))) > 1:
454 # we don't keep duplicate widgets
455 return self._onDelete()
456 return False
457
458
459 PLUGIN_INFO["factory"] = Chat.factory
460 quick_widgets.register(quick_chat.QuickChat, Chat)