Mercurial > libervia-web
diff src/browser/sat_browser/blog.py @ 716:3b91225b457a
server + browser side: blogging refactoring (draft), huge commit sorry:
/!\ everything is not working yet, group blog is not working for now
- adaptation to backend changes
- frontend commons part of blog have been moved to QuickFrontend
- (editors) button "WYSIWYG edition" renamed to "preview"
- (editors) Shift + [ENTER] is now used to send a text message, [ENTER] to finish a ligne normally
- (editors) fixed modifiers handling
- global simplification, resulting of the refactoring
- with backend refactoring, we are now using PEP again, XEP-0277 compatibility is restored \o/
author | Goffi <goffi@goffi.org> |
---|---|
date | Sun, 16 Aug 2015 01:51:12 +0200 |
parents | d75935e2b279 |
children | 0d5889b9313c |
line wrap: on
line diff
--- a/src/browser/sat_browser/blog.py Tue Jul 28 22:22:10 2015 +0200 +++ b/src/browser/sat_browser/blog.py Sun Aug 16 01:51:12 2015 +0200 @@ -21,23 +21,24 @@ from sat.core.log import getLogger log = getLogger(__name__) -from sat.core.i18n import _, D_ +from sat.core.i18n import _ #, D_ from pyjamas.ui.SimplePanel import SimplePanel from pyjamas.ui.VerticalPanel import VerticalPanel +from pyjamas.ui.ScrollPanel import ScrollPanel 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 import KeyboardListener as keyb +from pyjamas.ui.KeyboardListener import 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 @@ -46,50 +47,19 @@ import libervia_widget from constants import Const as C from sat_frontends.quick_frontend import quick_widgets -from sat_frontends.tools import jid - +from sat_frontends.quick_frontend import quick_blog unicode = str # XXX: pyjamas doesn't manage unicode +ENTRY_RICH = (C.ENTRY_MODE_RICH, C.ENTRY_MODE_XHTML) -class MicroblogItem(): - # XXX: should be moved in a separated module +class Entry(quick_blog.Entry, VerticalPanel, ClickHandler, FocusHandler, KeyboardHandler): + """Graphical representation of a quick_blog.Item""" - 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', '') - + def __init__(self, manager, item_data=None, comments_data=None, service=None, node=None): + quick_blog.Entry.__init__(self, manager, item_data, comments_data, service, 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 + VerticalPanel.__init__(self) self.panel = FlowPanel() self.panel.setStyleName('mb_entry') @@ -103,8 +73,9 @@ 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 + author_jid = self.author_jid + self.avatar = Image(self.blog.host.getAvatarURL(author_jid) if author_jid is not None else C.DEFAULT_AVATAR_URL) + # TODO: show a warning icon if author is not validated entry_avatar.add(self.avatar) self.panel.add(entry_avatar) @@ -112,76 +83,69 @@ self.entry_dialog.setStyleName('mb_entry_dialog') self.panel.add(self.entry_dialog) + self.comments_panel = None + self._current_comment = None + self.add(self.panel) ClickHandler.__init__(self) self.addClickListener(self) - self.__pub_data = (self.service, self.node, self.id) - self.__setContent() + self.refresh() + self.displayed = False # True when entry is added to parent + if comments_data: + self.addComments(comments_data) - 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 refresh(self): + self.header.clear() + self.entry_dialog.clear() + self.entry_actions.clear() + self._setHeader() + self._setBubble() + self._setIcons() - def __setHeader(self): + 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. + if not self.new: + update_text = u" — ✍ " + "<span class='mb_entry_timestamp'>%s</span>" % datetime.fromtimestamp(self.item.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.item.author)), + 'published': datetime.fromtimestamp(self.item.published) if self.item.published is not None else '', + 'updated': update_text if self.item.published != self.item.updated else '' + })) + if self.item.comments: + self.show_comments_link = HTML('') + self.header.add(self.show_comments_link) - @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 + def _setBubble(self): + """Set the bubble displaying the initial content.""" + content = {'text': self.item.content_xhtml if self.item.content_xhtml else self.item.content or '', + 'title': self.item.title_xhtml if self.item.title_xhtml else self.item.title or ''} - 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) + if self.mode == C.ENTRY_MODE_TEXT: + # assume raw text message have no title + self.bubble = editor_widget.LightTextEditor(content, modifiedCb=self._modifiedCb, afterEditCb=self._afterEditCb, options={'no_xhtml': True}) + elif self.mode in ENTRY_RICH: + content['syntax'] = C.SYNTAX_XHTML + if self.new: + options = [] + elif self.item.author_jid == self.blog.host.whoami.bare: + options = ['update_msg'] + else: + options = ['read_only'] + self.bubble = richtext.RichTextEditor(self.blog.host, content, modifiedCb=self._modifiedCb, afterEditCb=self._afterEditCb, options=options) 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 + log.error("Bad entry mode: %s" % self.mode) + self.bubble.addStyleName("bubble") + self._showSyntaxSwitchButton(False) + self.entry_dialog.add(self.bubble) + self.bubble.addEditListener(self._showWarning) # FIXME: remove edit listeners + self._setEditable() - self.show_comments_link.setHTML("""<span class='mb_entry_comments'>%(text)s</span></div>""" % {'text': text}) - - def __setIcons(self): + def _setIcons(self): """Set the entry icons (delete, update, comment)""" - if self.empty: + if self.new: return def addIcon(label, title): @@ -191,269 +155,291 @@ self.entry_actions.add(label) return label - if self.comments: + if self.item.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 + is_publisher = self.item.author_jid == self.blog.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)): + # TODO: add delete button if we are the owner of the node 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 _createCommentsPanel(self): + """Create the panel if it doesn't exists""" + if self.comments_panel is None: + self.comments_panel = VerticalPanel() + self.comments_panel.setStyleName('microblogPanel') + self.comments_panel.addStyleName('subPanel') + self.add(self.comments_panel) - 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) + def _setEditable(self): + self.bubble.edit(self.editable) + if self.editable: + self._showSyntaxSwitchButton() + + def setEditable(self, editable=True): + self.editable = editable + self._setEditable() + + def _showSyntaxSwitchButton(self, show=True): + if show: + if self.mode == C.ENTRY_MODE_TEXT: + html = '<a style="color: blue;">rich text</a>' else: - self._blog_panel.host.bridge.call('sendMblogComment', None, self._parent_entry.comments, content['text'], extra) + html = '<a style="color: blue;">raw text</a>' + self.toggle_syntax_button = HTML(html) + self.toggle_syntax_button.addClickListener(self.toggleContentSyntax) + self.toggle_syntax_button.addStyleName('mb_entry_toggle_syntax') + self.entry_dialog.add(self.toggle_syntax_button) + self.toggle_syntax_button.setStyleAttribute('top', '-20px') # XXX: need to force CSS + self.toggle_syntax_button.setStyleAttribute('left', '-20px') else: - self._blog_panel.host.bridge.call('updateMblog', None, self.__pub_data, self.comments, content['text'], extra) - return True + try: + self.toggle_syntax_button.removeFromParent() + except (AttributeError, TypeError): + pass - 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") + def edit(self, edit=True): + """Toggle the bubble between display and edit mode""" try: self.toggle_syntax_button.removeFromParent() except (AttributeError, TypeError): pass - self.entry_dialog.add(self.bubble) - self.edit(edit) - self.bubble.addEditListener(self.__showWarning) + self.bubble.edit(edit) + if edit: + if self.mode in ENTRY_RICH: + # 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') + self.toggle_syntax_button = HTML(html) + self.toggle_syntax_button.addClickListener(self.toggleContentSyntax) + self.toggle_syntax_button.addStyleName('mb_entry_toggle_syntax') + self.entry_dialog.add(self.toggle_syntax_button) + self.toggle_syntax_button.setStyleAttribute('top', '-20px') # XXX: need to force CSS + self.toggle_syntax_button.setStyleAttribute('left', '-20px') + + # 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.setSelectedEntry(self) + elif sender == self.delete_label: + self._onRetractClick() + elif sender == self.update_label: + self.edit(True) + elif sender == self.comment_label: + self._onCommentClick() + # 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 + return False + + self.item.content = self.item.content_rich = self.item.content_xhtml = None + self.item.title = self.item.title_rich = self.item.title_xhtml = None - def __showWarning(self, sender, keycode): - if keycode == KEY_ENTER: - self._blog_panel.host.showWarning(None, None) + if self.mode in ENTRY_RICH: + # 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 mb_data for the frontend to use it instead of current syntax. + self.item.content_rich = content['text'] + self.item.title = content['title'] else: - self._blog_panel.host.showWarning(*self._blog_panel.getWarningData(self.type == 'comment')) + self.item.content = content['text'] + + self.send() + + return True + + def _afterEditCb(self, content): + """Post edition treatments - def _delete(self, empty=False): - """Ask confirmation for deletion. - @return: False if the deletion has been cancelled.""" + 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 + @param content(dict): edited content + """ + if self.new: + if self.level == 0: + # we have a main item, we keep the edit entry + self.reset(None) + # FIXME: would be better to reset bubble + # but bubble.setContent() doesn't seem to work + self.bubble.removeFromParent() + self._setBubble() + else: + # we don't keep edit entries for comments + self.delete() + else: + self._showSyntaxSwitchButton(False) + + def _showWarning(self, sender, keycode, modifiers): + if keycode == keyb.KEY_ENTER & keyb.MODIFIER_SHIFT: # FIXME: fix edit_listeners, it's dirty (we have to check keycode/modifiers twice !) + self.blog.host.showWarning(None, None) + else: + # self.blog.host.showWarning(*self.blog.getWarningData(self.type == 'comment')) + self.blog.host.showWarning(*self.blog.getWarningData(False)) # FIXME: comments are not yet reimplemented + + def _onRetractClick(self): + """Ask confirmation then retract current entry.""" + assert not self.new + 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) + self.retract() - 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 + entry_type = _("message") if self.level == 0 else _("comment") + and_comments = _(" All comments will be also deleted!") if self.item.comments else "" + text = _("Do you really want to delete this {entry_type}?{and_comments}").format( + entry_type=entry_type, and_comments=and_comments) dialog.ConfirmDialog(confirm_cb, text=text).show() - def _comment(self): + def _onCommentClick(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) + if self._current_comment is None: + if not self.item.comments_service or not self.item.comments_node: + log.warning("Invalid service and node for comments, can pcreate a comment") + self._current_comment = self.addEntry(editable=True, service=self.item.comments_service, node=self.item.comments_node) + self.blog.setSelectedEntry(self._current_comment, 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 _changeMode(self, original_content, text): + self.mode = C.ENTRY_MODE_RICH if self.mode == C.ENTRY_MODE_TEXT else C.ENTRY_MODE_TEXT + if self.mode in ENTRY_RICH and not text: + text = ' ' # something different than empty string is needed to initialize the rich text editor + self.item.content = text + self.content_title = self.content_title_xhtml = '' + self.bubble.removeFromParent() + self._setBubble() + self.bubble.setOriginalContent(original_content) + if self.mode in ENTRY_RICH: + self.item.content_xhtml = text + self.bubble.setDisplayContent() # needed in case the edition is aborted, to not end with an empty bubble + else: + self.item.content_xhtml = '' def toggleContentSyntax(self): """Toggle the editor between raw and rich text""" original_content = self.bubble.getOriginalContent() - rich = not isinstance(self.bubble, richtext.RichTextEditor) + rich = self.mode in ENTRY_RICH 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) + text = self.bubble.getContent()['text'] + + if not text.strip(): + self._changeMode(original_content,'') + else: if rich: - self.bubble.setDisplayContent() # needed in case the edition is aborted, to not end with an empty bubble + def confirm_cb(answer): + if answer: + self.blog.host.bridge.syntaxConvert(text, C.SYNTAX_CURRENT, C.SYNTAX_TEXT, profile=None, + callback=lambda converted: self._changeMode(original_content, converted)) + dialog.ConfirmDialog(confirm_cb, text=_("Do you really want to lose the title and text formatting?")).show() + else: + self.blog.host.bridge.syntaxConvert(text, C.SYNTAX_TEXT, C.SYNTAX_XHTML, profile=None, + callback=lambda converted: self._changeMode(original_content, converted)) - 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) + def update(self, entry=None): + """Update comments""" + self._createCommentsPanel() + self.entries.sort(key=lambda entry: entry.item.published, reverse=True) + idx = 0 + for entry in self.entries: + if not entry.displayed: + self.comments_panel.insert(entry, idx) + entry.displayed = True + idx += 1 + + def delete(self): + quick_blog.Entry.delete(self) + + # _current comment is specific to libervia, we remove it + if isinstance(self.manager, Entry): + self.manager._current_comment = None + + # now we remove the pyjamas widgets + parent = self.parent + assert isinstance(parent, VerticalPanel) + self.removeFromParent() + if not parent.children: + # the vpanel is empty, we remove it + parent.removeFromParent() + try: + if self.manager.comments_panel == parent: + self.manager.comments_panel = None + except AttributeError: + assert isinstance(self.manager, quick_blog.QuickBlog) -class MicroblogPanel(quick_widgets.QuickWidget, libervia_widget.LiberviaWidget, MouseHandler): +class Blog(quick_blog.QuickBlog, libervia_widget.LiberviaWidget, MouseHandler): + """Panel used to show microblog""" 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) + quick_blog.QuickBlog.__init__(self, host, targets, C.PROF_KEY_NONE) + title = ", ".join(targets) if targets else "Blog" + libervia_widget.LiberviaWidget.__init__(self, host, title, selectable=True) MouseHandler.__init__(self) - self.entries = {} - self.comments = {} self.vpanel = VerticalPanel() self.vpanel.setStyleName('microblogPanel') self.setWidget(self.vpanel) - self.addNewMessageEntry() + self.addEntry(editable=True, first=True) + + self.getAll() - 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]) + # 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 def __str__(self): - return u"Blog Widget [target: {}, profile: {}]".format(', '.join(self.accepted_groups), self.profile) + return u"Blog Widget [targets: {}, profile: {}]".format(", ".join(self.targets) if self.targets else "meta blog", self.profile) - def onDelete(self): - quick_widgets.QuickWidget.onDelete(self) - self.host.removeListener('avatar', self.avatarListener) + def update(self): + self.entries.sort(key=lambda entry: entry.item.published, reverse=True) - 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_) + idx = 0 + if self._first_entry is not None: + idx += 1 + if not self._first_entry.displayed: + self.vpanel.insert(self._first_entry, 0) + self._first_entry.displayed = True - 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) + for entry in self.entries: + if not entry.displayed: + self.vpanel.insert(entry, idx) + entry.displayed = True + idx += 1 + + # def onDelete(self): + # quick_widgets.QuickWidget.onDelete(self) + # self.host.removeListener('avatar', self.avatarListener) - def getNewMainEntry(self): - """Get the new entry being edited, or None if it doesn't exists. + # def onAvatarUpdate(self, jid_, hash_, profile): + # """Called on avatar update events - @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 + # @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_) @staticmethod def onGroupDrop(host, targets): @@ -464,35 +450,13 @@ @return: the created MicroblogPanel """ # XXX: pyjamas doesn't support use of cls directly - widget = host.displayWidget(MicroblogPanel, targets, dropped=True) - widget.loadMoreMainEntries() + widget = host.displayWidget(Blog, targets, dropped=True) 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) + # @property + # def accepted_groups(self): + # """Return a set of the accepted groups""" + # return set().union(*self.targets) def getWarningData(self, comment): """ @@ -500,212 +464,29 @@ @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: + elif self._targets_type == C.ALL: # 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(u"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(u"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(u"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()] - try: - count = int(rsm['count']) - hidden = count - (int(rsm['index']) + len(mblogs)) - main_entry.updateHeader(count, hidden) - except KeyError: # target pubsub server doesn't support RSM - pass - - 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 + return (self._targets_type, self.warning_msg_group % ' '.join(self.targets)) 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!") + current = entry + while True: + parent = current.getParent() + if parent is None: + log.warning("Can't find any parent ScrollPanel") + return + elif isinstance(parent, ScrollPanel): + parent.ensureVisible(current) + return + else: + current = parent def setSelectedEntry(self, entry, ensure_visible=False): """Select an entry. @@ -720,55 +501,35 @@ 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. + # def updateValue(self, type_, jid_, value): + # """Update a jid value in entries - @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 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) - @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 onClick(self, sender): - if sender == self.footer: - self.loadMoreMainEntries() - - def onMouseEnter(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, ())) +# 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: Blog.onGroupDrop(host, ())) +quick_blog.registerClass("ENTRY", Entry) +quick_widgets.register(quick_blog.QuickBlog, Blog)