diff src/browser/sat_browser/panels.py @ 467:97c72fe4a5f2

browser_side: import fixes: - moved browser modules in a sat_browser packages, to avoid import conflicts with std lib (e.g. logging), and let pyjsbuild work normaly - refactored bad import practices: classes are most of time not imported directly, module is imported instead.
author Goffi <goffi@goffi.org>
date Mon, 09 Jun 2014 22:15:26 +0200
parents src/browser/panels.py@b62c1cf0dbf7
children 992b900ab876
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/browser/sat_browser/panels.py	Mon Jun 09 22:15:26 2014 +0200
@@ -0,0 +1,1419 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# Libervia: a Salut à Toi frontend
+# Copyright (C) 2011, 2012, 2013, 2014 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/>.
+
+import pyjd  # this is dummy in pyjs
+from sat.core.log import getLogger
+log = getLogger(__name__)
+
+from sat_frontends.tools.strings import addURLToText
+from sat_frontends.tools.games import SYMBOLS
+from sat.core.i18n import _
+
+from pyjamas.ui.SimplePanel import SimplePanel
+from pyjamas.ui.AbsolutePanel import AbsolutePanel
+from pyjamas.ui.VerticalPanel import VerticalPanel
+from pyjamas.ui.HorizontalPanel import HorizontalPanel
+from pyjamas.ui.HTMLPanel import HTMLPanel
+from pyjamas.ui.Frame import Frame
+from pyjamas.ui.TextArea import TextArea
+from pyjamas.ui.Label import Label
+from pyjamas.ui.Button import Button
+from pyjamas.ui.HTML import HTML
+from pyjamas.ui.Image import Image
+from pyjamas.ui.ClickListener import ClickHandler
+from pyjamas.ui.FlowPanel import FlowPanel
+from pyjamas.ui.KeyboardListener import KEY_ENTER, KEY_UP, KEY_DOWN, KeyboardHandler
+from pyjamas.ui.MouseListener import MouseHandler
+from pyjamas.ui.FocusListener import FocusHandler
+from pyjamas.Timer import Timer
+from pyjamas import DOM
+from pyjamas import Window
+from __pyjamas__ import doc
+
+from datetime import datetime
+from time import time
+
+import jid
+import html_tools
+import base_panels
+import card_game
+import radiocol
+import menu
+import dialog
+import base_widget
+import richtext
+import contact
+from constants import Const as C
+import plugin_xep_0085
+
+
+# TODO: at some point we should decide which behaviors to keep and remove these two constants
+TOGGLE_EDITION_USE_ICON = False  # set to True to use an icon inside the "toggle syntax" button
+NEW_MESSAGE_USE_BUTTON = False  # set to True to display the "New message" button instead of an empty entry
+
+
+class UniBoxPanel(HorizontalPanel):
+    """Panel containing the UniBox"""
+
+    def __init__(self, host):
+        HorizontalPanel.__init__(self)
+        self.host = host
+        self.setStyleName('uniBoxPanel')
+        self.unibox = None
+
+    def refresh(self):
+        """Enable or disable this panel. Contained widgets are created when necessary."""
+        enable = self.host.getUIParam('unibox')
+        self.setVisible(enable)
+        if enable and not self.unibox:
+            self.button = Button('<img src="media/icons/tango/actions/32/format-text-italic.png" class="richTextIcon"/>')
+            self.button.setTitle('Open the rich text editor')
+            self.button.addStyleName('uniBoxButton')
+            self.add(self.button)
+            self.unibox = UniBox(self.host)
+            self.add(self.unibox)
+            self.setCellWidth(self.unibox, '100%')
+            self.button.addClickListener(self.openRichMessageEditor)
+            self.unibox.addKey("@@: ")
+            self.unibox.onSelectedChange(self.host.getSelected())
+
+    def openRichMessageEditor(self):
+        """Open the rich text editor."""
+        self.button.setVisible(False)
+        self.unibox.setVisible(False)
+        self.setCellWidth(self.unibox, '0px')
+        self.host.panel._contactsMove(self)
+
+        def afterEditCb():
+            Window.removeWindowResizeListener(self)
+            self.host.panel._contactsMove(self.host.panel._hpanel)
+            self.setCellWidth(self.unibox, '100%')
+            self.button.setVisible(True)
+            self.unibox.setVisible(True)
+            self.host.resize()
+
+        richtext.RichMessageEditor.getOrCreate(self.host, self, afterEditCb)
+        Window.addWindowResizeListener(self)
+        self.host.resize()
+
+    def onWindowResized(self, width, height):
+        right = self.host.panel.menu.getAbsoluteLeft() + self.host.panel.menu.getOffsetWidth()
+        left = self.host.panel._contacts.getAbsoluteLeft() + self.host.panel._contacts.getOffsetWidth()
+        ideal_width = right - left - 40
+        self.host.richtext.setWidth("%spx" % ideal_width)
+
+
+class MessageBox(TextArea):
+    """A basic text area for entering messages"""
+
+    def __init__(self, host):
+        TextArea.__init__(self)
+        self.host = host
+        self.__size = (0, 0)
+        self.setStyleName('messageBox')
+        self.addKeyboardListener(self)
+        MouseHandler.__init__(self)
+        self.addMouseListener(self)
+        self._selected_cache = None
+
+    def onBrowserEvent(self, event):
+        # XXX: woraroung a pyjamas bug: self.currentEvent is not set
+        #     so the TextBox's cancelKey doens't work. This is a workaround
+        #     FIXME: fix the bug upstream
+        self.currentEvent = event
+        TextArea.onBrowserEvent(self, event)
+
+    def onKeyPress(self, sender, keycode, modifiers):
+        _txt = self.getText()
+
+        def history_cb(text):
+            self.setText(text)
+            Timer(5, lambda timer: self.setCursorPos(len(text)))
+
+        if keycode == KEY_ENTER:
+            if _txt:
+                self._selected_cache.onTextEntered(_txt)
+                self.host._updateInputHistory(_txt)
+            self.setText('')
+            sender.cancelKey()
+        elif keycode == KEY_UP:
+            self.host._updateInputHistory(_txt, -1, history_cb)
+        elif keycode == KEY_DOWN:
+            self.host._updateInputHistory(_txt, +1, history_cb)
+        else:
+            self.__onComposing()
+
+    def __onComposing(self):
+        """Callback when the user is composing a text."""
+        if hasattr(self._selected_cache, "target"):
+            self._selected_cache.state_machine._onEvent("composing")
+
+    def onMouseUp(self, sender, x, y):
+        size = (self.getOffsetWidth(), self.getOffsetHeight())
+        if size != self.__size:
+            self.__size = size
+            self.host.resize()
+
+    def onSelectedChange(self, selected):
+        self._selected_cache = selected
+
+
+class UniBox(MessageBox, MouseHandler):  # AutoCompleteTextBox):
+    """This text box is used as a main typing point, for message, microblog, etc"""
+
+    def __init__(self, host):
+        MessageBox.__init__(self, host)
+        #AutoCompleteTextBox.__init__(self)
+        self.setStyleName('uniBox')
+        host.addSelectedListener(self.onSelectedChange)
+
+    def addKey(self, key):
+        return
+        #self.getCompletionItems().completions.append(key)
+
+    def removeKey(self, key):
+        return
+        # TODO: investigate why AutoCompleteTextBox doesn't work here,
+        # maybe it can work on a TextBox but no TextArea. Remove addKey
+        # and removeKey methods if they don't serve anymore.
+        try:
+            self.getCompletionItems().completions.remove(key)
+        except KeyError:
+            log.warning("trying to remove an unknown key")
+
+    def _getTarget(self, txt):
+        """ Say who will receive the messsage
+        @return: a tuple (selected, target_type, target info) with:
+            - target_hook: None if we use the selected widget, (msg, data) if we have a hook (e.g. "@@: " for a public blog), where msg is the parsed message (i.e. without the "hook key: "@@: bla" become ("bla", None))
+            - target_type: one of PUBLIC, GROUP, ONE2ONE, STATUS, MISC
+            - msg: HTML message which will appear in the privacy warning banner """
+        target = self._selected_cache
+
+        def getSelectedOrStatus():
+            if target and target.isSelectable():
+                _type, msg = target.getWarningData()
+                target_hook = None  # we use the selected widget, not a hook
+            else:
+                _type, msg = "STATUS", "This will be your new status message"
+                target_hook = (txt, None)
+            return (target_hook, _type, msg)
+
+        if not txt.startswith('@'):
+            target_hook, _type, msg = getSelectedOrStatus()
+        elif txt.startswith('@@: '):
+            _type = "PUBLIC"
+            msg = MicroblogPanel.warning_msg_public
+            target_hook = (txt[4:], None)
+        elif txt.startswith('@'):
+            _end = txt.find(': ')
+            if _end == -1:
+                target_hook, _type, msg = getSelectedOrStatus()
+            else:
+                group = txt[1:_end]  # only one target group is managed for the moment
+                if not group or not group in self.host.contact_panel.getGroups():
+                    # the group doesn't exists, we ignore the key
+                    group = None
+                    target_hook, _type, msg = getSelectedOrStatus()
+                else:
+                    _type = "GROUP"
+                    msg = MicroblogPanel.warning_msg_group % group
+                    target_hook = (txt[_end + 2:], group)
+        else:
+            log.error("Unknown target")
+            target_hook, _type, msg = getSelectedOrStatus()
+
+        return (target_hook, _type, msg)
+
+    def onKeyPress(self, sender, keycode, modifiers):
+        _txt = self.getText()
+        target_hook, type_, msg = self._getTarget(_txt)
+
+        if keycode == KEY_ENTER:
+            if _txt:
+                if target_hook:
+                    parsed_txt, data = target_hook
+                    self.host.send([(type_, data)], parsed_txt)
+                    self.host._updateInputHistory(_txt)
+                    self.setText('')
+            self.host.showWarning(None, None)
+        else:
+            self.host.showWarning(type_, msg)
+        MessageBox.onKeyPress(self, sender, keycode, modifiers)
+
+    def getTargetAndData(self):
+        """For external use, to get information about the (hypothetical) message
+        that would be sent if we press Enter right now in the unibox.
+        @return a tuple (target, data) with:
+          - data: what would be the content of the message (body)
+          - target: JID, group with the prefix "@" or the public entity "@@"
+        """
+        _txt = self.getText()
+        target_hook, _type, _msg = self._getTarget(_txt)
+        if target_hook:
+            data, target = target_hook
+            if target is None:
+                return target_hook
+            return (data, "@%s" % (target if target != "" else "@"))
+        if isinstance(self._selected_cache, MicroblogPanel):
+            groups = self._selected_cache.accepted_groups
+            target = "@%s" % (groups[0] if len(groups) > 0 else "@")
+            if len(groups) > 1:
+                Window.alert("Sole the first group of the selected panel is taken in consideration: '%s'" % groups[0])
+        elif isinstance(self._selected_cache, ChatPanel):
+            target = self._selected_cache.target
+        else:
+            target = None
+        return (_txt, target)
+
+    def onWidgetClosed(self, lib_wid):
+        """Called when a libervia widget is closed"""
+        if self._selected_cache == lib_wid:
+            self.onSelectedChange(None)
+
+    """def complete(self):
+
+        #self.visible=False #XXX: self.visible is not unset in pyjamas when ENTER is pressed and a completion is done
+        #XXX: fixed directly on pyjamas, if the patch is accepted, no need to walk around this
+        return AutoCompleteTextBox.complete(self)"""
+
+
+class WarningPopup():
+
+    def __init__(self):
+        self._popup = None
+        self._timer = Timer(notify=self._timeCb)
+
+    def showWarning(self, type_=None, msg=None, duration=2000):
+        """Display a popup information message, e.g. to notify the recipient of a message being composed.
+        If type_ is None, a popup being currently displayed will be hidden.
+        @type_: a type determining the CSS style to be applied (see __showWarning)
+        @msg: message to be displayed
+        """
+        if type_ is None:
+            self.__removeWarning()
+            return
+        if not self._popup:
+            self.__showWarning(type_, msg)
+        elif (type_, msg) != self._popup.target_data:
+            self._timeCb(None)  # we remove the popup
+            self.__showWarning(type_, msg)
+
+        self._timer.schedule(duration)
+
+    def __showWarning(self, type_, msg):
+        """Display a popup information message, e.g. to notify the recipient of a message being composed.
+        @type_: a type determining the CSS style to be applied. For now the defined styles are
+        "NONE" (will do nothing), "PUBLIC", "GROUP", "STATUS" and "ONE2ONE".
+        @msg: message to be displayed
+        """
+        if type_ == "NONE":
+            return
+        if not msg:
+            log.warning("no msg set uniBox warning")
+            return
+        if type_ == "PUBLIC":
+            style = "targetPublic"
+        elif type_ == "GROUP":
+            style = "targetGroup"
+        elif type_ == "STATUS":
+            style = "targetStatus"
+        elif type_ == "ONE2ONE":
+            style = "targetOne2One"
+        else:
+            log.error("unknown message type")
+            return
+        contents = HTML(msg)
+
+        self._popup = dialog.PopupPanelWrapper(autoHide=False, modal=False)
+        self._popup.target_data = (type_, msg)
+        self._popup.add(contents)
+        self._popup.setStyleName("warningPopup")
+        if style:
+            self._popup.addStyleName(style)
+
+        left = 0
+        top = 0  # max(0, self.getAbsoluteTop() - contents.getOffsetHeight() - 2)
+        self._popup.setPopupPosition(left, top)
+        self._popup.show()
+
+    def _timeCb(self, timer):
+        if self._popup:
+            self._popup.hide()
+            del self._popup
+            self._popup = None
+
+    def __removeWarning(self):
+        """Remove the popup"""
+        self._timeCb(None)
+
+
+class MicroblogItem():
+    # XXX: should be moved in a separated module
+
+    def __init__(self, data):
+        self.id = data['id']
+        self.type = data.get('type', 'main_item')
+        self.empty = data.get('new', False)
+        self.title = data.get('title', '')
+        self.title_xhtml = data.get('title_xhtml', '')
+        self.content = data.get('content', '')
+        self.content_xhtml = data.get('content_xhtml', '')
+        self.author = data['author']
+        self.updated = float(data.get('updated', 0))  # XXX: int doesn't work here
+        self.published = float(data.get('published', self.updated))  # XXX: int doesn't work here
+        self.service = data.get('service', '')
+        self.node = data.get('node', '')
+        self.comments = data.get('comments', False)
+        self.comments_service = data.get('comments_service', '')
+        self.comments_node = data.get('comments_node', '')
+
+
+class MicroblogEntry(SimplePanel, ClickHandler, FocusHandler, KeyboardHandler):
+
+    def __init__(self, blog_panel, data):
+        """
+        @param blog_panel: the parent panel
+        @param data: dict containing the blog item data, or a MicroblogItem instance.
+        """
+        self._base_item = data if isinstance(data, MicroblogItem) else MicroblogItem(data)
+        for attr in ['id', 'type', 'empty', 'title', 'title_xhtml', 'content', 'content_xhtml',
+                     'author', 'updated', 'published', 'comments', 'service', 'node',
+                     'comments_service', 'comments_node']:
+            getter = lambda attr: lambda inst: getattr(inst._base_item, attr)
+            setter = lambda attr: lambda inst, value: setattr(inst._base_item, attr, value)
+            setattr(MicroblogEntry, attr, property(getter(attr), setter(attr)))
+
+        SimplePanel.__init__(self)
+        self._blog_panel = blog_panel
+
+        self.panel = FlowPanel()
+        self.panel.setStyleName('mb_entry')
+
+        self.header = HTMLPanel('')
+        self.panel.add(self.header)
+
+        self.entry_actions = VerticalPanel()
+        self.entry_actions.setStyleName('mb_entry_actions')
+        self.panel.add(self.entry_actions)
+
+        entry_avatar = SimplePanel()
+        entry_avatar.setStyleName('mb_entry_avatar')
+        self.avatar = Image(self._blog_panel.host.getAvatar(self.author))
+        entry_avatar.add(self.avatar)
+        self.panel.add(entry_avatar)
+
+        if TOGGLE_EDITION_USE_ICON:
+            self.entry_dialog = HorizontalPanel()
+        else:
+            self.entry_dialog = VerticalPanel()
+        self.entry_dialog.setStyleName('mb_entry_dialog')
+        self.panel.add(self.entry_dialog)
+
+        self.add(self.panel)
+        ClickHandler.__init__(self)
+        self.addClickListener(self)
+
+        self.__pub_data = (self.service, self.node, self.id)
+        self.__setContent()
+
+    def __setContent(self):
+        """Actually set the entry content (header, icons, bubble...)"""
+        self.delete_label = self.update_label = self.comment_label = None
+        self.bubble = self._current_comment = None
+        self.__setHeader()
+        self.__setBubble()
+        self.__setIcons()
+
+    def __setHeader(self):
+        """Set the entry header"""
+        if self.empty:
+            return
+        update_text = u" — ✍ " + "<span class='mb_entry_timestamp'>%s</span>" % datetime.fromtimestamp(self.updated)
+        self.header.setHTML("""<div class='mb_entry_header'>
+                                   <span class='mb_entry_author'>%(author)s</span> on
+                                   <span class='mb_entry_timestamp'>%(published)s</span>%(updated)s
+                               </div>""" % {'author': html_tools.html_sanitize(self.author),
+                                            'published': datetime.fromtimestamp(self.published),
+                                            'updated': update_text if self.published != self.updated else ''
+                                            }
+                            )
+
+    def __setIcons(self):
+        """Set the entry icons (delete, update, comment)"""
+        if self.empty:
+            return
+
+        def addIcon(label, title):
+            label = Label(label)
+            label.setTitle(title)
+            label.addClickListener(self)
+            self.entry_actions.add(label)
+            return label
+
+        if self.comments:
+            self.comment_label = addIcon(u"↶", "Comment this message")
+            self.comment_label.setStyleName('mb_entry_action_larger')
+        is_publisher = self.author == self._blog_panel.host.whoami.bare
+        if is_publisher:
+            self.update_label = addIcon(u"✍", "Edit this message")
+        if is_publisher or str(self.node).endswith(self._blog_panel.host.whoami.bare):
+            self.delete_label = addIcon(u"✗", "Delete this message")
+
+    def updateAvatar(self, new_avatar):
+        """Change the avatar of the entry
+        @param new_avatar: path to the new image"""
+        self.avatar.setUrl(new_avatar)
+
+    def onClick(self, sender):
+        if sender == self:
+            try:  # prevent re-selection of the main entry after a comment has been focused
+                if self.__ignoreNextEvent:
+                    self.__ignoreNextEvent = False
+                    return
+            except AttributeError:
+                pass
+            self._blog_panel.setSelectedEntry(self)
+        elif sender == self.delete_label:
+            self._delete()
+        elif sender == self.update_label:
+            self.edit(True)
+        elif sender == self.comment_label:
+            self.__ignoreNextEvent = True
+            self._comment()
+
+    def __modifiedCb(self, content):
+        """Send the new content to the backend
+        @return: False to restore the original content if a deletion has been cancelled
+        """
+        if not content['text']:  # previous content has been emptied
+            self._delete(True)
+            return False
+        extra = {'published': str(self.published)}
+        if isinstance(self.bubble, richtext.RichTextEditor):
+            # TODO: if the user change his parameters after the message edition started,
+            # the message syntax could be different then the current syntax: pass the
+            # message syntax in extra for the frontend to use it instead of current syntax.
+            extra.update({'content_rich': content['text'], 'title': content['title']})
+        if self.empty:
+            if self.type == 'main_item':
+                self._blog_panel.host.bridge.call('sendMblog', None, None, self._blog_panel.accepted_groups, content['text'], extra)
+            else:
+                self._blog_panel.host.bridge.call('sendMblogComment', None, self._parent_entry.comments, content['text'], extra)
+        else:
+            self._blog_panel.host.bridge.call('updateMblog', None, self.__pub_data, self.comments, content['text'], extra)
+        return True
+
+    def __afterEditCb(self, content):
+        """Remove the entry if it was an empty one (used for creating a new blog post).
+        Data for the actual new blog post will be received from the bridge"""
+        if self.empty:
+            self._blog_panel.removeEntry(self.type, self.id)
+            if self.type == 'main_item':  # restore the "New message" button
+                self._blog_panel.refresh()
+            else:  # allow to create a new comment
+                self._parent_entry._current_comment = None
+        self.entry_dialog.setWidth('auto')
+        try:
+            self.toggle_syntax_button.removeFromParent()
+        except TypeError:
+            pass
+
+    def __setBubble(self, edit=False):
+        """Set the bubble displaying the initial content."""
+        content = {'text': self.content_xhtml if self.content_xhtml else self.content,
+                   'title': self.title_xhtml if self.title_xhtml else self.title}
+        if self.content_xhtml:
+            content.update({'syntax': C.SYNTAX_XHTML})
+            if self.author != self._blog_panel.host.whoami.bare:
+                options = ['read_only']
+            else:
+                options = [] if self.empty else ['update_msg']
+            self.bubble = richtext.RichTextEditor(self._blog_panel.host, content, self.__modifiedCb, self.__afterEditCb, options)
+        else:  # assume raw text message have no title
+            self.bubble = base_panels.LightTextEditor(content, self.__modifiedCb, self.__afterEditCb, options={'no_xhtml': True})
+        self.bubble.addStyleName("bubble")
+        try:
+            self.toggle_syntax_button.removeFromParent()
+        except TypeError:
+            pass
+        self.entry_dialog.add(self.bubble)
+        self.edit(edit)
+        self.bubble.addEditListener(self.__showWarning)
+
+    def __showWarning(self, sender, keycode):
+        if keycode == KEY_ENTER:
+            self._blog_panel.host.showWarning(None, None)
+        else:
+            self._blog_panel.host.showWarning(*self._blog_panel.getWarningData(self.type == 'comment'))
+
+    def _delete(self, empty=False):
+        """Ask confirmation for deletion.
+        @return: False if the deletion has been cancelled."""
+        def confirm_cb(answer):
+            if answer:
+                self._blog_panel.host.bridge.call('deleteMblog', None, self.__pub_data, self.comments)
+            else:  # restore the text if it has been emptied during the edition
+                self.bubble.setContent(self.bubble._original_content)
+
+        if self.empty:
+            text = _("New ") + (_("message") if self.comments else _("comment")) + _(" without body has been cancelled.")
+            dialog.InfoDialog(_("Information"), text).show()
+            return
+        text = ""
+        if empty:
+            text = (_("Message") if self.comments else _("Comment")) + _(" body has been emptied.<br/>")
+        target = _('message and all its comments') if self.comments else _('comment')
+        text += _("Do you really want to delete this %s?") % target
+        dialog.ConfirmDialog(confirm_cb, text=text).show()
+
+    def _comment(self):
+        """Add an empty entry for a new comment"""
+        if self._current_comment:
+            self._current_comment.bubble.setFocus(True)
+            self._blog_panel.setSelectedEntry(self._current_comment)
+            return
+        data = {'id': str(time()),
+                'new': True,
+                'type': 'comment',
+                'author': self._blog_panel.host.whoami.bare,
+                'service': self.comments_service,
+                'node': self.comments_node
+                }
+        entry = self._blog_panel.addEntry(data)
+        if entry is None:
+            log.info("The entry of id %s can not be commented" % self.id)
+            return
+        entry._parent_entry = self
+        self._current_comment = entry
+        self.edit(True, entry)
+        self._blog_panel.setSelectedEntry(entry)
+
+    def edit(self, edit, entry=None):
+        """Toggle the bubble between display and edit mode
+        @edit: boolean value
+        @entry: MicroblogEntry instance, or None to use self
+        """
+        if entry is None:
+            entry = self
+        try:
+            entry.toggle_syntax_button.removeFromParent()
+        except TypeError:
+            pass
+        entry.bubble.edit(edit)
+        if edit:
+            if isinstance(entry.bubble, richtext.RichTextEditor):
+                image = '<a class="richTextIcon">A</a>'
+                html = '<a style="color: blue;">raw text</a>'
+                title = _('Switch to raw text edition')
+            else:
+                image = '<img src="media/icons/tango/actions/32/format-text-italic.png" class="richTextIcon"/>'
+                html = '<a style="color: blue;">rich text</a>'
+                title = _('Switch to rich text edition')
+            if TOGGLE_EDITION_USE_ICON:
+                entry.entry_dialog.setWidth('80%')
+                entry.toggle_syntax_button = Button(image, entry.toggleContentSyntax)
+                entry.toggle_syntax_button.setTitle(title)
+                entry.entry_dialog.add(entry.toggle_syntax_button)
+            else:
+                entry.toggle_syntax_button = HTML(html)
+                entry.toggle_syntax_button.addClickListener(entry.toggleContentSyntax)
+                entry.toggle_syntax_button.addStyleName('mb_entry_toggle_syntax')
+                entry.entry_dialog.add(entry.toggle_syntax_button)
+                entry.toggle_syntax_button.setStyleAttribute('top', '-20px')  # XXX: need to force CSS
+                entry.toggle_syntax_button.setStyleAttribute('left', '-20px')
+
+    def toggleContentSyntax(self):
+        """Toggle the editor between raw and rich text"""
+        original_content = self.bubble.getOriginalContent()
+        rich = not isinstance(self.bubble, richtext.RichTextEditor)
+        if rich:
+            original_content['syntax'] = C.SYNTAX_XHTML
+
+        def setBubble(text):
+            self.content = text
+            self.content_xhtml = text if rich else ''
+            self.content_title = self.content_title_xhtml = ''
+            self.bubble.removeFromParent()
+            self.__setBubble(True)
+            self.bubble.setOriginalContent(original_content)
+            if rich:
+                self.bubble.setDisplayContent()  # needed in case the edition is aborted, to not end with an empty bubble
+
+        text = self.bubble.getContent()['text']
+        if not text:
+            setBubble(' ')  # something different than empty string is needed to initialize the rich text editor
+            return
+        if not rich:
+            def confirm_cb(answer):
+                if answer:
+                    self._blog_panel.host.bridge.call('syntaxConvert', setBubble, text, C.SYNTAX_CURRENT, C.SYNTAX_TEXT)
+            dialog.ConfirmDialog(confirm_cb, text=_("Do you really want to lose the title and text formatting?")).show()
+        else:
+            self._blog_panel.host.bridge.call('syntaxConvert', setBubble, text, C.SYNTAX_TEXT, C.SYNTAX_XHTML)
+
+
+class MicroblogPanel(base_widget.LiberviaWidget):
+    warning_msg_public = "This message will be PUBLIC and everybody will be able to see it, even people you don't know"
+    warning_msg_group = "This message will be published for all the people of the group <span class='warningTarget'>%s</span>"
+
+    def __init__(self, host, accepted_groups):
+        """Panel used to show microblog
+        @param accepted_groups: groups displayed in this panel, if empty, show all microblogs from all contacts
+        """
+        base_widget.LiberviaWidget.__init__(self, host, ", ".join(accepted_groups), selectable=True)
+        self.setAcceptedGroup(accepted_groups)
+        self.host = host
+        self.entries = {}
+        self.comments = {}
+        self.selected_entry = None
+        self.vpanel = VerticalPanel()
+        self.vpanel.setStyleName('microblogPanel')
+        self.setWidget(self.vpanel)
+
+    def refresh(self):
+        """Refresh the display of this widget. If the unibox is disabled,
+        display the 'New message' button or an empty bubble on top of the panel"""
+        if hasattr(self, 'new_button'):
+            self.new_button.setVisible(self.host.uni_box is None)
+            return
+        if self.host.uni_box is None:
+            def addBox():
+                if hasattr(self, 'new_button'):
+                    self.new_button.setVisible(False)
+                data = {'id': str(time()),
+                        'new': True,
+                        'author': self.host.whoami.bare,
+                        }
+                entry = self.addEntry(data)
+                entry.edit(True)
+            if NEW_MESSAGE_USE_BUTTON:
+                self.new_button = Button("New message", listener=addBox)
+                self.new_button.setStyleName("microblogNewButton")
+                self.vpanel.insert(self.new_button, 0)
+            elif not self.getNewMainEntry():
+                addBox()
+
+    def getNewMainEntry(self):
+        """Get the new entry being edited, or None if it doesn't exists.
+
+        @return (MicroblogEntry): the new entry being edited.
+        """
+        try:
+            first = self.vpanel.children[0]
+        except IndexError:
+            return None
+        assert(first.type == 'main_item')
+        return first if first.empty else None
+
+    @classmethod
+    def registerClass(cls):
+        base_widget.LiberviaWidget.addDropKey("GROUP", cls.createPanel)
+        base_widget.LiberviaWidget.addDropKey("CONTACT_TITLE", cls.createMetaPanel)
+
+    @classmethod
+    def createPanel(cls, host, item):
+        """Generic panel creation for one, several or all groups (meta).
+        @parem host: the SatWebFrontend instance
+        @param item: single group as a string, list of groups
+         (as an array) or None (for the meta group = "all groups")
+        @return: the created MicroblogPanel
+        """
+        _items = item if isinstance(item, list) else ([] if item is None else [item])
+        _type = 'ALL' if _items == [] else 'GROUP'
+        # XXX: pyjamas doesn't support use of cls directly
+        _new_panel = MicroblogPanel(host, _items)
+        host.FillMicroblogPanel(_new_panel)
+        host.bridge.call('getMassiveLastMblogs', _new_panel.massiveInsert, _type, _items, 10)
+        host.setSelected(_new_panel)
+        _new_panel.refresh()
+        return _new_panel
+
+    @classmethod
+    def createMetaPanel(cls, host, item):
+        """Needed for the drop keys to not be mixed between meta panel and panel for "Contacts" group"""
+        return MicroblogPanel.createPanel(host, None)
+
+    @property
+    def accepted_groups(self):
+        return self._accepted_groups
+
+    def matchEntity(self, entity):
+        """
+        @param entity: single group as a string, list of groups
+        (as an array) or None (for the meta group = "all groups")
+        @return: True if self matches the given entity
+        """
+        entity = entity if isinstance(entity, list) else ([] if entity is None else [entity])
+        entity.sort()  # sort() do not return the sorted list: do it here, not on the "return" line
+        return self.accepted_groups == entity
+
+    def getWarningData(self, comment=None):
+        """
+        @param comment: True if the composed message is a comment. If None, consider we are
+        composing from the unibox and guess the message type from self.selected_entry
+        @return: a couple (type, msg) for calling self.host.showWarning"""
+        if comment is None:  # composing from the unibox
+            if self.selected_entry and not self.selected_entry.comments:
+                log.error("an item without comment is selected")
+                return ("NONE", None)
+            comment = self.selected_entry is not None
+        if comment:
+            return ("PUBLIC", "This is a <span class='warningTarget'>comment</span> and keep the initial post visibility, so it is potentialy public")
+        elif not self._accepted_groups:
+            # we have a meta MicroblogPanel, we publish publicly
+            return ("PUBLIC", self.warning_msg_public)
+        else:
+            # we only accept one group at the moment
+            # FIXME: manage several groups
+            return ("GROUP", self.warning_msg_group % self._accepted_groups[0])
+
+    def onTextEntered(self, text):
+        if self.selected_entry:
+            # we are entering a comment
+            comments_url = self.selected_entry.comments
+            if not comments_url:
+                raise Exception("ERROR: the comments URL is empty")
+            target = ("COMMENT", comments_url)
+        elif not self._accepted_groups:
+            # we are entering a public microblog
+            target = ("PUBLIC", None)
+        else:
+            # we are entering a microblog restricted to a group
+            # FIXME: manage several groups
+            target = ("GROUP", self._accepted_groups[0])
+        self.host.send([target], text)
+
+    def accept_all(self):
+        return not self._accepted_groups  # we accept every microblog only if we are not filtering by groups
+
+    def getEntries(self):
+        """Ask all the entries for the currenly accepted groups,
+        and fill the panel"""
+
+    def massiveInsert(self, mblogs):
+        """Insert several microblogs at once
+        @param mblogs: dictionary of microblogs, as the result of getMassiveLastGroupBlogs
+        """
+        log.debug("Massive insertion of %d microblogs" % len(mblogs))
+        for publisher in mblogs:
+            log.debug("adding blogs for [%s]" % publisher)
+            for mblog in mblogs[publisher]:
+                if not "content" in mblog:
+                    log.warning("No content found in microblog [%s]", mblog)
+                    continue
+                self.addEntry(mblog)
+
+    def mblogsInsert(self, mblogs):
+        """ Insert several microblogs at once
+        @param mblogs: list of microblogs
+        """
+        for mblog in mblogs:
+            if not "content" in mblog:
+                log.warning("No content found in microblog [%s]", mblog)
+                continue
+            self.addEntry(mblog)
+
+    def _chronoInsert(self, vpanel, entry, reverse=True):
+        """ Insert an entry in chronological order
+        @param vpanel: VerticalPanel instance
+        @param entry: MicroblogEntry
+        @param reverse: more recent entry on top if True, chronological order else"""
+        assert(isinstance(reverse, bool))
+        if entry.empty:
+            entry.published = time()
+        # we look for the right index to insert our entry:
+        # if reversed, we insert the entry above the first entry
+        # in the past
+        idx = 0
+
+        for child in vpanel.children:
+            if not isinstance(child, MicroblogEntry):
+                idx += 1
+                continue
+            condition_to_stop = child.empty or (child.published > entry.published)
+            if condition_to_stop != reverse:  # != is XOR
+                break
+            idx += 1
+
+        vpanel.insert(entry, idx)
+
+    def addEntry(self, data):
+        """Add an entry to the panel
+        @param data: dict containing the item data
+        @return: the added entry, or None
+        """
+        _entry = MicroblogEntry(self, data)
+        if _entry.type == "comment":
+            comments_hash = (_entry.service, _entry.node)
+            if not comments_hash in self.comments:
+                # The comments node is not known in this panel
+                return None
+            parent = self.comments[comments_hash]
+            parent_idx = self.vpanel.getWidgetIndex(parent)
+            # we find or create the panel where the comment must be inserted
+            try:
+                sub_panel = self.vpanel.getWidget(parent_idx + 1)
+            except IndexError:
+                sub_panel = None
+            if not sub_panel or not isinstance(sub_panel, VerticalPanel):
+                sub_panel = VerticalPanel()
+                sub_panel.setStyleName('microblogPanel')
+                sub_panel.addStyleName('subPanel')
+                self.vpanel.insert(sub_panel, parent_idx + 1)
+            for idx in xrange(0, len(sub_panel.getChildren())):
+                comment = sub_panel.getIndexedChild(idx)
+                if comment.id == _entry.id:
+                    # update an existing comment
+                    sub_panel.remove(comment)
+                    sub_panel.insert(_entry, idx)
+                    return _entry
+            # we want comments to be inserted in chronological order
+            self._chronoInsert(sub_panel, _entry, reverse=False)
+            return _entry
+
+        if _entry.id in self.entries:  # update
+            idx = self.vpanel.getWidgetIndex(self.entries[_entry.id])
+            self.vpanel.remove(self.entries[_entry.id])
+            self.vpanel.insert(_entry, idx)
+        else:  # new entry
+            self._chronoInsert(self.vpanel, _entry)
+        self.entries[_entry.id] = _entry
+
+        if _entry.comments:
+            # entry has comments, we keep the comments service/node as a reference
+            comments_hash = (_entry.comments_service, _entry.comments_node)
+            self.comments[comments_hash] = _entry
+            self.host.bridge.call('getMblogComments', self.mblogsInsert, _entry.comments_service, _entry.comments_node)
+
+        return _entry
+
+    def removeEntry(self, type_, id_):
+        """Remove an entry from the panel
+        @param type_: entry type ('main_item' or 'comment')
+        @param id_: entry id
+        """
+        for child in self.vpanel.getChildren():
+            if isinstance(child, MicroblogEntry) and type_ == 'main_item':
+                if child.id == id_:
+                    main_idx = self.vpanel.getWidgetIndex(child)
+                    try:
+                        sub_panel = self.vpanel.getWidget(main_idx + 1)
+                        if isinstance(sub_panel, VerticalPanel):
+                            sub_panel.removeFromParent()
+                    except IndexError:
+                        pass
+                    child.removeFromParent()
+                    self.selected_entry = None
+                    break
+            elif isinstance(child, VerticalPanel) and type_ == 'comment':
+                for comment in child.getChildren():
+                    if comment.id == id_:
+                        comment.removeFromParent()
+                        self.selected_entry = None
+                        break
+
+    def setSelectedEntry(self, entry):
+        try:
+            self.vpanel.getParent().ensureVisible(entry)  # scroll to the clicked entry
+        except AttributeError:
+            log.warning("FIXME: MicroblogPanel.vpanel should be wrapped in a ScrollPanel!")
+        removeStyle = lambda entry: entry.removeStyleName('selected_entry')
+        if not self.host.uni_box or not entry.comments:
+            entry.addStyleName('selected_entry')  # blink the clicked entry
+            clicked_entry = entry  # entry may be None when the timer is done
+            Timer(500, lambda timer: removeStyle(clicked_entry))
+        if not self.host.uni_box:
+            return  # unibox is disabled
+        # from here the previous behavior (toggle main item selection) is conserved
+        entry = entry if entry.comments else None
+        if self.selected_entry == entry:
+            entry = None
+        if self.selected_entry:
+            removeStyle(self.selected_entry)
+        if entry:
+            log.debug("microblog entry selected (author=%s)" % entry.author)
+            entry.addStyleName('selected_entry')
+        self.selected_entry = entry
+
+    def updateValue(self, type_, jid, value):
+        """Update a jid value in entries
+        @param type_: one of 'avatar', 'nick'
+        @param jid: jid concerned
+        @param value: new value"""
+        def updateVPanel(vpanel):
+            for child in vpanel.children:
+                if isinstance(child, MicroblogEntry) and child.author == jid:
+                    child.updateAvatar(value)
+                elif isinstance(child, VerticalPanel):
+                    updateVPanel(child)
+        if type_ == 'avatar':
+            updateVPanel(self.vpanel)
+
+    def setAcceptedGroup(self, group):
+        """Add one or more group(s) which can be displayed in this panel.
+        Prevent from duplicate values and keep the list sorted.
+        @param group: string of the group, or list of string
+        """
+        if not hasattr(self, "_accepted_groups"):
+            self._accepted_groups = []
+        groups = group if isinstance(group, list) else [group]
+        for _group in groups:
+            if _group not in self._accepted_groups:
+                self._accepted_groups.append(_group)
+        self._accepted_groups.sort()
+
+    def isJidAccepted(self, jid):
+        """Tell if a jid is actepted and shown in this panel
+        @param jid: jid
+        @return: True if the jid is accepted"""
+        if self.accept_all():
+            return True
+        for group in self._accepted_groups:
+            if self.host.contact_panel.isContactInGroup(group, jid):
+                return True
+        return False
+
+
+class StatusPanel(base_panels.HTMLTextEditor):
+
+    EMPTY_STATUS = '&lt;click to set a status&gt;'
+
+    def __init__(self, host, status=''):
+        self.host = host
+        modifiedCb = lambda content: self.host.bridge.call('setStatus', None, self.host.status_panel.presence, content['text']) or True
+        base_panels.HTMLTextEditor.__init__(self, {'text': status}, modifiedCb, options={'no_xhtml': True, 'listen_focus': True, 'listen_click': True})
+        self.edit(False)
+        self.setStyleName('statusPanel')
+
+    @property
+    def status(self):
+        return self._original_content['text']
+
+    def __cleanContent(self, content):
+        status = content['text']
+        if status == self.EMPTY_STATUS or status in C.PRESENCE.values():
+            content['text'] = ''
+        return content
+
+    def getContent(self):
+        return self.__cleanContent(base_panels.HTMLTextEditor.getContent(self))
+
+    def setContent(self, content):
+        content = self.__cleanContent(content)
+        base_panels.BaseTextEditor.setContent(self, content)
+
+    def setDisplayContent(self):
+        status = self._original_content['text']
+        try:
+            presence = self.host.status_panel.presence
+        except AttributeError:  # during initialization
+            presence = None
+        if not status:
+            if presence and presence in C.PRESENCE:
+                status = C.PRESENCE[presence]
+            else:
+                status = self.EMPTY_STATUS
+        self.display.setHTML(addURLToText(status))
+
+
+class PresenceStatusPanel(HorizontalPanel, ClickHandler):
+
+    def __init__(self, host, presence="", status=""):
+        self.host = host
+        HorizontalPanel.__init__(self, Width='100%')
+        self.presence_button = Label(u"◉")
+        self.presence_button.setStyleName("presence-button")
+        self.status_panel = StatusPanel(host, status=status)
+        self.setPresence(presence)
+        entries = {}
+        for value in C.PRESENCE.keys():
+            entries.update({C.PRESENCE[value]: {"value": value}})
+
+        def callback(sender, key):
+            self.setPresence(entries[key]["value"])  # order matters
+            self.host.send([("STATUS", None)], self.status_panel.status)
+
+        self.presence_list = base_panels.PopupMenuPanel(entries, callback=callback, style={"menu": "gwt-ListBox"})
+        self.presence_list.registerClickSender(self.presence_button)
+
+        panel = HorizontalPanel()
+        panel.add(self.presence_button)
+        panel.add(self.status_panel)
+        panel.setCellVerticalAlignment(self.presence_button, 'baseline')
+        panel.setCellVerticalAlignment(self.status_panel, 'baseline')
+        panel.setStyleName("marginAuto")
+        self.add(panel)
+
+        self.status_panel.edit(False)
+
+        ClickHandler.__init__(self)
+        self.addClickListener(self)
+
+    @property
+    def presence(self):
+        return self._presence
+
+    @property
+    def status(self):
+        return self.status_panel._original_content['text']
+
+    def setPresence(self, presence):
+        self._presence = presence
+        contact.setPresenceStyle(self.presence_button, self._presence)
+
+    def setStatus(self, status):
+        self.status_panel.setContent({'text': status})
+        self.status_panel.setDisplayContent()
+
+    def onClick(self, sender):
+        # As status is the default target of uniBar, we don't want to select anything if click on it
+        self.host.setSelected(None)
+
+
+class ChatPanel(base_widget.LiberviaWidget):
+
+    def __init__(self, host, target, type_='one2one'):
+        """Panel used for conversation (one 2 one or group chat)
+        @param host: SatWebFrontend instance
+        @param target: entity (jid.JID) with who we have a conversation (contact's jid for one 2 one chat, or MUC room)
+        @param type: one2one for simple conversation, group for MUC"""
+        base_widget.LiberviaWidget.__init__(self, host, title=target.bare, selectable=True)
+        self.vpanel = VerticalPanel()
+        self.vpanel.setSize('100%', '100%')
+        self.type = type_
+        self.nick = None
+        if not target:
+            log.error("Empty target !")
+            return
+        self.target = target
+        self.__body = AbsolutePanel()
+        self.__body.setStyleName('chatPanel_body')
+        chat_area = HorizontalPanel()
+        chat_area.setStyleName('chatArea')
+        if type_ == 'group':
+            self.occupants_list = base_panels.OccupantsList()
+            chat_area.add(self.occupants_list)
+        self.__body.add(chat_area)
+        self.content = AbsolutePanel()
+        self.content.setStyleName('chatContent')
+        self.content_scroll = base_widget.ScrollPanelWrapper(self.content)
+        chat_area.add(self.content_scroll)
+        chat_area.setCellWidth(self.content_scroll, '100%')
+        self.vpanel.add(self.__body)
+        self.vpanel.setCellHeight(self.__body, '100%')
+        self.addStyleName('chatPanel')
+        self.setWidget(self.vpanel)
+        self.state_machine = plugin_xep_0085.ChatStateMachine(self.host, str(self.target))
+        self._state = None
+
+    @classmethod
+    def registerClass(cls):
+        base_widget.LiberviaWidget.addDropKey("CONTACT", cls.createPanel)
+
+    @classmethod
+    def createPanel(cls, host, item):
+        _contact = item if isinstance(item, jid.JID) else jid.JID(item)
+        host.contact_panel.setContactMessageWaiting(_contact.bare, False)
+        _new_panel = ChatPanel(host, _contact)  # XXX: pyjamas doesn't seems to support creating with cls directly
+        _new_panel.historyPrint()
+        host.setSelected(_new_panel)
+        _new_panel.refresh()
+        return _new_panel
+
+    def refresh(self):
+        """Refresh the display of this widget. If the unibox is disabled,
+        add a message box at the bottom of the panel"""
+        self.host.contact_panel.setContactMessageWaiting(self.target.bare, False)
+        self.content_scroll.scrollToBottom()
+
+        enable_box = self.host.uni_box is None
+        if hasattr(self, 'message_box'):
+            self.message_box.setVisible(enable_box)
+            return
+        if enable_box:
+            self.message_box = MessageBox(self.host)
+            self.message_box.onSelectedChange(self)
+            self.vpanel.add(self.message_box)
+
+    def matchEntity(self, entity):
+        """
+        @param entity: target jid as a string or jid.JID instance.
+        Could also be a couple with a type in the second element.
+        @return: True if self matches the given entity
+        """
+        if isinstance(entity, tuple):
+            entity, type_ = entity if len(entity) > 1 else (entity[0], self.type)
+        else:
+            type_ = self.type
+        entity = entity if isinstance(entity, jid.JID) else jid.JID(entity)
+        try:
+            return self.target.bare == entity.bare and self.type == type_
+        except AttributeError as e:
+            e.include_traceback()
+            return False
+
+    def getWarningData(self):
+        if self.type not in ["one2one", "group"]:
+            raise Exception("Unmanaged type !")
+        if self.type == "one2one":
+            msg = "This message will be sent to your contact <span class='warningTarget'>%s</span>" % self.target
+        elif self.type == "group":
+            msg = "This message will be sent to all the participants of the multi-user room <span class='warningTarget'>%s</span>" % self.target
+        return ("ONE2ONE" if self.type == "one2one" else "GROUP", msg)
+
+    def onTextEntered(self, text):
+        self.host.send([("groupchat" if self.type == 'group' else "chat", str(self.target))], text)
+        self.state_machine._onEvent("active")
+
+    def onQuit(self):
+        base_widget.LiberviaWidget.onQuit(self)
+        if self.type == 'group':
+            self.host.bridge.call('mucLeave', None, self.target.bare)
+
+    def setUserNick(self, nick):
+        """Set the nick of the user, usefull for e.g. change the color of the user"""
+        self.nick = nick
+
+    def setPresents(self, nicks):
+        """Set the users presents in this room
+        @param occupants: list of nicks (string)"""
+        self.occupants_list.clear()
+        for nick in nicks:
+            self.occupants_list.addOccupant(nick)
+
+    def userJoined(self, nick, data):
+        self.occupants_list.addOccupant(nick)
+        self.printInfo("=> %s has joined the room" % nick)
+
+    def userLeft(self, nick, data):
+        self.occupants_list.removeOccupant(nick)
+        self.printInfo("<= %s has left the room" % nick)
+
+    def changeUserNick(self, old_nick, new_nick):
+        assert(self.type == "group")
+        self.occupants_list.removeOccupant(old_nick)
+        self.occupants_list.addOccupant(new_nick)
+        self.printInfo(_("%(old_nick)s is now known as %(new_nick)s") % {'old_nick': old_nick, 'new_nick': new_nick})
+
+    def historyPrint(self, size=20):
+        """Print the initial history"""
+        def getHistoryCB(history):
+            # display day change
+            day_format = "%A, %d %b %Y"
+            previous_day = datetime.now().strftime(day_format)
+            for line in history:
+                timestamp, from_jid, to_jid, message, mess_type, extra = line
+                message_day = datetime.fromtimestamp(float(timestamp or time())).strftime(day_format)
+                if previous_day != message_day:
+                    self.printInfo("* " + message_day)
+                    previous_day = message_day
+                self.printMessage(from_jid, message, extra, timestamp)
+        self.host.bridge.call('getHistory', getHistoryCB, self.host.whoami.bare, self.target.bare, size, True)
+
+    def printInfo(self, msg, type_='normal', link_cb=None):
+        """Print general info
+        @param msg: message to print
+        @param type_: one of:
+            "normal": general info like "toto has joined the room"
+            "link": general info that is clickable like "click here to join the main room"
+            "me": "/me" information like "/me clenches his fist" ==> "toto clenches his fist"
+        @param link_cb: method to call when the info is clicked, ignored if type_ is not 'link'
+        """
+        _wid = HTML(msg) if type_ == 'link' else Label(msg)
+        if type_ == 'normal':
+            _wid.setStyleName('chatTextInfo')
+        elif type_ == 'link':
+            _wid.setStyleName('chatTextInfo-link')
+            if link_cb:
+                _wid.addClickListener(link_cb)
+        elif type_ == 'me':
+            _wid.setStyleName('chatTextMe')
+        else:
+            _wid.setStyleName('chatTextInfo')
+        self.content.add(_wid)
+
+    def printMessage(self, from_jid, msg, extra, timestamp=None):
+        """Print message in chat window. Must be implemented by child class"""
+        _jid = jid.JID(from_jid)
+        nick = _jid.node if self.type == 'one2one' else _jid.resource
+        mymess = _jid.resource == self.nick if self.type == "group" else _jid.bare == self.host.whoami.bare  # mymess = True if message comes from local user
+        if msg.startswith('/me '):
+            self.printInfo('* %s %s' % (nick, msg[4:]), type_='me')
+            return
+        self.content.add(base_panels.ChatText(timestamp, nick, mymess, msg, extra.get('xhtml')))
+        self.content_scroll.scrollToBottom()
+
+    def startGame(self, game_type, waiting, referee, players, *args):
+        """Configure the chat window to start a game"""
+        classes = {"Tarot": card_game.CardPanel, "RadioCol": radiocol.RadioColPanel}
+        if game_type not in classes.keys():
+            return  # unknown game
+        attr = game_type.lower()
+        self.occupants_list.updateSpecials(players, SYMBOLS[attr])
+        if waiting or not self.nick in players:
+            return  # waiting for player or not playing
+        attr = "%s_panel" % attr
+        if hasattr(self, attr):
+            return
+        log.info("%s Game Started \o/" % game_type)
+        panel = classes[game_type](self, referee, self.nick, players, *args)
+        setattr(self, attr, panel)
+        self.vpanel.insert(panel, 0)
+        self.vpanel.setCellHeight(panel, panel.getHeight())
+
+    def getGame(self, game_type):
+        """Return class managing the game type"""
+        # TODO: check that the game is launched, and manage errors
+        if game_type == "Tarot":
+            return self.tarot_panel
+        elif game_type == "RadioCol":
+            return self.radiocol_panel
+
+    def setState(self, state, nick=None):
+        """Set the chat state (XEP-0085) of the contact. Leave nick to None
+        to set the state for a one2one conversation, or give a nickname or
+        C.ALL_OCCUPANTS to set the state of a participant within a MUC.
+        @param state: the new chat state
+        @param nick: None for one2one, the MUC user nick or ALL_OCCUPANTS
+        """
+        if nick:
+            assert(self.type == 'group')
+            occupants = self.occupants_list.occupants_list.keys() if nick == C.ALL_OCCUPANTS else [nick]
+            for occupant in occupants:
+                self.occupants_list.occupants_list[occupant].setState(state)
+        else:
+            assert(self.type == 'one2one')
+            self._state = state
+            self.refreshTitle()
+        self.state_machine.started = not not state  # start to send "composing" state from now
+
+    def refreshTitle(self):
+        """Refresh the title of this ChatPanel dialog"""
+        if self._state:
+            self.setTitle(self.target.bare + " (" + self._state + ")")
+        else:
+            self.setTitle(self.target.bare)
+
+
+class WebPanel(base_widget.LiberviaWidget):
+    """ (mini)browser like widget """
+
+    def __init__(self, host, url=None):
+        """
+        @param host: SatWebFrontend instance
+        """
+        base_widget.LiberviaWidget.__init__(self, host)
+        self._vpanel = VerticalPanel()
+        self._vpanel.setSize('100%', '100%')
+        self._url = dialog.ExtTextBox(enter_cb=self.onUrlClick)
+        self._url.setText(url or "")
+        self._url.setWidth('100%')
+        hpanel = HorizontalPanel()
+        hpanel.add(self._url)
+        btn = Button("Go", self.onUrlClick)
+        hpanel.setCellWidth(self._url, "100%")
+        #self.setCellWidth(btn, "10%")
+        hpanel.add(self._url)
+        hpanel.add(btn)
+        self._vpanel.add(hpanel)
+        self._vpanel.setCellHeight(hpanel, '20px')
+        self._frame = Frame(url or "")
+        self._frame.setSize('100%', '100%')
+        DOM.setStyleAttribute(self._frame.getElement(), "position", "relative")
+        self._vpanel.add(self._frame)
+        self.setWidget(self._vpanel)
+
+    def onUrlClick(self, sender):
+        self._frame.setUrl(self._url.getText())
+
+
+class MainPanel(AbsolutePanel):
+
+    def __init__(self, host):
+        self.host = host
+        AbsolutePanel.__init__(self)
+
+        # menu
+        self.menu = menu.Menu(host)
+
+        # unibox
+        self.unibox_panel = UniBoxPanel(host)
+        self.unibox_panel.setVisible(False)
+
+        # contacts
+        self._contacts = HorizontalPanel()
+        self._contacts.addStyleName('globalLeftArea')
+        self.contacts_switch = Button(u'«', self._contactsSwitch)
+        self.contacts_switch.addStyleName('contactsSwitch')
+        self._contacts.add(self.contacts_switch)
+        self._contacts.add(self.host.contact_panel)
+
+        # tabs
+        self.tab_panel = base_widget.MainTabPanel(host)
+        self.discuss_panel = base_widget.WidgetsPanel(self.host, locked=True)
+        self.tab_panel.add(self.discuss_panel, "Discussions")
+        self.tab_panel.selectTab(0)
+
+        self.header = AbsolutePanel()
+        self.header.add(self.menu)
+        self.header.add(self.unibox_panel)
+        self.header.add(self.host.status_panel)
+        self.header.setStyleName('header')
+        self.add(self.header)
+
+        self._hpanel = HorizontalPanel()
+        self._hpanel.add(self._contacts)
+        self._hpanel.add(self.tab_panel)
+        self.add(self._hpanel)
+
+        self.setWidth("100%")
+        Window.addWindowResizeListener(self)
+
+    def _contactsSwitch(self, btn=None):
+        """ (Un)hide contacts panel """
+        if btn is None:
+            btn = self.contacts_switch
+        cpanel = self.host.contact_panel
+        cpanel.setVisible(not cpanel.getVisible())
+        btn.setText(u"«" if cpanel.getVisible() else u"»")
+        self.host.resize()
+
+    def _contactsMove(self, parent):
+        """Move the contacts container (containing the contact list and
+        the "hide/show" button) to another parent, but always as the
+        first child position (insert at index 0).
+        """
+        if self._contacts.getParent():
+            if self._contacts.getParent() == parent:
+                return
+            self._contacts.removeFromParent()
+        parent.insert(self._contacts, 0)
+
+    def onWindowResized(self, width, height):
+        _elts = doc().getElementsByClassName('gwt-TabBar')
+        if not _elts.length:
+            tab_bar_h = 0
+        else:
+            tab_bar_h = _elts.item(0).offsetHeight
+        ideal_height = Window.getClientHeight() - tab_bar_h
+        self.setHeight("%s%s" % (ideal_height, "px"))
+
+    def refresh(self):
+        """Refresh the main panel"""
+        self.unibox_panel.refresh()