view browser_side/panels.py @ 293:7c79d4d66161

browser_side: blog post update: fix assignment to immutable element
author souliane <souliane@mailoo.org>
date Sun, 15 Dec 2013 12:01:25 +0100
parents 1a5dc08c2749
children a6b3715f0bd6
line wrap: on
line source

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

"""
Libervia: a Salut à Toi frontend
Copyright (C) 2011, 2012, 2013 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 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.PopupPanel import PopupPanel
from pyjamas.ui.StackPanel import StackPanel
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.Event import BUTTON_LEFT, BUTTON_MIDDLE, BUTTON_RIGHT
from pyjamas.ui.MouseListener import MouseHandler
from pyjamas.Timer import Timer
from pyjamas import DOM
from card_game import CardPanel
from radiocol import RadioColPanel
from menu import Menu
from jid import JID
from tools import html_sanitize, addURLToText, inlineRoot, setPresenceStyle
from datetime import datetime
from time import time
import dialog
import base_widget
from dialog import ConfirmDialog
import richtext
from plugin_xep_0085 import ChatStateMachine
from pyjamas import Window
from __pyjamas__ import doc
from sat_frontends.tools.games import SYMBOLS
from sat_frontends import constants
from pyjamas.ui.FocusListener import FocusHandler
import logging


const = constants.Const  # to directly import 'const' doesn't work


class UniBoxPanel(HorizontalPanel):
    """Panel containing the UniBox"""

    def __init__(self, host):
        HorizontalPanel.__init__(self)
        self.host = host
        self.setStyleName('uniBoxPanel')

        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(host)
        self.add(self.unibox)
        self.setCellWidth(self.unibox, '100%')

        self.button.addClickListener(self.openRichTextEditor)

    def openRichTextEditor(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 onCloseCallback():
            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.RichTextEditor.getOrCreate(self.host, self, onCloseCallback)
        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 UniBox(TextArea, MouseHandler): #AutoCompleteTextBox):
    """This text box is used as a main typing point, for message, microblog, etc"""

    def __init__(self, host):
        TextArea.__init__(self)
        #AutoCompleteTextBox.__init__(self)
        self.__size = (0, 0)
        self._popup = None
        self._timer = Timer(notify=self._timeCb)
        self.host = host
        self.setStyleName('uniBox')
        self.addKeyboardListener(self)
        MouseHandler.__init__(self)
        self.addMouseListener(self)
        self._selected_cache = None
        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:
            print "WARNING: trying to remove an unknown key"

    def showWarning(self, target_data):
        target_hook, _type, msg = target_data
        if _type == "NONE":
            return
        if not msg:
            print "WARNING: no msg set uniBox warning"
            return
        if _type == "PUBLIC":
            style = "targetPublic"
        elif _type == "GROUP":
            style = "targetGroup"
        elif _type == "STATUS":
            msg = "This will be your new status message"
            style = "targetStatus"
        elif _type == "ONE2ONE":
            style = "targetOne2One"
        else:
            print "ERROR: unknown message type"
            return
        contents = HTML(msg)

        self._popup = dialog.PopupPanelWrapper(autoHide=False, modal=False)
        self._popup.target_data = target_data
        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 _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:
                _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:
            print "ERROR: Unknown target"
            target_hook, _type, msg = getSelectedOrStatus()

        return (target_hook, _type, msg)

    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()
        target = self._getTarget(_txt)
        if not self._popup:
            self.showWarning(target)
        elif target != self._popup.target_data:
            self._timeCb(None)  # we remove the popup
            self.showWarning(target)

        self._timer.schedule(2000)

        def history_cb(text):
            self.setText(text)
            Timer(5, lambda: self.setCursorPos(len(text)))

        # if keycode == KEY_ENTER and not self.visible:
        if keycode == KEY_ENTER:
            if _txt:
                target_hook, type_, msg = target
                if target_hook:
                    parsed_txt, data = target_hook
                    self.host.send([(type_, data)], parsed_txt)
                else:  # we send the message to the selected target
                    self._selected_cache.onTextEntered(_txt)
                self.host._updateInputHistory(_txt)
            self.setText('')
            self._timeCb(None)  # we remove the popup
            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 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
        return (_txt, target)

    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

    """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 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.content = data['content']
        self.xhtml = data.get('xhtml')
        self.author = data['author']
        self.timestamp = float(data.get('timestamp', 0))  # XXX: int doesn't work here
        self.comments = data.get('comments', False)
        if self.comments:
            try:
                self.comments_hash = (data['comments_service'], data['comments_node'])
                self.comments_service = data['comments_service']
                self.comments_node = data['comments_node']
            except KeyError:
                print "Warning: can't manage comment [%s], some keys are missing in microblog data (%s)" % (data["comments"], data.keys())
                self.comments = False
        if set(("service", "node")).issubset(data.keys()):
            # comment item
            self.service = data["service"]
            self.node = data["node"]
        else:
            # main item
            try:
                self.service = data['comments_service']
                self.node = data['comments_node']
            except KeyError:
                logging.error("Main item %s is missing its comments information!" % self.id)
        self.hash = (self.service, self.node)


