diff frontends/src/primitivus/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 d727aab9a80e
line wrap: on
line diff
--- a/frontends/src/primitivus/chat.py	Sun Jun 19 22:22:13 2016 +0200
+++ b/frontends/src/primitivus/chat.py	Mon Jun 20 18:41:53 2016 +0200
@@ -23,7 +23,7 @@
 import urwid
 from urwid_satext import sat_widgets
 from sat_frontends.quick_frontend import quick_widgets
-from sat_frontends.quick_frontend.quick_chat import QuickChat
+from sat_frontends.quick_frontend import quick_chat
 from sat_frontends.quick_frontend import quick_games
 from sat_frontends.primitivus import game_tarot
 from sat_frontends.primitivus.constants import Const as C
@@ -31,19 +31,63 @@
 from sat_frontends.primitivus.widget import PrimitivusWidget
 import time
 from sat_frontends.tools import jid
+from functools import total_ordering
+import bisect
 
 
-class ChatText(urwid.FlowWidget):
-    """Manage the printing of chat message"""
+class MessageWidget(urwid.WidgetWrap):
+
+    def __init__(self, mess_data):
+        """
+        @param mess_data(quick_chat.Message, None): message data
+            None: used only for non text widgets (e.g.: focus separator)
+        """
+        self.mess_data = mess_data
+        mess_data.widgets.add(self)
+        self.timestamp = time.localtime(mess_data.timestamp)
+        super(MessageWidget, self).__init__(urwid.Text(self.markup))
+
+    @property
+    def markup(self):
+        return self._generateInfoMarkup() if self.mess_data.type == C.MESS_TYPE_INFO else self._generateMarkup()
+
+    @property
+    def info_type(self):
+        return self.mess_data.info_type
+
+    @property
+    def parent(self):
+        return self.mess_data.parent
 
-    def __init__(self, parent, timestamp, nick, my_mess, message, align='left', is_info=False):
-        self.parent = parent
-        self.timestamp = time.localtime(timestamp)
-        self.nick = nick
-        self.my_mess = my_mess
-        self.message = unicode(message)
-        self.align = align
-        self.is_info = is_info
+    @property
+    def message(self):
+        """Return currently displayed message"""
+        message = self.mess_data.message
+        if self.parent.lang in message:
+            self.selected_lang = self.parent.lang
+            return message[self.parent.lang]
+        try:
+            self.selected_lang = ''
+            return message['']
+        except KeyError:
+            try:
+                lang, mess = message.iteritems().next()
+                self.selected_lang = lang
+                return mess
+            except StopIteration:
+                log.error(u"Can't find message for uid {}".format(self.mess_data.uid))
+
+    @message.setter
+    def message(self, value):
+        self.mess_data.message = {'':value}
+        self._w.set_text(self.markup)
+
+    @property
+    def type(self):
+        try:
+            return self.mess_data.type
+        except AttributeError:
+            return C.MESS_TYPE_INFO
 
     def selectable(self):
         return True
@@ -51,42 +95,103 @@
     def keypress(self, size, key):
         return key
 
-    def rows(self, size, focus=False):
-        return self.display_widget(size, focus).rows(size, focus)
+    def get_cursor_coords(self, size):
+        return 0, 0
 
     def render(self, size, focus=False):
-        canvas = urwid.CompositeCanvas(self.display_widget(size, focus).render(size, focus))
+        # Text widget doesn't render cursor, but we want one
+        # so we add it here
+        canvas = urwid.CompositeCanvas(self._w.render(size, focus))
         if focus:
             canvas.set_cursor(self.get_cursor_coords(size))
         return canvas
 
