Mercurial > libervia-web
view src/browser/sat_browser/blog.py @ 659:8e7d4de56e75 frontends_multi_profiles
browser_side: allow to drop a widget in the "+" tab
author | souliane <souliane@mailoo.org> |
---|---|
date | Fri, 27 Feb 2015 16:05:28 +0100 |
parents | e1d067378ad3 |
children | ebb602d8b3f2 |
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 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 # 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 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 = 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') 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) 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(unicode(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(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() 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, 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) 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 (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': str(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) 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') 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, libervia_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 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) self.entries = {} self.comments = {} self.selected_entry = None self.vpanel = VerticalPanel() self.vpanel.setStyleName('microblogPanel') self.setWidget(self.vpanel) # 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 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': unicode(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 @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 """ type_ = 'ALL' if not targets else 'GROUP' # XXX: pyjamas doesn't support use of cls directly widget = host.displayWidget(MicroblogPanel, targets, dropped=True) host.FillMicroblogPanel(widget) host.bridge.getMassiveLastMblogs(type_, targets, 10, profile=C.PROF_KEY_NONE, callback=widget.massiveInsert) widget.refresh() # FIXME: needed ? return widget @property def accepted_groups(self): """Return a set of the accepted groups""" return set().union(*self.targets) 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: # FIXME: manage several groups return ("GROUP", self.warning_msg_group % ' '.join(self.accepted_groups)) 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") self.bridge.call("sendMblogComment", None, comments_url, text, {}) target = ("COMMENT", comments_url) elif 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: 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 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, ignore_invalid=False): """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 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) 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)" % unicode(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.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 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, ()))