view sat_frontends/quick_frontend/quick_chat.py @ 2845:42380a4f6433

quick frontend (app): new synchronisation mechanism: on some frontend (notably on mobiles) frontend can be paused and lose synchronisation with backend, this can also happen if backend restart a connexion without resuming using stream management. To manage that, QuickApp now has a sync attribute which is True when the backend is in sync with frontend. When synchronisation has been lost and is possible again, all widget will have their "resync" method called, which can be used to do necessary calls to update the status. It's the responsability of each widget to then reset the "sync" attribute, once everything has been resynchronised.
author Goffi <goffi@goffi.org>
date Sat, 09 Mar 2019 16:33:31 +0100
parents 76f714b12d21
children c055a3a4ecb0
line wrap: on
line source

#!/usr/bin/env python2
# -*- coding: utf-8 -*-

# helper class for making a SAT frontend
# Copyright (C) 2009-2019 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.log import getLogger
from sat.tools.common import data_format
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 sat_frontends.tools import jid
import time
log = getLogger(__name__)

try:
    from locale import getlocale
except ImportError:
    # FIXME: pyjamas workaround
    getlocale = lambda x: (None, "utf-8")


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
    unicode("")  # XXX: unicode doesn't exist in pyjamas
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)
        self._status = None
        # 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)
        )
        # is user mentioned here ?
        if self.parent.type == C.CHAT_GROUP and not self.own_mess:
            for m in msg.itervalues():
                if self.parent.nick.lower() in m.lower():
                    self._mention = True
                    break
        self.handleMe()
        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")

    @property
    def mention(self):
        try:
            return self._mention
        except AttributeError:
            return False

    @property
    def history(self):
        """True if message come from history"""
        return self.extra.get("history", False)

    @property
    def main_message(self):
        """currently displayed message"""
        if self.parent.lang in self.message:
            self.selected_lang = self.parent.lang
            return self.message[self.parent.lang]
        try:
            self.selected_lang = ""
            return self.message[""]
        except KeyError:
            try:
                lang, mess = self.message.iteritems().next()
                self.selected_lang = lang
                return mess
            except StopIteration:
                log.error(u"Can't find message for uid {}".format(self.uid))
                return ""

    @property
    def main_message_xhtml(self):
        """rich message"""
        xhtml = {k: v for k, v in self.extra.iteritems() if "html" in k}
        if xhtml:
            # FIXME: we only return first found value for now
            return next(xhtml.itervalues())

    @property
    def time_text(self):
        """Return timestamp in a nicely formatted way"""
        # if the message was sent before today, we print the full date
        timestamp = time.localtime(self.timestamp)
        time_format = u"%c" if timestamp < self.parent.day_change else u"%H:%M"
        return time.strftime(time_format, timestamp).decode(getlocale()[1] or "utf-8")

    @property
    def avatar(self):
        """avatar full path or None if no avatar is found"""
        ret = self.host.getAvatar(self.from_jid, profile=self.profile)
        return ret

    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 ""
        # FIXME: converted getSpecials to list for pyjamas
        if self.parent.type == C.CHAT_GROUP or entity in list(
            contact_list.getSpecials(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

    @property
    def status(self):
        return self._status

    @status.setter
    def status(self, status):
        if status != self._status:
            self._status = status
            for w in self.widgets:
                w.update({"status": status})

    def handleMe(self):
        """Check if messages starts with "/me " and change them if it is the case

        if several messages (different languages) are presents, they all need to start with "/me "
        """
        # TODO: XHTML-IM /me are not handled
        me = False
        # we need to check /me for every message
        for m in self.message.itervalues():
            if m.startswith(u"/me "):
                me = True
            else:
                me = False
                break
        if me:
            self.type = C.MESS_TYPE_INFO
            self.extra["info_type"] = "me"
            nick = self.nick
            for lang, mess in self.message.iteritems():
                self.message[lang] = u"* " + nick + mess[3:]


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")
        self.affiliation = data["affiliation"]
        self.role = data["role"]
        self.widgets = set()  # widgets linked to this occupant
        self._state = None

    @property
    def data(self):
        """reconstruct data dict from attributes"""
        data = {}
        data["nick"] = self.nick
        if self._entity is not None:
            data["entity"] = self._entity
        data["affiliation"] = self.affiliation
        data["role"] = self.role
        return data

    @property
    def jid(self):
        """jid in the room"""
        return jid.JID(u"{}/{}".format(self.parent.target.bare, self.nick))

    @property
    def real_jid(self):
        """real jid if known else None"""
        return self._entity

    @property
    def host(self):
        return self.parent.host

    @property
    def state(self):
        return self._state

    @state.setter
    def state(self, new_state):
        if new_state != self._state:
            self._state = new_state
            for w in self.widgets:
                w.update({"state": new_state})

    def update(self, update_dict=None):
        for w in self.widgets:
            w.update(update_dict)


class QuickChat(quick_widgets.QuickWidget):
    visible_states = ["chat_state"]  # FIXME: to be removed, used only in quick_games

    def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, 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 = True  # True when we are waiting for history/search
        # messageNew signals are cached when locked
        self._cache = OrderedDict()
        assert type_ in (C.CHAT_ONE2ONE, C.CHAT_GROUP)
        self.current_target = target
        self.type = type_
        self.encrypted = False  # True if this session is currently encrypted
        if type_ == C.CHAT_GROUP:
            if target.resource:
                raise exceptions.InternalError(
                    u"a group chat entity can't have a resource"
                )
            if nick is None:
                raise exceptions.InternalError(u"nick must not be None for group chat")

            self.nick = nick
            self.occupants = {}
            self.setOccupants(occupants)
        else:
            if occupants is not None or nick is not None:
                raise exceptions.InternalError(
                    u"only group chat can have occupants or nick"
                )
        self.messages = OrderedDict()  # key: uid, value: Message instance
        self.games = {}  # key=game name (unicode), value=instance of quick_games.RoomGame
        self.subject = subject
        lt = time.localtime()
        self.day_change = (
            lt.tm_year,
            lt.tm_mon,
            lt.tm_mday,
            0,
            0,
            0,
            lt.tm_wday,
            lt.tm_yday,
            lt.tm_isdst,
        )  # struct_time of day changing time
        if self.host.AVATARS_HANDLER:
            self.host.addListener("avatar", self.onAvatar, profiles)

    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)
        if self.host.ENCRYPTION_HANDLERS:
            self.getEncryptionState()

    def onDelete(self):
        if self.host.AVATARS_HANDLER:
            self.host.removeListener("avatar", self.onAvatar)

    @property
    def contact_list(self):
        return self.host.contact_lists[self.profile]

    ## Widget management ##

    def __str__(self):
        return u"Chat Widget [target: {}, type: {}, profile: {}]".format(
            self.target, self.type, self.profile
        )

    @staticmethod
    def getWidgetHash(target, profiles):
        profile = list(profiles)[0]
        return profile + "\n" + unicode(target.bare)

    @staticmethod
    def getPrivateHash(target, profile):
        """Get unique hash for private conversations

        This method should be used with force_hash to get unique widget for private MUC conversations
        """
        return (unicode(profile), target)

    def addTarget(self, target):
        super(QuickChat, self).addTarget(target)
        if target.resource:
            self.current_target = (
                target
            )  # FIXME: tmp, must use resource priority throught contactList instead

    def recreateArgs(self, args, kwargs):
        """copy important attribute for a new widget"""
        kwargs["type_"] = self.type
        if self.type == C.CHAT_GROUP:
            kwargs["occupants"] = {o.nick: o.data for o in self.occupants.itervalues()}
        kwargs["subject"] = self.subject
        try:
            kwargs["nick"] = self.nick
        except AttributeError:
            pass

    def onPrivateCreated(self, widget):
        """Method called when a new widget for private conversation (MUC) is created"""
        raise NotImplementedError

    def getOrCreatePrivateWidget(self, entity):
        """Create a widget for private conversation, or get it if it already exists

        @param entity: full jid of the target
        """
        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

    @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:
            occupant = self.occupants.pop(nick)
        except KeyError:
            log.warning(u"Trying to remove an unknown occupant: {}".format(nick))
        else:
            return occupant

    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"""
        log.info("{old} is now known as {new} in room {room_jid}".format(
            old = old_nick,
            new = new_nick,
            room_jid = self.target))

    ## 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:
            if mess_type != C.MESS_TYPE_GROUPCHAT and entity in self.targets:
                return True
        return False

    def updateHistory(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, 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 filters (str): patterns to filter the history results
        @param profile (str): %(doc_profile)s
        """
        self._locked = True
        self._cache = OrderedDict()
        self.messages.clear()
        self.historyPrint(size, filters, profile)

    def _onHistoryPrinted(self):
        """Method called when history is printed (or failed)

        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.itervalues():
            self.messageNew(*data)
        del self._cache

    def historyPrint(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, 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
        """
        if filters is None:
            filters = {}
        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)

        if self.type == C.CHAT_ONE2ONE:
            special = self.host.contact_lists[self.profile].getCache(
                self.target, C.CONTACT_SPECIAL, create_if_not_found=True
            )
            if special == C.CONTACT_SPECIAL_GROUP:
                # we have a private conversation
                # so we need full jid for the history
                # (else we would get history from group itself)
                # and to filter out groupchat message
                target = self.target
                filters["not_types"] = C.MESS_TYPE_GROUPCHAT
            else:
                target = self.target.bare
        else:
            # groupchat
            target = self.target.bare
            # FIXME: info not handled correctly
            filters["types"] = C.MESS_TYPE_GROUPCHAT

        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
                # cached messages may already be in history
                # so we check it to avoid duplicates, they'll be added later
                if uid in self._cache:
                    continue
                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
                extra["history"] = True
                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: {}").format(err))
            self._onHistoryPrinted()

        self.host.bridge.historyGet(
            unicode(self.host.profiles[profile].whoami.bare),
            unicode(target),
            size,
            True,
            filters,
            profile,
            callback=_historyGetCb,
            errback=_historyGetEb,
        )

    def messageEncryptionGetCb(self, session_data):
        if session_data:
            session_data = data_format.deserialise(session_data)
            self.messageEncryptionStarted(session_data)

    def messageEncryptionGetEb(self, failure_):
        log.error(_(u"Can't get encryption state: {reason}").format(reason=failure_))

    def getEncryptionState(self):
        """Retrieve encryption state with current target.

        Once state is retrieved, default messageEncryptionStarted will be called if
        suitable
        """
        if self.type == C.CHAT_GROUP:
            return
        self.host.bridge.messageEncryptionGet(unicode(self.target.bare), self.profile,
                                              callback=self.messageEncryptionGetCb,
                                              errback=self.messageEncryptionGetEb)


    def messageNew(self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra,
                   profile):
        if self._locked:
            self._cache[uid] = (
                uid,
                timestamp,
                from_jid,
                to_jid,
                msg,
                subject,
                type_,
                extra,
                profile,
            )
            return

        if not msg and not subject and type_ != C.MESS_TYPE_INFO:
            log.warning(u"Received an empty message for uid {}".format(uid))
            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)

        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 messageEncryptionStarted(self, session_data):
        self.encrypted = True
        log.debug(_(u"message encryption started with {target} using {encryption}").format(
            target=self.target, encryption=session_data[u'name']))

    def messageEncryptionStopped(self, session_data):
        self.encrypted = False
        log.debug(_(u"message encryption stopped with {target} (was using {encryption})")
                 .format(target=self.target, encryption=session_data[u'name']))

    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
        """
        raise NotImplementedError

    def printDayChange(self, day):
        """Display the day on a new line.

        @param day(unicode): day to display (or not if this method is not overwritten)
        """
        # FIXME: not called anymore after refactoring
        pass

    ## Room ##

    def setSubject(self, subject):
        """Set title for a group chat"""
        if self.type != C.CHAT_GROUP:
            raise exceptions.InternalError(
                "trying to set subject for a non group chat window"
            )
        self.subject = subject

    def changeSubject(self, new_subject):
        """Change the subject of the room

        This change the subject on the room itself (i.e. via XMPP),
        while setSubject change the subject of this widget
        """
        self.host.bridge.mucSubject(unicode(self.target), new_subject, self.profile)

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

        @param widget (Widget): the game panel
        """
        raise NotImplementedError

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

        @param widget (Widget): the game panel
        """
        raise NotImplementedError

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

        @param entity (jid.JID): entity to update
        """
        # FIXME: to remove ?
        raise NotImplementedError

    ## events ##

    def onChatState(self, from_jid, state, profile):
        """A chat state has been received"""
        if self.type == C.CHAT_GROUP:
            nick = from_jid.resource
            try:
                self.occupants[nick].state = state
            except KeyError:
                log.warning(
                    u"{nick} not found in {room}, ignoring new chat state".format(
                        nick=nick, room=self.target.bare
                    )
                )

    def onMessageState(self, uid, status, profile):
        try:
            mess_data = self.messages[uid]
        except KeyError:
            pass
        else:
            mess_data.status = status

    def onAvatar(self, entity, filename, profile):
        if self.type == C.CHAT_GROUP:
            if entity.bare == self.target:
                try:
                    self.occupants[entity.resource].update({"avatar": filename})
                except KeyError:
                    # can happen for a message in history where the
                    # entity is not here anymore
                    pass

                for m in self.messages.values():
                    if m.nick == entity.resource:
                        for w in m.widgets:
                            w.update({"avatar": filename})
        else:
            if (
                entity.bare == self.target.bare
                or entity.bare == self.host.profiles[profile].whoami.bare
            ):
                log.info(u"avatar updated for {}".format(entity))
                for m in self.messages.values():
                    if m.from_jid.bare == entity.bare:
                        for w in m.widgets:
                            w.update({"avatar": filename})


quick_widgets.register(QuickChat)