view src/cagou/plugins/plugin_wid_chat.py @ 57:a51ea7874e43

chat: XHTML parsing first draft: a XHTML mini parser is used so rich text can be handled. For now it manages only a few basic elements (bold, italic, color). Body sizing/alignment is currenly broken.
author Goffi <goffi@goffi.org>
date Sun, 25 Sep 2016 16:06:56 +0200
parents 514c187afebc
children 7aa2ffff9067
line wrap: on
line source

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

# Cagou: desktop/mobile frontend for Salut à Toi XMPP client
# Copyright (C) 2016 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 import log as logging
log = logging.getLogger(__name__)
from sat.core.i18n import _
from cagou.core.constants import Const as C
from kivy.uix.gridlayout import GridLayout
from kivy.uix.stacklayout import StackLayout
from kivy.uix.scrollview import ScrollView
from kivy.uix.textinput import TextInput
from kivy.uix.label import Label
from kivy.metrics import dp
from kivy.utils import escape_markup
from kivy import properties
from sat_frontends.quick_frontend import quick_widgets
from sat_frontends.quick_frontend import quick_chat
from sat_frontends.tools import jid, css_color
from cagou.core import cagou_widget
from cagou.core.image import Image
from cagou import G
from xml.etree import ElementTree as ET


PLUGIN_INFO = {
    "name": _(u"chat"),
    "main": "Chat",
    "description": _(u"instant messaging with one person or a group"),
    "icon_small": u"{media}/icons/muchoslava/png/chat_rouge_32.png",
    "icon_medium": u"{media}/icons/muchoslava/png/chat_rouge_44.png"
}


class MessAvatar(Image):
    pass


class Escape(unicode):
    """Class used to mark that a message need to be escaped"""

    def __init__(self, text):
        super(Escape, self).__init__(text)