class MicroblogEntry(SimplePanel, ClickHandler, FocusHandler, KeyboardHandler):

    def __init__(self, blog_panel, mblog_entry):
        SimplePanel.__init__(self)
        self._blog_panel = blog_panel

        self.entry = mblog_entry
        self.author = mblog_entry.author
        self.timestamp = mblog_entry.timestamp
        _datetime = datetime.fromtimestamp(mblog_entry.timestamp)
        self.comments = mblog_entry.comments
        self.pub_data = (mblog_entry.hash[0], mblog_entry.hash[1], mblog_entry.id)

        self.editable_content = [mblog_entry.xhtml, const._SYNTAX_XHTML] if mblog_entry.xhtml else [mblog_entry.content, None]

        self.panel = FlowPanel()
        self.panel.setStyleName('mb_entry')
        header = HTMLPanel("""<div class='mb_entry_header'>
                                  <span class='mb_entry_author'>%(author)s</span>
                                  <span>on</span>
                                  <span class='mb_entry_timestamp'>%(timestamp)s</span>
                              </div>""" % {'author': html_sanitize(self.author), 'timestamp': _datetime})
        self.panel.add(header)

        if self.author == blog_panel.host.whoami.bare:
            entry_delete_update = VerticalPanel()
            entry_delete_update.setStyleName('mb_entry_delete_update')
            self.delete_label = Label(u"✗")
            self.delete_label.setTitle("Delete this message")
            self.update_label = Label(u"✍")
            self.update_label.setTitle("Edit this message")
            entry_delete_update.add(self.delete_label)
            entry_delete_update.add(self.update_label)
            self.delete_label.addClickListener(self)
            self.update_label.addClickListener(self)
            self.panel.add(entry_delete_update)
        else:
            self.update_label = self.delete_label = None

        entry_avatar = SimplePanel()
        entry_avatar.setStyleName('mb_entry_avatar')
        self.avatar = Image(blog_panel.host.getAvatar(self.author))
        entry_avatar.add(self.avatar)
        self.panel.add(entry_avatar)

        self.entry_dialog = SimplePanel()
        self.entry_dialog.setStyleName('mb_entry_dialog')
        body = addURLToText(html_sanitize(mblog_entry.content)) if not mblog_entry.xhtml else mblog_entry.xhtml
        self.bubble = HTML(body)
        self.bubble.setStyleName("bubble")
        self.entry_dialog.add(self.bubble)
        self.panel.add(self.entry_dialog)

        self.editbox = None
        self.add(self.panel)
        ClickHandler.__init__(self)
        self.addClickListener(self)

    def onWindowResized(self, width=None, height=None):
        """The listener is active when the text is being modified"""
        left = self.avatar.getAbsoluteLeft() + self.avatar.getOffsetWidth()
        right = self.delete_label.getAbsoluteLeft()
        ideal_width = right - left - 60
        self.entry_dialog.setWidth("%spx" % ideal_width)

    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:
            self._blog_panel.setSelectedEntry(self if self.comments else None)
        elif sender == self.update_label:
            self._update()
        elif sender == self.delete_label:
            self._delete()

    def onKeyUp(self, sender, keycode, modifiers):
        """Update is done when ENTER key is pressed within the raw editbox"""
        if sender != self.editbox or not self.editbox.getVisible():
            return
        if keycode == KEY_ENTER:
            self.updateContent()

    def onLostFocus(self, sender):
        """Update is done when the focus leaves the raw editbox"""
        if sender != self.editbox or not self.editbox.getVisible():
            return
        self.updateContent()

    def updateContent(self, cancel=False):
        """Send the new content to the backend"""
        if not self.editbox or not self.editbox.getVisible():
            return
        Window.removeWindowResizeListener(self)
        self.entry_dialog.setWidth("auto")
        self.entry_dialog.remove(self.edit_panel)
        self.entry_dialog.add(self.bubble)
        new_text = self.editbox.getText().strip()
        self.edit_panel = self.editbox = None
        if cancel or new_text == self.editable_content[0] or new_text == "":
            return
        self.editable_content[0] = new_text
        self._blog_panel.host.bridge.call('updateMblog', None, self.pub_data, self.comments, new_text,
                                          {'rich': new_text} if self.entry.xhtml else {})

    def _update(self):
        """Change the bubble to an editbox"""
        if self.editbox and self.editbox.getVisible():
            return

        def setOriginalText(text, container):
            text = text.strip()
            container.original_text = text
            self.editbox.setWidth('100%')
            self.editbox.setText(text)
            panel = SimplePanel()
            panel.add(container)
            panel.setStyleName("bubble")
            panel.addStyleName('bubble-editbox')
            Window.addWindowResizeListener(self)
            self.onWindowResized()
            self.entry_dialog.remove(self.bubble)
            self.entry_dialog.add(panel)
            self.editbox.setFocus(True)
            self.editbox.setSelectionRange(len(text), 0)
            self.edit_panel = panel
            self.editable_content = [text, container.format if isinstance(container, richtext.RichTextEditor) else None]

        if self.entry.xhtml:
            options = ('no_recipient', 'no_sync_unibox', 'no_style', 'update_msg', 'no_close')

            def cb(result):
                self.updateContent(result == richtext.CANCEL)

            editor = richtext.RichTextEditor(self._blog_panel.host, self.panel, cb, options=options)
            editor.setWidth('100%')
            editor.setVisible(True)  # needed to build the toolbar
            self.editbox = editor.textarea
            self._blog_panel.host.bridge.call('syntaxConvert', lambda d: setOriginalText(d, editor),
                                              self.editable_content[0], self.editable_content[1])
        else:
            self.editbox = TextArea()
            self.editbox.addFocusListener(self)
            self.editbox.addKeyboardListener(self)
            setOriginalText(self.editable_content[0], self.editbox)

    def _delete(self):
        """Ask confirmation for deletion"""
        def confirm_cb(answer):
            if answer:
                self._blog_panel.host.bridge.call('deleteMblog', None, self.pub_data, self.comments)

        target = 'message and all its comments' if self.comments else 'comment'
        _dialog = ConfirmDialog(confirm_cb, text="Do you really want to delete this %s?" % target)
        _dialog.show()


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.entries = {}
        self.comments = {}
        self.selected_entry = None
        self.vpanel = VerticalPanel()
        self.vpanel.setStyleName('microblogPanel')
        self.setWidget(self.vpanel)

    @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)
        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):
        if self.selected_entry:
            if not self.selected_entry.comments:
                print ("ERROR: an item without comment is selected")
                return ("NONE", None)
            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_node = self.selected_entry.comments
            if not comments_node:
                raise Exception("ERROR: comments node is empty")
            target = ("COMMENT", comments_node)
        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
        """
        print "Massive insertion of %d microblogs" % len(mblogs)
        for publisher in mblogs:
            print "adding blogs for [%s]" % publisher
            for mblog in mblogs[publisher]:
                if not "content" in mblog:
                    print ("WARNING: No content found in microblog [%s]", mblog)
                    continue
                mblog_item = MicroblogItem(mblog)
                self.addEntry(mblog_item)

    def mblogsInsert(self, mblogs):
        """ Insert several microblogs at once
        @param mblogs: list of microblogs
        """
        for mblog in mblogs:
            if not "content" in mblog:
                print ("WARNING: No content found in microblog [%s]", mblog)
                continue
            mblog_item = MicroblogItem(mblog)
            self.addEntry(mblog_item)

    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"""
        # 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
            if reverse:
                if child.timestamp < entry.timestamp:
                    break
            else:
                if child.timestamp > entry.timestamp:
                    break
            idx += 1

        vpanel.insert(entry, idx)

    def addEntry(self, mblog_item):
        """Add an entry to the panel
        @param mblog_item: MicroblogItem instance
        """
        if mblog_item.type == "comment":
            if not mblog_item.hash in self.comments:
                # The comments node is not known in this panel
                return
            _entry = MicroblogEntry(self, mblog_item)
            parent = self.comments[mblog_item.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.pub_data[2] == mblog_item.id:
                    # update an existing comment
                    sub_panel.remove(comment)
                    sub_panel.insert(_entry, idx)
                    return
            # we want comments to be inserted in chronological order
            self._chronoInsert(sub_panel, _entry, reverse=False)
            return

        update = mblog_item.id in self.entries
        _entry = MicroblogEntry(self, mblog_item)
        if update:
            idx = self.vpanel.getWidgetIndex(self.entries[mblog_item.id])
            self.vpanel.remove(self.entries[mblog_item.id])
            self.vpanel.insert(_entry, idx)
        else:
            self._chronoInsert(self.vpanel, _entry)
        self.entries[mblog_item.id] = _entry

        if mblog_item.comments:
            # entry has comments, we keep the comment node as a reference
            self.comments[mblog_item.comments_hash] = _entry
            self.host.bridge.call('getMblogComments', self.mblogsInsert, mblog_item.comments_service, mblog_item.comments_node)

    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':
                print child.pub_data
                if child.pub_data[2] == 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.pub_data[2] == id_:
                        comment.removeFromParent()
                        self.selected_entry = None
                        break

    def setSelectedEntry(self, entry):
        if self.selected_entry == entry:
            entry = None
        if self.selected_entry:
            self.selected_entry.removeStyleName('selected_entry')
        if entry:
            print "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(HTMLPanel):
    def __init__(self, host, status=''):
        self.host = host
        self.status = status or '&nbsp;'
        HTMLPanel.__init__(self, self.__getContent())
        self.setStyleName('statusPanel')

    def __getContent(self):
        return "<span class='status'>%(status)s</span>" % {'status': html_sanitize(self.status)}

    def changeStatus(self, new_status):
        self.status = new_status or '&nbsp;'
        self.setHTML(self.__getContent())


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 const.PRESENCE.keys():
            entries.update({const.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 = 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.setStyleName("marginAuto")
        self.add(panel)

        ClickHandler.__init__(self)
        self.addClickListener(self)

    def getPresence(self):
        return self.presence

    def setPresence(self, presence):
        status = self.status_panel.status
        if not status.strip() or status == "&nbsp;" or status == const.PRESENCE[self.presence]:
            self.changeStatus(const.PRESENCE[presence])
        self.presence = presence
        setPresenceStyle(self.presence_button, self.presence)

    def changeStatus(self, new_status):
        self.status_panel.changeStatus(new_status)

    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 ChatText(HTMLPanel):

    def __init__(self, timestamp, nick, mymess, msg, xhtml = None):
        _date = datetime.fromtimestamp(float(timestamp or time()))
        _msg_class = ["chat_text_msg"]
        if mymess:
            _msg_class.append("chat_text_mymess")
        HTMLPanel.__init__(self, "<span class='chat_text_timestamp'>%(timestamp)s</span> <span class='chat_text_nick'>%(nick)s</span> <span class='%(msg_class)s'>%(msg)s</span>" %
                           {"timestamp": _date.strftime("%H:%M"),
                            "nick": "[%s]" % html_sanitize(nick),
                            "msg_class": ' '.join(_msg_class),
                            "msg": addURLToText(html_sanitize(msg)) if not xhtml else inlineRoot(xhtml)} #FIXME: images and external links must be removed according to preferences
                           )
        self.setStyleName('chatText')


class Occupant(HTML):
    """Occupant of a MUC room"""

    def __init__(self, nick, special=""):
        HTML.__init__(self)
        self.nick = nick
        self.special = special
        self._refresh()

    def __str__(self):
        return self.nick

    def addSpecial(self, special=""):
        if special not in self.special:
            self.special += special
            self._refresh()

    def _refresh(self):
        special = "" if len(self.special) == 0 else " %s" % self.special
        self.setHTML("<div class='occupant'>%s%s</div>" % (html_sanitize(self.nick), special))


class OccupantsList(AbsolutePanel):
    """Panel user to show occupants of a room"""

    def __init__(self):
        AbsolutePanel.__init__(self)
        self.occupants_list = {}
        self.setStyleName('occupantsList')

    def addOccupant(self, nick):
        _occupant = Occupant(nick)
        self.occupants_list[nick] = _occupant
        self.add(_occupant)

    def removeOccupant(self, nick):
        try:
            self.remove(self.occupants_list[nick])
        except KeyError:
            print "ERROR: trying to remove an unexisting nick"

    def clear(self):
        self.occupants_list.clear()
        AbsolutePanel.clear(self)

    def addSpecials(self, occupants=[], html=""):
        index = 0
        special = html
        for occupant in occupants:
            if occupant in self.occupants_list.keys():
                if isinstance(html, list):
                    special = html[index]
                    index = (index + 1) % len(html)
                self.occupants_list[occupant].addSpecial(special)


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) 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, target.bare, selectable=True)
        self.vpanel = VerticalPanel()
        self.vpanel.setSize('100%', '100%')
        self.type = type_
        self.nick = None
        if not target:
            print "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 = 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.addStyleName('chatPanel')
        self.setWidget(self.vpanel)
        self.state_machine = ChatStateMachine(self.host, str(self.target))

    """def doDetachChildren(self):
        #We need to force the use of a panel subclass method here,
        #for the same reason as doAttachChildren
        base_widget.ScrollPanelWrapper.doDetachChildren(self)

    def doAttachChildren(self):
        #We need to force the use of a panel subclass method here, else
        #the event will not propagate to children
        base_widget.ScrollPanelWrapper.doAttachChildren(self)"""

    @classmethod
    def registerClass(cls):
        base_widget.LiberviaWidget.addDropKey("CONTACT", cls.createPanel)

    @classmethod
    def createPanel(cls, host, item):
        _contact = item if isinstance(item, JID) else 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)
        return _new_panel

    def refresh(self):
        """Refresh the display of this widget."""
        self.host.contact_panel.setContactMessageWaiting(self.target.bare, False)
        self.content_scroll.scrollToBottom()

    def matchEntity(self, entity):
        """
        @param entity: target jid as a string or 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) else 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 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'):
        """Print general info
        @param msg: message to print
        @type: one of:
            normal: general info like "toto has joined the room"
            me: "/me" information like "/me clenches his fist" ==> "toto clenches his fist"
        """
        _wid = Label(msg)
        if type == 'normal':
            _wid.setStyleName('chatTextInfo')
        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(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(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": CardPanel, "RadioCol": RadioColPanel}
        if game_type not in classes.keys():
            return  # unknown game
        attr = game_type.lower()
        self.occupants_list.addSpecials(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
        print ("%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


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

        # unibox
        unibox_panel = UniBoxPanel(host)
        self.host.setUniBox(unibox_panel.unibox)

        # status bar
        status = host.status_panel

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

        header = AbsolutePanel()
        header.add(self.menu)
        header.add(unibox_panel)
        header.add(status)
        header.setStyleName('header')
        self.add(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"))


class PopupMenuPanel(PopupPanel):
    """This implementation of a popup menu (context menu) allow you to assign
    two special methods which are common to all the items, in order to hide
    certain items and also easily define their callbacks. The menu can be
    bound to any of the mouse button (left, middle, right).
    """
    def __init__(self, entries, hide=None, callback=None, vertical=True, style={}, **kwargs):
        """
        @param entries: a dict of dicts, where each sub-dict is representing
        one menu item: the sub-dict key can be used as the item text and
        description, but optional "title" and "desc" entries would be used
        if they exists. The sub-dicts may be extended later to do
        more complicated stuff or overwrite the common methods.
        @param hide: function  with 2 args: widget, key as string and
        returns True if that item should be hidden from the context menu.
        @param callback: function with 2 args: sender, key as string
        @param vertical: True or False, to set the direction
        @param item_style: alternative CSS class for the menu items
        @param menu_style: supplementary CSS class for the sender widget
        """
        PopupPanel.__init__(self, autoHide=True, **kwargs)
        self._entries = entries
        self._hide = hide
        self._callback = callback
        self.vertical = vertical
        self.style = {"selected": None, "menu": "recipientTypeMenu", "item": "popupMenuItem"}
        self.style.update(style)
        self._senders = {}

    def _show(self, sender):
        """Popup the menu relative to this sender's position.
        @param sender: the widget that has been clicked
        """
        menu = VerticalPanel() if self.vertical is True else HorizontalPanel()
        menu.setStyleName(self.style["menu"])

        def button_cb(item):
            """You can not put that method in the loop and rely
            on _key, because it is overwritten by each step.
            You can rely on item.key instead, which is copied
            from _key after the item creation.
            @param item: the menu item that has been clicked
            """
            if self._callback is not None:
                self._callback(sender=sender, key=item.key)
            self.hide(autoClosed=True)

        for _key in self._entries.keys():
            entry = self._entries[_key]
            if self._hide is not None and self._hide(sender=sender, key=_key) is True:
                continue
            title = entry["title"] if "title" in entry.keys() else _key
            item = Button(title, button_cb)
            item.key = _key
            item.setStyleName(self.style["item"])
            item.setTitle(entry["desc"] if "desc" in entry.keys() else title)
            menu.add(item)
        if len(menu.getChildren()) == 0:
            return
        self.add(menu)
        if self.vertical is True:
            x = sender.getAbsoluteLeft() + sender.getOffsetWidth()
            y = sender.getAbsoluteTop()
        else:
            x = sender.getAbsoluteLeft()
            y = sender.getAbsoluteTop() + sender.getOffsetHeight()
        self.setPopupPosition(x, y)
        self.show()
        if self.style["selected"]:
            sender.addStyleDependentName(self.style["selected"])

        def _onHide(popup):
            if self.style["selected"]:
                sender.removeStyleDependentName(self.style["selected"])
            return PopupPanel.onHideImpl(self, popup)

        self.onHideImpl = _onHide

    def registerClickSender(self, sender, button=BUTTON_LEFT):
        """Bind the menu to the specified sender.
        @param sender: the widget to which the menu should be bound
        @param: BUTTON_LEFT, BUTTON_MIDDLE or BUTTON_RIGHT
        """
        self._senders.setdefault(sender, [])
        self._senders[sender].append(button)

        if button == BUTTON_RIGHT:
            # WARNING: to disable the context menu is a bit tricky...
            # The following seems to work on Firefox 24.0, but:
            # TODO: find a cleaner way to disable the context menu
            sender.getElement().setAttribute("oncontextmenu", "return false")

        def _onBrowserEvent(event):
            button = DOM.eventGetButton(event)
            if DOM.eventGetType(event) == "mousedown" and button in self._senders[sender]:
                self._show(sender)
            return sender.__class__.onBrowserEvent(sender, event)

        sender.onBrowserEvent = _onBrowserEvent

    def registerMiddleClickSender(self, sender):
        self.registerClickSender(sender, BUTTON_MIDDLE)

    def registerRightClickSender(self, sender):
        self.registerClickSender(sender, BUTTON_RIGHT)


class ToggleStackPanel(StackPanel):
    """This is a pyjamas.ui.StackPanel with modified behavior. All sub-panels ca be
    visible at the same time, clicking a sub-panel header will not display it and hide
    the others but only toggle its own visibility. The argument 'visibleStack' is ignored.
    Note that the argument 'visible' has been added to listener's 'onStackChanged' method.
    """

    def __init__(self, **kwargs):
        StackPanel.__init__(self, **kwargs)

    def onBrowserEvent(self, event):
        if DOM.eventGetType(event) == "click":
            index = self.getDividerIndex(DOM.eventGetTarget(event))
            if index != -1:
                self.toggleStack(index)

    def add(self, widget, stackText="", asHTML=False, visible=False):
        StackPanel.add(self, widget, stackText, asHTML)
        self.setStackVisible(self.getWidgetCount() - 1, visible)

    def toggleStack(self, index):
        if index >= self.getWidgetCount():
            return
        visible = not self.getWidget(index).getVisible()
        self.setStackVisible(index, visible)
        for listener in self.stackListeners:
            listener.onStackChanged(self, index, visible)