+    def _generateInfoMarkup(self):
+        return ('info_msg', self.message)
+
+    def _generateMarkup(self):
+        """Generate text markup according to message data and Widget options"""
+        markup = []
+        d = self.mess_data
+
+        # timestamp
+        if self.parent.show_timestamp:
+            # if the message was sent before today, we print the full date
+            time_format = "%c" if self.timestamp < self.parent.day_change else "%H:%M"
+            markup.append(('date', "[{}]".format(time.strftime(time_format, self.timestamp).decode('utf-8'))))
+
+        # nickname
+        if self.parent.show_short_nick:
+            markup.append(('my_nick' if d.own_mess else 'other_nick', "**" if d.own_mess else "*"))
+        else:
+            markup.append(('my_nick' if d.own_mess else 'other_nick', u"[{}] ".format(d.nick or '')))
+
+        msg = self.message  # needed to generate self.selected_lang
+
+        if self.selected_lang:
+            markup.append(("msg_lang", u"[{}] ".format(self.selected_lang)))
+
+        # message body
+        markup.append(msg)
+
+        return markup
+
+@total_ordering
+class OccupantWidget(urwid.WidgetWrap):
+
+    def __init__(self, occupant_data):
+        self.occupant_data = occupant_data
+        occupant_data.widgets.add(self)
+        markup = self._generateMarkup()
+        super(OccupantWidget, self).__init__(urwid.Text(markup))
+
+    def __eq__(self, other):
+        return self.occupant_data.nick == other.occupant_data.nick
+
+    def __lt__(self, other):
+        return self.occupant_data.nick.lower() < other.occupant_data.nick.lower()
+
+    @property
+    def parent(self):
+        return self.mess_data.parent
+
+    def selectable(self):
+        return True
+
+    def keypress(self, size, key):
+        return key
+
     def get_cursor_coords(self, size):
         return 0, 0
 
-    def display_widget(self, size, focus):
-        render_txt = []
-        if not self.is_info:
-            if self.parent.show_timestamp:
-                time_format = "%c" if self.timestamp < self.parent.day_change else "%H:%M"  # if the message was sent before today, we print the full date
-                render_txt.append(('date', "[%s]" % time.strftime(time_format, self.timestamp).decode('utf-8')))
-            if self.parent.show_short_nick:
-                render_txt.append(('my_nick' if self.my_mess else 'other_nick', "**" if self.my_mess else "*"))
-            else:
-                render_txt.append(('my_nick' if self.my_mess else 'other_nick', "[%s] " % (self.nick or '')))
-        render_txt.append(self.message)
-        txt_widget = urwid.Text(render_txt, align=self.align)
-        if self.is_info:
-            return urwid.AttrMap(txt_widget, 'info_msg')
-        return txt_widget
+    def render(self, size, focus=False):
+        # Text widget doesn't render cursor, but we want one
+        # so we add it here
+        canvas = urwid.CompositeCanvas(self._w.render(size, focus))
+        if focus:
+            canvas.set_cursor(self.get_cursor_coords(size))
+        return canvas
+
+    def _generateMarkup(self):
+        # TODO: role and affiliation are shown in a Q&D way
+        #       should be more intuitive and themable
+        o = self.occupant_data
+        markup = []
+        markup.append(('info_msg', '{}{} '.format(
+            o.role[0].upper(),
+            o.affiliation[0].upper(),
+            )))
+        markup.append(o.nick)
+        return markup
 
 
-class Chat(PrimitivusWidget, QuickChat):
+class Chat(PrimitivusWidget, quick_chat.QuickChat):
 
-    def __init__(self, host, target, type_=C.CHAT_ONE2ONE, profiles=None):
-        QuickChat.__init__(self, host, target, type_, profiles=profiles)
-        self.content = urwid.SimpleListWalker([])
-        self.text_list = urwid.ListBox(self.content)
-        self.chat_widget = urwid.Frame(self.text_list)
+    def __init__(self, host, target, type_=C.CHAT_ONE2ONE, occupants=None, subject=None, profiles=None):
+        quick_chat.QuickChat.__init__(self, host, target, type_, occupants, subject, profiles=profiles)
+        self.mess_walker = urwid.SimpleListWalker([])
+        self.mess_widgets = urwid.ListBox(self.mess_walker)
+        self.chat_widget = urwid.Frame(self.mess_widgets)
         self.chat_colums = urwid.Columns([('weight', 8, self.chat_widget)])
         self.pile = urwid.Pile([self.chat_colums])
         PrimitivusWidget.__init__(self, self.pile, self.target)
@@ -94,16 +199,28 @@
         # we must adapt the behaviour with the type
         if type_ == C.CHAT_GROUP:
             if len(self.chat_colums.contents) == 1:
-                self.occupants_list = sat_widgets.GenericList([], option_type=sat_widgets.ClickableText, on_click=self._occupantsClicked)
-                self.occupants_panel = sat_widgets.VerticalSeparator(self.occupants_list)
+                self.occupants_walker = urwid.SimpleListWalker([])
+                # TODO: put a real ContactPanel class here, based on FocusWidget ?
+                self.occupants_widgets = urwid.ListBox(self.occupants_walker)
+                # FIXME
+                # , option_type=sat_widgets.ClickableText, on_click=self._occupantsClicked)
+                self.occupants_panel = sat_widgets.VerticalSeparator(self.occupants_widgets)
                 self._appendOccupantsPanel()
+                occupants_list = sorted(self.occupants.keys(), key=lambda o:o.lower())
+                for occupant in occupants_list:
+                    occupant_data = self.occupants[occupant]
+                    self.occupants_walker.append(OccupantWidget(occupant_data))
+
                 self.host.addListener('presence', self.presenceListener, [profiles])
 
+        # focus marker is a separator indicated last visible message before focus was lost
+        self.focus_marker = None  # link to current marker
+        self.focus_marker_set = None  # True if a new marker has been inserted
         self.day_change = time.strptime(time.strftime("%a %b %d 00:00:00  %Y"))  # struct_time of day changing time
         self.show_timestamp = True
         self.show_short_nick = False
         self.show_title = 1  # 0: clip title; 1: full title; 2: no title
-        self.subject = None
+        self.postInit()
 
     def keypress(self, size, key):
         if key == a_key['OCCUPANTS_HIDE']:  # user wants to (un)hide the occupants panel
@@ -115,11 +232,11 @@
                     self._appendOccupantsPanel()
         elif key == a_key['TIMESTAMP_HIDE']:  # user wants to (un)hide timestamp
             self.show_timestamp = not self.show_timestamp
-            for wid in self.content:
+            for wid in self.mess_walker:
                 wid._invalidate()
         elif key == a_key['SHORT_NICKNAME']:  # user wants to (not) use short nick
             self.show_short_nick = not self.show_short_nick
-            for wid in self.content:
+            for wid in self.mess_walker:
                 wid._invalidate()
         elif key == a_key['SUBJECT_SWITCH']:  # user wants to (un)hide group's subject or change its apperance
             if self.subject:
@@ -160,76 +277,87 @@
         @param statuses: dict of statuses
         @param profile: %(doc_profile)s
         """
-        assert self.type == C.CHAT_GROUP
-        if entity.bare != self.target:
-            return
-        self.update(entity)
+        # FIXME: disable for refactoring, need to be checked and re-enabled
+        return
+        # assert self.type == C.CHAT_GROUP
+        # if entity.bare != self.target:
+        #     return
+        # self.update(entity)
 
-    def update(self, entity=None):
-        """Update one or all entities.
+    def createMessage(self, message):
+        self.appendMessage(message)
+
+    def _user_moved(self, message):
+        """return true if message is a user left/joined message
 
-        @param entity (jid.JID): entity to update
+        @param message(quick_chat.Message): message to add
         """
-        contact_list = self.host.contact_lists[self.profile]
+        if message.type != C.MESS_TYPE_INFO:
+            return False
+        try:
+            info_type = message.extra['info_type']
+        except KeyError:
+            return False
+        else:
+            return info_type in quick_chat.ROOM_USER_MOVED
 
