view frontends/src/primitivus/chat.py @ 1596:b7ee113183fc

jp: better profile commands: - new "profile/default" command - info doesn't show password anymore by default, need to be explicitly requested - info and modify don't need to connect anymore - modify can now set default profile. As use_profile is set, at least a profile session need to be started when it would not be mandatory technicaly (if just setting the profile as default is needed). But this option should not be used often, and it's not a big side effect, so I don't feel the need to create a new dedicated command, or to do complicated checks to avoid the session start.
author Goffi <goffi@goffi.org>
date Sat, 14 Nov 2015 19:18:10 +0100
parents 641cfd2faefe
children 95ea323e7d04
line wrap: on
line source

#!/usr/bin/python
# -*- coding: utf-8 -*-

# Primitivus: a SAT frontend
# Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014, 2015 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 import log as logging
log = logging.getLogger(__name__)
import urwid
from urwid_satext import sat_widgets
# from urwid_satext.files_management import FileDialog # FIXME
from sat_frontends.quick_frontend import quick_widgets
from sat_frontends.quick_frontend.quick_chat import QuickChat
from sat_frontends.quick_frontend import quick_games
from sat_frontends.primitivus import game_tarot
from sat_frontends.primitivus.constants import Const as C
from sat_frontends.primitivus.keys import action_key_map as a_key
from sat_frontends.primitivus.widget import PrimitivusWidget
import time
from sat_frontends.tools import jid


class ChatText(urwid.FlowWidget):
    """Manage the printing of chat message"""

    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

    def selectable(self):
        return True

    def keypress(self, size, key):
        return key

    def rows(self, size, focus=False):
        return self.display_widget(size, focus).rows(size, focus)

    def render(self, size, focus=False):
        canvas = urwid.CompositeCanvas(self.display_widget(size, focus).render(size, focus))
        if focus:
            canvas.set_cursor(self.get_cursor_coords(size))
        return canvas

    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


