comparison sat_frontends/quick_frontend/quick_chat.py @ 2562:26edcf3a30eb

core, setup: huge cleaning: - moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention - move twisted directory to root - removed all hacks from setup.py, and added missing dependencies, it is now clean - use https URL for website in setup.py - removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed - renamed sat.sh to sat and fixed its installation - added python_requires to specify Python version needed - replaced glib2reactor which use deprecated code by gtk3reactor sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author Goffi <goffi@goffi.org>
date Mon, 02 Apr 2018 19:44:50 +0200
parents frontends/src/quick_frontend/quick_chat.py@0046283a285d
children 5e54afd17321
comparison
equal deleted inserted replaced
2561:bd30dc3ffe5a 2562:26edcf3a30eb
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3
4 # helper class for making a SAT frontend
5 # Copyright (C) 2009-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 from sat.core.i18n import _
21 from sat.core.log import getLogger
22 log = getLogger(__name__)
23 from sat.core import exceptions
24 from sat_frontends.quick_frontend import quick_widgets
25 from sat_frontends.quick_frontend.constants import Const as C
26 from collections import OrderedDict
27 from sat_frontends.tools import jid
28 import time
29 try:
30 from locale import getlocale
31 except ImportError:
32 # FIXME: pyjamas workaround
33 getlocale = lambda x: (None, 'utf-8')
34
35
36 ROOM_USER_JOINED = 'ROOM_USER_JOINED'
37 ROOM_USER_LEFT = 'ROOM_USER_LEFT'
38 ROOM_USER_MOVED = (ROOM_USER_JOINED, ROOM_USER_LEFT)
39
40 # from datetime import datetime
41
42 try:
43 # FIXME: to be removed when an acceptable solution is here
44 unicode('') # XXX: unicode doesn't exist in pyjamas
45 except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options
46 unicode = str
47
48 # FIXME: day_format need to be settable (i18n)
49
50 class Message(object):
51 """Message metadata"""
52
53 def __init__(self, parent, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile):
54 self.parent = parent
55 self.profile = profile
56 self.uid = uid
57 self.timestamp = timestamp
58 self.from_jid = from_jid
59 self.to_jid = to_jid
60 self.message = msg
61 self.subject = subject
62 self.type = type_
63 self.extra = extra
64 self.nick = self.getNick(from_jid)
65 self._status = None
66 # own_mess is True if message was sent by profile's jid
67 self.own_mess = (from_jid.resource == self.parent.nick) if self.parent.type == C.CHAT_GROUP else (from_jid.bare == self.host.profiles[profile].whoami.bare)
68 # is user mentioned here ?
69 if self.parent.type == C.CHAT_GROUP and not self.own_mess:
70 for m in msg.itervalues():
71 if self.parent.nick.lower() in m.lower():
72 self._mention = True
73 break
74 self.handleMe()
75 self.widgets = set() # widgets linked to this message
76
77 @property
78 def host(self):
79 return self.parent.host
80
81 @property
82 def info_type(self):
83 return self.extra.get('info_type')
84
85 @property
86 def mention(self):
87 try:
88 return self._mention
89 except AttributeError:
90 return False
91
92 @property
93 def main_message(self):
94 """currently displayed message"""
95 if self.parent.lang in self.message:
96 self.selected_lang = self.parent.lang
97 return self.message[self.parent.lang]
98 try:
99 self.selected_lang = ''
100 return self.message['']
101 except KeyError:
102 try:
103 lang, mess = self.message.iteritems().next()
104 self.selected_lang = lang
105 return mess
106 except StopIteration:
107 log.error(u"Can't find message for uid {}".format(self.uid))
108 return ''
109
110 @property
111 def main_message_xhtml(self):
112 """rich message"""
113 xhtml = {k:v for k,v in self.extra.iteritems() if 'html' in k}
114 if xhtml:
115 # FIXME: we only return first found value for now
116 return next(xhtml.itervalues())
117
118
119 @property
120 def time_text(self):
121 """Return timestamp in a nicely formatted way"""
122 # if the message was sent before today, we print the full date
123 timestamp = time.localtime(self.timestamp)
124 time_format = u"%c" if timestamp < self.parent.day_change else u"%H:%M"
125 return time.strftime(time_format, timestamp).decode(getlocale()[1] or 'utf-8')
126
127 @property
128 def avatar(self):
129 """avatar full path or None if no avatar is found"""
130 ret = self.host.getAvatar(self.from_jid, profile=self.profile)
131 return ret
132
133 def getNick(self, entity):
134 """Return nick of an entity when possible"""
135 contact_list = self.host.contact_lists[self.profile]
136 if self.type == C.MESS_TYPE_INFO and self.info_type in ROOM_USER_MOVED:
137 try:
138 return self.extra['user_nick']
139 except KeyError:
140 log.error(u"extra data is missing user nick for uid {}".format(self.uid))
141 return ""
142 # FIXME: converted getSpecials to list for pyjamas
143 if self.parent.type == C.CHAT_GROUP or entity in list(contact_list.getSpecials(C.CONTACT_SPECIAL_GROUP)):
144 return entity.resource or ""
145 if entity.bare in contact_list:
146 return contact_list.getCache(entity, 'nick') or contact_list.getCache(entity, 'name') or entity.node or entity
147 return entity.node or entity
148
149 @property
150 def status(self):
151 return self._status
152
153 @status.setter
154 def status(self, status):
155 if status != self._status:
156 self._status = status
157 for w in self.widgets:
158 w.update({"status": status})
159
160 def handleMe(self):
161 """Check if messages starts with "/me " and change them if it is the case
162
163 if several messages (different languages) are presents, they all need to start with "/me "
164 """
165 # TODO: XHTML-IM /me are not handled
166 me = False
167 # we need to check /me for every message
168 for m in self.message.itervalues():
169 if m.startswith(u"/me "):
170 me = True
171 else:
172 me = False
173 break
174 if me:
175 self.type = C.MESS_TYPE_INFO
176 self.extra['info_type'] = 'me'
177 nick = self.nick
178 for lang, mess in self.message.iteritems():
179 self.message[lang] = u"* " + nick + mess[3:]
180
181
182 class Occupant(object):
183 """Occupant metadata"""
184
185 def __init__(self, parent, data, profile):
186 self.parent = parent
187 self.profile = profile
188 self.nick = data['nick']
189 self._entity = data.get('entity')
190 self.affiliation = data['affiliation']
191 self.role = data['role']
192 self.widgets = set() # widgets linked to this occupant
193 self._state = None
194
195 @property
196 def data(self):
197 """reconstruct data dict from attributes"""
198 data = {}
199 data['nick'] = self.nick
200 if self._entity is not None:
201 data['entity'] = self._entity
202 data['affiliation'] = self.affiliation
203 data['role'] = self.role
204 return data
205
206 @property
207 def jid(self):
208 """jid in the room"""
209 return jid.JID(u"{}/{}".format(self.parent.target.bare, self.nick))
210
211 @property
212 def real_jid(self):
213 """real jid if known else None"""
214 return self._entity
215
216 @property
217 def host(self):
218 return self.parent.host
219
220 @property
221 def state(self):
222 return self._state
223
224 @state.setter
225 def state(self, new_state):
226 if new_state != self._state:
227 self._state = new_state
228 for w in self.widgets:
229 w.update({"state": new_state})
230
231 def update(self, update_dict=None):
232 for w in self.widgets:
233 w.update(update_dict)
234
235
236 class QuickChat(quick_widgets.QuickWidget):
237 visible_states = ['chat_state'] # FIXME: to be removed, used only in quick_games
238
239 def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None, subject=None, profiles=None):
240 """
241 @param type_: can be C.CHAT_ONE2ONE for single conversation or C.CHAT_GROUP for chat à la IRC
242 """
243 self.lang = '' # default language to use for messages
244 quick_widgets.QuickWidget.__init__(self, host, target, profiles=profiles)
245 self._locked = True # True when we are waiting for history/search
246 # messageNew signals are cached when locked
247 self._cache = OrderedDict()
248 assert type_ in (C.CHAT_ONE2ONE, C.CHAT_GROUP)
249 self.current_target = target
250 self.type = type_
251 if type_ == C.CHAT_GROUP:
252 if target.resource:
253 raise exceptions.InternalError(u"a group chat entity can't have a resource")
254 if nick is None:
255 raise exceptions.InternalError(u"nick must not be None for group chat")
256
257 self.nick = nick
258 self.occupants = {}
259 self.setOccupants(occupants)
260 else:
261 if occupants is not None or nick is not None:
262 raise exceptions.InternalError(u"only group chat can have occupants or nick")
263 self.messages = OrderedDict() # key: uid, value: Message instance
264 self.games = {} # key=game name (unicode), value=instance of quick_games.RoomGame
265 self.subject = subject
266 lt = time.localtime()
267 self.day_change = (lt.tm_year, lt.tm_mon, lt.tm_mday, 0, 0, 0, lt.tm_wday, lt.tm_yday, lt.tm_isdst) # struct_time of day changing time
268 if self.host.AVATARS_HANDLER:
269 self.host.addListener('avatar', self.onAvatar, profiles)
270
271 def postInit(self):
272 """Method to be called by frontend after widget is initialised
273
274 handle the display of history and subject
275 """
276 self.historyPrint(profile=self.profile)
277 if self.subject is not None:
278 self.setSubject(self.subject)
279
280 def onDelete(self):
281 if self.host.AVATARS_HANDLER:
282 self.host.removeListener('avatar', self.onAvatar)
283
284 @property
285 def contact_list(self):
286 return self.host.contact_lists[self.profile]
287
288 ## Widget management ##
289
290 def __str__(self):
291 return u"Chat Widget [target: {}, type: {}, profile: {}]".format(self.target, self.type, self.profile)
292
293 @staticmethod
294 def getWidgetHash(target, profiles):
295 profile = list(profiles)[0]
296 return profile + "\n" + unicode(target.bare)
297
298 @staticmethod
299 def getPrivateHash(target, profile):
300 """Get unique hash for private conversations
301
302 This method should be used with force_hash to get unique widget for private MUC conversations
303 """
304 return (unicode(profile), target)
305
306 def addTarget(self, target):
307 super(QuickChat, self).addTarget(target)
308 if target.resource:
309 self.current_target = target # FIXME: tmp, must use resource priority throught contactList instead
310
311 def recreateArgs(self, args, kwargs):
312 """copy important attribute for a new widget"""
313 kwargs['type_'] = self.type
314 if self.type == C.CHAT_GROUP:
315 kwargs['occupants'] = {o.nick: o.data for o in self.occupants.itervalues()}
316 kwargs['subject'] = self.subject
317 try:
318 kwargs['nick'] = self.nick
319 except AttributeError:
320 pass
321
322 def onPrivateCreated(self, widget):
323 """Method called when a new widget for private conversation (MUC) is created"""
324 raise NotImplementedError
325
326 def getOrCreatePrivateWidget(self, entity):
327 """Create a widget for private conversation, or get it if it already exists
328
329 @param entity: full jid of the target
330 """
331 return self.host.widgets.getOrCreateWidget(QuickChat, entity, type_=C.CHAT_ONE2ONE, force_hash=self.getPrivateHash(self.profile, entity), on_new_widget=self.onPrivateCreated, profile=self.profile) # we force hash to have a new widget, not this one again
332
333 @property
334 def target(self):
335 if self.type == C.CHAT_GROUP:
336 return self.current_target.bare
337 return self.current_target
338
339 ## occupants ##
340
341 def setOccupants(self, occupants):
342 """set the whole list of occupants"""
343 assert len(self.occupants) == 0
344 for nick, data in occupants.iteritems():
345 self.occupants[nick] = Occupant(
346 self,
347 data,
348 self.profile
349 )
350
351 def addUser(self, occupant_data):
352 """Add user if it is not in the group list"""
353 occupant = Occupant(
354 self,
355 occupant_data,
356 self.profile
357 )
358 self.occupants[occupant.nick] = occupant
359 return occupant
360
361 def removeUser(self, occupant_data):
362 """Remove a user from the group list"""
363 nick = occupant_data['nick']
364 try:
365 occupant = self.occupants.pop(nick)
366 except KeyError:
367 log.warning(u"Trying to remove an unknown occupant: {}".format(nick))
368 else:
369 return occupant
370
371 def setUserNick(self, nick):
372 """Set the nick of the user, usefull for e.g. change the color of the user"""
373 self.nick = nick
374
375 def changeUserNick(self, old_nick, new_nick):
376 """Change nick of a user in group list"""
377 self.printInfo("%s is now known as %s" % (old_nick, new_nick))
378
379 ## Messages ##
380
381 def manageMessage(self, entity, mess_type):
382 """Tell if this chat widget manage an entity and message type couple
383
384 @param entity (jid.JID): (full) jid of the sending entity
385 @param mess_type (str): message type as given by messageNew
386 @return (bool): True if this Chat Widget manage this couple
387 """
388 if self.type == C.CHAT_GROUP:
389 if mess_type in (C.MESS_TYPE_GROUPCHAT, C.MESS_TYPE_INFO) and self.target == entity.bare:
390 return True
391 else:
392 if mess_type != C.MESS_TYPE_GROUPCHAT and entity in self.targets:
393 return True
394 return False
395
396 def updateHistory(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, profile='@NONE@'):
397 """Called when history need to be recreated
398
399 Remove all message from history then call historyPrint
400 Must probably be overriden by frontend to clear widget
401 @param size (int): number of messages
402 @param filters (str): patterns to filter the history results
403 @param profile (str): %(doc_profile)s
404 """
405 self._locked = True
406 self._cache = OrderedDict()
407 self.messages.clear()
408 self.historyPrint(size, filters, profile)
409
410 def _onHistoryPrinted(self):
411 """Method called when history is printed (or failed)
412
413 unlock the widget, and can be used to refresh or scroll down
414 the focus after the history is printed
415 """
416 self._locked = False
417 for data in self._cache.itervalues():
418 self.messageNew(*data)
419 del self._cache
420
421 def historyPrint(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, profile='@NONE@'):
422 """Print the current history
423
424 @param size (int): number of messages
425 @param search (str): pattern to filter the history results
426 @param profile (str): %(doc_profile)s
427 """
428 if filters is None:
429 filters = {}
430 if size == 0:
431 log.debug(u"Empty history requested, skipping")
432 self._onHistoryPrinted()
433 return
434 log_msg = _(u"now we print the history")
435 if size != C.HISTORY_LIMIT_DEFAULT:
436 log_msg += _(u" ({} messages)".format(size))
437 log.debug(log_msg)
438
439 if self.type == C.CHAT_ONE2ONE:
440 special = self.host.contact_lists[self.profile].getCache(self.target, C.CONTACT_SPECIAL)
441 if special == C.CONTACT_SPECIAL_GROUP:
442 # we have a private conversation
443 # so we need full jid for the history
444 # (else we would get history from group itself)
445 # and to filter out groupchat message
446 target = self.target
447 filters['not_types'] = C.MESS_TYPE_GROUPCHAT
448 else:
449 target = self.target.bare
450 else:
451 # groupchat
452 target = self.target.bare
453 # FIXME: info not handled correctly
454 filters['types'] = C.MESS_TYPE_GROUPCHAT
455
456 def _historyGetCb(history):
457 # day_format = "%A, %d %b %Y" # to display the day change
458 # previous_day = datetime.now().strftime(day_format)
459 # message_day = datetime.fromtimestamp(timestamp).strftime(self.day_format)
460 # if previous_day != message_day:
461 # self.printDayChange(message_day)
462 # previous_day = message_day
463 for data in history:
464 uid, timestamp, from_jid, to_jid, message, subject, type_, extra = data
465 # cached messages may already be in history
466 # so we check it to avoid duplicates, they'll be added later
467 if uid in self._cache:
468 continue
469 from_jid = jid.JID(from_jid)
470 to_jid = jid.JID(to_jid)
471 # if ((self.type == C.CHAT_GROUP and type_ != C.MESS_TYPE_GROUPCHAT) or
472 # (self.type == C.CHAT_ONE2ONE and type_ == C.MESS_TYPE_GROUPCHAT)):
473 # continue
474 self.messages[uid] = Message(self, uid, timestamp, from_jid, to_jid, message, subject, type_, extra, profile)
475 self._onHistoryPrinted()
476
477 def _historyGetEb(err):
478 log.error(_(u"Can't get history: {}").format(err))
479 self._onHistoryPrinted()
480
481 self.host.bridge.historyGet(unicode(self.host.profiles[profile].whoami.bare), unicode(target), size, True, filters, profile, callback=_historyGetCb, errback=_historyGetEb)
482
483 def messageNew(self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile):
484 if self._locked:
485 self._cache[uid] = (uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile)
486 return
487 if self.type == C.CHAT_GROUP:
488 if to_jid.resource and type_ != C.MESS_TYPE_GROUPCHAT:
489 # we have a private message, we forward it to a private conversation widget
490 chat_widget = self.getOrCreatePrivateWidget(to_jid)
491 chat_widget.messageNew(uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile)
492 return
493 if type_ == C.MESS_TYPE_INFO:
494 try:
495 info_type = extra['info_type']
496 except KeyError:
497 pass
498 else:
499 user_data = {k[5:]:v for k,v in extra.iteritems() if k.startswith('user_')}
500 if info_type == ROOM_USER_JOINED:
501 self.addUser(user_data)
502 elif info_type == ROOM_USER_LEFT:
503 self.removeUser(user_data)
504
505 message = Message(self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile)
506 self.messages[uid] = message
507
508 if 'received_timestamp' in extra:
509 log.warning(u"Delayed message received after history, this should not happen")
510 self.createMessage(message)
511
512 def createMessage(self, message, append=False):
513 """Must be implemented by frontend to create and show a new message widget
514
515 This is only called on messageNew, not on history.
516 You need to override historyPrint to handle the later
517 @param message(Message): message data
518 """
519 raise NotImplementedError
520
521 def printDayChange(self, day):
522 """Display the day on a new line.
523
524 @param day(unicode): day to display (or not if this method is not overwritten)
525 """
526 # FIXME: not called anymore after refactoring
527 pass
528
529 ## Room ##
530
531 def setSubject(self, subject):
532 """Set title for a group chat"""
533 if self.type != C.CHAT_GROUP:
534 raise exceptions.InternalError("trying to set subject for a non group chat window")
535 self.subject = subject
536
537 def changeSubject(self, new_subject):
538 """Change the subject of the room
539
540 This change the subject on the room itself (i.e. via XMPP),
541 while setSubject change the subject of this widget
542 """
543 self.host.bridge.mucSubject(unicode(self.target), new_subject, self.profile)
544
545 def addGamePanel(self, widget):
546 """Insert a game panel to this Chat dialog.
547
548 @param widget (Widget): the game panel
549 """
550 raise NotImplementedError
551
552 def removeGamePanel(self, widget):
553 """Remove the game panel from this Chat dialog.
554
555 @param widget (Widget): the game panel
556 """
557 raise NotImplementedError
558
559 def update(self, entity=None):
560 """Update one or all entities.
561
562 @param entity (jid.JID): entity to update
563 """
564 # FIXME: to remove ?
565 raise NotImplementedError
566
567 ## events ##
568
569 def onChatState(self, from_jid, state, profile):
570 """A chat state has been received"""
571 if self.type == C.CHAT_GROUP:
572 nick = from_jid.resource
573 try:
574 self.occupants[nick].state = state
575 except KeyError:
576 log.warning(u"{nick} not found in {room}, ignoring new chat state".format(
577 nick=nick, room=self.target.bare))
578
579 def onMessageState(self, uid, status, profile):
580 try:
581 mess_data = self.messages[uid]
582 except KeyError:
583 pass
584 else:
585 mess_data.status = status
586
587 def onAvatar(self, entity, filename, profile):
588 if self.type == C.CHAT_GROUP:
589 if entity.bare == self.target:
590 try:
591 self.occupants[entity.resource].update({'avatar': filename})
592 except KeyError:
593 # can happen for a message in history where the
594 # entity is not here anymore
595 pass
596
597 for m in self.messages.values():
598 if m.nick == entity.resource:
599 for w in m.widgets:
600 w.update({'avatar': filename})
601 else:
602 if entity.bare == self.target.bare or entity.bare == self.host.profiles[profile].whoami.bare:
603 log.info(u"avatar updated for {}".format(entity))
604 for m in self.messages.values():
605 if m.from_jid.bare == entity.bare:
606 for w in m.widgets:
607 w.update({'avatar': filename})
608
609
610 quick_widgets.register(QuickChat)