-        if self.type == C.CHAT_ONE2ONE:  # only update the chat title
-            states = self.getEntityStates(self.target)
-            self.title_dynamic = ' '.join([u'({})'.format(state) for state in states.values()])
-            self.host.redraw()
-            return
+    def appendMessage(self, message):
+        """Create a MessageWidget and append it
 
-        nicks = list(self.occupants)
-        if entity is None:  # rebuild all the occupants list
-            values = []
-            nicks.sort()
-            for nick in nicks:
-                values.append(self._buildOccupantMarkup(jid.newResource(self.target, nick)))
-            self.occupants_list.changeValues(values)
-        else:  # add, remove or update only one occupant
-            nick = entity.resource
-            show = contact_list.getCache(entity, C.PRESENCE_SHOW)
-            if show == C.PRESENCE_UNAVAILABLE or show is None:
-                try:
-                    self.occupants_list.deleteValue(nick)
-                except ValueError:
-                    pass
-            else:
-                values = self.occupants_list.getAllValues()
-                markup = self._buildOccupantMarkup(entity)
-                if not values:  # room has just been created
-                    values = [markup]
-                else:  # add or update the occupant, keep the list sorted
-                    index = 0
-                    for entry in values:
-                        order = cmp(entry.value if hasattr(entry, 'value') else entry, nick)
-                        if order < 0:
-                            index += 1
-                            continue
-                        if order > 0:  # insert the occupant
-                            values.insert(index, markup)
-                        else:  # update an existing occupant
-                            values[index] = markup
-                        break
-                    if index == len(values):  # add to the end of the list
-                        values.append(markup)
-                self.occupants_list.changeValues(values)
-        self.host.redraw()
+        Can merge messages together is desirable (e.g.: multiple joined/leave)
+        @param message(quick_chat.Message): message to add
+        """
+        if self._user_moved(message):
+            for wid in reversed(self.mess_walker):
+                # we merge in/out messages if no message was sent meanwhile
+                if not isinstance(wid, MessageWidget):
+                    continue
+                if wid.mess_data.type != C.MESS_TYPE_INFO:
+                    break
+                if wid.info_type in quick_chat.ROOM_USER_MOVED and wid.mess_data.nick == message.nick:
+                    try:
+                        count = wid.reentered_count
+                    except AttributeError:
+                        count = wid.reentered_count = 1
+                    nick = wid.mess_data.nick
+                    if message.info_type == quick_chat.ROOM_USER_LEFT:
+                        wid.message = _(u"<= {nick} has left the room ({count})").format(nick=nick, count=count)
+                    else:
+                        wid.message = _(u"<=> {nick} re-entered the room ({count})") .format(nick=nick, count=count)
+                        wid.reentered_count+=1
+                    return
 
-    def _buildOccupantMarkup(self, entity):
-        """Return the option attributes for a MUC occupant.
+        if ((self.host.selected_widget != self or not self.host.x_notify.hasFocus())
+            and self.focus_marker_set is not None):
+            if not self.focus_marker_set and not self._locked and self.mess_walker:
+                if self.focus_marker is not None:
+                    self.mess_walker.remove(self.focus_marker)
+                self.focus_marker = urwid.Divider('—')
+                self.mess_walker.append(self.focus_marker)
+                self.focus_marker_set = True
+        else:
+            if self.focus_marker_set:
+                self.focus_marker_set = False
 
-        @param nick (unicode): occupant nickname
-        """
-        # TODO: for now it's not a markup but a simple text, the problem is that ListOption is unicode and not urwid.Text
-        contact_list = self.host.contact_lists[self.profile]
-        show = contact_list.getCache(entity, C.PRESENCE_SHOW)
-        states = self.getEntityStates(entity)
-        nick = entity.resource
-        show_icon, entity_attr = C.PRESENCE.get(show, (u'', u'default'))  # TODO: use entity_attr and return (nick, markup)
-        text = "%s%s %s" % (u''.join(states.values()), show_icon, nick)
-        return (nick, text)
+        if not message.message:
+            log.error(u"Received an empty message for uid {}".format(message.uid))
+        else:
+            self.mess_walker.append(MessageWidget(message))
+            self.mess_widgets.focus_position = len(self.mess_walker) - 1  # scroll down
+            self.host.redraw()  # FIXME: should not be necessary
+
+    def addUser(self, nick):
+        occupant = super(Chat, self).addUser(nick)
+        bisect.insort(self.occupants_walker, OccupantWidget(occupant))
+
+    def removeUser(self, occupant_data):
+        occupant = super(Chat, self).removeUser(occupant_data)
+        if occupant is not None:
+            for widget in occupant.widgets:
+                self.occupants_walker.remove(widget)
 
     def _occupantsClicked(self, list_wid, clicked_wid):
+        # FIXME: not called anymore after refactoring
         assert self.type == C.CHAT_GROUP
         nick = clicked_wid.getValue().value
         if nick == self.nick:
