view src/browser/sat_browser/blog.py @ 684:e876f493dccc

browser_side: follow changes made on quick_frontend for chat states and MUC symbols + minor fixes following the refactorisation: - some MUC handlers are no more needed, the presence handler is enough - move the chat states logic to quick_frontend - display MUC games symbols - remove classes contact_list.ContactsPanel, contact_panel.Occupant and contact_panel.OccupantsList - move buildPresenceStyle and setPresenceStyle to html_tools - fixes games menu callback
author souliane <souliane@mailoo.org>
date Wed, 18 Mar 2015 10:17:04 +0100
parents a90cc8fc9605
children 9877607c719a
line wrap: on
line source

#!/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.core.i18n import _, D_

from pyjamas.ui.SimplePanel import SimplePanel
from pyjamas.ui.VerticalPanel import VerticalPanel
from pyjamas.ui.HorizontalPanel import HorizontalPanel
from pyjamas.ui.Label import Label
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, KeyboardHandler
from pyjamas.ui.FocusListener import FocusHandler
from pyjamas.ui.MouseListener import MouseHandler
from pyjamas.Timer import Timer

from datetime import datetime
from time import time

import html_tools
import dialog
import richtext
import editor_widget
import libervia_widget
from constants import Const as C
from sat_frontends.quick_frontend import quick_widgets
from sat_frontends.tools import jid


