changeset 586:3eb3a2c0c011

browser and server side: uses RSM (XEP-0059)
author souliane <souliane@mailoo.org>
date Fri, 28 Nov 2014 00:31:27 +0100
parents bade589dbd5a
children 6a1fea10ae8c
files server_css/blog.css src/browser/libervia_main.py src/browser/public/libervia.css src/browser/sat_browser/panels.py src/common/constants.py src/server/blog.py src/server/server.py
diffstat 7 files changed, 475 insertions(+), 128 deletions(-) [+]
line wrap: on
line diff
--- a/server_css/blog.css	Thu Oct 23 16:56:36 2014 +0200
+++ b/server_css/blog.css	Fri Nov 28 00:31:27 2014 +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);
 }
--- a/src/browser/libervia_main.py	Thu Oct 23 16:56:36 2014 +0200
+++ b/src/browser/libervia_main.py	Fri Nov 28 00:31:27 2014 +0100
@@ -132,7 +132,7 @@
     def __init__(self):
         LiberviaJsonProxy.__init__(self, "/json_api",
                         ["getContacts", "addContact", "sendMessage", "sendMblog", "sendMblogComment",
-                         "getLastMblogs", "getMassiveLastMblogs", "getMblogComments", "getProfileJid",
+                         "getMblogs", "getMassiveMblogs", "getMblogComments", "getProfileJid",
                          "getHistory", "getPresenceStatuses", "joinMUC", "mucLeave", "getRoomsJoined",
                          "inviteMUC", "launchTarotGame", "getTarotCardsPaths", "tarotGameReady",
                          "tarotGamePlayCards", "launchRadioCollective", "getMblogs", "getMblogsWithComments",
@@ -203,6 +203,7 @@
         self.initialised = False
         self.init_cache = []  # used to cache events until initialisation is done
         self.cached_params = {}
+        self.next_rsm_index = 0
 
     def importPlugins(self):
         self.plugins = {}
@@ -541,10 +542,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
@@ -553,13 +559,19 @@
                 else:
                     _groups = None
                 mblog_entry = panels.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 lib_wid in self.libervia_widgets:
+
+        widget_list = [mblog_panel] if mblog_panel else self.libervia_widgets
+        for lib_wid in widget_list:
             if isinstance(lib_wid, panels.MicroblogPanel):
-                self.FillMicroblogPanel(lib_wid)
+                self.fillMicroblogPanel(lib_wid, cache)
+
+        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._personalEventCb(*event_data)
@@ -577,18 +589,38 @@
         for lib_wid in self.libervia_widgets:
             if isinstance(lib_wid, panels.MicroblogPanel):
                 if lib_wid.accept_all():
-                    self.bridge.call('getMassiveLastMblogs', lib_wid.massiveInsert, 'ALL', [], 10)
+                    self.bridge.call('getMassiveMblogs', lib_wid.massiveInsert, 'ALL', [])
                 else:
-                    self.bridge.call('getMassiveLastMblogs', lib_wid.massiveInsert, 'GROUP', lib_wid.accepted_groups, 10)
+                    self.bridge.call('getMassiveMblogs', lib_wid.massiveInsert, 'GROUP', lib_wid.accepted_groups)
 
         #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.call('getMassiveMblogs', cb, 'JID', [self.whoami.bare], rsm)
+        self.next_rsm_index = index + C.RSM_MAX_ITEMS
+
     ## Signals callbacks ##
 
     def _personalEventCb(self, sender, event_type, data):
@@ -647,12 +679,12 @@
            or (_groups and _groups.intersection(mblog_panel.accepted_groups)):
             mblog_panel.addEntry(mblog_entry)
 
-    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
             self.addBlogEntry(mblog_panel, self.whoami.bare, *cache_entry)
 
@@ -661,7 +693,7 @@
         for lib_wid in self.libervia_widgets:
             if isinstance(lib_wid, panels.MicroblogPanel):
                 if lib_wid.isJidAccepted(entity):
-                    self.bridge.call('getMassiveLastMblogs', lib_wid.massiveInsert, 'JID', [entity], 10)
+                    self.bridge.call('getMassiveMblogs', lib_wid.massiveInsert, 'JID', [entity])
 
     def getLiberviaWidget(self, class_, entity, ignoreOtherTabs=True):
         """Get the corresponding panel if it exists.
--- a/src/browser/public/libervia.css	Thu Oct 23 16:56:36 2014 +0200
+++ b/src/browser/public/libervia.css	Fri Nov 28 00:31:27 2014 +0100
@@ -827,6 +827,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;
@@ -845,18 +860,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 {
--- a/src/browser/sat_browser/panels.py	Thu Oct 23 16:56:36 2014 +0200
+++ b/src/browser/sat_browser/panels.py	Fri Nov 28 00:31:27 2014 +0100
@@ -23,7 +23,7 @@
 
 from sat_frontends.tools.strings import addURLToText
 from sat_frontends.tools.games import SYMBOLS
-from sat.core.i18n import _
+from sat.core.i18n import _, D_
 
 from pyjamas.ui.SimplePanel import SimplePanel
 from pyjamas.ui.AbsolutePanel import AbsolutePanel
@@ -406,7 +406,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()
@@ -442,18 +442,56 @@
         self.__setIcons()
 
     def __setHeader(self):
-        """Set the entry header"""
+        """Set the entry header."""
         if self.empty:
             return
         update_text = u" — ✍ " + "<span class='mb_entry_timestamp'>%s</span>" % datetime.fromtimestamp(self.updated)
-        self.header.setHTML("""<div class='mb_entry_header'>
-                                   <span class='mb_entry_author'>%(author)s</span> on
-                                   <span class='mb_entry_timestamp'>%(published)s</span>%(updated)s
-                               </div>""" % {'author': html_tools.html_sanitize(self.author),
-                                            'published': datetime.fromtimestamp(self.published),
-                                            'updated': update_text if self.published != self.updated else ''
-                                            }
-                            )
+        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(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)"""
@@ -490,6 +528,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
@@ -517,7 +557,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.refresh()
             else:  # allow to create a new comment
@@ -589,7 +629,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
@@ -662,7 +702,7 @@
             self._blog_panel.host.bridge.call('syntaxConvert', setBubble, text, C.SYNTAX_TEXT, C.SYNTAX_XHTML)
 
 
-class MicroblogPanel(base_widget.LiberviaWidget):
+class MicroblogPanel(base_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 group <span class='warningTarget'>%s</span>"
 
@@ -671,6 +711,7 @@
         @param accepted_groups: groups displayed in this panel, if empty, show all microblogs from all contacts
         """
         base_widget.LiberviaWidget.__init__(self, host, ", ".join(accepted_groups), selectable=True)
+        MouseHandler.__init__(self)
         self.setAcceptedGroup(accepted_groups)
         self.host = host
         self.entries = {}
@@ -679,6 +720,12 @@
         self.vpanel = VerticalPanel()
         self.vpanel.setStyleName('microblogPanel')
         self.setWidget(self.vpanel)
+        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 refresh(self):
         """Refresh the display of this widget. If the unibox is disabled,
@@ -694,7 +741,7 @@
                         'new': True,
                         'author': self.host.whoami.bare,
                         }
-                entry = self.addEntry(data)
+                entry = self.addEntry(data, update_header=False)
                 entry.edit(True)
             if NEW_MESSAGE_USE_BUTTON:
                 self.new_button = Button("New message", listener=addBox)
@@ -708,10 +755,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
 
@@ -729,11 +775,9 @@
         @return: the created MicroblogPanel
         """
         _items = item if isinstance(item, list) else ([] if item is None else [item])
-        _type = 'ALL' if _items == [] else 'GROUP'
         # XXX: pyjamas doesn't support use of cls directly
         _new_panel = MicroblogPanel(host, _items)
-        host.FillMicroblogPanel(_new_panel)
-        host.bridge.call('getMassiveLastMblogs', _new_panel.massiveInsert, _type, _items, 10)
+        _new_panel.loadMoreMainEntries()
         host.setSelected(_new_panel)
         _new_panel.refresh()
         return _new_panel
@@ -747,6 +791,27 @@
     def accepted_groups(self):
         return self._accepted_groups
 
+    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.call('getMassiveMblogs', self.massiveInsert, type_, self.accepted_groups, rsm)
+
     def matchEntity(self, item):
         """
         @param item: single group as a string, list of groups
@@ -802,61 +867,84 @@
 
     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
 
         vpanel.insert(entry, idx)
 
-    def addEntry(self, data):
+    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":
             comments_hash = (_entry.service, _entry.node)
-            if not comments_hash in self.comments:
+            if comments_hash not in self.comments:
                 # The comments node is not known in this panel
                 return None
             parent = self.comments[comments_hash]
@@ -871,6 +959,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:
@@ -880,28 +969,37 @@
                     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':
@@ -919,6 +1017,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()
                         self.selected_entry = None
                         break
@@ -998,6 +1099,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()
+
 
 class StatusPanel(base_panels.HTMLTextEditor):
 
--- a/src/common/constants.py	Thu Oct 23 16:56:36 2014 +0200
+++ b/src/common/constants.py	Fri Nov 28 00:31:27 2014 +0100
@@ -51,3 +51,7 @@
 
     # Default avatar
     DEFAULT_AVATAR = "/media/misc/default_avatar.png"
+
+    RSM_MAX_ITEMS = 5
+    RSM_MAX_COMMENTS = 5
+
--- a/src/server/blog.py	Thu Oct 23 16:56:36 2014 +0200
+++ b/src/server/blog.py	Fri Nov 28 00:31:27 2014 +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 <http://www.gnu.org/licenses/>.
 
-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__)
@@ -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 = """<a href="%(link)s" class="%(class)s">%(text)s</a>""" % {'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 = """<a href="%(link)s" class="%(class)s">%(text)s</a>""" % {'link': after_link, 'class': class_, 'text': text}
+            else:
+                after_tag = None
+
+        # display navigation header
+        request.write("""<div class="header">""")
+        if before_tag:
+            request.write(before_tag)
+        request.write("&nbsp;")
+        if display_single and after_tag:
+            request.write(after_tag)
+        request.write("""</div>""")
+
+        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("""<a href="%(link)s" class="comments_link">%(text)s</a>""" % {'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("""<div class="footer">""")
+        if not display_single and after_tag:
+            request.write(after_tag)
+        request.write("""</div>""")
+
         request.write('</body></html>')
         request.finish()
 
@@ -244,12 +342,6 @@
         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 = '&nbsp;'
-            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:
@@ -264,12 +356,31 @@
                 return elem
             return """<a href="%(link)s" class="item_link">%(elem)s</a>""" % {'link': item_link, 'elem': elem}
 
-        header = addMainItemLink("""<div class="mblog_header">
-                                      <div class="mblog_metadata">
-                                        <div class="mblog_author">%(author)s</div>
-                                        <div class="mblog_timestamp">%(date)s</div>
-                                      </div>
-                                    </div>""" % {'author': author, 'date': datetime_})
+        if is_comment:
+            author = (_("from %s") % entry['author']).encode('utf-8')
+            item_link = ''
+            footer = ''
+        else:
+            author = '&nbsp;'
+            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("""<div class="mblog_footer mblog_footer_main">
+                                          <div class="mblog_metadata">
+                                            <div class="mblog_comments">%(count)s %(comments)s</div>
+                                          </div>
+                                        </div>""" % {'count': comments_count,
+                                                     'comments': comments_text})
+
+        header = """<div class="mblog_header %(class)s">
+                      <div class="mblog_metadata">
+                        <div class="mblog_author">%(author)s</div>
+                        <div class="mblog_timestamp">%(date)s</div>
+                      </div>
+                    </div>""" % {'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("""<div class="mblog_entry %(extra_style)s">
                            %(header)s
                            <span class="mblog_content">%(content)s</span>
-                         </div>""" %
-                         {'extra_style': 'mblog_comment' if entry['type'] == 'comment' else '',
-                          'item_link': item_link,
-                          'header': header,
-                          'content': body})
+                           %(footer)s
+                         </div>""" % {'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'))
--- a/src/server/server.py	Thu Oct 23 16:56:36 2014 +0200
+++ b/src/server/server.py	Fri Nov 28 00:31:27 2014 +0100
@@ -301,51 +301,46 @@
         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_list, max_item):
+    def jsonrpc_getMassiveMblogs(self, publishers_type, publishers_list, rsm=None):
         """Get lasts microblogs posted by several contacts at once
         @param publishers_type: one of "ALL", "GROUP", "JID"
         @param publishers_list: list of publishers type (empty list of all, list of groups or list of jids)
         @param max_item: number of items to ask
         @return: dictionary key=publisher's jid, value=list of microblog data (dict)"""
         profile = ISATSession(self.session).profile
-        d = self.asyncBridgeCall("getMassiveLastGroupBlogs", publishers_type, publishers_list, max_item, profile)
+        if rsm is None:
+            rsm = {'max': unicode(C.RSM_MAX_ITEMS)}
+        d = self.asyncBridgeCall("getMassiveGroupBlogs", publishers_type, publishers_list, rsm, profile)
         self.sat_host.bridge.massiveSubscribeGroupBlogs(publishers_type, publishers_list, 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):