view src/browser/sat_browser/blog.py @ 589:a5019e62c3e9 frontends_multi_profiles

browser side: big refactoring to base Libervia on QuickFrontend, first draft: /!\ not finished, partially working and highly instable - add collections module with an OrderedDict like class - SatWebFrontend inherit from QuickApp - general sat_frontends tools.jid module is used - bridge/json methods have moved to json module - UniBox is partially removed (should be totally removed before merge to trunk) - Signals are now register with the generic registerSignal method (which is called mainly in QuickFrontend) - the generic getOrCreateWidget method from QuickWidgetsManager is used instead of Libervia's specific methods - all Widget are now based more or less directly on QuickWidget - with the new QuickWidgetsManager.getWidgets method, it's no more necessary to check all widgets which are instance of a particular class - ChatPanel and related moved to chat module - MicroblogPanel and related moved to blog module - global and overcomplicated send method has been disabled: each class should manage its own sending - for consistency with other frontends, former ContactPanel has been renamed to ContactList and vice versa - for the same reason, ChatPanel has been renamed to Chat - for compatibility with QuickFrontend, a fake profile is used in several places, it is set to C.PROF_KEY_NONE (real profile is managed server side for obvious security reasons) - changed default url for web panel to SàT website, and contact address to generic SàT contact address - ContactList is based on QuickContactList, UI changes are done in update method - bride call (now json module) have been greatly improved, in particular call can be done in the same way as for other frontends (bridge.method_name(arg1, arg2, ..., callback=cb, errback=eb). Blocking method must be called like async methods due to javascript architecture - in bridge calls, a callback can now exists without errback - hard reload on BridgeSignals remote error has been disabled, a better option should be implemented - use of constants where that make sens, some style improvments - avatars are temporarily disabled - lot of code disabled, will be fixed or removed before merge - various other changes, check diff for more details server side: manage remote exception on getEntityData, removed getProfileJid call, added getWaitingConf, added getRoomsSubjects
author Goffi <goffi@goffi.org>
date Sat, 24 Jan 2015 01:45:39 +0100
parents src/browser/sat_browser/panels.py@bade589dbd5a
children d78126d82ca0
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 _

from pyjamas.ui.SimplePanel import SimplePanel
from pyjamas.ui.VerticalPanel import VerticalPanel
from pyjamas.ui.HorizontalPanel import HorizontalPanel
from pyjamas.ui.HTMLPanel import HTMLPanel
from pyjamas.ui.Label import Label
from pyjamas.ui.Button import Button
from pyjamas.ui.HTML import HTML
from pyjamas.ui.Image import Image
from pyjamas.ui.ClickListener import ClickHandler
from pyjamas.ui.FlowPanel import FlowPanel
from pyjamas.ui.KeyboardListener import KEY_ENTER, KeyboardHandler
from pyjamas.ui.FocusListener import FocusHandler
from pyjamas.Timer import Timer

from datetime import datetime
from time import time

import html_tools
import base_panels
import dialog
import base_widget
import richtext
from constants import Const as C
from sat_frontends.quick_frontend import quick_widgets

# TODO: at some point we should decide which behaviors to keep and remove these two constants
TOGGLE_EDITION_USE_ICON = False  # set to True to use an icon inside the "toggle syntax" button
NEW_MESSAGE_USE_BUTTON = False  # set to True to display the "New message" button instead of an empty entry


class MicroblogItem():
    # XXX: should be moved in a separated module

    def __init__(self, data):
        self.id = data['id']
        self.type = data.get('type', 'main_item')
        self.empty = data.get('new', False)
        self.title = data.get('title', '')
        self.title_xhtml = data.get('title_xhtml', '')
        self.content = data.get('content', '')
        self.content_xhtml = data.get('content_xhtml', '')
        self.author = data['author']
        self.updated = float(data.get('updated', 0))  # XXX: int doesn't work here
        self.published = float(data.get('published', self.updated))  # XXX: int doesn't work here
        self.service = data.get('service', '')
        self.node = data.get('node', '')
        self.comments = data.get('comments', False)
        self.comments_service = data.get('comments_service', '')
        self.comments_node = data.get('comments_node', '')


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

    def __init__(self, blog_panel, data):
        """
        @param blog_panel: the parent panel
        @param data: dict containing the blog item data, or a MicroblogItem instance.
        """
        self._base_item = data if isinstance(data, MicroblogItem) else MicroblogItem(data)
        for attr in ['id', 'type', 'empty', 'title', 'title_xhtml', 'content', 'content_xhtml',
                     'author', 'updated', 'published', 'comments', 'service', 'node',
                     'comments_service', 'comments_node']:
            getter = lambda attr: lambda inst: getattr(inst._base_item, attr)
            setter = lambda attr: lambda inst, value: setattr(inst._base_item, attr, value)
            setattr(MicroblogEntry, attr, property(getter(attr), setter(attr)))

        SimplePanel.__init__(self)
        self._blog_panel = blog_panel

        self.panel = FlowPanel()
        self.panel.setStyleName('mb_entry')

        self.header = HTMLPanel('')
        self.panel.add(self.header)

        self.entry_actions = VerticalPanel()
        self.entry_actions.setStyleName('mb_entry_actions')
        self.panel.add(self.entry_actions)

        entry_avatar = SimplePanel()
        entry_avatar.setStyleName('mb_entry_avatar')
        # FIXME
        self.avatar = Image(C.DEFAULT_AVATAR) # self._blog_panel.host.getAvatar(self.author))
        entry_avatar.add(self.avatar)
        self.panel.add(entry_avatar)

        if TOGGLE_EDITION_USE_ICON:
            self.entry_dialog = HorizontalPanel()
        else:
            self.entry_dialog = VerticalPanel()
        self.entry_dialog.setStyleName('mb_entry_dialog')
        self.panel.add(self.entry_dialog)

        self.add(self.panel)
        ClickHandler.__init__(self)
        self.addClickListener(self)

        self.__pub_data = (self.service, self.node, self.id)
        self.__setContent()

    def __setContent(self):
        """Actually set the entry content (header, icons, bubble...)"""
        self.delete_label = self.update_label = self.comment_label = None
        self.bubble = self._current_comment = None
        self.__setHeader()
        self.__setBubble()
        self.__setIcons()

    def __setHeader(self):
        """Set the entry header"""
        if self.empty:
            return
        update_text = u" — ✍ " + "<span class='mb_entry_timestamp'>%s</span>" % datetime.fromtimestamp(self.updated)
        self.header.setHTML("""<div class='mb_entry_header'>
                                   <span class='mb_entry_author'>%(author)s</span> on
                                   <span class='mb_entry_timestamp'>%(published)s</span>%(updated)s
                               </div>""" % {'author': html_tools.html_sanitize(self.author),
                                            'published': datetime.fromtimestamp(self.published),
                                            'updated': update_text if self.published != self.updated else ''
                                            }
                            )

    def __setIcons(self):
        """Set the entry icons (delete, update, comment)"""
        if self.empty:
            return

        def addIcon(label, title):
            label = Label(label)
            label.setTitle(title)
            label.addClickListener(self)
            self.entry_actions.add(label)
            return label

        if self.comments:
            self.comment_label = addIcon(u"↶", "Comment this message")
            self.comment_label.setStyleName('mb_entry_action_larger')
        is_publisher = self.author == self._blog_panel.host.whoami.bare
        if is_publisher:
            self.update_label = addIcon(u"✍", "Edit this message")
        if is_publisher or str(self.node).endswith(self._blog_panel.host.whoami.bare):
            self.delete_label = addIcon(u"✗", "Delete this message")

    def updateAvatar(self, new_avatar):
        """Change the avatar of the entry
        @param new_avatar: path to the new image"""
        self.avatar.setUrl(new_avatar)

    def onClick(self, sender):
        if sender == self:
            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()

    def __modifiedCb(self, content):
        """Send the new content to the backend
        @return: False to restore the original content if a deletion has been cancelled
        """
        if not content['text']:  # previous content has been emptied
            self._delete(True)
            return False
        extra = {'published': str(self.published)}
        if isinstance(self.bubble, richtext.RichTextEditor):
            # TODO: if the user change his parameters after the message edition started,
            # the message syntax could be different then the current syntax: pass the
            # message syntax in extra for the frontend to use it instead of current syntax.
            extra.update({'content_rich': content['text'], 'title': content['title']})
        if self.empty:
            if self.type == 'main_item':
                self._blog_panel.host.bridge.call('sendMblog', None, None, self._blog_panel.accepted_groups, content['text'], extra)
            else:
                self._blog_panel.host.bridge.call('sendMblogComment', None, self._parent_entry.comments, content['text'], extra)
        else:
            self._blog_panel.host.bridge.call('updateMblog', None, self.__pub_data, self.comments, content['text'], extra)
        return True

    def __afterEditCb(self, content):
        """Remove the entry if it was an empty one (used for creating a new blog post).
        Data for the actual new blog post will be received from the bridge"""
        if self.empty:
            self._blog_panel.removeEntry(self.type, self.id)
            if self.type == 'main_item':  # restore the "New message" button
                self._blog_panel.refresh()
            else:  # allow to create a new comment
                self._parent_entry._current_comment = None
        self.entry_dialog.setWidth('auto')
        try:
            self.toggle_syntax_button.removeFromParent()
        except TypeError:
            pass

    def __setBubble(self, edit=False):
        """Set the bubble displaying the initial content."""
        content = {'text': self.content_xhtml if self.content_xhtml else self.content,
                   'title': self.title_xhtml if self.title_xhtml else self.title}
        if self.content_xhtml:
            content.update({'syntax': C.SYNTAX_XHTML})
            if self.author != self._blog_panel.host.whoami.bare:
                options = ['read_only']
            else:
                options = [] if self.empty else ['update_msg']
            self.bubble = richtext.RichTextEditor(self._blog_panel.host, content, self.__modifiedCb, self.__afterEditCb, options)
        else:  # assume raw text message have no title
            self.bubble = base_panels.LightTextEditor(content, self.__modifiedCb, self.__afterEditCb, options={'no_xhtml': True})
        self.bubble.addStyleName("bubble")
        try:
            self.toggle_syntax_button.removeFromParent()
        except TypeError:
            pass
        self.entry_dialog.add(self.bubble)
        self.edit(edit)
        self.bubble.addEditListener(self.__showWarning)

    def __showWarning(self, sender, keycode):
        if keycode == KEY_ENTER:
            self._blog_panel.host.showWarning(None, None)
        else:
            self._blog_panel.host.showWarning(*self._blog_panel.getWarningData(self.type == 'comment'))

    def _delete(self, empty=False):
        """Ask confirmation for deletion.
        @return: False if the deletion has been cancelled."""
        def confirm_cb(answer):
            if answer:
                self._blog_panel.host.bridge.call('deleteMblog', None, self.__pub_data, self.comments)
            else:  # restore the text if it has been emptied during the edition
                self.bubble.setContent(self.bubble._original_content)

        if self.empty:
            text = _("New ") + (_("message") if self.comments else _("comment")) + _(" without body has been cancelled.")
            dialog.InfoDialog(_("Information"), text).show()
            return
        text = ""
        if empty:
            text = (_("Message") if self.comments else _("Comment")) + _(" body has been emptied.<br/>")
        target = _('message and all its comments') if self.comments else _('comment')
        text += _("Do you really want to delete this %s?") % target
        dialog.ConfirmDialog(confirm_cb, text=text).show()

    def _comment(self):
        """Add an empty entry for a new comment"""
        if self._current_comment:
            self._current_comment.bubble.setFocus(True)
            self._blog_panel.setSelectedEntry(self._current_comment, True)
            return
        data = {'id': str(time()),
                'new': True,
                'type': 'comment',
                'author': self._blog_panel.host.whoami.bare,
                'service': self.comments_service,
                'node': self.comments_node
                }
        entry = self._blog_panel.addEntry(data)
        if entry is None:
            log.info("The entry of id %s can not be commented" % self.id)
            return
        entry._parent_entry = self
        self._current_comment = entry
        self.edit(True, entry)
        self._blog_panel.setSelectedEntry(entry, 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 TypeError:
            pass
        entry.bubble.edit(edit)
        if edit:
            if isinstance(entry.bubble, richtext.RichTextEditor):
                image = '<a class="richTextIcon">A</a>'
                html = '<a style="color: blue;">raw text</a>'
                title = _('Switch to raw text edition')
            else:
                image = '<img src="media/icons/tango/actions/32/format-text-italic.png" class="richTextIcon"/>'
                html = '<a style="color: blue;">rich text</a>'
                title = _('Switch to rich text edition')
            if TOGGLE_EDITION_USE_ICON:
                entry.entry_dialog.setWidth('80%')
                entry.toggle_syntax_button = Button(image, entry.toggleContentSyntax)
                entry.toggle_syntax_button.setTitle(title)
                entry.entry_dialog.add(entry.toggle_syntax_button)
            else:
                entry.toggle_syntax_button = HTML(html)
                entry.toggle_syntax_button.addClickListener(entry.toggleContentSyntax)
                entry.toggle_syntax_button.addStyleName('mb_entry_toggle_syntax')
                entry.entry_dialog.add(entry.toggle_syntax_button)
                entry.toggle_syntax_button.setStyleAttribute('top', '-20px')  # XXX: need to force CSS
                entry.toggle_syntax_button.setStyleAttribute('left', '-20px')

    def toggleContentSyntax(self):
        """Toggle the editor between raw and rich text"""
        original_content = self.bubble.getOriginalContent()
        rich = not isinstance(self.bubble, richtext.RichTextEditor)
        if rich:
            original_content['syntax'] = C.SYNTAX_XHTML

        def setBubble(text):
            self.content = text
            self.content_xhtml = text if rich else ''
            self.content_title = self.content_title_xhtml = ''
            self.bubble.removeFromParent()
            self.__setBubble(True)
            self.bubble.setOriginalContent(original_content)
            if rich:
                self.bubble.setDisplayContent()  # needed in case the edition is aborted, to not end with an empty bubble

        text = self.bubble.getContent()['text']
        if not text:
            setBubble(' ')  # something different than empty string is needed to initialize the rich text editor
            return
        if not rich:
            def confirm_cb(answer):
                if answer:
                    self._blog_panel.host.bridge.call('syntaxConvert', setBubble, text, C.SYNTAX_CURRENT, C.SYNTAX_TEXT)
            dialog.ConfirmDialog(confirm_cb, text=_("Do you really want to lose the title and text formatting?")).show()
        else:
            self._blog_panel.host.bridge.call('syntaxConvert', setBubble, text, C.SYNTAX_TEXT, C.SYNTAX_XHTML)


class MicroblogPanel(quick_widgets.QuickWidget, base_widget.LiberviaWidget):
    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 group <span class='warningTarget'>%s</span>"
    # FIXME: all the generic parts must be moved to quick_frontends

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

        @param accepted_groups: groups displayed in this panel, if empty, show all microblogs from all contacts
        """
        self.setAcceptedGroup(accepted_groups)
        quick_widgets.QuickWidget.__init__(self, host, self.target, C.PROF_KEY_NONE)
        base_widget.LiberviaWidget.__init__(self, host, ", ".join(accepted_groups), selectable=True)
        self.entries = {}
        self.comments = {}
        self.selected_entry = None
        self.vpanel = VerticalPanel()
        self.vpanel.setStyleName('microblogPanel')
        self.setWidget(self.vpanel)

    @property
    def target(self):
        return tuple(self.accepted_groups)

    def refresh(self):
        """Refresh the display of this widget. If the unibox is disabled,
        display the 'New message' button or an empty bubble on top of the panel"""
        if hasattr(self, 'new_button'):
            self.new_button.setVisible(self.host.uni_box is None)
            return
        if self.host.uni_box is None:
            def addBox():
                if hasattr(self, 'new_button'):
                    self.new_button.setVisible(False)
                data = {'id': str(time()),
                        'new': True,
                        'author': self.host.whoami.bare,
                        }
                entry = self.addEntry(data)
                entry.edit(True)
            if NEW_MESSAGE_USE_BUTTON:
                self.new_button = Button("New message", listener=addBox)
                self.new_button.setStyleName("microblogNewButton")
                self.vpanel.insert(self.new_button, 0)
            elif not self.getNewMainEntry():
                addBox()

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

        @return (MicroblogEntry): the new entry being edited.
        """
        try:
            first = self.vpanel.children[0]
        except IndexError:
            return None
        assert(first.type == 'main_item')
        return first if first.empty else None

    @classmethod
    def registerClass(cls):
        base_widget.LiberviaWidget.addDropKey("GROUP", cls.createPanel)
        base_widget.LiberviaWidget.addDropKey("CONTACT_TITLE", cls.createMetaPanel)

    @classmethod
    def createPanel(cls, host, item):
        """Generic panel creation for one, several or all groups (meta).
        @parem host: the SatWebFrontend instance
        @param item: single group as a string, list of groups
         (as an array) or None (for the meta group = "all groups")
        @return: the created MicroblogPanel
        """
        _items = item if isinstance(item, list) else ([] if item is None else [item])
        _type = 'ALL' if _items == [] else 'GROUP'
        # XXX: pyjamas doesn't support use of cls directly
        _new_panel = MicroblogPanel(host, _items)
        host.FillMicroblogPanel(_new_panel)
        host.bridge.call('getMassiveLastMblogs', _new_panel.massiveInsert, _type, _items, 10)
        host.setSelected(_new_panel)
        _new_panel.refresh()
        return _new_panel

    @classmethod
    def createMetaPanel(cls, host, item):
        """Needed for the drop keys to not be mixed between meta panel and panel for "Contacts" group"""
        return MicroblogPanel.createPanel(host, None)

    @property
    def accepted_groups(self):
        return self._accepted_groups

    def matchEntity(self, item):
        """
        @param item: 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
        """
        groups = item if isinstance(item, list) else ([] if item is None else [item])
        groups.sort()  # sort() do not return the sorted list: do it here, not on the "return" line
        return self.accepted_groups == groups

    def getWarningData(self, comment=None):
        """
        @param comment: True if the composed message is a comment. If None, consider we are
        composing from the unibox and guess the message type from self.selected_entry
        @return: a couple (type, msg) for calling self.host.showWarning"""
        if comment is None:  # composing from the unibox
            if self.selected_entry and not self.selected_entry.comments:
                log.error("an item without comment is selected")
                return ("NONE", None)
            comment = self.selected_entry is not None
        if comment:
            return ("PUBLIC", "This is a <span class='warningTarget'>comment</span> and keep the initial post visibility, so it is potentialy public")
        elif not self._accepted_groups:
            # we have a meta MicroblogPanel, we publish publicly
            return ("PUBLIC", self.warning_msg_public)
        else:
            # we only accept one group at the moment
            # FIXME: manage several groups
            return ("GROUP", self.warning_msg_group % self._accepted_groups[0])

    def onTextEntered(self, text):
        if self.selected_entry:
            # we are entering a comment
            comments_url = self.selected_entry.comments
            if not comments_url:
                raise Exception("ERROR: the comments URL is empty")
            target = ("COMMENT", comments_url)
        elif not self._accepted_groups:
            # we are entering a public microblog
            target = ("PUBLIC", None)
        else:
            # we are entering a microblog restricted to a group
            # FIXME: manage several groups
            target = ("GROUP", self._accepted_groups[0])
        self.host.send([target], text)

    def accept_all(self):
        return not self._accepted_groups  # we accept every microblog only if we are not filtering by groups

    def getEntries(self):
        """Ask all the entries for the currenly accepted groups,
        and fill the panel"""

    def massiveInsert(self, mblogs):
        """Insert several microblogs at once
        @param mblogs: dictionary of microblogs, as the result of getMassiveLastGroupBlogs
        """
        count = sum([len(value) for value in mblogs.values()])
        log.debug("Massive insertion of %d microblogs" % count)
        for publisher in mblogs:
            log.debug("adding blogs for [%s]" % publisher)
            for mblog in mblogs[publisher]:
                if not "content" in mblog:
                    log.warning("No content found in microblog [%s]" % mblog)
                    continue
                self.addEntry(mblog)

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

    def _chronoInsert(self, vpanel, entry, reverse=True):
        """ Insert an entry in chronological order
        @param vpanel: VerticalPanel instance
        @param entry: MicroblogEntry
        @param reverse: more recent entry on top if True, chronological order else"""
        assert(isinstance(reverse, bool))
        if entry.empty:
            entry.published = time()
        # we look for the right index to insert our entry:
        # if reversed, we insert the entry above the first entry
        # in the past
        idx = 0

        for child in vpanel.children:
            if not isinstance(child, MicroblogEntry):
                idx += 1
                continue
            condition_to_stop = child.empty or (child.published > entry.published)
            if condition_to_stop != reverse:  # != is XOR
                break
            idx += 1

        vpanel.insert(entry, idx)

    def addEntry(self, data):
        """Add an entry to the panel
        @param data: dict containing the item data
        @return: the added entry, or None
        """
        _entry = MicroblogEntry(self, data)
        if _entry.type == "comment":
            comments_hash = (_entry.service, _entry.node)
            if not comments_hash in self.comments:
                # The comments node is not known in this panel
                return None
            parent = self.comments[comments_hash]
            parent_idx = self.vpanel.getWidgetIndex(parent)
            # we find or create the panel where the comment must be inserted
            try:
                sub_panel = self.vpanel.getWidget(parent_idx + 1)
            except IndexError:
                sub_panel = None
            if not sub_panel or not isinstance(sub_panel, VerticalPanel):
                sub_panel = VerticalPanel()
                sub_panel.setStyleName('microblogPanel')
                sub_panel.addStyleName('subPanel')
                self.vpanel.insert(sub_panel, parent_idx + 1)
            for idx in xrange(0, len(sub_panel.getChildren())):
                comment = sub_panel.getIndexedChild(idx)
                if comment.id == _entry.id:
                    # update an existing comment
                    sub_panel.remove(comment)
                    sub_panel.insert(_entry, idx)
                    return _entry
            # we want comments to be inserted in chronological order
            self._chronoInsert(sub_panel, _entry, reverse=False)
            return _entry

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

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

        return _entry

    def removeEntry(self, type_, id_):
        """Remove an entry from the panel
        @param type_: entry type ('main_item' or 'comment')
        @param id_: entry id
        """
        for child in self.vpanel.getChildren():
            if isinstance(child, MicroblogEntry) and type_ == 'main_item':
                if child.id == id_:
                    main_idx = self.vpanel.getWidgetIndex(child)
                    try:
                        sub_panel = self.vpanel.getWidget(main_idx + 1)
                        if isinstance(sub_panel, VerticalPanel):
                            sub_panel.removeFromParent()
                    except IndexError:
                        pass
                    child.removeFromParent()
                    self.selected_entry = None
                    break
            elif isinstance(child, VerticalPanel) and type_ == 'comment':
                for comment in child.getChildren():
                    if comment.id == id_:
                        comment.removeFromParent()
                        self.selected_entry = None
                        break

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

        if not self.host.uni_box or not entry.comments:
            entry.addStyleName('selected_entry')  # blink the clicked entry
            clicked_entry = entry  # entry may be None when the timer is done
            Timer(500, lambda timer: clicked_entry.removeStyleName('selected_entry'))
        if not self.host.uni_box:
            return  # unibox is disabled

        # from here the previous behavior (toggle main item selection) is conserved
        entry = entry if entry.comments else None
        if self.selected_entry == entry:
            entry = None
        if self.selected_entry:
            self.selected_entry.removeStyleName('selected_entry')
        if entry:
            log.debug("microblog entry selected (author=%s)" % entry.author)
            entry.addStyleName('selected_entry')
        self.selected_entry = entry

    def updateValue(self, type_, jid, value):
        """Update a jid value in entries
        @param type_: one of 'avatar', 'nick'
        @param jid: jid concerned
        @param value: new value"""
        def updateVPanel(vpanel):
            for child in vpanel.children:
                if isinstance(child, MicroblogEntry) and child.author == jid:
                    child.updateAvatar(value)
                elif isinstance(child, VerticalPanel):
                    updateVPanel(child)
        if type_ == 'avatar':
            updateVPanel(self.vpanel)

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

        Prevent from duplicate values and keep the list sorted.
        @param group: string of the group, or list of string
        """
        if isinstance(group, basestring):
            groups = [group]
        else:
            groups = list(group)
        try:
            self._accepted_groups.extend(groups)
        except (AttributeError, TypeError): # XXX: should be AttributeError, but pyjamas bugs here
            self._accepted_groups = groups
        self._accepted_groups.sort()

    def isJidAccepted(self, jid_s):
        """Tell if a jid is actepted and shown in this panel
        @param jid_s: 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_s):
                return True
        return False