class Chat(PrimitivusWidget, 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)
        self.chat_colums = urwid.Columns([('weight', 8, self.chat_widget)])
        self.chat_colums = urwid.Columns([('weight', 8, self.chat_widget)])
        self.pile = urwid.Pile([self.chat_colums])
        PrimitivusWidget.__init__(self, self.pile, self.target)

        # we must adapt the behaviour with the type
        if type_ == C.CHAT_ONE2ONE:
            self.historyPrint(profile=self.profile)
        elif 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._appendOccupantsPanel()
                self.host.addListener('presence', self.presenceListener, [profiles])

        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

    def keypress(self, size, key):
        if key == a_key['OCCUPANTS_HIDE']:  # user wants to (un)hide the occupants panel
            if self.type == C.CHAT_GROUP:
                widgets = [widget for (widget, options) in self.chat_colums.contents]
                if self.occupants_panel in widgets:
                    self._removeOccupantsPanel()
                else:
                    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:
                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:
                wid._invalidate()
        elif key == a_key['SUBJECT_SWITCH']:  # user wants to (un)hide group's subject or change its apperance
            if self.subject:
                self.show_title = (self.show_title + 1) % 3
                if self.show_title == 0:
                    self.setSubject(self.subject, 'clip')
                elif self.show_title == 1:
                    self.setSubject(self.subject, 'space')
                elif self.show_title == 2:
                    self.chat_widget.header = None
                self._invalidate()

        return super(Chat, self).keypress(size, key)

    def getMenu(self):
        """Return Menu bar"""
        menu = sat_widgets.Menu(self.host.loop)
        if self.type == C.CHAT_GROUP:
            self.host.addMenus(menu, C.MENU_ROOM, {'room_jid': self.target.bare})
            game = _("Game")
            menu.addMenu(game, "Tarot", self.onTarotRequest)
        elif self.type == C.CHAT_ONE2ONE:
            self.host.addMenus(menu, C.MENU_SINGLE, {'jid': self.target})
            menu.addMenu(_("Action"), _("Send file"), self.onSendFileRequest)
        return menu

    def presenceListener(self, entity, show, priority, statuses, profile):
        """Update entity's presence status

        @param entity (jid.JID): entity updated
        @param show: availability
        @param priority: resource's priority
        @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)

    def update(self, entity=None):
        """Update one or all entities.

        @param entity (jid.JID): entity to update
        """
        contact_list = self.host.contact_lists[self.profile]

        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

        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()

    def _buildOccupantMarkup(self, entity):
        """Return the option attributes for a MUC occupant.

        @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)

    def _occupantsClicked(self, list_wid, clicked_wid):
        assert self.type == C.CHAT_GROUP
        nick = clicked_wid.getValue().value
        if nick == self.nick:
            # We ignore clicks on our own nick
            return
        contact_list = self.host.contact_lists[self.profile]
        full_jid = jid.JID("%s/%s" % (self.target.bare, nick))

        # we have a click on a nick, we need to create the widget if it doesn't exists
        self.getOrCreatePrivateWidget(full_jid)

        # now we select the new window
        contact_list.setFocus(full_jid, True)

    def _appendOccupantsPanel(self):
        self.chat_colums.contents.append((self.occupants_panel, ('weight', 2, False)))

    def _removeOccupantsPanel(self):
        for widget, options in self.chat_colums.contents:
            if widget is self.occupants_panel:
                self.chat_colums.contents.remove((widget, options))
                break

    def addGamePanel(self, widget):
        """Insert a game panel to this Chat dialog.

        @param widget (Widget): the game panel
        """
        assert (len(self.pile.contents) == 1)
        self.pile.contents.insert(0, (widget, ('weight', 1)))
        self.pile.contents.insert(1, (urwid.Filler(urwid.Divider('-'), ('fixed', 1))))
        self.host.redraw()

    def removeGamePanel(self, widget):
        """Remove the game panel from this Chat dialog.

        @param widget (Widget): the game panel
        """
        assert (len(self.pile.contents) == 3)
        del self.pile.contents[0]
        self.host.redraw()

    def setSubject(self, subject, wrap='space'):
        """Set title for a group chat"""
        QuickChat.setSubject(self, subject)
        self.subject = 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[:]

    def afterHistoryPrint(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()

    def onPrivateCreated(self, widget):
        self.host.contact_lists[widget.profile].specialResourceVisible(widget.target)

    def printMessage(self, from_jid, msg, extra=None, profile=C.PROF_KEY_NONE):
        assert isinstance(from_jid, jid.JID)
        if extra is None:
            extra = {}
        try:
            timestamp = float(extra['timestamp'])
        except KeyError:
            timestamp = None
        try:
            nick, mymess = QuickChat.printMessage(self, from_jid, msg, extra, profile)
        except TypeError:
            # None is returned, the message is managed
            return
        new_text = ChatText(self, timestamp, nick, mymess, msg)

        if timestamp and self.content:
            for idx in range(len(self.content) - 1, -1, -1):
                current_text = self.content[idx]
                older = new_text.timestamp < current_text.timestamp
                if older and idx > 0:
                    continue  # the new message is older, we need to insert it upper

                # we discard double messages, to avoid backlog / history conflict
                # FIXME: messages that have been sent several times will be displayed only once
                if ((idx and self.content[idx - 1].message == msg) or
                    (self.content[idx].message == msg) or
                    (idx < len(self.content) - 2 and self.content[idx + 1].message)):
                    return

                self.content.insert(0 if older else idx + 1, new_text)
                break
        else:
            self.content.append(new_text)
        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(from_jid, msg)

    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)
        self._notify(msg=msg)

    def _notify(self, from_jid="somebody", msg=""):
        """Notify the user of a new message if primitivus doesn't have the focus.
        @param from_jid: contact who wrote to the users
        @param msg: the message that has been received
        """
        if msg == "":
            return
        if self.text_list.get_focus()[1] == len(self.content) - 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.host.redraw()
        if not self.host.x_notify.hasFocus():
            if self.type == C.CHAT_ONE2ONE:
                self.host.x_notify.sendNotification(_("Primitivus: %s is talking to you") % from_jid)
            elif self.nick is not None and self.nick.lower() in msg.lower():
                self.host.x_notify.sendNotification(_("Primitivus: %(user)s mentioned you in room '%(room)s'") % {'user': from_jid, 'room': self.target})

    # MENU EVENTS #
    def onTarotRequest(self, menu):
        # TODO: move this to plugin_misc_tarot with dynamic menu
        if len(self.occupants) != 4:
            self.host.showPopUp(sat_widgets.Alert(_("Can't start game"), _("You need to be exactly 4 peoples in the room to start a Tarot game"), ok_cb=self.host.removePopUp))
        else:
            self.host.bridge.tarotGameCreate(self.target, list(self.occupants), self.profile)

    def onSendFileRequest(self, menu):
        # TODO: move this to core with dynamic menus
        pass
        # dialog = FileDialog(ok_cb=self.onFileSelected, cancel_cb=self.host.removePopUp) # FIXME: to be removed
        # self.host.showPopUp(dialog, 80, 80)

    # MISC EVENTS #
    # FIXME: to be removed
    # def onFileSelected(self, filepath):
    #     self.host.removePopUp()
    #     try:
    #         filepath = filepath.decode('utf-8')  # FIXME: correctly manage unicode
    #     except UnicodeError:
    #         log.error("FIXME: filepath with unicode error are not managed yet")
    #         self.host.showDialog(_(u"File has a unicode error in its name, it's not yet managed by SàT"), title=_("Can't send file"), type_="error")
    #         return
    #     # FIXME: check last_resource: what if self.target.resource exists ?
    #     last_resource = self.host.bridge.getMainResource(unicode(self.target.bare), self.profile)
    #     if last_resource:
    #         full_jid = jid.JID("%s/%s" % (self.target.bare, last_resource))
    #     else:
    #         full_jid = self.target
    #     progress_id = self.host.bridge.sendFile(full_jid, filepath, {}, self.profile)
    #     self.host.addProgress(progress_id, filepath)
    #     self.host.showDialog(_(u"You file request has been sent, we are waiting for your contact answer"), title=_("File request sent"))

    def onDelete(self):
        QuickChat.onDelete(self)
        if self.type == C.CHAT_GROUP:
            self.host.removeListener('presence', self.presenceListener)


quick_widgets.register(QuickChat, Chat)
quick_widgets.register(quick_games.Tarot, game_tarot.TarotGame)