@@ -274,55 +402,32 @@
 
     def setSubject(self, subject, wrap='space'):
         """Set title for a group chat"""
-        QuickChat.setSubject(self, subject)
-        self.subject = subject
+        quick_chat.QuickChat.setSubject(self, subject)
         self.subj_wid = urwid.Text(unicode(subject.replace('\n', '|') if wrap == 'clip' else subject),
                                    align='left' if wrap == 'clip' else 'center', wrap=wrap)
         self.chat_widget.header = urwid.AttrMap(self.subj_wid, 'title')
         self.host.redraw()
 
-    def clearHistory(self):
-        """Clear the content of this chat."""
-        del self.content[:]
+    ## Messages
 
-    def afterHistoryPrint(self):
+    def updateHistory(self, size=C.HISTORY_LIMIT_DEFAULT, search='', profile='@NONE@'):
+        del self.mess_walker[:]
+        if search:
+            self.mess_walker.append(urwid.Text(_(u"Results for searching the globbing pattern: {}").format(search)))
+            self.mess_walker.append(urwid.Text(_(u"Type ':history <lines>' to reset the chat history").format(search)))
+        super(Chat, self).updateHistory(size, search, profile)
+
+    def _onHistoryPrinted(self):
         """Refresh or scroll down the focus after the history is printed"""
-        if len(self.content):
-            self.text_list.focus_position = len(self.content) - 1  # scroll down
-        self.host.redraw()
+        for message in self.messages.itervalues():
+            self.appendMessage(message)
+        super(Chat, self)._onHistoryPrinted()
 
     def onPrivateCreated(self, widget):
         self.host.contact_lists[widget.profile].specialResourceVisible(widget.target)
 
-    def printMessage(self, nick, my_message, message, timestamp, extra=None, profile=C.PROF_KEY_NONE):
-        """Print message in chat window.
-
-        @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
-        """
-        new_text = ChatText(self, timestamp, nick, my_message, message)
-        self.content.append(new_text)
-        QuickChat.printMessage(self, nick, my_message, message, timestamp, extra, profile)
-
-    def printInfo(self, msg, type_='normal', extra=None):
-        """Print general info
-        @param msg: message to print
-        @type_: one of:
-            normal: general info like "toto has joined the room"
-            me: "/me" information like "/me clenches his fist" ==> "toto clenches his fist"
-        @param timestamp (float): number of seconds since epoch
-        """
-        if extra is None:
-            extra = {}
-        try:
-            timestamp = float(extra['timestamp'])
-        except KeyError:
-            timestamp = None
-        _widget = ChatText(self, timestamp, None, False, msg, is_info=True)
-        self.content.append(_widget)
-        QuickChat.printInfo(self, msg, type_, extra)
+    def onSelected(self):
+        self.focus_marker_set = False
 
     def notify(self, contact="somebody", msg=""):
         """Notify the user of a new message if primitivus doesn't have the focus.
@@ -330,12 +435,13 @@
         @param contact (unicode): contact who wrote to the users
         @param msg (unicode): the message that has been received
         """
+        # FIXME: not called anymore after refactoring
         if msg == "":
             return
-        if self.text_list.get_focus()[1] == len(self.content) - 2:
+        if self.mess_widgets.get_focus()[1] == len(self.mess_walker) - 2:
             # we don't change focus if user is not at the bottom
             # as that mean that he is probably watching discussion history
-            self.text_list.focus_position = len(self.content) - 1
+            self.mess_widgets.focus_position = len(self.mess_walker) - 1
         self.host.redraw()
         if not self.host.x_notify.hasFocus():
             if self.type == C.CHAT_ONE2ONE:
@@ -354,10 +460,11 @@
     # MISC EVENTS #
 
     def onDelete(self):
-        QuickChat.onDelete(self)
+        # FIXME: to be checked after refactoring
+        quick_chat.QuickChat.onDelete(self)
         if self.type == C.CHAT_GROUP:
             self.host.removeListener('presence', self.presenceListener)
 
 
-quick_widgets.register(QuickChat, Chat)
+quick_widgets.register(quick_chat.QuickChat, Chat)
 quick_widgets.register(quick_games.Tarot, game_tarot.TarotGame)