Mercurial > libervia-web
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, ()))