unicode = str # XXX: pyjamas doesn't manage unicode


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 = jid.JID(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 = HorizontalPanel(StyleName='mb_entry_header')
        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')
        assert isinstance(self.author, jid.JID) # FIXME: temporary
        self.avatar = Image(self._blog_panel.host.getAvatarURL(self.author)) # FIXME: self.author should be initially a jid.JID
        entry_avatar.add(self.avatar)
        self.panel.add(entry_avatar)

        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.add(HTML("""<span class='mb_entry_header_info'>
                                  <span class='mb_entry_author'>%(author)s</span> on
                                  <span class='mb_entry_timestamp'>%(published)s</span>%(updated)s
                                </span>""" % {'author': html_tools.html_sanitize(unicode(self.author)),
                                              'published': datetime.fromtimestamp(self.published),
                                              'updated': update_text if self.published != self.updated else ''
                                              }))
        if self.comments:
            self.comments_count = self.hidden_count = 0
            self.show_comments_link = HTML('')
            self.header.add(self.show_comments_link)

    def updateHeader(self, comments_count=None, hidden_count=None, inc=None):
        """Update the header.

        @param comments_count (int): total number of comments.
        @param hidden_count (int): number of hidden comments.
        @param inc (int): number to increment the total number of comments with.
        """
        if comments_count is not None:
            self.comments_count = comments_count
        if hidden_count is not None:
            self.hidden_count = hidden_count
        if inc is not None:
            self.comments_count += inc

        if self.hidden_count > 0:
            comments = D_('comments') if self.hidden_count > 1 else D_('comment')
            text = D_("<a>show %(count)d previous %(comments)s</a>") % {'count': self.hidden_count,
                                                                        'comments': comments}
            if self not in self.show_comments_link._clickListeners:
                self.show_comments_link.addClickListener(self)
        else:
            if self.comments_count > 1:
                text = "%(count)d %(comments)s" % {'count': self.comments_count,
                                                   'comments': D_('comments')}
            elif self.comments_count == 1:
                text = D_('1 comment')
            else:
                text = ''
            try:
                self.show_comments_link.removeClickListener(self)
            except ValueError:
                pass

        self.show_comments_link.setHTML("""<span class='mb_entry_comments'>%(text)s</span></div>""" % {'text': text})

    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 unicode(self.node).endswith(unicode(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:
            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._comment()
        elif sender == self.show_comments_link:
            self._blog_panel.loadAllCommentsForEntry(self)

    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': unicode(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, tuple(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, update_header=False)
            if self.type == 'main_item':  # restore the "New message" button
                self._blog_panel.addNewMessageEntry()
            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 (AttributeError, 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 = editor_widget.LightTextEditor(content, self.__modifiedCb, self.__afterEditCb, options={'no_xhtml': True})
        self.bubble.addStyleName("bubble")
        try:
            self.toggle_syntax_button.removeFromParent()
        except (AttributeError, 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, True)
            return
        data = {'id': unicode(time()),
                'new': True,
                'type': 'comment',
                'author': unicode(self._blog_panel.host.whoami.bare),
                'service': self.comments_service,
                'node': self.comments_node
                }
        entry = self._blog_panel.addEntry(data, update_header=False)
        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, True)

    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 (AttributeError, 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')
            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(quick_widgets.QuickWidget, libervia_widget.LiberviaWidget, MouseHandler):
    warning_msg_public = "This message will be <b>PUBLIC</b> 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 following groups: <span class='warningTarget'>%s</span>"
    # FIXME: all the generic parts must be moved to quick_frontends

    def __init__(self, host, targets, profiles=None):
        """Panel used to show microblog

        @param targets (tuple(unicode)): contact groups displayed in this panel.
            If empty, show all microblogs from all contacts.
        """
        # do not mix self.targets (set of tuple of unicode) and self.accepted_groups (set of unicode)
        quick_widgets.QuickWidget.__init__(self, host, targets, C.PROF_KEY_NONE)
        libervia_widget.LiberviaWidget.__init__(self, host, ", ".join(self.accepted_groups), selectable=True)
        MouseHandler.__init__(self)
        self.entries = {}
        self.comments = {}
        self.vpanel = VerticalPanel()
        self.vpanel.setStyleName('microblogPanel')
        self.setWidget(self.vpanel)
        self.addNewMessageEntry()

        self.footer = HTML('', StyleName='microblogPanel_footer')
        self.footer.waiting = False
        self.footer.addClickListener(self)
        self.footer.addMouseListener(self)
        self.vpanel.add(self.footer)
        self.next_rsm_index = 0

        # FIXME: workaround for a pyjamas issue: calling hash on a class method always return a different value if that method is defined directly within the class (with the "def" keyword)
        self.avatarListener = self.onAvatarUpdate
        host.addListener('avatar', self.avatarListener, [C.PROF_KEY_NONE])

    def __str__(self):
        return u"Blog Widget [target: {}, profile: {}]".format(', '.join(self.accepted_groups), self.profile)

    def onDelete(self):
        quick_widgets.QuickWidget.onDelete(self)
        self.host.removeListener('avatar', self.avatarListener)

    def onAvatarUpdate(self, jid_, hash_, profile):
        """Called on avatar update events

        @param jid_: jid of the entity with updated avatar
        @param hash_: hash of the avatar
        @param profile: %(doc_profile)s
        """
        whoami = self.host.profiles[self.profile].whoami
        if self.isJidAccepted(jid_) or jid_.bare == whoami.bare:
            self.updateValue('avatar', jid_, hash_)

    def addNewMessageEntry(self):
        """Add an empty entry for writing a new message if needed."""
        if self.getNewMainEntry():
            return  # there's already one
        data = {'id': unicode(time()),
                'new': True,
                'author': unicode(self.host.whoami.bare),
                }
        entry = self.addEntry(data, update_header=False)
        entry.edit(True)

    def getNewMainEntry(self):
        """Get the new entry being edited, or None if it doesn't exists.

        @return (MicroblogEntry): the new entry being edited.
        """
        if len(self.vpanel.children) < 2:
            return None  # there's only the footer
        first = self.vpanel.children[0]
        assert(first.type == 'main_item')
        return first if first.empty else None

    @staticmethod
    def onGroupDrop(host, targets):
        """Create a microblog panel for one, several or all contact groups.

        @param host (SatWebFrontend): the SatWebFrontend instance
        @param targets (tuple(unicode)): tuple of groups (empty for "all groups")
        @return: the created MicroblogPanel
        """
        # XXX: pyjamas doesn't support use of cls directly
        widget = host.displayWidget(MicroblogPanel, targets, dropped=True)
        widget.loadMoreMainEntries()
        return widget

    @property
    def accepted_groups(self):
        """Return a set of the accepted groups"""
        return set().union(*self.targets)

    def loadAllCommentsForEntry(self, main_entry):
        """Load all the comments for the given main entry.

        @param main_entry (MicroblogEntry): main entry having comments.
        """
        index = str(main_entry.comments_count - main_entry.hidden_count)
        rsm = {'max': str(main_entry.hidden_count), 'index': index}
        self.host.bridge.call('getMblogComments', self.mblogsInsert, main_entry.comments_service, main_entry.comments_node, rsm)

    def loadMoreMainEntries(self):
        if self.footer.waiting:
            return
        self.footer.waiting = True
        self.footer.setHTML("loading...")

        self.host.loadOurMainEntries(self.next_rsm_index, self)

        type_ = 'ALL' if self.accepted_groups == [] else 'GROUP'
        rsm = {'max': str(C.RSM_MAX_ITEMS), 'index': str(self.next_rsm_index)}
        self.host.bridge.getMassiveMblogs(type_, list(self.accepted_groups), rsm, profile=C.PROF_KEY_NONE, callback=self.massiveInsert)

    def getWarningData(self, comment):
        """
        @param comment: set to True if the composed message is a comment
        @return: a couple (type, msg) for calling self.host.showWarning"""
        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:
            # FIXME: manage several groups
            return ("GROUP", self.warning_msg_group % ' '.join(self.accepted_groups))

    def onTextEntered(self, text):
        if not self.accepted_groups:
            # we are entering a public microblog
            self.bridge.call("sendMblog", None, "PUBLIC", (), text, {})
        else:
            self.bridge.call("sendMblog", None, "GROUP", tuple(self.accepted_groups), 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 (dict): dictionary mapping a publisher to microblogs data:
            - key: publisher (str)
            - value: couple (list[dict], dict) with:
                - list of microblogs data
                - RSM response data
        """
        count_pub = len(mblogs)
        count_msg = sum([len(value) for value in mblogs.values()])
        log.debug("massive insertion of {count_msg} blogs for {count_pub} contacts".format(count_msg=count_msg, count_pub=count_pub))
        for publisher in mblogs:
            log.debug("adding {count} blogs for [{publisher}]".format(count=len(mblogs[publisher]), publisher=publisher))
            self.mblogsInsert(mblogs[publisher])
        self.next_rsm_index += C.RSM_MAX_ITEMS
        self.footer.waiting = False
        self.footer.setHTML('show older messages')

    def mblogsInsert(self, mblogs):
        """ Insert several microblogs from the same node at once.

        @param mblogs (list): couple (list[dict], dict) with:
            - list of microblogs data
            - RSM response data
        """
        mblogs, rsm = mblogs

        for mblog in mblogs:
            if "content" not in mblog:
                log.warning("No content found in microblog [%s]" % mblog)
                continue
            self.addEntry(mblog, update_header=False)

        hashes = set([(entry['service'], entry['node']) for entry in mblogs if entry['type'] == 'comment'])
        assert(len(hashes) < 2)  # ensure the blogs come from the same node
        if len(hashes) == 1:
            main_entry = self.comments[hashes.pop()]
            count = int(rsm['count'])
            hidden = count - (int(rsm['index']) + len(mblogs))
            main_entry.updateHeader(count, hidden)

    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"""
        # XXX: for now we can't use "published" timestamp because the entries
        # are retrieved using the "updated" field. We don't want new items
        # inserted with RSM to be inserted "randomly" in the panel, they
        # should be added at the bottom of the list.
        assert(isinstance(reverse, bool))
        if entry.empty:
            entry.updated = 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[0:-1]:  # ignore the footer
            if not isinstance(child, MicroblogEntry):
                idx += 1
                continue
            condition_to_stop = child.empty or (child.updated > entry.updated)
            if condition_to_stop != reverse:  # != is XOR
                break
            idx += 1

        vpanel.insert(entry, idx)

    def addEntryIfAccepted(self, sender, groups, mblog_entry):
        """Check if an entry can go in MicroblogPanel and add to it

        @param sender(jid.JID): jid of the entry sender
        @param groups: groups which can receive this entry
        @param mblog_entry: panels.MicroblogItem instance
        """
        assert isinstance(sender, jid.JID) # FIXME temporary
        if (mblog_entry.type == "comment"
            or self.isJidAccepted(sender)
            or (groups is None and sender == self.host.profiles[self.profile].whoami.bare)
            or (groups and groups.intersection(self.accepted_groups))):
            self.addEntry(mblog_entry)

    def addEntry(self, data, update_header=True):
        """Add an entry to the panel

        @param data (dict): dict containing the item data
        @param update_header (bool): update or not the main comment header
        @return: the added MicroblogEntry instance, or None
        """
        _entry = MicroblogEntry(self, data)
        if _entry.type == "comment":
            comments_hash = (_entry.service, _entry.node)
            if comments_hash not 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)
            if update_header:
                parent.updateHeader(inc=+1)
            return _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)

        if _entry.id in self.entries:  # update
            old_entry = self.entries[_entry.id]
            idx = self.vpanel.getWidgetIndex(old_entry)
            counts = (old_entry.comments_count, old_entry.hidden_count)
            self.vpanel.remove(old_entry)
            self.vpanel.insert(_entry, idx)
            _entry.updateHeader(*counts)
        else:  # new entry
            self._chronoInsert(self.vpanel, _entry)
            if _entry.comments:
                self.host.bridge.call('getMblogComments', self.mblogsInsert, _entry.comments_service, _entry.comments_node)

        self.entries[_entry.id] = _entry

        return _entry

    def removeEntry(self, type_, id_, update_header=True):
        """Remove an entry from the panel

        @param type_ (str): entry type ('main_item' or 'comment')
        @param id_ (str): entry id
        @param update_header (bool): update or not the main comment header
        """
        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()
                    break
            elif isinstance(child, VerticalPanel) and type_ == 'comment':
                for comment in child.getChildren():
                    if comment.id == id_:
                        if update_header:
                            hash_ = (comment.service, comment.node)
                            self.comments[hash_].updateHeader(inc=-1)
                        comment.removeFromParent()
                        break

    def ensureVisible(self, entry):
        """Scroll to an entry to ensure its visibility

        @param entry (MicroblogEntry): the 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!")

    def setSelectedEntry(self, entry, ensure_visible=False):
        """Select an entry.

        @param entry (MicroblogEntry): the entry to select
        @param ensure_visible (boolean): if True, also scroll to the entry
        """
        if ensure_visible:
            self.ensureVisible(entry)

        entry.addStyleName('selected_entry')  # blink the clicked entry
        clicked_entry = entry  # entry may be None when the timer is done
        Timer(500, lambda timer: clicked_entry.removeStyleName('selected_entry'))

    def updateValue(self, type_, jid_, value):
        """Update a jid value in entries

        @param type_: one of 'avatar', 'nick'
        @param jid_(jid.JID): jid concerned
        @param value: new value"""
        assert isinstance(jid_, jid.JID) # FIXME: temporary
        def updateVPanel(vpanel):
            avatar_url = self.host.getAvatarURL(jid_)
            for child in vpanel.children:
                if isinstance(child, MicroblogEntry) and child.author == jid_:
                    child.updateAvatar(avatar_url)
                elif isinstance(child, VerticalPanel):
                    updateVPanel(child)
        if type_ == 'avatar':
            updateVPanel(self.vpanel)

    def addAcceptedGroups(self, groups):
        """Add one or more group(s) which can be displayed in this panel.

        @param groups (tuple(unicode)): tuple of groups to add
        """
        # FIXME: update the widget's hash in QuickApp._widgets[MicroblogPanel]
        self.targets.update(groups)

    def isJidAccepted(self, jid_):
        """Tell if a jid is actepted and must be shown in this panel

        @param jid_(jid.JID): jid to check
        @return: True if the jid is accepted
        """
        assert isinstance(jid_, jid.JID) # FIXME temporary
        if self.accept_all():
            return True
        for group in self.accepted_groups:
            if self.host.contact_lists[self.profile].isEntityInGroup(jid_, group):
                return True
        return False

    def onClick(self, sender):
        if sender == self.footer:
            self.loadMoreMainEntries()

    def onMouseEnter(self, sender):
        if sender == self.footer:
            self.loadMoreMainEntries()


libervia_widget.LiberviaWidget.addDropKey("GROUP", lambda host, item: MicroblogPanel.onGroupDrop(host, (item,)))

# Needed for the drop keys to not be mixed between meta panel and panel for "Contacts" group
libervia_widget.LiberviaWidget.addDropKey("CONTACT_TITLE", lambda host, item: MicroblogPanel.onGroupDrop(host, ()))