diff src/browser/sat_browser/blog.py @ 679:a90cc8fc9605

merged branch frontends_multi_profiles
author Goffi <goffi@goffi.org>
date Wed, 18 Mar 2015 16:15:18 +0100
parents src/browser/sat_browser/panels.py@3eb3a2c0c011 src/browser/sat_browser/panels.py@166f3b624816
children 9877607c719a
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/browser/sat_browser/blog.py	Wed Mar 18 16:15:18 2015 +0100
@@ -0,0 +1,771 @@
+#!/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, ()))