# HG changeset patch # User Goffi # Date 1426691718 -3600 # Node ID a90cc8fc96059dcdc68a8ff500074901b91ff1e3 # Parent 1bffc4c244c345817441981695627750debf02e4# Parent 2e087e093e7fbb835339939ea2fbc4ae6a42ea41 merged branch frontends_multi_profiles diff -r 2e087e093e7f -r a90cc8fc9605 server_css/blog.css --- a/server_css/blog.css Tue Mar 17 22:20:36 2015 +0100 +++ b/server_css/blog.css Wed Mar 18 16:15:18 2015 +0100 @@ -40,7 +40,13 @@ color: rgb(51, 51, 51); } +.mblog_comments{ +} + .mblog_comment { + position: relative; + left: 2%; + width: 56%; } .mblog_header { @@ -51,6 +57,28 @@ width: 100%; } +.mblog_header_main:hover { + background-color: #f0f0f0; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + border-radius: 5px; +} + +.mblog_footer { + font-size: small; + border-top: 1px dashed LightGrey; + color: gray; + display: table; + width: 100%; +} + +.mblog_footer_main:hover { + background-color: #f0f0f0; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + border-radius: 5px; +} + .mblog_metadata { display: table-row; width: 100%; @@ -74,6 +102,44 @@ text-decoration: none; } +.comments_link { + text-decoration: none; + text-align: center; + display: block; +} + +.header { + width: 60%; + margin: auto; + margin-bottom: 20px; + text-align: center; +} + +.footer { + width: 100%; + text-align:center; +} + +.later_message { + text-decoration: none; + float: left; +} + +.later_messages { + text-decoration: none; + text-align:center; +} + +.older_message { + text-decoration: none; + float: right; +} + +.older_messages { + text-decoration: none; + text-align:center; +} + .mblog_entry h1, h2, h3, h4, h5, h6 { border-bottom: 1px solid rgb(170, 170, 170); } diff -r 2e087e093e7f -r a90cc8fc9605 src/browser/libervia_main.py --- a/src/browser/libervia_main.py Tue Mar 17 22:20:36 2015 +0100 +++ b/src/browser/libervia_main.py Wed Mar 18 16:15:18 2015 +0100 @@ -101,6 +101,7 @@ self.initialised = False self.init_cache = [] # used to cache events until initialisation is done self.cached_params = {} + self.next_rsm_index = 0 #FIXME: microblog cache should be managed directly in blog module self.mblog_cache = [] # used to keep our own blog entries in memory, to show them in new mblog panel @@ -361,12 +362,12 @@ # we fill the panels already here for wid in self.widgets.getWidgets(blog.MicroblogPanel): if wid.accept_all(): - self.bridge.getMassiveLastMblogs('ALL', (), 10, profile=C.PROF_KEY_NONE, callback=wid.massiveInsert) + self.bridge.getMassiveMblogs('ALL', (), None, profile=C.PROF_KEY_NONE, callback=wid.massiveInsert) else: - self.bridge.getMassiveLastMblogs('GROUP', wid.accepted_groups, 10, profile=C.PROF_KEY_NONE, callback=wid.massiveInsert) + self.bridge.getMassiveMblogs('GROUP', list(wid.accepted_groups), None, profile=C.PROF_KEY_NONE, callback=wid.massiveInsert) #we ask for our own microblogs: - self.bridge.getMassiveLastMblogs('JID', (unicode(self.whoami.bare),), 10, profile=C.PROF_KEY_NONE, callback=self._ownBlogsFills) + self.loadOurMainEntries() def addContactList(self, dummy): contact_list = ContactList(self) @@ -465,10 +466,15 @@ if not xml_ui: self.panel.menu.removeItemParams() - def _ownBlogsFills(self, mblogs): - #put our own microblogs in cache, then fill all panels with them + def _ownBlogsFills(self, mblogs, mblog_panel=None): + """Put our own microblogs in cache, then fill the panels with them. + + @param mblogs (dict): dictionary mapping a publisher JID to blogs data. + @param mblog_panel (MicroblogPanel): the panel to fill, or all if None. + """ + cache = [] for publisher in mblogs: - for mblog in mblogs[publisher]: + for mblog in mblogs[publisher][0]: if 'content' not in mblog: log.warning("No content found in microblog [%s]" % mblog) continue @@ -477,14 +483,21 @@ else: _groups = None mblog_entry = blog.MicroblogItem(mblog) - self.mblog_cache.append((_groups, mblog_entry)) + cache.append((_groups, mblog_entry)) + self.mblog_cache.extend(cache) if len(self.mblog_cache) > MAX_MBLOG_CACHE: del self.mblog_cache[0:len(self.mblog_cache - MAX_MBLOG_CACHE)] - for wid in self.widgets.getWidgets(blog.MicroblogPanel): - self.FillMicroblogPanel(wid) + + widget_list = [mblog_panel] if mblog_panel else self.widgets.getWidgets(blog.MicroblogPanel) + + for wid in widget_list: + self.fillMicroblogPanel(wid, cache) # FIXME + + if self.initialised: + return self.initialised = True # initialisation phase is finished here for event_data in self.init_cache: # so we have to send all the cached events self.personalEventHandler(*event_data) @@ -509,13 +522,33 @@ # self.bridge.call('getMassiveLastMblogs', lib_wid.massiveInsert, 'GROUP', lib_wid.accepted_groups, 10) # #we ask for our own microblogs: - # self.bridge.call('getMassiveLastMblogs', self._ownBlogsFills, 'JID', [self.whoami.bare], 10) + # self.loadOurMainEntries() # # initialize plugins which waited for the connection to be done # for plugin in self.plugins.values(): # if hasattr(plugin, 'profileConnected'): # plugin.profileConnected() + def loadOurMainEntries(self, index=0, mblog_panel=None): + """Load a page of our own blogs from the cache or ask them to the + backend. Then fill the panels with them. + + @param index (int): starting index of the blog page to retrieve. + @param mblog_panel (MicroblogPanel): the panel to fill, or all if None. + """ + delta = index - self.next_rsm_index + if delta < 0: + assert mblog_panel is not None + self.fillMicroblogPanel(mblog_panel, self.mblog_cache[index:index + C.RSM_MAX_ITEMS]) + return + + def cb(result): + self._ownBlogsFills(result, mblog_panel) + + rsm = {'max': str(delta + C.RSM_MAX_ITEMS), 'index': str(self.next_rsm_index)} + self.bridge.getMassiveMblogs('JID', [unicode(self.whoami.bare)], rsm, callback=cb, profile=C.PROF_KEY_NONE) + self.next_rsm_index = index + C.RSM_MAX_ITEMS + ## Signals callbacks ## def personalEventHandler(self, sender, event_type, data): @@ -563,13 +596,13 @@ self.mblog_cache.remove(entry) break - def FillMicroblogPanel(self, mblog_panel): + def fillMicroblogPanel(self, mblog_panel, mblogs): """Fill a microblog panel with entries in cache @param mblog_panel: MicroblogPanel instance """ #XXX: only our own entries are cached - for cache_entry in self.mblog_cache: + for cache_entry in mblogs: _groups, mblog_entry = cache_entry mblog_panel.addEntryIfAccepted(self.whoami.bare, *cache_entry) @@ -578,7 +611,7 @@ for lib_wid in self.libervia_widgets: if isinstance(lib_wid, blog.MicroblogPanel): if lib_wid.isJidAccepted(entity): - self.bridge.call('getMassiveLastMblogs', lib_wid.massiveInsert, 'JID', (entity,), 10) + self.bridge.call('getMassiveMblogs', lib_wid.massiveInsert, 'JID', [unicode(entity)]) # def getLiberviaWidget(self, class_, entity, ignoreOtherTabs=True): # """Get the corresponding panel if it exists. diff -r 2e087e093e7f -r a90cc8fc9605 src/browser/public/contrat_social.html --- a/src/browser/public/contrat_social.html Tue Mar 17 22:20:36 2015 +0100 +++ b/src/browser/public/contrat_social.html Wed Mar 18 16:15:18 2015 +0100 @@ -96,7 +96,7 @@
  • nous ferons notre possible pour aider les utilisateurs, quel que soit leur niveau
  • de même, des efforts seront fait quant à -l'accessibilité aux personnes victimes d'un handicap
  • +l'accessibilité pour tous
  • « Salut à Toi », XMPP, et les technologies utilisées facilitent les échanges électroniques, mais nous désirons mettre l'accent sur les rencontres diff -r 2e087e093e7f -r a90cc8fc9605 src/browser/public/libervia.css --- a/src/browser/public/libervia.css Tue Mar 17 22:20:36 2015 +0100 +++ b/src/browser/public/libervia.css Wed Mar 18 16:15:18 2015 +0100 @@ -852,6 +852,21 @@ width: 100%; } +.microblogPanel_footer { + cursor: pointer; + text-align: center; + background-color: #ededed; + border-radius: 5px; + width: 85%; + margin: auto; + margin-top: 5px; + margin-bottom: 5px; +} + +.microblogPanel_footer a { + color: blue; +} + .microblogNewButton { width: 100%; height: 35px; @@ -870,18 +885,32 @@ .mb_entry_header { - cursor: pointer; + width: 100%; } -.selected_widget .selected_entry .mb_entry_header +.mb_entry_header_info { + cursor: pointer; + padding: 0px 5px 0px 5px; +} + +.selected_widget .selected_entry .mb_entry_header_info { background: #cf2828; border-radius: 5px 5px 0px 0px; } +.mb_entry_comments { + float: right; + padding-right: 5px; +} + +.mb_entry_comments a { + color: blue; + cursor: pointer; +} + .mb_entry_author { font-weight: bold; - padding-left: 5px; } .mb_entry_avatar { @@ -931,6 +960,7 @@ .bubble textarea{ width: 100%; + min-width: 350px; } .mb_entry_timestamp { diff -r 2e087e093e7f -r a90cc8fc9605 src/browser/sat_browser/blog.py --- a/src/browser/sat_browser/blog.py Tue Mar 17 22:20:36 2015 +0100 +++ b/src/browser/sat_browser/blog.py Wed Mar 18 16:15:18 2015 +0100 @@ -21,20 +21,19 @@ from sat.core.log import getLogger log = getLogger(__name__) -from sat.core.i18n import _ +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.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.ui.MouseListener import MouseHandler from pyjamas.Timer import Timer from datetime import datetime @@ -95,7 +94,7 @@ self.panel = FlowPanel() self.panel.setStyleName('mb_entry') - self.header = HTMLPanel('') + self.header = HorizontalPanel(StyleName='mb_entry_header') self.panel.add(self.header) self.entry_actions = VerticalPanel() @@ -129,18 +128,56 @@ self.__setIcons() def __setHeader(self): - """Set the entry header""" + """Set the entry header.""" if self.empty: return update_text = u" — ✠" + "" % datetime.fromtimestamp(self.updated) - self.header.setHTML("""
    - on - %(updated)s -
    """ % {'author': html_tools.html_sanitize(unicode(self.author)), - 'published': datetime.fromtimestamp(self.published), - 'updated': update_text if self.published != self.updated else '' - } - ) + self.header.add(HTML(""" + on + %(updated)s + """ % {'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_("show %(count)d previous %(comments)s") % {'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("""%(text)s""" % {'text': text}) def __setIcons(self): """Set the entry icons (delete, update, comment)""" @@ -177,6 +214,8 @@ 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 @@ -204,7 +243,7 @@ """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) + 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 @@ -276,7 +315,7 @@ 'service': self.comments_service, 'node': self.comments_node } - entry = self._blog_panel.addEntry(data) + 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 @@ -343,7 +382,7 @@ self._blog_panel.host.bridge.call('syntaxConvert', setBubble, text, C.SYNTAX_TEXT, C.SYNTAX_XHTML) -class MicroblogPanel(quick_widgets.QuickWidget, libervia_widget.LiberviaWidget): +class MicroblogPanel(quick_widgets.QuickWidget, libervia_widget.LiberviaWidget, MouseHandler): warning_msg_public = "This message will be PUBLIC 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: %s" # FIXME: all the generic parts must be moved to quick_frontends @@ -357,6 +396,7 @@ # 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() @@ -364,6 +404,13 @@ 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]) @@ -394,7 +441,7 @@ 'new': True, 'author': unicode(self.host.whoami.bare), } - entry = self.addEntry(data) + entry = self.addEntry(data, update_header=False) entry.edit(True) def getNewMainEntry(self): @@ -402,10 +449,9 @@ @return (MicroblogEntry): the new entry being edited. """ - try: - first = self.vpanel.children[0] - except IndexError: - return None + 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 @@ -417,11 +463,9 @@ @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.loadMoreMainEntries() return widget @property @@ -429,6 +473,27 @@ """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 @@ -458,46 +523,68 @@ def massiveInsert(self, mblogs): """Insert several microblogs at once - @param mblogs: dictionary of microblogs, as the result of getMassiveLastGroupBlogs + + @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 = sum([len(value) for value in mblogs.values()]) - log.debug("Massive insertion of %d microblogs" % count) + 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 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) + 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 at once - @param mblogs: list of microblogs + """ 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 not "content" in mblog: + if "content" not in mblog: log.warning("No content found in microblog [%s]" % mblog) continue - self.addEntry(mblog) + 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.published = time() + 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: + 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.published > entry.published) + condition_to_stop = child.empty or (child.updated > entry.updated) if condition_to_stop != reverse: # != is XOR break idx += 1 @@ -518,11 +605,12 @@ or (groups and groups.intersection(self.accepted_groups))): self.addEntry(mblog_entry) - def addEntry(self, data, ignore_invalid=False): + def addEntry(self, data, update_header=True): """Add an entry to the panel - @param data: dict containing the item data - @return: the added entry, or None + @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": @@ -542,6 +630,7 @@ 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: @@ -551,28 +640,38 @@ 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.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) + 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_): + def removeEntry(self, type_, id_, update_header=True): """Remove an entry from the panel - @param type_: entry type ('main_item' or 'comment') - @param id_: entry id + + @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': @@ -589,6 +688,9 @@ 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 @@ -654,6 +756,14 @@ 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,))) diff -r 2e087e093e7f -r a90cc8fc9605 src/browser/sat_browser/chat.py diff -r 2e087e093e7f -r a90cc8fc9605 src/browser/sat_browser/contact_list.py --- a/src/browser/sat_browser/contact_list.py Tue Mar 17 22:20:36 2015 +0100 +++ b/src/browser/sat_browser/contact_list.py Wed Mar 18 16:15:18 2015 +0100 @@ -28,7 +28,6 @@ from pyjamas.ui.Label import Label from pyjamas import Window from pyjamas import DOM -from __pyjamas__ import doc from constants import Const as C import libervia_widget diff -r 2e087e093e7f -r a90cc8fc9605 src/browser/sat_browser/json.py --- a/src/browser/sat_browser/json.py Tue Mar 17 22:20:36 2015 +0100 +++ b/src/browser/sat_browser/json.py Wed Mar 18 16:15:18 2015 +0100 @@ -178,14 +178,14 @@ def __init__(self): LiberviaJsonProxy.__init__(self, "/json_api", ["getContacts", "addContact", "sendMessage", "sendMblog", "sendMblogComment", - "getLastMblogs", "getMassiveLastMblogs", "getMblogComments", + "getMblogs", "getMassiveMblogs", "getMblogComments", "getHistory", "getPresenceStatuses", "joinMUC", "mucLeave", "getRoomsJoined", "getRoomsSubjects", "inviteMUC", "launchTarotGame", "getTarotCardsPaths", "tarotGameReady", "tarotGamePlayCards", "launchRadioCollective", "getMblogs", "getMblogsWithComments", "getWaitingSub", "subscription", "delContact", "updateContact", "getCard", "getEntityData", "getParamsUI", "asyncGetParamA", "setParam", "launchAction", "disconnect", "chatStateComposing", "getNewAccountDomain", "confirmationAnswer", - "syntaxConvert", "getAccountDialogUI", "getLastResource", "getWaitingConf", "getEntitiesData", + "syntaxConvert", "getAccountDialogUI", "getMainResource", "getWaitingConf", "getEntitiesData", ]) def __call__(self, *args, **kwargs): return LiberviaJsonProxy.__call__(self, *args, **kwargs) diff -r 2e087e093e7f -r a90cc8fc9605 src/browser/sat_browser/main_panel.py diff -r 2e087e093e7f -r a90cc8fc9605 src/browser/sat_browser/widget.py diff -r 2e087e093e7f -r a90cc8fc9605 src/common/constants.py --- a/src/common/constants.py Tue Mar 17 22:20:36 2015 +0100 +++ b/src/common/constants.py Wed Mar 18 16:15:18 2015 +0100 @@ -58,3 +58,7 @@ DEFAULT_AVATAR_URL = os.path.join(MEDIA_DIR, "misc", DEFAULT_AVATAR_FILE) EMPTY_AVATAR_FILE = "empty_avatar" EMPTY_AVATAR_URL = os.path.join(MEDIA_DIR, "misc", EMPTY_AVATAR_FILE) + + RSM_MAX_ITEMS = 5 + RSM_MAX_COMMENTS = 5 + diff -r 2e087e093e7f -r a90cc8fc9605 src/server/blog.py --- a/src/server/blog.py Tue Mar 17 22:20:36 2015 +0100 +++ b/src/server/blog.py Wed Mar 18 16:15:18 2015 +0100 @@ -17,7 +17,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from sat.core.i18n import _ +from sat.core.i18n import _, D_ from sat_frontends.tools.strings import addURLToText from sat.core.log import getLogger log = getLogger(__name__) @@ -31,7 +31,7 @@ import re import os -from libervia.server.html_tools import sanitizeHtml +from libervia.server.html_tools import sanitizeHtml, convertNewLinesToXHTML from libervia.server.constants import Const as C @@ -130,25 +130,52 @@ pub_jid = JID(pub_jid_s) d2 = defer.Deferred() item_id = None - try: - max_items = int(request.args['max_items'][0]) - except (ValueError, KeyError): - max_items = 10 + atom = None + rsm_ = {} + if len(request.postpath) > 1: if request.postpath[1] == 'atom.xml': # return the atom feed - d2.addCallbacks(self.render_atom_feed, self.render_error_blog, [request], None, [request, prof_found], None) - self.host.bridge.getLastGroupBlogsAtom(pub_jid.userhost(), max_items, C.SERVICE_PROFILE, d2.callback, d2.errback) - return - try: # check if the given path is a valid UUID - uuid.UUID(request.postpath[1]) - item_id = request.postpath[1] - except ValueError: - pass + atom = True + else: + try: # check if the given path is a valid UUID + uuid.UUID(request.postpath[1]) + item_id = request.postpath[1] + except ValueError: + pass + + # retrieve RSM request data from URL parameters + try: + max_items = int(request.args['max'][0]) + except (ValueError, KeyError): + max_items = C.RSM_MAX_ITEMS if item_id else C.RSM_MAX_COMMENTS + rsm_['max'] = unicode(max_items) + try: + rsm_['index'] = request.args['index'][0] + except (ValueError, KeyError): + try: + rsm_['before'] = request.args['before'][0] + except KeyError: + try: + rsm_['after'] = request.args['after'][0] + except KeyError: + pass + + if atom is not None: + d2.addCallbacks(self.render_atom_feed, self.render_error_blog, [request], None, [request, prof_found], None) + self.host.bridge.getGroupBlogsAtom(pub_jid.userhost(), rsm_, C.SERVICE_PROFILE, d2.callback, d2.errback) + return + d2.addCallbacks(self.render_html_blog, self.render_error_blog, [request, prof_found], None, [request, prof_found], None) - if item_id: # display one message and its comments - self.host.bridge.getGroupBlogsWithComments(pub_jid.userhost(), [item_id], C.SERVICE_PROFILE, d2.callback, d2.errback) - else: # display the last messages without comment - self.host.bridge.getLastGroupBlogs(pub_jid.userhost(), max_items, C.SERVICE_PROFILE, d2.callback, d2.errback) + if item_id: + if max_items > 0: # display one message and its comments + self.host.bridge.getGroupBlogsWithComments(pub_jid.userhost(), [item_id], {}, max_items, C.SERVICE_PROFILE, d2.callback, d2.errback) + else: # display one message, count its comments + self.host.bridge.getGroupBlogs(pub_jid.userhost(), [item_id], {}, True, C.SERVICE_PROFILE, d2.callback, d2.errback) + else: + if max_items == 1: # display one message and its comments + self.host.bridge.getGroupBlogsWithComments(pub_jid.userhost(), [], rsm_, C.RSM_MAX_COMMENTS, C.SERVICE_PROFILE, d2.callback, d2.errback) + else: # display the last messages, count their comments + self.host.bridge.getGroupBlogs(pub_jid.userhost(), [], rsm_, True, C.SERVICE_PROFILE, d2.callback, d2.errback) d1 = defer.Deferred() JID(self.host.bridge.asyncGetParamA('JabberID', 'Connection', 'value', C.SERVER_SECURITY_LIMIT, prof_found, callback=d1.callback, errback=d1.errback)) @@ -158,7 +185,12 @@ def render_html_blog(self, mblog_data, request, profile): """Retrieve the user parameters before actually rendering the static blog - @param mblog_data: list of microblog data or list of couple (microblog data, list of microblog data) + + @param mblog_data (list): couple (list, dict) with: + - a list of microblog data, or a list of couple containing: + - microblog data (main item) + - couple (comments data, RSM response data for the comments) + - RSM response data for the main items @param request: HTTP request @param profile """ @@ -184,7 +216,12 @@ """Actually render the static blog. If mblog_data is a list of dict, we are missing the comments items so we just display the main items. If mblog_data is a list of couple, each couple is associating a main item data with the list of its comments, so we render all. - @param mblog_data: list of microblog data or list of couple (microblog data, list of microblog data) + + @param mblog_data (list): couple (list, dict) with: + - a list of microblog data, or a list of couple containing: + - microblog data (main item) + - couple (comments data, RSM response data for the comments) + - RSM response data for the main items @param options: dict defining the blog's parameters @param request: the HTTP request @profile @@ -225,13 +262,74 @@ 'title': getOption(C.STATIC_BLOG_PARAM_TITLE) or "%s's microblog" % user, 'favicon': os.path.normpath(root_url + getOption('avatar')), 'banner_elt': getImageOption(C.STATIC_BLOG_PARAM_BANNER, getOption(C.STATIC_BLOG_PARAM_TITLE) or user)}) - mblog_data = [(entry if isinstance(entry, tuple) else (entry, [])) for entry in mblog_data] - mblog_data = sorted(mblog_data, key=lambda entry: (-float(entry[0].get('published', 0)))) - for entry in mblog_data: - self.__render_html_entry(entry[0], base_url, request) - comments = sorted(entry[1], key=lambda entry: (float(entry.get('published', 0)))) + mblog_data, main_rsm = mblog_data + display_single = len(mblog_data) == 1 + + # build the navigation links + count = int(main_rsm['count']) if 'count' in main_rsm else 0 + if count > 0: + index = int(main_rsm['index']) + if index > 0: + before_link = ("%(base)s?before=%(item_id)s" % {'base': base_url, 'item_id': main_rsm['first']}).encode('utf-8') + if display_single: + before_link += '&max=1' + tmp_text = D_("Later message") + class_ = 'later_message' + else: + tmp_text = D_("Later messages") + class_ = 'later_messages' + before_tag = """%(text)s""" % {'link': before_link, 'class': class_, 'text': tmp_text} + else: + before_tag = None + if index + len(mblog_data) < count: + after_link = ("%(base)s?after=%(item_id)s" % {'base': base_url, 'item_id': main_rsm['last']}).encode('utf-8') + if display_single: + after_link += '&max=1' + text = D_("Older message") + class_ = 'older_message' + else: + text = D_("Older messages") + class_ = 'older_messages' + after_tag = """%(text)s""" % {'link': after_link, 'class': class_, 'text': text} + else: + after_tag = None + + # display navigation header + request.write("""
    """) + if before_tag: + request.write(before_tag) + request.write(" ") + if display_single and after_tag: + request.write(after_tag) + request.write("""
    """) + + mblog_data = [(entry if isinstance(entry, tuple) else (entry, ([], {}))) for entry in mblog_data] + mblog_data = sorted(mblog_data, key=lambda entry: (-float(entry[0].get('updated', 0)))) + for main_data, comments_data in mblog_data: + self.__render_html_entry(main_data, base_url, request) + comments, comments_rsm = comments_data + + # eventually display the link to show all comments + comments_count = int(main_data['comments_count']) + delta = comments_count - len(comments) + if display_single and delta > 0: + link = ("%(base)s/%(item_id)s?max=%(max)s" % {'base': base_url, + 'item_id': main_data['id'], + 'max': main_data['comments_count']}).encode('utf-8') + text = D_("Show %(count)d previous %(comments)s") % {'count': delta, + 'comments': D_('comments') if delta > 1 else D_('comment')} + request.write("""%(text)s""" % {'link': link, 'text': text}) + + comments = sorted(comments, key=lambda entry: (float(entry.get('published', 0)))) for comment in comments: self.__render_html_entry(comment, base_url, request) + + # display navigation footer + request.write("""""") + request.write('') request.finish() @@ -244,19 +342,13 @@ timestamp = float(entry.get('published', 0)) datetime_ = datetime.fromtimestamp(timestamp) is_comment = entry['type'] == 'comment' - if is_comment: - author = (_("comment from %s") % entry['author']).encode('utf-8') - item_link = '' - else: - author = ' ' - item_link = ("%(base)s/%(item_id)s" % {'base': base_url, 'item_id': entry['id']}).encode('utf-8') def getText(key): if ('%s_xhtml' % key) in entry: return entry['%s_xhtml' % key].encode('utf-8') elif key in entry: processor = addURLToText if key.startswith('content') else sanitizeHtml - return processor(entry[key]).encode('utf-8') + return convertNewLinesToXHTML(processor(entry[key])).encode('utf-8') return '' def addMainItemLink(elem): @@ -264,12 +356,31 @@ return elem return """%(elem)s""" % {'link': item_link, 'elem': elem} - header = addMainItemLink("""
    - -
    """ % {'author': author, 'date': datetime_}) + if is_comment: + author = (_("from %s") % entry['author']).encode('utf-8') + item_link = '' + footer = '' + else: + author = ' ' + item_link = ("%(base)s/%(item_id)s" % {'base': base_url, 'item_id': entry['id']}).encode('utf-8') + comments_count = int(entry['comments_count']) + comments_text = (D_('comments') if comments_count > 1 else D_('comment')).encode('utf-8') + footer = addMainItemLink("""""" % {'count': comments_count, + 'comments': comments_text}) + + header = """
    + +
    """ % {'author': author, 'date': datetime_, + 'class': '' if is_comment else 'mblog_header_main'} + if not is_comment: + header = addMainItemLink(header) title = addMainItemLink(getText('title')) body = getText('content') @@ -279,11 +390,12 @@ request.write("""
    %(header)s %(content)s -
    """ % - {'extra_style': 'mblog_comment' if entry['type'] == 'comment' else '', - 'item_link': item_link, - 'header': header, - 'content': body}) + %(footer)s + """ % {'extra_style': 'mblog_comment' if entry['type'] == 'comment' else '', + 'item_link': item_link, + 'header': header, + 'content': body, + 'footer': footer}) def render_atom_feed(self, feed, request): request.write(feed.encode('utf-8')) diff -r 2e087e093e7f -r a90cc8fc9605 src/server/html_tools.py --- a/src/server/html_tools.py Tue Mar 17 22:20:36 2015 +0100 +++ b/src/server/html_tools.py Wed Mar 18 16:15:18 2015 +0100 @@ -30,3 +30,7 @@ } return "".join(html_escape_table.get(c, c) for c in text) + + +def convertNewLinesToXHTML(text): + return text.replace('\n', '
    ') diff -r 2e087e093e7f -r a90cc8fc9605 src/server/server.py --- a/src/server/server.py Tue Mar 17 22:20:36 2015 +0100 +++ b/src/server/server.py Wed Mar 18 16:15:18 2015 +0100 @@ -298,55 +298,50 @@ else: raise Exception("Invalid data") - def jsonrpc_getMblogs(self, publisher_jid, item_ids): + def jsonrpc_getMblogs(self, publisher_jid, item_ids, max_items=C.RSM_MAX_ITEMS): """Get specified microblogs posted by a contact @param publisher_jid: jid of the publisher @param item_ids: list of microblogs items IDs @return list of microblog data (dict)""" profile = ISATSession(self.session).profile - d = self.asyncBridgeCall("getGroupBlogs", publisher_jid, item_ids, profile) + d = self.asyncBridgeCall("getGroupBlogs", publisher_jid, item_ids, {'max': unicode(max_items)}, False, profile) return d - def jsonrpc_getMblogsWithComments(self, publisher_jid, item_ids): + def jsonrpc_getMblogsWithComments(self, publisher_jid, item_ids, max_comments=C.RSM_MAX_COMMENTS): """Get specified microblogs posted by a contact and their comments @param publisher_jid: jid of the publisher @param item_ids: list of microblogs items IDs @return list of couple (microblog data, list of microblog data)""" profile = ISATSession(self.session).profile - d = self.asyncBridgeCall("getGroupBlogsWithComments", publisher_jid, item_ids, profile) + d = self.asyncBridgeCall("getGroupBlogsWithComments", publisher_jid, item_ids, {}, max_comments, profile) return d - def jsonrpc_getLastMblogs(self, publisher_jid, max_item): - """Get last microblogs posted by a contact - @param publisher_jid: jid of the publisher - @param max_item: number of items to ask - @return list of microblog data (dict)""" - profile = ISATSession(self.session).profile - d = self.asyncBridgeCall("getLastGroupBlogs", publisher_jid, max_item, profile) - return d - - def jsonrpc_getMassiveLastMblogs(self, publishers_type, publishers, max_item): + def jsonrpc_getMassiveMblogs(self, publishers_type, publishers, rsm=None): """Get lasts microblogs posted by several contacts at once @param publishers_type (unicode): one of "ALL", "GROUP", "JID" @param publishers (tuple(unicode)): tuple of publishers (empty list for all, list of groups or list of jids) - @param max_item (int): number of items to ask + @param rsm (dict): TODO @return: dict{unicode: list[dict]) key: publisher's jid value: list of microblog data (dict) """ profile = ISATSession(self.session).profile - d = self.asyncBridgeCall("getMassiveLastGroupBlogs", publishers_type, publishers, max_item, profile) + if rsm is None: + rsm = {'max': unicode(C.RSM_MAX_ITEMS)} + d = self.asyncBridgeCall("getMassiveGroupBlogs", publishers_type, publishers, rsm, profile) self.sat_host.bridge.massiveSubscribeGroupBlogs(publishers_type, publishers, profile) return d - def jsonrpc_getMblogComments(self, service, node): + def jsonrpc_getMblogComments(self, service, node, rsm=None): """Get all comments of given node @param service: jid of the service hosting the node @param node: comments node """ profile = ISATSession(self.session).profile - d = self.asyncBridgeCall("getGroupBlogComments", service, node, profile) + if rsm is None: + rsm = {'max': unicode(C.RSM_MAX_COMMENTS)} + d = self.asyncBridgeCall("getGroupBlogComments", service, node, rsm, profile) return d def jsonrpc_getPresenceStatuses(self):