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.