Mercurial > libervia-backend
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_frontends/quick_frontend/quick_chat.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,610 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# helper class for making a SAT frontend +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +from sat.core.i18n import _ +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core import exceptions +from sat_frontends.quick_frontend import quick_widgets +from sat_frontends.quick_frontend.constants import Const as C +from collections import OrderedDict +from sat_frontends.tools import jid +import time +try: + from locale import getlocale +except ImportError: + # FIXME: pyjamas workaround + getlocale = lambda x: (None, 'utf-8') + + +ROOM_USER_JOINED = 'ROOM_USER_JOINED' +ROOM_USER_LEFT = 'ROOM_USER_LEFT' +ROOM_USER_MOVED = (ROOM_USER_JOINED, ROOM_USER_LEFT) + +# from datetime import datetime + +try: + # FIXME: to be removed when an acceptable solution is here + unicode('') # XXX: unicode doesn't exist in pyjamas +except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options + unicode = str + +# FIXME: day_format need to be settable (i18n) + +class Message(object): + """Message metadata""" + + def __init__(self, parent, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile): + self.parent = parent + self.profile = profile + self.uid = uid + self.timestamp = timestamp + self.from_jid = from_jid + self.to_jid = to_jid + self.message = msg + self.subject = subject + self.type = type_ + self.extra = extra + self.nick = self.getNick(from_jid) + self._status = None + # own_mess is True if message was sent by profile's jid + 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) + # is user mentioned here ? + if self.parent.type == C.CHAT_GROUP and not self.own_mess: + for m in msg.itervalues(): + if self.parent.nick.lower() in m.lower(): + self._mention = True + break + self.handleMe() + self.widgets = set() # widgets linked to this message + + @property + def host(self): + return self.parent.host + + @property + def info_type(self): + return self.extra.get('info_type') + + @property + def mention(self): + try: + return self._mention + except AttributeError: + return False + + @property + def main_message(self): + """currently displayed message""" + if self.parent.lang in self.message: + self.selected_lang = self.parent.lang + return self.message[self.parent.lang] + try: + self.selected_lang = '' + return self.message[''] + except KeyError: + try: + lang, mess = self.message.iteritems().next() + self.selected_lang = lang + return mess + except StopIteration: + log.error(u"Can't find message for uid {}".format(self.uid)) + return '' + + @property + def main_message_xhtml(self): + """rich message""" + xhtml = {k:v for k,v in self.extra.iteritems() if 'html' in k} + if xhtml: + # FIXME: we only return first found value for now + return next(xhtml.itervalues()) + + + @property + def time_text(self): + """Return timestamp in a nicely formatted way""" + # if the message was sent before today, we print the full date + timestamp = time.localtime(self.timestamp) + time_format = u"%c" if timestamp < self.parent.day_change else u"%H:%M" + return time.strftime(time_format, timestamp).decode(getlocale()[1] or 'utf-8') + + @property + def avatar(self): + """avatar full path or None if no avatar is found""" + ret = self.host.getAvatar(self.from_jid, profile=self.profile) + return ret + + def getNick(self, entity): + """Return nick of an entity when possible""" + contact_list = self.host.contact_lists[self.profile] + if self.type == C.MESS_TYPE_INFO and self.info_type in ROOM_USER_MOVED: + try: + return self.extra['user_nick'] + except KeyError: + log.error(u"extra data is missing user nick for uid {}".format(self.uid)) + return "" + # FIXME: converted getSpecials to list for pyjamas + if self.parent.type == C.CHAT_GROUP or entity in list(contact_list.getSpecials(C.CONTACT_SPECIAL_GROUP)): + return entity.resource or "" + if entity.bare in contact_list: + return contact_list.getCache(entity, 'nick') or contact_list.getCache(entity, 'name') or entity.node or entity + return entity.node or entity + + @property + def status(self): + return self._status + + @status.setter + def status(self, status): + if status != self._status: + self._status = status + for w in self.widgets: + w.update({"status": status}) + + def handleMe(self): + """Check if messages starts with "/me " and change them if it is the case + + if several messages (different languages) are presents, they all need to start with "/me " + """ + # TODO: XHTML-IM /me are not handled + me = False + # we need to check /me for every message + for m in self.message.itervalues(): + if m.startswith(u"/me "): + me = True + else: + me = False + break + if me: + self.type = C.MESS_TYPE_INFO + self.extra['info_type'] = 'me' + nick = self.nick + for lang, mess in self.message.iteritems(): + self.message[lang] = u"* " + nick + mess[3:] + + +class Occupant(object): + """Occupant metadata""" + + def __init__(self, parent, data, profile): + self.parent = parent + self.profile = profile + self.nick = data['nick'] + self._entity = data.get('entity') + self.affiliation = data['affiliation'] + self.role = data['role'] + self.widgets = set() # widgets linked to this occupant + self._state = None + + @property + def data(self): + """reconstruct data dict from attributes""" + data = {} + data['nick'] = self.nick + if self._entity is not None: + data['entity'] = self._entity + data['affiliation'] = self.affiliation + data['role'] = self.role + return data + + @property + def jid(self): + """jid in the room""" + return jid.JID(u"{}/{}".format(self.parent.target.bare, self.nick)) + + @property + def real_jid(self): + """real jid if known else None""" + return self._entity + + @property + def host(self): + return self.parent.host + + @property + def state(self): + return self._state + + @state.setter + def state(self, new_state): + if new_state != self._state: + self._state = new_state + for w in self.widgets: + w.update({"state": new_state}) + + def update(self, update_dict=None): + for w in self.widgets: + w.update(update_dict) + + +class QuickChat(quick_widgets.QuickWidget): + visible_states = ['chat_state'] # FIXME: to be removed, used only in quick_games + + def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None, subject=None, profiles=None): + """ + @param type_: can be C.CHAT_ONE2ONE for single conversation or C.CHAT_GROUP for chat à la IRC + """ + self.lang = '' # default language to use for messages + quick_widgets.QuickWidget.__init__(self, host, target, profiles=profiles) + self._locked = True # True when we are waiting for history/search + # messageNew signals are cached when locked + self._cache = OrderedDict() + assert type_ in (C.CHAT_ONE2ONE, C.CHAT_GROUP) + self.current_target = target + self.type = type_ + if type_ == C.CHAT_GROUP: + if target.resource: + raise exceptions.InternalError(u"a group chat entity can't have a resource") + if nick is None: + raise exceptions.InternalError(u"nick must not be None for group chat") + + self.nick = nick + self.occupants = {} + self.setOccupants(occupants) + else: + if occupants is not None or nick is not None: + raise exceptions.InternalError(u"only group chat can have occupants or nick") + self.messages = OrderedDict() # key: uid, value: Message instance + self.games = {} # key=game name (unicode), value=instance of quick_games.RoomGame + self.subject = subject + lt = time.localtime() + 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 + if self.host.AVATARS_HANDLER: + self.host.addListener('avatar', self.onAvatar, profiles) + + def postInit(self): + """Method to be called by frontend after widget is initialised + + handle the display of history and subject + """ + self.historyPrint(profile=self.profile) + if self.subject is not None: + self.setSubject(self.subject) + + def onDelete(self): + if self.host.AVATARS_HANDLER: + self.host.removeListener('avatar', self.onAvatar) + + @property + def contact_list(self): + return self.host.contact_lists[self.profile] + + ## Widget management ## + + def __str__(self): + return u"Chat Widget [target: {}, type: {}, profile: {}]".format(self.target, self.type, self.profile) + + @staticmethod + def getWidgetHash(target, profiles): + profile = list(profiles)[0] + return profile + "\n" + unicode(target.bare) + + @staticmethod + def getPrivateHash(target, profile): + """Get unique hash for private conversations + + This method should be used with force_hash to get unique widget for private MUC conversations + """ + return (unicode(profile), target) + + def addTarget(self, target): + super(QuickChat, self).addTarget(target) + if target.resource: + self.current_target = target # FIXME: tmp, must use resource priority throught contactList instead + + def recreateArgs(self, args, kwargs): + """copy important attribute for a new widget""" + kwargs['type_'] = self.type + if self.type == C.CHAT_GROUP: + kwargs['occupants'] = {o.nick: o.data for o in self.occupants.itervalues()} + kwargs['subject'] = self.subject + try: + kwargs['nick'] = self.nick + except AttributeError: + pass + + def onPrivateCreated(self, widget): + """Method called when a new widget for private conversation (MUC) is created""" + raise NotImplementedError + + def getOrCreatePrivateWidget(self, entity): + """Create a widget for private conversation, or get it if it already exists + + @param entity: full jid of the target + """ + 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 + + @property + def target(self): + if self.type == C.CHAT_GROUP: + return self.current_target.bare + return self.current_target + + ## occupants ## + + def setOccupants(self, occupants): + """set the whole list of occupants""" + assert len(self.occupants) == 0 + for nick, data in occupants.iteritems(): + self.occupants[nick] = Occupant( + self, + data, + self.profile + ) + + def addUser(self, occupant_data): + """Add user if it is not in the group list""" + occupant = Occupant( + self, + occupant_data, + self.profile + ) + self.occupants[occupant.nick] = occupant + return occupant + + def removeUser(self, occupant_data): + """Remove a user from the group list""" + nick = occupant_data['nick'] + try: + occupant = self.occupants.pop(nick) + except KeyError: + log.warning(u"Trying to remove an unknown occupant: {}".format(nick)) + else: + return occupant + + def setUserNick(self, nick): + """Set the nick of the user, usefull for e.g. change the color of the user""" + self.nick = nick + + def changeUserNick(self, old_nick, new_nick): + """Change nick of a user in group list""" + self.printInfo("%s is now known as %s" % (old_nick, new_nick)) + + ## Messages ## + + def manageMessage(self, entity, mess_type): + """Tell if this chat widget manage an entity and message type couple + + @param entity (jid.JID): (full) jid of the sending entity + @param mess_type (str): message type as given by messageNew + @return (bool): True if this Chat Widget manage this couple + """ + if self.type == C.CHAT_GROUP: + if mess_type in (C.MESS_TYPE_GROUPCHAT, C.MESS_TYPE_INFO) and self.target == entity.bare: + return True + else: + if mess_type != C.MESS_TYPE_GROUPCHAT and entity in self.targets: + return True + return False + + def updateHistory(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, profile='@NONE@'): + """Called when history need to be recreated + + Remove all message from history then call historyPrint + Must probably be overriden by frontend to clear widget + @param size (int): number of messages + @param filters (str): patterns to filter the history results + @param profile (str): %(doc_profile)s + """ + self._locked = True + self._cache = OrderedDict() + self.messages.clear() + self.historyPrint(size, filters, profile) + + def _onHistoryPrinted(self): + """Method called when history is printed (or failed) + + unlock the widget, and can be used to refresh or scroll down + the focus after the history is printed + """ + self._locked = False + for data in self._cache.itervalues(): + self.messageNew(*data) + del self._cache + + def historyPrint(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, profile='@NONE@'): + """Print the current history + + @param size (int): number of messages + @param search (str): pattern to filter the history results + @param profile (str): %(doc_profile)s + """ + if filters is None: + filters = {} + if size == 0: + log.debug(u"Empty history requested, skipping") + self._onHistoryPrinted() + return + log_msg = _(u"now we print the history") + if size != C.HISTORY_LIMIT_DEFAULT: + log_msg += _(u" ({} messages)".format(size)) + log.debug(log_msg) + + if self.type == C.CHAT_ONE2ONE: + special = self.host.contact_lists[self.profile].getCache(self.target, C.CONTACT_SPECIAL) + if special == C.CONTACT_SPECIAL_GROUP: + # we have a private conversation + # so we need full jid for the history + # (else we would get history from group itself) + # and to filter out groupchat message + target = self.target + filters['not_types'] = C.MESS_TYPE_GROUPCHAT + else: + target = self.target.bare + else: + # groupchat + target = self.target.bare + # FIXME: info not handled correctly + filters['types'] = C.MESS_TYPE_GROUPCHAT + + def _historyGetCb(history): + # day_format = "%A, %d %b %Y" # to display the day change + # previous_day = datetime.now().strftime(day_format) + # message_day = datetime.fromtimestamp(timestamp).strftime(self.day_format) + # if previous_day != message_day: + # self.printDayChange(message_day) + # previous_day = message_day + for data in history: + uid, timestamp, from_jid, to_jid, message, subject, type_, extra = data + # cached messages may already be in history + # so we check it to avoid duplicates, they'll be added later + if uid in self._cache: + continue + from_jid = jid.JID(from_jid) + to_jid = jid.JID(to_jid) + # if ((self.type == C.CHAT_GROUP and type_ != C.MESS_TYPE_GROUPCHAT) or + # (self.type == C.CHAT_ONE2ONE and type_ == C.MESS_TYPE_GROUPCHAT)): + # continue + self.messages[uid] = Message(self, uid, timestamp, from_jid, to_jid, message, subject, type_, extra, profile) + self._onHistoryPrinted() + + def _historyGetEb(err): + log.error(_(u"Can't get history: {}").format(err)) + self._onHistoryPrinted() + + self.host.bridge.historyGet(unicode(self.host.profiles[profile].whoami.bare), unicode(target), size, True, filters, profile, callback=_historyGetCb, errback=_historyGetEb) + + def messageNew(self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile): + if self._locked: + self._cache[uid] = (uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile) + return + if self.type == C.CHAT_GROUP: + if to_jid.resource and type_ != C.MESS_TYPE_GROUPCHAT: + # we have a private message, we forward it to a private conversation widget + chat_widget = self.getOrCreatePrivateWidget(to_jid) + chat_widget.messageNew(uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile) + return + if type_ == C.MESS_TYPE_INFO: + try: + info_type = extra['info_type'] + except KeyError: + pass + else: + user_data = {k[5:]:v for k,v in extra.iteritems() if k.startswith('user_')} + if info_type == ROOM_USER_JOINED: + self.addUser(user_data) + elif info_type == ROOM_USER_LEFT: + self.removeUser(user_data) + + message = Message(self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile) + self.messages[uid] = message + + if 'received_timestamp' in extra: + log.warning(u"Delayed message received after history, this should not happen") + self.createMessage(message) + + def createMessage(self, message, append=False): + """Must be implemented by frontend to create and show a new message widget + + This is only called on messageNew, not on history. + You need to override historyPrint to handle the later + @param message(Message): message data + """ + raise NotImplementedError + + def printDayChange(self, day): + """Display the day on a new line. + + @param day(unicode): day to display (or not if this method is not overwritten) + """ + # FIXME: not called anymore after refactoring + pass + + ## Room ## + + def setSubject(self, subject): + """Set title for a group chat""" + if self.type != C.CHAT_GROUP: + raise exceptions.InternalError("trying to set subject for a non group chat window") + self.subject = subject + + def changeSubject(self, new_subject): + """Change the subject of the room + + This change the subject on the room itself (i.e. via XMPP), + while setSubject change the subject of this widget + """ + self.host.bridge.mucSubject(unicode(self.target), new_subject, self.profile) + + def addGamePanel(self, widget): + """Insert a game panel to this Chat dialog. + + @param widget (Widget): the game panel + """ + raise NotImplementedError + + def removeGamePanel(self, widget): + """Remove the game panel from this Chat dialog. + + @param widget (Widget): the game panel + """ + raise NotImplementedError + + def update(self, entity=None): + """Update one or all entities. + + @param entity (jid.JID): entity to update + """ + # FIXME: to remove ? + raise NotImplementedError + + ## events ## + + def onChatState(self, from_jid, state, profile): + """A chat state has been received""" + if self.type == C.CHAT_GROUP: + nick = from_jid.resource + try: + self.occupants[nick].state = state + except KeyError: + log.warning(u"{nick} not found in {room}, ignoring new chat state".format( + nick=nick, room=self.target.bare)) + + def onMessageState(self, uid, status, profile): + try: + mess_data = self.messages[uid] + except KeyError: + pass + else: + mess_data.status = status + + def onAvatar(self, entity, filename, profile): + if self.type == C.CHAT_GROUP: + if entity.bare == self.target: + try: + self.occupants[entity.resource].update({'avatar': filename}) + except KeyError: + # can happen for a message in history where the + # entity is not here anymore + pass + + for m in self.messages.values(): + if m.nick == entity.resource: + for w in m.widgets: + w.update({'avatar': filename}) + else: + if entity.bare == self.target.bare or entity.bare == self.host.profiles[profile].whoami.bare: + log.info(u"avatar updated for {}".format(entity)) + for m in self.messages.values(): + if m.from_jid.bare == entity.bare: + for w in m.widgets: + w.update({'avatar': filename}) + + +quick_widgets.register(QuickChat)