Mercurial > libervia-backend
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)