Mercurial > libervia-backend
diff frontends/src/quick_frontend/quick_chat.py @ 1963:a2bc5089c2eb
backend, frontends: message refactoring (huge commit):
/!\ several features are temporarily disabled, like notifications in frontends
next step in refactoring, with the following changes:
- jp: updated jp message to follow changes in backend/bridge
- jp: added --lang, --subject, --subject_lang, and --type options to jp message + fixed unicode handling for jid
- quick_frontend (QuickApp, QuickChat):
- follow backend changes
- refactored chat, message are now handled in OrderedDict and uid are kept so they can be updated
- Message and Occupant classes handle metadata, so frontend just have to display them
- Primitivus (Chat):
- follow backend/QuickFrontend changes
- info & standard messages are handled in the same MessageWidget class
- improved/simplified handling of messages, removed update() method
- user joined/left messages are merged when next to each other
- a separator is shown when message is received while widget is out of focus, so user can quickly see the new messages
- affiliation/role are shown (in a basic way for now) in occupants panel
- removed "/me" messages handling, as it will be done by a backend plugin
- message language is displayed when available (only one language per message for now)
- fixed :history and :search commands
- core (constants): new constants for messages type, XML namespace, entity type
- core: *Message methods renamed to follow new code sytle (e.g. sendMessageToBridge => messageSendToBridge)
- core (messages handling): fixed handling of language
- core (messages handling): mes_data['from'] and ['to'] are now jid.JID
- core (core.xmpp): reorganised message methods, added getNick() method to client.roster
- plugin text commands: fixed plugin and adapted to new messages behaviour. client is now used in arguments instead of profile
- plugins: added information for cancellation reason in CancelError calls
- plugin XEP-0045: various improvments, but this plugin still need work:
- trigger is used to avoid message already handled by the plugin to be handled a second time
- changed the way to handle history, the last message from DB is checked and we request only messages since this one, in seconds (thanks Poezio folks :))
- subject reception is waited before sending the roomJoined signal, this way we are sure that everything including history is ready
- cmd_* method now follow the new convention with client instead of profile
- roomUserJoined and roomUserLeft messages are removed, the events are now handled with info message with a "ROOM_USER_JOINED" info subtype
- probably other forgotten stuffs :p
author | Goffi <goffi@goffi.org> |
---|---|
date | Mon, 20 Jun 2016 18:41:53 +0200 |
parents | 633b5c21aefd |
children | 02d21a589be2 |
line wrap: on
line diff
--- a/frontends/src/quick_frontend/quick_chat.py Sun Jun 19 22:22:13 2016 +0200 +++ b/frontends/src/quick_frontend/quick_chat.py Mon Jun 20 18:41:53 2016 +0200 @@ -20,11 +20,17 @@ from sat.core.i18n import _ from sat.core.log import getLogger log = getLogger(__name__) -from sat_frontends.tools import jid +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 datetime import datetime +from sat_frontends.tools import jid + +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 @@ -32,26 +38,109 @@ 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) + # 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) + 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') + + 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 "" + if self.parent.type == C.CHAT_GROUP or entity in contact_list.getSpecialExtras(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 + + +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') + if not self.entity: + self.entity = jid.JID(u"{}/{}".format(parent.target.bare, self.nick)), + self.affiliation = data['affiliation'] + self.role = data['role'] + self.widgets = set() # widgets linked to this occupant + + @property + def host(self): + return self.parent.host + class QuickChat(quick_widgets.QuickWidget): visible_states = ['chat_state'] - def __init__(self, host, target, type_=C.CHAT_ONE2ONE, profiles=None): + def __init__(self, host, target, type_=C.CHAT_ONE2ONE, 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 = False # True when we are waiting for history/search + # messageNew signals are cached when locked + self._cache = [] assert type_ in (C.CHAT_ONE2ONE, C.CHAT_GROUP) - if type_ == C.CHAT_GROUP and target.resource: - raise ValueError("A group chat entity can't have a resource") self.current_target = target self.type = type_ - self.id = "" # FIXME: to be removed - self.nick = None + if type_ == C.CHAT_GROUP: + if target.resource: + raise exceptions.InternalError(u"a group chat entity can't have a resource") + self.nick = None + self.occupants = {} + self.setOccupants(occupants) + else: + if occupants is not None: + raise exceptions.InternalError(u"only group chat can have occupants") + self.messages = OrderedDict() # key: uid, value: Message instance self.games = {} # key=game name (unicode), value=instance of quick_games.RoomGame + self.subject = subject + 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) + + ## Widget management ## def __str__(self): return u"Chat Widget [target: {}, type: {}, profile: {}]".format(self.target, self.type, self.profile) @@ -74,109 +163,6 @@ if target.resource: self.current_target = target # FIXME: tmp, must use resource priority throught contactList instead - @property - def target(self): - if self.type == C.CHAT_GROUP: - return self.current_target.bare - return self.current_target - - @property - def occupants(self): - """Return the occupants of a group chat (nicknames). - - @return: set(unicode) - """ - if self.type != C.CHAT_GROUP: - return set() - contact_list = self.host.contact_lists[self.profile] - return contact_list.getCache(self.target, C.CONTACT_RESOURCES).keys() - - 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 == C.MESS_TYPE_GROUPCHAT 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 addUser(self, nick): - """Add user if it is not in the group list""" - self.printInfo("=> %s has joined the room" % nick) - - def removeUser(self, nick): - """Remove a user from the group list""" - self.printInfo("<= %s has left the room" % nick) - - 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)) - - def setSubject(self, subject): - """Set title for a group chat""" - log.debug(_("Setting subject to %s") % subject) - if self.type != C.CHAT_GROUP: - log.error (_("[INTERNAL] trying to set subject for a non group chat window")) - raise Exception("INTERNAL ERROR") #TODO: raise proper Exception here - - def afterHistoryPrint(self): - """Refresh or scroll down the focus after the history is printed""" - pass - - def historyPrint(self, size=C.HISTORY_LIMIT_DEFAULT, search='', 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 - """ - log_msg = _(u"now we print the history") - if size != C.HISTORY_LIMIT_DEFAULT: - log_msg += _(u" (%d messages)" % size) - log.debug(log_msg) - - target = self.target.bare - - def _historyGetCb(history): - day_format = "%A, %d %b %Y" # to display the day change - previous_day = datetime.now().strftime(day_format) - for data in history: - uid, timestamp, from_jid, to_jid, message, subject, type_, extra = data # FIXME: extra is unused ! - 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 - message_day = datetime.fromtimestamp(timestamp).strftime(day_format) - if previous_day != message_day: - self.printDayChange(message_day) - previous_day = message_day - extra["timestamp"] = timestamp - self.messageNew(uid, timestamp, jid.JID(from_jid), target, message, subject, type_, extra, profile) - self.afterHistoryPrint() - - def _historyGetEb(err): - log.error(_("Can't get history")) - - self.host.bridge.historyGet(unicode(self.host.profiles[profile].whoami.bare), unicode(target), size, True, search, profile, callback=_historyGetCb, errback=_historyGetEb) - - def _get_nick(self, entity): - """Return nick of this entity when possible""" - contact_list = self.host.contact_lists[self.profile] - if self.type == C.CHAT_GROUP or entity in contact_list.getSpecialExtras(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 - def onPrivateCreated(self, widget): """Method called when a new widget for private conversation (MUC) is created""" raise NotImplementedError @@ -188,57 +174,171 @@ """ 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 - def messageNew(self, uid, timestamp, from_jid, target, msg, subject, type_, extra, profile): + @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: - msg = msg.itervalues().next() # FIXME: tmp fix until message refactoring is finished (msg is now a dict) - except StopIteration: - log.warning(u"No message found (uid: {})".format(uid)) - msg = '' - if self.type == C.CHAT_GROUP and target.resource and type_ != C.MESS_TYPE_GROUPCHAT: - # we have a private message, we forward it to a private conversation widget - chat_widget = self.getOrCreatePrivateWidget(target) - chat_widget.messageNew(uid, timestamp, from_jid, target, msg, subject, type_, extra, profile) - return + occupant = self.occupants.pop(nick) + except KeyError: + log.warning(u"Trying to remove an unknown occupant: {}".format(nick)) + else: + return occupant - if type_ == C.MESS_TYPE_INFO: - self.printInfo(msg, extra=extra) + 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: - nick = self._get_nick(from_jid) - if msg.startswith('/me '): - self.printInfo('* {} {}'.format(nick, msg[4:]), type_='me', extra=extra) - else: - # my_message is True if message comes from local user - my_message = (from_jid.resource == self.nick) if self.type == C.CHAT_GROUP else (from_jid.bare == self.host.profiles[profile].whoami.bare) - self.printMessage(nick, my_message, msg, timestamp, extra, profile) - # FIXME: to be checked/removed after message refactoring - # if timestamp: - self.afterHistoryPrint() + if mess_type != C.MESS_TYPE_GROUPCHAT and entity in self.targets: + return True + return False + + def updateHistory(self, size=C.HISTORY_LIMIT_DEFAULT, search='', 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 search (str): pattern to filter the history results + @param profile (str): %(doc_profile)s + """ + self._locked = True + self.messages.clear() + self.historyPrint(size, search, profile) + + def _onHistoryPrinted(self): + """Method called when history is printed (or failed) - def printMessage(self, nick, my_message, message, timestamp, extra=None, profile=C.PROF_KEY_NONE): - """Print message in chat window. + 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: + self.messageNew(*data) - @param nick (unicode): author nick - @param my_message (boolean): True if profile is the author - @param message (unicode): message content - @param extra (dict): extra data + def historyPrint(self, size=C.HISTORY_LIMIT_DEFAULT, search='', 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 """ - # FIXME: check/remove this if necessary (message refactoring) - # if not timestamp: - # # XXX: do not send notifications for each line of the history being displayed - # # FIXME: this must be changed in the future if the timestamp is passed with - # # all messages and not only with the messages coming from the history. - self.notify(nick, message) + 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) + + target = self.target.bare + + 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 + 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")) + self._onHistoryPrinted() + + self.host.bridge.historyGet(unicode(self.host.profiles[profile].whoami.bare), unicode(target), size, True, search, profile, callback=_historyGetCb, errback=_historyGetEb) - def printInfo(self, msg, type_='normal', extra=None): - """Print general info. + def messageNew(self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile): + log.debug(u"messageNew ==> {}".format((uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile))) + if self._locked: + self._cache.append(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) - @param msg (unicode): message to print - @param type_ (unicode): - - 'normal': general info like "toto has joined the room" - - 'me': "/me" information like "/me clenches his fist" ==> "toto clenches his fist" - @param extra (dict): message 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 """ - self.notify(msg=msg) + raise NotImplementedError def notify(self, contact="somebody", msg=""): """Notify the user of a new message if the frontend doesn't have the focus. @@ -246,6 +346,7 @@ @param contact (unicode): contact who wrote to the users @param msg (unicode): the message that has been received """ + # FIXME: not called anymore after refactoring raise NotImplemented def printDayChange(self, day): @@ -253,21 +354,16 @@ @param day(unicode): day to display (or not if this method is not overwritten) """ + # FIXME: not called anymore after refactoring pass - def getEntityStates(self, entity): - """Retrieve states for an entity. + ## Room ## - @param entity (jid.JID): entity - @return: OrderedDict{unicode: unicode} - """ - states = OrderedDict() - clist = self.host.contact_lists[self.profile] - for key in self.visible_states: - value = clist.getCache(entity, key) - if value: - states[key] = value - return states + def setSubject(self, subject): + """Set title for a group chat""" + self.subject = subject + if self.type != C.CHAT_GROUP: + raise exceptions.InternalError("trying to set subject for a non group chat window") def addGamePanel(self, widget): """Insert a game panel to this Chat dialog.