class SimpleXHTMLWidget(StackLayout):
    """widget handling simple XHTML parsing"""
    xhtml = properties.StringProperty()
    color = properties.ListProperty([1, 1, 1, 1])
    # XXX: bold is only used for escaped text
    bold = properties.BooleanProperty(False)

    def on_xhtml(self, instance, xhtml):
        """parse xhtml and set content accordingly

        if xhtml is an instance of Escape, a Label with not markup
        will be used
        """
        self.clear_widgets()
        if isinstance(xhtml, Escape):
            label = Label(text=xhtml, color=self.color)
            self.bind(color=label.setter('color'))
            self.bind(bold=label.setter('bold'))
            self.add_widget(label)
        else:
            xhtml = ET.fromstring(xhtml.encode('utf-8'))
            self.current_wid = None
            self.styles = []
            self._callParseMethod(xhtml)

    def escape(self, text):
        """mark that a text need to be escaped (i.e. no markup)"""
        return Escape(text)

    # XHTML parsing methods

    def _callParseMethod(self, e):
        """call the suitable method to parse the element

        self.xhtml_[tag] will be called if it exists, else
        self.xhtml_generic will be used
        @param e(ET.Element): element to parse
        """
        try:
            method = getattr(self, "xhtml_{}".format(e.tag))
        except AttributeError:
            log.warning(u"Unhandled XHTML tag: {}".format(e.tag))
            method = self.xhtml_generic
        method(e)

    def _addStyle(self, tag, value=None, append_to_list=True):
        """add a markup style to label

        @param tag(unicode): markup tag
        @param value(unicode): markup value if suitable
        @param append_to_list(bool): if True style we be added to self.styles
            self.styles is needed to keep track of styles to remove
            should most probably be set to True
        """
        if append_to_list:
            self.styles.append((tag, value))
        label = self._getLabel()
        label.text += u'[{tag}{value}]'.format(
            tag = tag,
            value = u'={}'.format(value) if value else ''
            )

    def _removeStyle(self, tag, remove_from_list=True):
        """remove a markup style from the label

        @param tag(unicode): markup tag to remove
        @param remove_from_list(bool): if True, remove from self.styles too
            should most probably be set to True
        """
        label = self._getLabel()
        label.text += u'[/{tag}]'.format(
            tag = tag
            )
        if remove_from_list:
            for rev_idx, style in enumerate(reversed(self.styles)):
                if style[0] == tag:
                    tag_idx = len(self.styles) - 1 - rev_idx
                    del self.styles[tag_idx]
                    break

    def _getLabel(self):
        """get current Label if it exists, or create a new one"""
        if not isinstance(self.current_wid, Label):
            self._addLabel()
        return self.current_wid

    def _addLabel(self):
        """add a new Label

        current styles will be closed and reopened if needed
        """
        self._closeLabel()
        label = Label(color=self.color, markup=True)
        self.current_wid = label
        self.bind(color=self.current_wid.setter('color'))
        label.bind(texture_size=label.setter('size'))
        for tag, value in self.styles:
            self._addStyle(tag, value, append_to_list=False)
        self.add_widget(self.current_wid)

    def _closeLabel(self):
        """close current style tags in current label

        needed when you change label to keep style between
        different widgets
        """
        if isinstance(self.current_wid, Label):
            for tag, value in reversed(self.styles):
                self._removeStyle(tag, remove_from_list=False)

    def _parseCSS(self, e):
        """parse CSS found in "style" attribute of element

        self._css_styles will be created and contained markup styles added by this method
        @param e(ET.Element): element which may have a "style" attribute
        """
        styles_limit = len(self.styles)
        styles = e.attrib['style'].split(u';')
        for style in styles:
            try:
                prop, value = style.split(u':')
            except ValueError:
                log.warning(u"can't parse style: {}".format(style))
                continue
            prop = prop.strip().replace(u'-', '_')
            value = value.strip()
            try:
                method = getattr(self, "css_{}".format(prop))
            except AttributeError:
                log.warning(u"Unhandled CSS: {}".format(prop))
            else:
                method(e, value)
        self._css_styles = self.styles[styles_limit:]

    def _closeCSS(self):
        """removed CSS styles

        styles in self._css_styles will be removed
        and the attribute will be deleted
        """
        for tag, dummy in reversed(self._css_styles):
            self._removeStyle(tag)
        del self._css_styles

    def xhtml_generic(self, elem, style=True, markup=None):
        """generic method for adding HTML elements

        this method handle content, style and children parsing
        @param elem(ET.Element): element to add
        @param style(bool): if True handle style attribute (CSS)
        @param markup(tuple[unicode, (unicode, None)], None): kivy markup to use
        """
        # we first add markup and CSS style
        if markup is not None:
            if isinstance(markup, basestring):
                tag, value = markup, None
            else:
                tag, value = markup
            self._addStyle(tag, value)
        style_ = 'style' in elem.attrib and style
        if style_:
            self._parseCSS(elem)

        # then content
        if elem.text:
            self._getLabel().text += escape_markup(elem.text)

        # we parse the children
        for child in elem:
            self._callParseMethod(child)

        # closing CSS style and markup
        if style_:
            self._closeCSS()
        if markup is not None:
            self._removeStyle(tag)

        # and the tail, which is regular text
        if elem.tail:
            self._getLabel().text += escape_markup(elem.tail)

    # method handling XHTML elements

    def xhtml_br(self, elem):
        label = self._getLabel()
        label.text+='\n'
        self.xhtml_generic(style=False)

    def xhtml_em(self, elem):
        self.xhtml_generic(elem, markup='i')

    def xhtml_p(self, elem):
        self._addLabel()
        self.xhtml_generic(elem)

    def xhtml_span(self, elem):
        self.xhtml_generic(elem)

    def xhtml_strong(self, elem):
        self.xhtml_generic(elem, markup='b')

    # methods handling CSS properties

    def css_color(self, elem, value):
        self._addStyle(u"color", css_color.parse(value))

    def css_text_decoration(self, elem, value):
        if value == u'underline':
            log.warning(u"{} not handled yet, it needs Kivy 1.9.2 to be released".format(value))
            # FIXME: activate when 1.9.2 is out
            # self._addStyle('u')
        elif value == u'line-through':
            log.warning(u"{} not handled yet, it needs Kivy 1.9.2 to be released".format(value))
            # FIXME: activate when 1.9.2 is out
            # self._addStyle('s')
        else:
            log.warning(u"unhandled text decoration: {}".format(value))


class MessageWidget(GridLayout):
    mess_data = properties.ObjectProperty()
    mess_xhtml = properties.ObjectProperty()
    mess_padding = (dp(5), dp(5))
    avatar = properties.ObjectProperty()

    def __init__(self, **kwargs):
        super(MessageWidget, self).__init__(**kwargs)
        self.mess_data.widgets.add(self)

    @property
    def chat(self):
        """return parent Chat instance"""
        return self.mess_data.parent

    @property
    def message(self):
        """Return currently displayed message"""
        return self.mess_data.main_message

    @property
    def message_xhtml(self):
        """Return currently displayed message"""
        return self.mess_data.main_message_xhtml

    def widthAdjust(self):
        """this widget grows up with its children"""
        pass
        # parent = self.mess_xhtml.parent
        # padding_x = self.mess_padding[0]
        # text_width, text_height = self.mess_xhtml.texture_size
        # if text_width > parent.width:
        #     self.mess_xhtml.text_size = (parent.width - padding_x, None)
        #     self.text_max = text_width
        # elif self.mess_xhtml.text_size[0] is not None and text_width  < parent.width - padding_x:
        #     if text_width < self.text_max:
        #         self.mess_xhtml.text_size = (None, None)
        #     else:
        #         self.mess_xhtml.text_size = (parent.width  - padding_x, None)

    def update(self, update_dict):
        if 'avatar' in update_dict:
            self.avatar.source = update_dict['avatar']


