changeset 679:a90cc8fc9605

merged branch frontends_multi_profiles
author Goffi <goffi@goffi.org>
date Wed, 18 Mar 2015 16:15:18 +0100
parents 1bffc4c244c3 (diff) 2e087e093e7f (current diff)
children 3b185ccb70b4
files src/browser/libervia_main.py src/browser/public/libervia.css src/browser/sat_browser/base_panels.py src/browser/sat_browser/blog.py src/browser/sat_browser/card_game.py src/browser/sat_browser/chat.py src/browser/sat_browser/contact.py src/browser/sat_browser/contact_list.py src/browser/sat_browser/jid.py src/browser/sat_browser/json.py src/browser/sat_browser/main_panel.py src/browser/sat_browser/panels.py src/browser/sat_browser/radiocol.py src/browser/sat_browser/widget.py src/common/constants.py src/server/blog.py src/server/server.py
diffstat 11 files changed, 488 insertions(+), 135 deletions(-) [+]
line wrap: on
line diff
--- 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);
 }
--- 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.
--- 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 @@
       <li>nous ferons notre
 possible pour aider les utilisateurs, quel que soit leur niveau</li>
       <li>de même, des efforts seront fait quant à
-l'accessibilité aux personnes victimes d'un handicap</li>
+l'accessibilité pour tous</li>
       <li>« Salut à Toi »,
 XMPP, et les technologies utilisées facilitent les échanges
 électroniques, mais nous désirons mettre l'accent sur les rencontres
--- 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 {
--- 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" — ✠" + "<span class='mb_entry_timestamp'>%s</span>" % datetime.fromtimestamp(self.updated)
-        self.header.setHTML("""<div class='mb_entry_header'>
-                                   <span class='mb_entry_author'>%(author)s</span> on
-                                   <span class='mb_entry_timestamp'>%(published)s</span>%(updated)s
-                               </div>""" % {'author': html_tools.html_sanitize(unicode(self.author)),
-                                            'published': datetime.fromtimestamp(self.published),
-                                            'updated': update_text if self.published != self.updated else ''
-                                            }
-                            )
+        self.header.add(HTML("""<span class='mb_entry_header_info'>
+                                  <span class='mb_entry_author'>%(author)s</span> on
+                                  <span class='mb_entry_timestamp'>%(published)s</span>%(updated)s
+                                </span>""" % {'author': html_tools.html_sanitize(unicode(self.author)),
+                                              'published': datetime.fromtimestamp(self.published),
+                                              'updated': update_text if self.published != self.updated else ''
+                                              }))
+        if self.comments:
+            self.comments_count = self.hidden_count = 0
+            self.show_comments_link = HTML('')
+            self.header.add(self.show_comments_link)
+
+    def updateHeader(self, comments_count=None, hidden_count=None, inc=None):
+        """Update the header.
+
+        @param comments_count (int): total number of comments.
+        @param hidden_count (int): number of hidden comments.
+        @param inc (int): number to increment the total number of comments with.
+        """
+        if comments_count is not None:
+            self.comments_count = comments_count
+        if hidden_count is not None:
+            self.hidden_count = hidden_count
+        if inc is not None:
+            self.comments_count += inc
+
+        if self.hidden_count > 0:
+            comments = D_('comments') if self.hidden_count > 1 else D_('comment')
+            text = D_("<a>show %(count)d previous %(comments)s</a>") % {'count': self.hidden_count,
+                                                                        'comments': comments}
+            if self not in self.show_comments_link._clickListeners:
+                self.show_comments_link.addClickListener(self)
+        else:
+            if self.comments_count > 1:
+                text = "%(count)d %(comments)s" % {'count': self.comments_count,
+                                                   'comments': D_('comments')}
+            elif self.comments_count == 1:
+                text = D_('1 comment')
+            else:
+                text = ''
+            try:
+                self.show_comments_link.removeClickListener(self)
+            except ValueError:
+                pass
+
+        self.show_comments_link.setHTML("""<span class='mb_entry_comments'>%(text)s</span></div>""" % {'text': text})
 
     def __setIcons(self):
         """Set the entry icons (delete, update, comment)"""
@@ -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 <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
@@ -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,)))
 
--- 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
--- 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)
--- 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
+
--- 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 <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__)
@@ -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 = """<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,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 = '&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:
                 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 """<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/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', '<br/>')
--- 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):