class MessageInputWidget(TextInput):

    def _key_down(self, key, repeat=False):
        displayed_str, internal_str, internal_action, scale = key
        if internal_action == 'enter':
            self.dispatch('on_text_validate')
        else:
            super(MessageInputWidget, self)._key_down(key, repeat)


class MessagesWidget(GridLayout):
    pass


class Chat(quick_chat.QuickChat, cagou_widget.CagouWidget):

    def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None, subject=None, profiles=None):
        quick_chat.QuickChat.__init__(self, host, target, type_, nick, occupants, subject, profiles=profiles)
        cagou_widget.CagouWidget.__init__(self)
        self.header_input.hint_text = u"You are talking with {}".format(target)
        scroll_view = ScrollView(size_hint=(1,0.8), scroll_y=0, do_scroll_x=False)
        self.messages_widget = MessagesWidget()
        scroll_view.add_widget(self.messages_widget)
        self.add_widget(scroll_view)
        message_input = MessageInputWidget()
        message_input.bind(on_text_validate=self.onSend)
        self.add_widget(message_input)
        self.postInit()

    @classmethod
    def factory(cls, plugin_info, target, profiles):
        profiles = list(profiles)
        if len(profiles) > 1:
            raise NotImplementedError(u"Multi-profiles is not available yet for chat")
        if target is None:
            target = G.host.profiles[profiles[0]].whoami
        return G.host.widgets.getOrCreateWidget(cls, target, on_new_widget=None, on_existing_widget=C.WIDGET_RECREATE, profiles=profiles)

    def messageDataConverter(self, idx, mess_id):
        return {"mess_data": self.messages[mess_id]}

    def _onHistoryPrinted(self):
        """Refresh or scroll down the focus after the history is printed"""
        # self.adapter.data = self.messages
        for mess_data in self.messages.itervalues():
            self.appendMessage(mess_data)
        super(Chat, self)._onHistoryPrinted()

    def createMessage(self, message):
        self.appendMessage(message)

    def appendMessage(self, mess_data):
        self.messages_widget.add_widget(MessageWidget(mess_data=mess_data))

    def onSend(self, input_widget):
        G.host.messageSend(
            self.target,
            {'': input_widget.text}, # TODO: handle language
            mess_type = C.MESS_TYPE_GROUPCHAT if self.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT, # TODO: put this in QuickChat
            profile_key=self.profile
            )
        input_widget.text = ''

    def _mucJoinCb(self, joined_data):
        joined, room_jid_s, occupants, user_nick, subject, profile = joined_data
        self.host.mucRoomJoinedHandler(*joined_data[1:])
        jid_ = jid.JID(room_jid_s)
        self.changeWidget(jid_)

    def _mucJoinEb(self, failure):
        log.warning(u"Can't join room: {}".format(failure))

    def changeWidget(self, jid_):
        """change current widget for a new one with given jid

        @param jid_(jid.JID): jid of the widget to create
        """
        plugin_info = G.host.getPluginInfo(main=Chat)
        factory = plugin_info['factory']
        G.host.switchWidget(self, factory(plugin_info, jid_, profiles=[self.profile]))
        self.header_input.text = ''

    def onHeaderInput(self):
        text = self.header_input.text.strip()
        try:
            if text.count(u'@') != 1 or text.count(u' '):
                raise ValueError
            jid_ = jid.JID(text)
        except ValueError:
            log.info(u"entered text is not a jid")
            return

        def discoCb(disco):
            # TODO: check if plugin XEP-0045 is activated
            if "conference" in [i[0] for i in disco[1]]:
                G.host.bridge.mucJoin(unicode(jid_), "", "", self.profile, callback=self._mucJoinCb, errback=self._mucJoinEb)
            else:
                self.changeWidget(jid_)

        def discoEb(failure):
            log.warning(u"Disco failure, ignore this text: {}".format(failure))

        G.host.bridge.discoInfos(jid_.domain, self.profile, callback=discoCb, errback=discoEb)

    def onDelete(self, force=False):
        if force==True:
            return super(Chat, self).onDelete()
        if len(list(G.host.widgets.getWidgets(self.__class__, self.target, profiles=self.profiles))) > 1:
            # we don't keep duplicate widgets
            return super(Chat, self).onDelete()
        return False


PLUGIN_INFO["factory"] = Chat.factory
quick_widgets.register(quick_chat.QuickChat, Chat)