diff --git a/reviewboard/reviews/models/review.py b/reviewboard/reviews/models/review.py
index d4d9d17d8d01ad281072d694a51cc71f382282b9..11789c18fd451768534eed2f502f386ea1925ef8 100644
--- a/reviewboard/reviews/models/review.py
+++ b/reviewboard/reviews/models/review.py
@@ -240,7 +240,9 @@ class Review(models.Model):
         # Update the last_updated timestamp and the last review activity
         # timestamp on the review request.
         self.review_request.last_review_activity_timestamp = self.timestamp
-        self.review_request.save(
+        self.review_request.last_updated = self.timestamp
+        self.review_request.save_base(
+            raw=True,
             update_fields=['last_review_activity_timestamp', 'last_updated'])
 
         if self.is_reply():
diff --git a/reviewboard/reviews/models/review_request.py b/reviewboard/reviews/models/review_request.py
index 0155fdb79da6805229f6d15064254a9b160543a1..7be8c6b33fd8dfb3b28db1455bd73e696e4ec322 100644
--- a/reviewboard/reviews/models/review_request.py
+++ b/reviewboard/reviews/models/review_request.py
@@ -541,7 +541,7 @@ class ReviewRequest(BaseReviewRequestDetails):
         of that object. It can be used to judge whether something on a
         review request has been made public more recently.
         """
-        timestamp = self.last_updated
+        server_timestamp = self.last_updated
         updated_object = self
 
         # Check if the diff was updated along with this.
@@ -554,8 +554,7 @@ class ReviewRequest(BaseReviewRequestDetails):
 
         if diffsets:
             for diffset in diffsets:
-                if diffset.timestamp >= timestamp:
-                    timestamp = diffset.timestamp
+                if diffset.timestamp >= server_timestamp:
                     updated_object = diffset
 
         # Check for the latest review or reply.
@@ -566,11 +565,10 @@ class ReviewRequest(BaseReviewRequestDetails):
                 reviews = []
 
         for review in reviews:
-            if review.public and review.timestamp >= timestamp:
-                timestamp = review.timestamp
+            if review.public and review.timestamp >= server_timestamp:
                 updated_object = review
 
-        return timestamp, updated_object
+        return server_timestamp, updated_object
 
     def changeset_is_pending(self, commit_id):
         """Returns whether the associated changeset is pending commit.
diff --git a/reviewboard/reviews/models/review_request_draft.py b/reviewboard/reviews/models/review_request_draft.py
index 9fb8c09db850f48fd1c5b98c37480c6e760e1697..c352ee4ec3cdfc85b3679ed4b278fc1b394a956a 100644
--- a/reviewboard/reviews/models/review_request_draft.py
+++ b/reviewboard/reviews/models/review_request_draft.py
@@ -242,15 +242,28 @@ class ReviewRequestDraft(BaseReviewRequestDetails):
 
         if self.changedesc:
             self.changedesc.user = user
-            self.changedesc.timestamp = timezone.now()
+            field = self.changedesc.fields_changed.keys()[0]
+
+            if field == 'diff':
+                self.changedesc.timestamp = self.diffset.timestamp
+            else:
+                self.changedesc.timestamp = timezone.now()
+
             self.changedesc.public = True
             self.changedesc.save()
+            review_request.last_updated = self.changedesc.timestamp
             review_request.changedescs.add(self.changedesc)
 
         review_request.description_rich_text = self.description_rich_text
         review_request.testing_done_rich_text = self.testing_done_rich_text
         review_request.rich_text = self.rich_text
-        review_request.save()
+        '''
+        Use save_base() instead of save() to bypass the special pre-save
+        logic that ModificationTimestampField is doing that causes it to set
+        the latest timestamp.
+        '''
+        review_request.save_base(raw=True,
+                                 update_fields='last_update')
 
         if send_notification:
             review_request_published.send(sender=review_request.__class__,
diff --git a/reviewboard/reviews/tests/test_review_request.py b/reviewboard/reviews/tests/test_review_request.py
index 5ee0086c9e407c063234a62ce93ab600b330c973..7b22bdfe382774478e5ca8a84c313aa9748de28f 100644
--- a/reviewboard/reviews/tests/test_review_request.py
+++ b/reviewboard/reviews/tests/test_review_request.py
@@ -229,6 +229,32 @@ class ReviewRequestTests(SpyAgency, TestCase):
         self.assertEqual(change3.user, grumpy)
         self.assertEqual(change4.user, grumpy)
 
+    @add_fixtures(['test_scmtools'])
+    def test_get_last_activity(self):
+        """Testing ReviewRequest.get_last_activity to retrieve the last
+        update activity for review requests
+        """
+        review_request = self.create_review_request(
+            publish=True, repository=self.create_repository())
+
+        diffset = self.create_diffset(review_request)
+        server_timestamp, updated_object = \
+            review_request.get_last_activity()
+        timestamp = server_timestamp.replace(microsecond=0)
+        diffset_timestamp = diffset.timestamp.replace(microsecond=0)
+
+        self.assertEqual(timestamp, diffset_timestamp)
+        self.assertEqual(updated_object, diffset)
+
+        review = self.create_review(review_request, publish=True)
+        server_timestamp, updated_object = \
+            review_request.get_last_activity()
+        timestamp = server_timestamp.replace(microsecond=0)
+        review_timestamp = review.timestamp.replace(microsecond=0)
+
+        self.assertEqual(timestamp, review_timestamp)
+        self.assertEqual(updated_object, review)
+
 
 class IssueCounterTests(TestCase):
     """Unit tests for review request issue counters."""
diff --git a/reviewboard/static/rb/css/pages/review-request.less b/reviewboard/static/rb/css/pages/review-request.less
index 8a0307443cbc9cbdb6a7b4be2bedae2c7c277d65..db7fd02606b74aee46c0777101daa4feb789bfa1 100644
--- a/reviewboard/static/rb/css/pages/review-request.less
+++ b/reviewboard/static/rb/css/pages/review-request.less
@@ -480,9 +480,10 @@ a.mobile-actions-menu-label {
 /****************************************************************************
  * Updates Bubble
  ****************************************************************************/
+@updates-bubble-bg: #FFF1AB;
 
 #updates-bubble {
-  background: #FFF1AB;
+  background: @updates-bubble-bg;
   border-top: 1px #888866 solid;
   border-left: 1px #888866 solid;
   bottom: 0;
@@ -494,12 +495,128 @@ a.mobile-actions-menu-label {
   right: 0;
   z-index: @z-index-page-overlay;
 
+  .update-bubble-content {
+
+    &:hover {
+      -webkit-transition-duration: 5s;
+      -webkit-transition-delay: 8s;
+      transition-duration: 5s;
+      transition-delay: 8s;
+      background: darken(@updates-bubble-bg, 50%);
+    }
+  }
+
   a, a:visited {
     color: #0000CC;
     text-decoration: none;
   }
 
   #updates-bubble-buttons {
+    margin-top: 2em;
     margin-left: 2em;
   }
 }
+
+/****************************************************************************
+ * Updates Text Bubble
+ ****************************************************************************/
+
+#updates-text-bubble {
+  background: @updates-bubble-bg;
+  border-top: 1px #888866 solid;
+  border-left: 1px #888866 solid;
+  bottom: 0;
+  border-radius: 10px 0 0 0;
+  box-shadow: -1px -1px 2px rgba(0, 0, 0, 0.15);
+  font-size: 110%;
+  padding: 1em;
+  position: fixed;
+  max-width: 25%;
+  max-height: 400px;
+  right: 0;
+  z-index: @z-index-page-overlay;
+
+  .text-bubble-content {
+    margin-bottom: 2em;
+    position: relative;
+    overflow-y:auto;
+    max-height: 370px;
+
+    .text-bubble-entries {
+      position: relative;
+      margin-top: 2em;
+
+      .avatar-container {
+        background: white;
+        border-radius: 50%;
+        display: inline-block;
+        overflow: hidden;
+        float: right;
+        width: 24px;
+        height: 24px;
+      }
+
+      .text-bubble-entry-header {
+        a, a:visited {
+          color: #0000CC;
+          text-decoration: none;
+        }
+      }
+
+      .text-bubble-entry-body {
+        a, a:visited {
+          color: #0b0b0d;
+          text-decoration: none;
+        }
+      }
+
+      .text-bubble-text {
+        background: white;
+        border-radius: 5px;
+        border: 1px #b8b5a0 solid;
+        font-size: 12px;
+        font-family: verdana, sans-serif;
+        color: #0b0b0d;
+        margin:15px 2px 15px 5px;
+        padding: 10px;
+        position: relative;
+        cursor: pointer;
+
+        label {
+          border: 1px darken(#aeff60, 40%) solid;
+          border-radius: 6px;
+          background: #aeff60;
+          font-size: 13px;
+          display: block;
+          line-height: 1.5;
+          margin-bottom: 4em;
+          position: absolute;
+          top: -1em;
+          left: 40%;
+          padding: 2px 2px;
+          width: 4em;
+
+          .on-mobile-medium-screen-720({ margin-bottom: 0; });
+        }
+
+        .text-bubble-summary {
+          color:#0000CC;
+          margin-bottom: 1em;
+        }
+      }
+    }
+  }
+
+  #text-bubble-buttons {
+    margin-top: 10px;
+    position: absolute;
+    right: 2em;
+    bottom: 1em;
+    text-align: right;
+
+    a, a:visited {
+      color: #0000CC;
+      text-decoration: none;
+    }
+  }
+}
diff --git a/reviewboard/static/rb/css/pages/reviews.less b/reviewboard/static/rb/css/pages/reviews.less
index b72cad41741b6896f62c7c1e67a0a6d2ae03aa04..c0058a45512df90721dcdbc349357bc59d0c6839 100644
--- a/reviewboard/static/rb/css/pages/reviews.less
+++ b/reviewboard/static/rb/css/pages/reviews.less
@@ -71,8 +71,8 @@
         border-radius: 50%;
         display: inline-block;
         overflow: hidden;
-        width: 32px;
-        height: 32px;
+        width: 24px;
+        height: 24px;
       }
 
       .user-reply-info {
@@ -1089,9 +1089,10 @@
 /****************************************************************************
  * Updates Bubble
  ****************************************************************************/
+@updates-bubble-bg: #FFF1AB;
 
 #updates-bubble {
-  background: #FFF1AB;
+  background: @updates-bubble-bg;
   border-top: 1px #888866 solid;
   border-left: 1px #888866 solid;
   bottom: 0;
@@ -1103,16 +1104,131 @@
   right: 0;
   z-index: @z-index-page-overlay;
 
+  .update-bubble-content {
+
+    &:hover {
+      -webkit-transition-duration: 5s;
+      -webkit-transition-delay: 8s;
+      transition-duration: 5s;
+      transition-delay: 8s;
+      background: darken(@updates-bubble-bg, 50%);
+    }
+  }
+
   a, a:visited {
     color: #0000CC;
     text-decoration: none;
   }
 
   #updates-bubble-buttons {
+    margin-top: 2em;
     margin-left: 2em;
   }
 }
 
+/****************************************************************************
+ * Updates Text Bubble
+ ****************************************************************************/
+
+#updates-text-bubble {
+  background: @updates-bubble-bg;
+  border-top: 1px #888866 solid;
+  border-left: 1px #888866 solid;
+  bottom: 0;
+  border-radius: 10px 0 0 0;
+  box-shadow: -1px -1px 2px rgba(0, 0, 0, 0.15);
+  font-size: 110%;
+  padding: 1em;
+  position: fixed;
+  max-width: 25%;
+  max-height: 400px;
+  right: 0;
+  z-index: @z-index-page-overlay;
+
+  .text-bubble-content {
+    margin-bottom: 2em;
+    position: relative;
+    overflow-y:auto;
+    max-height: 370px;
+
+    .text-bubble-entries {
+      position: relative;
+      margin-top: 2em;
+
+      .avatar-container {
+        background: white;
+        border-radius: 50%;
+        display: inline-block;
+        overflow: hidden;
+        float: right;
+        width: 24px;
+        height: 24px;
+      }
+
+      .text-bubble-entry-header {
+        a, a:visited {
+          color: #0000CC;
+          text-decoration: none;
+        }
+      }
+
+      .text-bubble-entry-body {
+        a, a:visited {
+          color: #0b0b0d;
+          text-decoration: none;
+        }
+      }
+
+      .text-bubble-text {
+        background: white;
+        border-radius: 5px;
+        border: 1px #b8b5a0 solid;
+        font-size: 12px;
+        font-family: verdana, sans-serif;
+        color: #0b0b0d;
+        margin:15px 2px 15px 5px;
+        padding: 10px;
+        position: relative;
+        cursor: pointer;
+
+        label {
+          border: 1px darken(#aeff60, 40%) solid;
+          border-radius: 6px;
+          background: #aeff60;
+          font-size: 13px;
+          display: block;
+          line-height: 1.5;
+          margin-bottom: 4em;
+          position: absolute;
+          top: -1em;
+          left: 40%;
+          padding: 2px 2px;
+          width: 4em;
+
+          .on-mobile-medium-screen-720({ margin-bottom: 0; });
+        }
+
+        .text-bubble-summary {
+          color:#0000CC;
+          margin-bottom: 1em;
+        }
+      }
+    }
+  }
+
+  #text-bubble-buttons {
+    margin-top: 10px;
+    position: absolute;
+    right: 2em;
+    bottom: 1em;
+    text-align: right;
+
+    a, a:visited {
+      color: #0000CC;
+      text-decoration: none;
+    }
+  }
+}
 
 /****************************************************************************
  * Review Dialog
diff --git a/reviewboard/static/rb/js/pages/views/reviewablePageView.es6.js b/reviewboard/static/rb/js/pages/views/reviewablePageView.es6.js
index ca061e20aca8ba6a21ae6db89578044dffd4b4f3..40c05028574e5e4b82fbf65a4fde4f700260c402 100644
--- a/reviewboard/static/rb/js/pages/views/reviewablePageView.es6.js
+++ b/reviewboard/static/rb/js/pages/views/reviewablePageView.es6.js
@@ -2,29 +2,159 @@
 
 
 /**
- * An update bubble showing an update to the review request or a review.
+ * An update bubble showing updates to the review request or a review.
  */
 const UpdatesBubbleView = Backbone.View.extend({
     id: 'updates-bubble',
 
     template: _.template([
-        '<span id="updates-bubble-summary"><%- summary %></span>',
-        ' by ',
-        '<a href="<%- user.url %>" id="updates-bubble-user">',
-        '<%- user.fullname || user.username %>',
-        '</a>',
-        '<span id="updates-bubble-buttons">',
-        ' <a href="#" class="update-page"><%- updatePageText %></a>',
-        ' | ',
-        ' <a href="#" class="ignore"><%- ignoreText %></a>',
+        '<div class="update-bubble-content">',
+        '<% if (new_reviews) { %>',
+            '<% _.each(new_reviews, function(review) { %>',
+            '<div class="update-bubble-review-entry" id="review-<%- review.username %>">',
+                '<%- review.count %>',
+                '<% if (review.count == 1) { %>',
+                ' new review by ',
+                '<% } else { %>',
+                ' new reviews by ',
+                '<% } %>',
+                '<a href="<%- review.user.url %>" id="updates-bubble-user">',
+                '<%- review.username %>',
+                '</a>',
+            '</div>',
+            '<% }); %>',
+        '<% } %>',
+        '<% if (new_replies) { %>',
+            '<% _.each(new_replies, function(reply) { %>',
+            '<div class="update-bubble-reply-entry" id="reply-<%- reply.username %>">',
+                '<%- reply.count %>',
+                '<% if (reply.count == 1) { %>',
+                ' new reply by ',
+                '<% } else { %>',
+                ' new replies by ',
+                '<% } %>',
+                '<a href="<%- reply.user.url %>" id="updates-bubble-user">',
+                '<%- reply.username %>',
+                '</a>',
+            '</div>',
+            '<% }); %>',
+        '<% } %>',
+        '<% if (new_updates) { %>',
+            '<% _.each(new_updates, function(update) { %>',
+            '<div class="update-bubble-update-entry" id="update-<%- update.username %>">',
+                '<%- update.count %>',
+                '<% if (update.count == 1) { %>',
+                ' new update by ',
+                '<% } else { %>',
+                ' new updates by ',
+                '<% } %>',
+                '<a href="<%- update.user.url %>" id="updates-bubble-user">',
+                '<%- update.username %>',
+                '</a>',
+            '</div>',
+            '<% }); %>',
+        '<% } %>',
+        '</div>',
+        '<div id="updates-bubble-buttons">',
+            '<a href="#" class="update-page"><%- updatePageText %></a>',
+            ' | ',
+            '<a href="#" class="ignore"><%- ignoreText %></a>',
+        '</div>'
     ].join('')),
 
     events: {
         'click .update-page': '_onUpdatePageClicked',
         'click .ignore': '_onIgnoreClicked',
+        'hover .update-bubble-content': '_onMouseOverUpdate',
+    },
+
+    initialize(options) {
+        this._updatesListByUsername = this._getCount(this.options.updateInfo);
+    },
+
+    /**
+     * Traverse the last-update server response
+     * to count the updates by username and update type
+     *
+     * Args:
+     *      info (JSON object):
+     *          The last-update JSON object returned by server.
+     *
+     * Return:
+     *      updateCountByUserName (object):
+     *          The result update object counted by username and update type.
+     */
+    _getCount(info) {
+        let updatesListByUsername = {};
+
+        for (const key in info) {
+            let result = [];
+
+            if (key !== "timestamp" && info[key].length) {
+                let count = _.countBy(info[key], 'username');
+
+                for (const i in count) {
+                    let item = {};
+                    item.username = i;
+                    item.count = count[i];
+                    item.user = this._getUserInfo(i, info[key]);
+                    item.list = this._getUpdates(i, info[key]);
+                    result.push(item);
+                }
+            }
+
+            updatesListByUsername[key] = result;
+        }
+
+        return updatesListByUsername;
+    },
+
+    /**
+     * Retrieve a user's url based on username.
+     *
+     * Args:
+     *      username (string):
+     *          The user's username.
+     *
+     *      updates (object):
+     *          The updates list.
+     *
+     * Return:
+     *      The user info.
+     */
+    _getUserInfo(username, updates) {
+        for (const update in updates) {
+            if (username === updates[update].username) {
+                return updates[update].user;
+            }
+        }
     },
 
     /**
+     * Retrieve a user's updates.
+     *
+     * Args:
+     *      username (string):
+     *          The user's username.
+     *
+     *      updates (object):
+     *          The updates list.
+     *
+     * Return:
+     *      The user's updates list.
+     */
+    _getUpdates(username, updates) {
+        let list = [];
+
+        for (const update in updates) {
+            if (username === updates[update].username) {
+                list.push(updates[update]);
+            }
+        }
+
+        return list;
+    },
+    /**
      * Render the bubble with the information provided during construction.
      *
      * The bubble starts hidden. The caller must call open() to display it.
@@ -38,7 +168,7 @@ const UpdatesBubbleView = Backbone.View.extend({
             .html(this.template(_.defaults({
                 updatePageText: gettext('Update Page'),
                 ignoreText: gettext('Ignore'),
-            }, this.options.updateInfo)))
+            }, this._updatesListByUsername)))
             .hide();
 
         return this;
@@ -64,6 +194,22 @@ const UpdatesBubbleView = Backbone.View.extend({
     },
 
     /**
+     * Handle mouse move over on the update content.
+     *
+     * Loads the update text bubbles.
+     *
+     * Args:
+     *     e (Event):
+     *         The event which triggered the action.
+     */
+    _onMouseOverUpdate(e) {
+        e.preventDefault();
+        e.stopPropagation();
+
+        this.trigger('updatedText', this._updatesListByUsername);
+    },
+
+    /**
      * Handle clicks on the "Update Page" link.
      *
      * Loads the review request page.
@@ -79,7 +225,7 @@ const UpdatesBubbleView = Backbone.View.extend({
         this.trigger('updatePage');
     },
 
-    /*
+    /**
      * Handle clicks on the "Ignore" link.
      *
      * Ignores the update and closes the page.
@@ -96,6 +242,265 @@ const UpdatesBubbleView = Backbone.View.extend({
     },
 });
 
+/**
+ * Update text bubbles showing updates' texts to the review request.
+ */
+const TextBubbleView = Backbone.View.extend({
+    id: 'updates-text-bubble',
+
+    template: _.template([
+        '<div class="text-bubble-content">',
+            '<div class="text-bubble-entries" id="reviews">',
+                '<% if (new_reviews) { %>',
+                '<% _.each(new_reviews, function(review) { %>',
+                '<div class="text-bubble-entry" id="<%- review.username %>">',
+                    '<div class="text-bubble-entry-header">',
+                        '<div class="text-bubble-entry-header-details">',
+                            '<%- review.count %>',
+                            '<% if (review.count == 1) { %>',
+                            ' new review by ',
+                            '<% } else { %>',
+                            ' new reviews by ',
+                            '<% } %>',
+                            '<a href="<%- review.user.url %>" id="updates-bubble-user">',
+                            '<%- review.username %>',
+                            '</a>',
+                            '<div class="avatar-container">',
+                                '<img src="<%- review.user.avatar_url %>" alt="<%- review.username %>" ' +
+                                'width="24" height="24" class="avatar">',
+                            '</div>',
+                        '</div>',
+                        //end of header-details
+                    '</div>',
+                    //end of header
+                    '<div class="text-bubble-entry-body">',
+                    '<% _.each(review.list, function(item) { %>',
+                        '<a data-anchor="<%- item.anchor %>" class="text-bubble-anchor" id="<%- item.pk %>">',
+                            '<div class="text-bubble-text" id="<%- item.pk %>">',
+                                '<% if (item.ship_it) { %>',
+                                '<label class="ship-it-label">Ship it!</label>',
+                                '<% } %>',
+                                '<p>',
+                                '<% if (item.text != item.ship_it) { %>',
+                                '<% if (item.text.length > 300) { %>',
+                                '<%- item.text.substring(0, 300) %>[...]',
+                                '<% } else { %>',
+                                '<%- item.text %>',
+                                '<% }}%>',
+                                '</p>',
+                            '</div>',
+                        '</a>',
+                        '<% }); %>',
+                    '</div>',
+                    //end of body
+                '</div>',
+                //end of entry
+                '<% }); %>',
+                '<% } %>',
+            '</div>',
+            //end of reviews
+            '<div class="text-bubble-entries" id="replies">',
+            '<% if (new_replies) { %>',
+            '<% _.each(new_replies, function(reply) { %>',
+                '<div class="text-bubble-entry" id="<%- reply.username %>">',
+                    '<div class="text-bubble-entry-header">',
+                        '<div class="text-bubble-entry-header-details">',
+                        '<%- reply.count %>',
+                        '<% if (reply.count == 1) { %>',
+                        ' new reply by ',
+                        '<% } else { %>',
+                        ' new replies by ',
+                        '<% } %>',
+                        '<a href="<%- reply.user.url %>" id="updates-bubble-user">',
+                        '<%- reply.username %>',
+                        '</a>',
+                        '<div class="avatar-container">',
+                        '<img src="<%- reply.user.avatar_url %>" alt="<%- reply.username %>" ' +
+                        'width="24" height="24" class="avatar">',
+                        '</div>',
+                        '</div>',
+                        //end of header-details
+                    '</div>',
+                    //end of header
+                    '<div class="text-bubble-entry-body">',
+                        '<% _.each(reply.list, function(item) { %>',
+                        '<a data-anchor="<%- item.anchor %>" class="text-bubble-anchor" id="<%- item.pk %>">',
+                        '<div class="text-bubble-text" id="<%- item.pk %>">',
+                        '<% if (item.text.length > 300) { %>',
+                        '<%- item.text.substring(0, 300) %> [...]',
+                        '<% } else { %>',
+                        '<%- item.text %>',
+                        '<% }%>',
+                        '</div>',
+                        '</a>',
+                        '<% }); %>',
+                    '</div>',
+                    //end of body
+                '</div>',
+                //end of entry
+                '<% }); %>',
+                '<% } %>',
+            '</div>',
+            //end of replies
+            '<div class="text-bubble-entries" id="updates">',
+            '<% if (new_updates) { %>',
+            '<% _.each(new_updates, function(update) { %>',
+                '<div class="text-bubble-entry" id="<%- update.username %>">',
+                    '<div class="text-bubble-entry-header">',
+                        '<div class="text-bubble-entry-header-details">',
+                            '<%- update.count %>',
+                            '<% if (update.count == 1) { %>',
+                            ' new update by ',
+                            '<% } else { %>',
+                            ' new updates by ',
+                            '<% } %>',
+                            '<a href="<%- update.user.url %>" id="updates-bubble-user">',
+                            '<%- update.username %>',
+                            '</a>',
+                            '<div class="avatar-container">',
+                                '<img src="<%- update.user.avatar_url %>" alt="<%- update.username %>" ' +
+                                'width="24" height="24" class="avatar">',
+                            '</div>',
+                        '</div>',
+                        //end of header-details
+                    '</div>',
+                    //end of header
+                    '<div class="text-bubble-entry-body">',
+                        '<% _.each(update.list, function(item) { %>',
+                        '<a data-anchor="<%- item.anchor %>" class="text-bubble-anchor" id="<%- item.pk %>">',
+                            '<div class="text-bubble-text" id="<%- item.pk %>">',
+                                '<div class="text-bubble-summary">',
+                                '<% if (item.field == "files") { %> ',
+                                '<%- item.summary %>',
+                                '<% } else if (item.field == "diff") { %>',
+                                'Diff revision <%- item.revision %>',
+                                '<% } else { %>',
+                                '<%- item.field %>',
+                                ' field updated',
+                                '<% } %>',
+                                '</div>',
+                                //end of summary
+                                '<p>',
+                                '<% if (item.text.length > 300) { %>',
+                                '<%- item.text.substring(0, 300) %> [...]',
+                                '<% } else { %>',
+                                '<%- item.text %>',
+                                '<% }%>',
+                                '</p>',
+                            '</div>',
+                        '</a>',
+                        '<% }); %>',
+                    '</div>',
+                    //end of body
+                '</div>',
+                //end of entry
+                '<% }); %>',
+                '<% } %>',
+            '</div>',
+            //end of updates
+        '</div>',
+        '<div id="text-bubble-buttons">',
+        '<a href="#" class="update-page"><%- updatePageText %></a>',
+        ' | ',
+        '<a href="#" class="ignore"><%- ignoreText %></a>',
+        '</div>'
+    ].join('')),
+
+    events: {
+        'click .update-page': '_onUpdatePageClicked',
+        'click .ignore': '_onIgnoreClicked',
+        'click .text-bubble-anchor': '_onUpdateEntryClicked',
+    },
+
+
+    /**
+     * Render the bubble with the information provided during construction.
+     *
+     * The bubble starts hidden. The caller must call open() to display it.
+     *
+     * Returns:
+     *     UpdatesBubbleView:
+     *     This object, for chaining.
+     */
+    render() {
+        this.$el
+            .html(this.template(_.defaults({
+                updatePageText: gettext('Update Page'),
+                ignoreText: gettext('Ignore'),
+            }, this.options.updateInfo)))
+            .hide();
+
+        return this;
+    },
+
+    /**
+     * Open the text bubbles on the screen.
+     */
+    open() {
+        this.$el
+            .css('position', 'fixed')
+            .fadeIn();
+    },
+
+    /**
+     * Close the update text bubbles.
+     *
+     * After closing, the text bubbles will be removed from the DOM.
+     */
+    close() {
+        this.trigger('closed');
+        this.$el.fadeOut(_.bind(this.remove, this));
+    },
+
+    /**
+     * Handle the hover event on the text bubbles
+     *
+     * Refreshs the page and navigates to the specific update.
+     *
+     * After refreshing, the text bubbles will be removed from the DOM.
+     */
+    _onUpdateEntryClicked(e){
+        e.preventDefault();
+        e.stopPropagation();
+
+        window.location.href = window.location.href;
+        const anchor = $(e.currentTarget).data('anchor');
+
+        this.trigger('updateEntry', anchor);
+    },
+
+    /**
+     * Handle clicks on the "Update Page" link.
+     *
+     * Loads the review request page.
+     *
+     * Args:
+     *     e (Event):
+     *         The event which triggered the action.
+     */
+    _onUpdatePageClicked(e) {
+        e.preventDefault();
+        e.stopPropagation();
+
+        this.trigger('updatePage');
+    },
+
+    /**
+     * Handle clicks on the "Ignore" link.
+     *
+     * Ignores the update and closes the page.
+     *
+     * Args:
+     *     e (Event):
+     *         The event which triggered the action.
+     */
+    _onIgnoreClicked(e) {
+        e.preventDefault();
+        e.stopPropagation();
+
+        this.close();
+    },
+});
 
 /**
  * A page managing reviewable content for a review request.
@@ -177,6 +582,7 @@ RB.ReviewablePageView = Backbone.View.extend({
         });
 
         this._updatesBubble = null;
+        this._textBubbles = null;
         this._favIconURL = null;
         this._favIconNotifyURL = null;
         this._logoNotificationsURL = null;
@@ -247,7 +653,6 @@ RB.ReviewablePageView = Backbone.View.extend({
         this.listenTo(this.reviewRequest, 'updated', this._onReviewRequestUpdated);
 
         this.reviewRequest.beginCheckForUpdates(
-            this.options.checkUpdatesType,
             this.options.lastActivityTimestamp);
     },
 
@@ -283,11 +688,16 @@ RB.ReviewablePageView = Backbone.View.extend({
             this._updatesBubble.remove();
         }
 
+        if (this._textBubbles) {
+            this._textBubbles.remove();
+        }
+
         this._updatesBubble = new UpdatesBubbleView({
             updateInfo: info,
             reviewRequest: this.reviewRequest,
         });
 
+        this.listenTo(this._updatesBubble, 'updatedText', this._showTextBubbles);
         this.listenTo(this._updatesBubble, 'closed',
                       () => this._updateFavIcon(this._favIconURL));
 
@@ -300,6 +710,55 @@ RB.ReviewablePageView = Backbone.View.extend({
     },
 
     /**
+     * Create the updates text bubbles showing information about updates.
+     *
+     * Args:
+     *     info (object):
+     *         The updates information for the request.
+     */
+    _showTextBubbles(info) {
+        this._updateFavIcon(this._favIconNotifyURL);
+
+        if (this._updatesBubble) {
+            this._updatesBubble.remove();
+        }
+
+        this._textBubbles = new TextBubbleView({
+            updateInfo: info,
+            reviewRequest: this.reviewRequest,
+        });
+
+        this.listenTo(this._textBubbles, 'updateEntry', this._getUpdateEntry);
+
+        this.listenTo(this._textBubbles, 'closed',
+                      () => this._updateFavIcon(this._favIconURL));
+
+        this.listenTo(this._textBubbles, 'updatePage', () => {
+            window.location = this.reviewRequest.get('reviewURL');
+        });
+
+        this._textBubbles.render().$el.appendTo(this.$el);
+        this._textBubbles.open();
+    },
+
+    /**
+     * Refreshs the page, and navigate to the specific update.
+     *
+     * Args:
+     *    anchor (String):
+     *          The anchor link for the update.
+     */
+    _getUpdateEntry(anchor) {
+        if (anchor.includes("#")) {
+            window.location.hash = anchor;
+        } else {
+            window.location.hash = "";
+            window.location.pathname += anchor;
+        }
+        window.location.reload(true);
+    },
+
+    /**
      * Show the user a desktop notification for the last update.
      *
      * This function will create a notification if the user has not
diff --git a/reviewboard/static/rb/js/pages/views/tests/reviewablePageViewTests.js b/reviewboard/static/rb/js/pages/views/tests/reviewablePageViewTests.js
index c6eb9e4680d15024d2268a400921b4e0fbab19e9..ef8716b8c07efe74d36109e8bf420086606ba86d 100644
--- a/reviewboard/static/rb/js/pages/views/tests/reviewablePageViewTests.js
+++ b/reviewboard/static/rb/js/pages/views/tests/reviewablePageViewTests.js
@@ -127,39 +127,76 @@ suite('rb/pages/views/ReviewablePageView', function() {
     });
 
     describe('Update bubble', function() {
-        var summary = 'My summary',
-            user = {
-                url: '/users/foo/',
-                fullname: 'Mr. User',
-                username: 'user'
+        const info = {
+            user: {
+                username: 'submitter',
+                fullname: 'Bob Smith'
             },
-            $bubble,
-            bubbleView;
-
-        beforeEach(function() {
-            pageView.reviewRequest.trigger('updated', {
-                summary: summary,
-                user: user
-            });
-
+            timestamp: '',
+            new_reviews: [{
+                id: 1,
+                username: 'adam',
+                text: 'Review 1',
+                anchor: '#review1',
+                user: {
+                    url: '/users/adam',
+                }
+            },
+                {
+                    id: 2,
+                    username: 'adam',
+                    ship_it: 'Ship it!',
+                    text: 'Please ship it',
+                    anchor: '#review2',
+                    user: {
+                        url: '/users/adam',
+                    }
+                }],
+            new_replies: [{
+                id: 3,
+                username: 'cherry',
+                text: 'Reply 1',
+                anchor: '#gcomment3',
+                user: {
+                    url: '/users/cherry',
+                }
+            }],
+            new_updates: [{
+                id: 4,
+                username: 'michael',
+                field: 'summary',
+                summary: 'summary field updated',
+                text: 'New summary',
+                anchor: '#changedesc4',
+                user: {
+                    url: '/users/michael',
+                }
+            }]
+        };
+
+        var $bubble;
+        var bubbleView;
+        var updatesListByUsername;
+
+        beforeEach(function () {
+            pageView.reviewRequest.trigger('updated', info);
             $bubble = $('#updates-bubble');
             bubbleView = pageView._updatesBubble;
+            updatesListByUsername = bubbleView._getCount(info);
         });
 
-        it('Displays', function() {
-            expect($bubble.length).toBe(1);
-            expect(bubbleView.$el[0]).toBe($bubble[0]);
+        it('Displays', function () {
             expect($bubble.is(':visible')).toBe(true);
-            expect($bubble.find('#updates-bubble-summary').text())
-                .toBe(summary);
-            expect($bubble.find('#updates-bubble-user').text())
-                .toBe(user.fullname);
-            expect($bubble.find('#updates-bubble-user').attr('href'))
-                .toBe(user.url);
+            expect($bubble.find('#review-adam').text())
+                .toBe("2 new reviews by adam");
+            expect($bubble.find('#reply-cherry').text())
+                .toBe("1 new reply by cherry");
+            expect($bubble.find('#update-michael').text())
+                .toBe("1 new update by michael");
         });
 
-        describe('Actions', function() {
-            it('Ignore', function() {
+        describe('Actions', function () {
+            it('Ignore', function () {
                 spyOn(bubbleView, 'close').and.callThrough();
                 spyOn(bubbleView, 'trigger').and.callThrough();
                 spyOn(bubbleView, 'remove').and.callThrough();
@@ -171,25 +208,28 @@ suite('rb/pages/views/ReviewablePageView', function() {
                 expect(bubbleView.trigger).toHaveBeenCalledWith('closed');
             });
 
-            it('Update Page displays Updates Bubble', function() {
+            it('Update Page', function () {
                 spyOn(bubbleView, 'trigger');
-
                 $bubble.find('.update-page').click();
 
                 expect(bubbleView.trigger).toHaveBeenCalledWith('updatePage');
             });
 
-            it('Update Page calls notify if shouldNotify', function() {
-                var info = {
-                    user: {
-                        fullname: 'Hello'
-                    }
-                };
+            it('Displays Text Bubbles', function () {
+                spyOn(bubbleView, 'trigger');
+
+                $bubble.find('.update-bubble-content').trigger('mouseover');
+
+                expect(bubbleView.trigger).toHaveBeenCalledWith('updatedText', updatesListByUsername);
 
+                pageView._updatesBubble.trigger('updatedText', updatesListByUsername);
+            });
+
+            it('Update Page calls notify if shouldNotify', function () {
                 RB.NotificationManager.instance._canNotify = true;
                 spyOn(RB.NotificationManager.instance, 'notify');
                 spyOn(RB.NotificationManager.instance,
-                      '_haveNotificationPermissions').and.returnValue(true);
+                    '_haveNotificationPermissions').and.returnValue(true);
                 spyOn(pageView, '_showUpdatesBubble');
 
                 pageView._onReviewRequestUpdated(info);
@@ -199,5 +239,64 @@ suite('rb/pages/views/ReviewablePageView', function() {
                 expect(pageView._showUpdatesBubble).toHaveBeenCalled();
             });
         });
+
+        describe('Text Bubbles', function () {
+            var $textbubble;
+            var textbubbleview;
+
+            beforeEach(function () {
+                pageView._updatesBubble.trigger('updatedText', updatesListByUsername);
+                $textbubble = $('#updates-text-bubble');
+                textbubbleview = pageView._textBubbles;
+            });
+
+            it('Displays', function () {
+                expect($textbubble.is(':visible')).toBe(true);
+                expect($textbubble.find('.text-bubble-text#1').text())
+                    .toBe("Review 1");
+                expect($textbubble.find('.text-bubble-text#2 p').text())
+                    .toBe("Please ship it");
+                expect($textbubble.find('.ship-it-label').text())
+                    .toBe("Ship it!");
+                expect($textbubble.find('.text-bubble-text#3').text())
+                    .toBe("Reply 1");
+                expect($textbubble.find('.text-bubble-text#4 .text-bubble-summary').text())
+                    .toBe("summary field updated");
+                expect($textbubble.find('.text-bubble-text#4 p').text())
+                    .toBe("New summary");
+            });
+
+            describe('Actions', function () {
+                it('Ignore', function () {
+                    spyOn(textbubbleview, 'close').and.callThrough();
+                    spyOn(textbubbleview, 'trigger').and.callThrough();
+                    spyOn(textbubbleview, 'remove').and.callThrough();
+
+                    $textbubble.find('.ignore').click();
+
+                    expect(textbubbleview.close).toHaveBeenCalled();
+                    expect(textbubbleview.remove).toHaveBeenCalled();
+                    expect(textbubbleview.trigger).toHaveBeenCalledWith('closed');
+                });
+
+                it('Update Page', function () {
+                    spyOn(textbubbleview, 'trigger');
+
+                    $textbubble.find('.update-page').click();
+
+                    expect(textbubbleview.trigger).toHaveBeenCalledWith('updatePage');
+                });
+
+                it('Update Page displays Text Bubbles', function () {
+                    spyOn(textbubbleview, 'trigger');
+
+                    $textbubble.find('.text-bubble-anchor#1').click();
+
+                    expect(textbubbleview.trigger).toHaveBeenCalledWith('updateEntry', '#review1');
+                });
+            });
+        });
     });
+
 });
+
diff --git a/reviewboard/static/rb/js/resources/models/reviewRequestModel.es6.js b/reviewboard/static/rb/js/resources/models/reviewRequestModel.es6.js
index 94b4a092b4a4456eda054bb6e8c90aafb47eb606..32a44c0b5c6b5ee621aa2907c864efde9fdc5e20 100644
--- a/reviewboard/static/rb/js/resources/models/reviewRequestModel.es6.js
+++ b/reviewboard/static/rb/js/resources/models/reviewRequestModel.es6.js
@@ -130,6 +130,7 @@ RB.ReviewRequest = RB.BaseResource.extend({
         return this.isNew() ? url : `${url}${this.id}/`;
     },
 
+
     /**
      * Create the review request from an existing commit.
      *
@@ -399,14 +400,11 @@ RB.ReviewRequest = RB.BaseResource.extend({
      * The 'updated' event will be triggered when there's a new update.
      *
      * Args:
-     *     type (string):
-     *         The type of updates to check for.
      *
      *     lastUpdateTimestamp (string):
      *         The timestamp of the last known update.
      */
-    beginCheckForUpdates(type, lastUpdateTimestamp) {
-        this._checkUpdatesType = type;
+    beginCheckForUpdates(lastUpdateTimestamp) {
         this._lastUpdateTimestamp = lastUpdateTimestamp;
 
         this.ready({
@@ -428,12 +426,13 @@ RB.ReviewRequest = RB.BaseResource.extend({
             prefix: this.get('sitePrefix'),
             noActivityIndicator: true,
             url: this.get('links').last_update.href,
+            data: {
+                timestamp: this._lastUpdateTimestamp,
+            },
             success: rsp => {
                 const lastUpdate = rsp.last_update;
 
-                if ((this._checkUpdatesType === undefined ||
-                     this._checkUpdatesType === lastUpdate.type) &&
-                    this._lastUpdateTimestamp !== lastUpdate.timestamp) {
+                if (this._lastUpdateTimestamp !== lastUpdate.timestamp) {
                     this.trigger('updated', lastUpdate);
                 }
 
diff --git a/reviewboard/testing/testcase.py b/reviewboard/testing/testcase.py
index 5d137fa138d1e72460b70643334b7012f5927cde..436e76538c9246658f1f365997f541f66c86bfd8 100644
--- a/reviewboard/testing/testcase.py
+++ b/reviewboard/testing/testcase.py
@@ -426,7 +426,8 @@ class TestCase(FixturesCompilerMixin, DjbletsTestCase):
                               bugs_closed='', status='P', public=False,
                               publish=False, commit_id=None, changenum=None,
                               repository=None, id=None,
-                              create_repository=False):
+                              create_repository=False,
+                              timestamp=None):
         """Create a ReviewRequest for testing.
 
         The ReviewRequest may optionally be attached to a LocalSite. It's also
@@ -481,6 +482,9 @@ class TestCase(FixturesCompilerMixin, DjbletsTestCase):
         if publish:
             review_request.publish(review_request.submitter)
 
+        if timestamp:
+            review_request.time_added = timestamp
+
         return review_request
 
     def create_visit(self, review_request, visibility, user='doc',
@@ -502,7 +506,7 @@ class TestCase(FixturesCompilerMixin, DjbletsTestCase):
 
     def create_review(self, review_request, user='dopey', username=None,
                       body_top='Test Body Top', body_bottom='Test Body Bottom',
-                      ship_it=False, publish=False):
+                      ship_it=False, publish=False, timestamp=None):
         """Creates a Review for testing.
 
         The Review is tied to the given ReviewRequest. It's populated with
@@ -522,6 +526,9 @@ class TestCase(FixturesCompilerMixin, DjbletsTestCase):
             body_bottom=body_bottom,
             ship_it=ship_it)
 
+        if timestamp:
+            review.timestamp = timestamp
+
         if publish:
             review.publish()
 
diff --git a/reviewboard/webapi/resources/review_request_last_update.py b/reviewboard/webapi/resources/review_request_last_update.py
index 27b4c3903c933728bcbc81841f74bc34977405c6..b03e8d266d13a123dbff6de120d5569141d0f8fc 100644
--- a/reviewboard/webapi/resources/review_request_last_update.py
+++ b/reviewboard/webapi/resources/review_request_last_update.py
@@ -1,13 +1,21 @@
 from __future__ import unicode_literals
 
+from datetime import datetime, timedelta
+
 from django.core.exceptions import ObjectDoesNotExist
 from django.http import HttpResponseNotModified
 from django.utils import six
-from django.utils.translation import ugettext as _
+from django.utils.timezone import utc
 from djblets.util.http import encode_etag, etag_if_none_match
+from djblets.webapi.decorators import webapi_request_fields
 from djblets.webapi.errors import DOES_NOT_EXIST
 from reviewboard.diffviewer.models import DiffSet
-from reviewboard.reviews.models import Review, ReviewRequest
+from reviewboard.reviews.models import (Comment,
+                                        FileAttachmentComment,
+                                        GeneralComment,
+                                        Review,
+                                        ReviewRequest,
+                                        ScreenshotComment)
 from reviewboard.webapi.base import WebAPIResource
 from reviewboard.webapi.decorators import (webapi_check_local_site,
                                            webapi_check_login_required)
@@ -15,7 +23,7 @@ from reviewboard.webapi.resources import resources
 
 
 class ReviewRequestLastUpdateResource(WebAPIResource):
-    """Provides information on the last update made to a review request.
+    """Provides information on the new updates made to a review request.
 
     Clients can periodically poll this to see if any new updates have been
     made.
@@ -26,35 +34,48 @@ class ReviewRequestLastUpdateResource(WebAPIResource):
     allowed_methods = ('GET',)
 
     fields = {
-        'summary': {
-            'type': six.text_type,
-            'description': 'A short summary of the update. This should be one '
-                           'of "Review request updated", "Diff updated", '
-                           '"New reply" or "New review".',
-        },
         'timestamp': {
             'type': six.text_type,
             'description': 'The timestamp of this most recent update '
                            '(YYYY-MM-DD HH:MM:SS format).',
         },
-        'type': {
-            'type': ('review-request', 'diff', 'reply', 'review'),
-            'description': "The type of the last update. ``review-request`` "
-                           "means the last update was an update of the "
-                           "review request's information. ``diff`` means a "
-                           "new diff was uploaded. ``reply`` means a reply "
-                           "was made to an existing review. ``review`` means "
-                           "a new review was posted.",
+        'new_reviews': {
+            'type': list,
+            'description': 'The collection of review objects newer than the '
+                           'timestamp provided that do not have a base '
+                           'review.',
+            'added_in': '3.0',
         },
-        'user': {
-            'type': six.text_type,
-            'description': 'The user who made the last update.',
+        'new_replies': {
+            'type': list,
+            'description': 'The collection of review objects newer than the '
+                           'timestamp provided that have a base review.',
+            'added_in': '3.0',
         },
+        'new_updates': {
+            'type': list,
+            'description': 'The collection of diffset, file attachment '
+                           'and field changes objects '
+                           'newer than the timestamp provided.',
+            'added_in': '3.0',
+        }
     }
 
+    @webapi_request_fields(
+        optional={
+            'timestamp': {
+                'type': six.text_type,
+                'description': 'The timestamp of the most recent update '
+                               'received by the client. Used to identify '
+                               'out-of-date objects. Timestamp should follow'
+                               'the format: <YYYY>-<MM>-<DD>T<HH>:<MM>:<SS>Z',
+                'added_in': '3.0',
+            }
+        }
+    )
     @webapi_check_login_required
     @webapi_check_local_site
-    def get(self, request, *args, **kwargs):
+    def get(self, request, timestamp=None, *args, **kwargs):
         """Returns the last update made to the review request.
 
         This shows the type of update that was made, the user who made the
@@ -76,55 +97,201 @@ class ReviewRequestLastUpdateResource(WebAPIResource):
                                                                review_request):
             return self.get_no_access_error(request)
 
-        timestamp, updated_object = review_request.get_last_activity()
+        last_updated_timestamp, last_activity = review_request.get_last_activity()
 
-        etag = encode_etag('%s:%s' % (timestamp, updated_object.pk))
+        etag = encode_etag('%s:%s' % (last_updated_timestamp, review_request.pk))
 
         if etag_if_none_match(request, etag):
             return HttpResponseNotModified()
 
-        user = None
-        summary = None
-        update_type = None
+        if timestamp is not None:
+            try:
+                timestamp = datetime.strptime(
+                    timestamp, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=utc)
+            except ValueError:
+                timestamp = None
 
-        if isinstance(updated_object, ReviewRequest):
-            user = updated_object.submitter
+        if timestamp is None:
+            response_keys = self._get_default_response(
+                last_updated_timestamp, last_activity, review_request)
+            return 200, {
+                self.item_result_key: response_keys,
+            }, {
+                'ETag': etag,
+            }
 
-            if updated_object.status == ReviewRequest.SUBMITTED:
-                summary = _("Review request submitted")
-            elif updated_object.status == ReviewRequest.DISCARDED:
-                summary = _("Review request discarded")
-            else:
-                summary = _("Review request updated")
-
-            update_type = "review-request"
-        elif isinstance(updated_object, DiffSet):
-            summary = _("Diff updated")
-            update_type = "diff"
-        elif isinstance(updated_object, Review):
-            user = updated_object.user
-
-            if updated_object.is_reply():
-                summary = _("New reply")
-                update_type = "reply"
+        '''
+        For the same timestamp, eliminate the millisecond error
+        due to different timestamp format
+        between server database and passed parameter so that
+        when querying the previous last update will be excluded
+        '''
+
+        timestamp = timestamp + timedelta(seconds=1)
+
+        new_reviews = []
+        new_replies = []
+
+        for review in Review.objects.filter(review_request=review_request,
+                                            timestamp__gt=timestamp,
+                                            public=True):
+            if review.base_reply_to:
+                new_reply = self._get_reply(review)
+                new_replies.append(new_reply)
             else:
-                summary = _("New review")
-                update_type = "review"
-        else:
-            # Should never be able to happen. The object will always at least
-            # be a ReviewRequest.
-            assert False
+                new_review = self._get_review(review)
+                new_reviews.append(new_review)
+
+        new_updates = []
+
+        for diffset in DiffSet.objects.filter(
+                history__pk=review_request.diffset_history_id,
+                timestamp__gt=timestamp, review_request_draft=None):
+            new_diffset = self._get_diffset(diffset, review_request)
+            new_updates.append(new_diffset)
+
+        for cd in review_request.changedescs.filter(
+                timestamp__gt=timestamp, public=True):
+            new_update = self._get_changedes(cd)
+            new_updates.append(new_update)
+
+        response_keys = {
+            'timestamp': last_updated_timestamp,
+            'new_updates': new_updates,
+            'new_reviews': new_reviews,
+            'new_replies': new_replies,
+        }
 
         return 200, {
-            self.item_result_key: {
-                'timestamp': timestamp,
-                'user': user,
-                'summary': summary,
-                'type': update_type,
-            }
+            self.item_result_key: response_keys,
         }, {
             'ETag': etag,
         }
 
+    def _get_default_response(self, server_timestamp,
+                              last_activity, review_request):
+        """Retrieve last update activity as default response key
+        if the timestamp is in improper format or None.
+        """
+        new_updates = []
+        new_reviews = []
+        new_replies = []
+
+        if isinstance(last_activity, ReviewRequest):
+            cd = last_activity.changedescs.latest()
+            new_update = self._get_changedes(cd)
+            new_updates.append(new_update)
+        elif isinstance(last_activity, DiffSet):
+            new_diffset = self._get_diffset(last_activity,
+                                            review_request)
+            new_updates.append(new_diffset)
+        elif isinstance(last_activity, Review):
+            if last_activity.is_reply():
+                new_reply = self._get_reply(last_activity)
+                new_replies.append(new_reply)
+            else:
+                new_review = self._get_review(last_activity)
+                new_reviews.append(new_review)
+
+        response_keys = {
+            'timestamp': server_timestamp,
+            'new_updates': new_updates,
+            'new_reviews': new_reviews,
+            'new_replies': new_replies,
+        }
+        return response_keys
+
+    def _get_reply(self, review):
+        """Retrieve the new reply and return as a new update object."""
+        new_reply = {
+            'user': review.user,
+            'username': review.user.username,
+        }
+        new_reply['pk'], new_reply['anchor'], new_reply['text'] = \
+            self._get_review_text(review)
+        return new_reply
+
+    def _get_review(self, review):
+        """Retrieve the new review and return as a new update object."""
+        ship_it_text = ''
+
+        if review.ship_it:
+            ship_it_text = review.SHIP_IT_TEXT
+        new_review = {
+            'user': review.user,
+            'username': review.user.username,
+            'ship_it': ship_it_text,
+        }
+        new_review['pk'], new_review['anchor'], new_review['text'] = \
+            self._get_review_text(review)
+        return new_review
+
+    def _get_changedes(self, changedes):
+        """Retrieve the new changedesciption and
+        return as a new update object.
+        """
+        summary = ""
+        text = ""
+        field = changedes.fields_changed.keys()[0]
+
+        if field == 'files':
+            if changedes.fields_changed[field]['added']:
+                summary = "File added"
+                text = changedes.fields_changed[field]['added'][0][0]
+            else:
+                summary = "File removed"
+                text = changedes.fields_changed[field]['removed'][0][0]
+        elif field != 'diff':
+            text = changedes.fields_changed[field]['new'][0]
+            summary = "field changed"
+        new_update = {
+            'pk': changedes.id,
+            'field': field,
+            'user': changedes.user,
+            'username': changedes.user.username,
+            'anchor': "#changedesc" + str(changedes.id),
+            'summary': summary,
+            'text': text,
+        }
+        return new_update
+
+    def _get_diffset(self, diffset, review_request):
+        """Retrieve the new diffset as a new update object."""
+        new_update = {
+            'pk': diffset.id,
+            'field': 'diffset',
+            'user': review_request.submitter,
+            'username': review_request.submitter.username,
+            'text': diffset.name,
+            'revision': diffset.revision,
+            'anchor': "diff/" + str(diffset.revision)
+        }
+        return new_update
+
+    def _get_review_text(self, review):
+        """Retrieve the new review text."""
+        pk = ""
+        anchor = ""
+        text = ""
+
+        if review.body_top:
+            text = review.body_top
+
+        for comment_class in (Comment,
+                              FileAttachmentComment,
+                              GeneralComment,
+                              ScreenshotComment):
+            for comment in comment_class.objects.filter(
+                    review__pk=review.pk):
+                pk = comment.id
+                anchor = "#" + comment.anchor_prefix + str(comment.id)
+                if not text:
+                    text = comment.text
+
+        if not anchor:
+            pk = review.id
+            anchor = "#review" + str(review.id)
+
+        return pk, anchor, text
 
 review_request_last_update_resource = ReviewRequestLastUpdateResource()
diff --git a/reviewboard/webapi/tests/mimetypes.py b/reviewboard/webapi/tests/mimetypes.py
index da92fe21cab17842a2e2c83aba7bbd3a9df6c941..7ecd798298c2a8bc4ce9980680eca0c1966e543a 100644
--- a/reviewboard/webapi/tests/mimetypes.py
+++ b/reviewboard/webapi/tests/mimetypes.py
@@ -138,6 +138,7 @@ review_request_item_mimetype = _build_mimetype('review-request')
 
 review_request_draft_item_mimetype = _build_mimetype('review-request-draft')
 
+review_request_last_update_mimetype = _build_mimetype('last-update')
 
 root_item_mimetype = _build_mimetype('root')
 
diff --git a/reviewboard/webapi/tests/test_review_request_last_update.py b/reviewboard/webapi/tests/test_review_request_last_update.py
new file mode 100644
index 0000000000000000000000000000000000000000..71eb351fb3053e30a6ab71491d091b45ee9395e7
--- /dev/null
+++ b/reviewboard/webapi/tests/test_review_request_last_update.py
@@ -0,0 +1,211 @@
+from __future__ import unicode_literals
+
+from datetime import timedelta
+from django.utils import timezone
+
+from django.utils import six
+
+from reviewboard.reviews.models import Review
+from reviewboard.reviews.models import ReviewRequestDraft
+from reviewboard.changedescs.models import ChangeDescription
+from reviewboard.webapi.resources import resources
+from reviewboard.webapi.tests.base import BaseWebAPITestCase
+from reviewboard.webapi.tests.mimetypes import \
+    review_request_last_update_mimetype
+from reviewboard.webapi.tests.mixins import BasicTestsMetaclass
+from reviewboard.webapi.tests.urls import get_review_request_last_update_url
+
+
+@six.add_metaclass(BasicTestsMetaclass)
+class ResourceTests(BaseWebAPITestCase):
+    """Testing the ReviewRequestLastUpdate API."""
+
+    fixtures = ['test_users', 'test_scmtools']
+    test_http_methods = ('GET',)
+    sample_api_url = 'review-requests/<id>/last-update/'
+    resource = resources.review_request_last_update
+
+    def compare_item(self, item_rsp, review_request):
+        self.assertEqual(item_rsp['timestamp'],
+                         review_request.last_updated.
+                         strftime('%Y-%m-%dT%H:%M:%SZ'))
+
+    #
+    # HTTP GET tests
+    #
+
+    def setup_basic_get_test(self, user, with_local_site, local_site_name):
+        review_request = self.create_review_request(
+            create_repository=True,
+            with_local_site=with_local_site,
+            submitter=user,
+            publish=True)
+
+        self.create_review(review_request, publish=True)
+
+        return (get_review_request_last_update_url(
+                review_request, local_site_name),
+                review_request_last_update_mimetype,
+                review_request)
+
+    def test_get_review_updates(self):
+        """Testing the GET review-requests/<id>/last-update/ API with new
+        reviews since timestamp
+        """
+        review_request = self.create_review_request(publish=True)
+
+        self.create_review(review_request, publish=True)
+        self.create_review(review_request, publish=True)
+
+        rsp = self.api_get(
+            get_review_request_last_update_url(review_request), {
+                'timestamp': '2017-02-01T00:00:00Z',
+            }, expected_mimetype=review_request_last_update_mimetype)
+
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(len(rsp['last_update']['new_reviews']), 2)
+
+    def test_get_comment_updates(self):
+        """Testing the GET review-requests/<id>/last-update/ API with new
+        comments since timestamp
+        """
+        review_request = self.create_review_request(
+            create_repository=True,publish=True)
+
+        diffset = self.create_diffset(review_request)
+        filediff = self.create_filediff(diffset)
+
+        # Create a diff review.
+        diff_review = self.create_review(review_request)
+        self.create_diff_comment(diff_review, filediff)
+        diff_review.publish()
+
+        rsp = self.api_get(
+            get_review_request_last_update_url(review_request), {
+                'timestamp': '2017-02-01T00:00:00Z',
+            }, expected_mimetype=review_request_last_update_mimetype)
+
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(len(rsp['last_update']['new_reviews']), 1)
+
+    def test_get_reply_updates(self):
+        """Testing the GET review-requests/<id>/last-update/ API with new
+        replies since timestamp
+        """
+        review_request = self.create_review_request(publish=True)
+
+        review_1 = self.create_review(review_request, publish=True)
+        review_2 = self.create_review(review_request, publish=True)
+        self.create_reply(review_1, publish=True)
+        self.create_reply(review_2, publish=True)
+
+        rsp = self.api_get(
+            get_review_request_last_update_url(review_request), {
+                'timestamp': '2017-02-01T00:00:00Z',
+            }, expected_mimetype=review_request_last_update_mimetype)
+
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(len(rsp['last_update']['new_replies']), 2)
+
+    def test_get_diffset_updates(self):
+        """Testing the GET review-requests/<id>/last-update/ API with new
+        diffsets since timestamp
+        """
+        review_request = self.create_review_request(
+            publish=True, repository=self.create_repository())
+
+        diffset1 = self.create_diffset(review_request,
+                                       revision=1,
+                                       name='diffset1')
+        diffset2 = self.create_diffset(review_request,
+                                       revision=2,
+                                       name='diffset2')
+
+        rsp = self.api_get(
+            get_review_request_last_update_url(review_request), {
+                'timestamp': '2017-02-01T00:00:00Z',
+            }, expected_mimetype=review_request_last_update_mimetype)
+
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(len(rsp['last_update']['new_updates']), 2)
+        self.assertEqual(rsp['last_update']['new_updates'][0]['text'],
+                         diffset1.name)
+        self.assertEqual(rsp['last_update']['new_updates'][0]['revision'],
+                         diffset1.revision)
+        self.assertEqual(rsp['last_update']['new_updates'][1]['text'],
+                         diffset2.name)
+        self.assertEqual(rsp['last_update']['new_updates'][1]['revision'],
+                         diffset2.revision)
+
+    def test_get_changedes_updates(self):
+        """Testing the GET review-requests/<id>/last-update/ API with new
+        field changes since timestamp
+        """
+        review_request = self.create_review_request(
+            publish=True, repository=self.create_repository())
+
+        now = timezone.now()
+        change1 = ChangeDescription(public=True,
+                                    user=review_request.submitter,
+                                    timestamp=now)
+        change1.record_field_change('summary', 'old', 'new')
+        change1.save()
+        review_request.changedescs.add(change1)
+
+        change2 = ChangeDescription(public=True,
+                                    user=review_request.submitter,
+                                    timestamp=now + timedelta(seconds=2))
+        change2.record_field_change('description', 'old', 'new')
+        change2.save()
+        review_request.changedescs.add(change2)
+
+        draft = ReviewRequestDraft.create(review_request)
+        self.create_file_attachment(draft, orig_filename='file1.png')
+        draft.publish(user=review_request.submitter)
+
+        rsp = self.api_get(
+            get_review_request_last_update_url(review_request), {
+                'timestamp': '2017-02-01T00:00:00Z',
+            }, expected_mimetype=review_request_last_update_mimetype)
+
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(len(rsp['last_update']['new_updates']), 3)
+
+    def test_improperly_formatted_timestamp(self):
+        """Testing the GET review-requests/<id>/last-update/ API with
+        improperly formatted timestamp defaults to a response with
+        only the last update activity
+        """
+        review_request = self.create_review_request(publish=True)
+
+        self.create_review(review_request, publish=True)
+        self.create_review(review_request, publish=True)
+
+        rsp = self.api_get(
+            get_review_request_last_update_url(review_request), {
+                'timestamp': '2017-02',
+            }, expected_mimetype=review_request_last_update_mimetype)
+
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(len(rsp['last_update']['new_reviews']), 1)
+
+    def test_no_update(self):
+        """Testing the GET review-requests/<id>/last-update/ API with
+        no updates
+        """
+        review_request = self.create_review_request(publish=True)
+        yesterday = timezone.now() - timedelta(days=1)
+
+        self.create_review(review_request, publish=True)
+        self.create_review(review_request, publish=True)
+        Review.objects.update(timestamp=yesterday)
+
+        rsp = self.api_get(
+            get_review_request_last_update_url(review_request), {
+                'timestamp': timezone.now().strftime('%Y-%m-%dT%H:%M:%SZ'),
+            }, expected_mimetype=review_request_last_update_mimetype)
+
+        self.assertEqual(rsp['stat'], 'ok')
+        self.assertEqual(len(rsp['last_update']['new_reviews']), 0)
+        self.assertEqual(len(rsp['last_update']['new_replies']), 0)
+        self.assertEqual(len(rsp['last_update']['new_updates']), 0)
diff --git a/reviewboard/webapi/tests/urls.py b/reviewboard/webapi/tests/urls.py
index 668dcd9f4cd59d81b2511c68ac99c0679b4ef5d4..9229280112292453c677cd00cd4c22401a2cb0fc 100644
--- a/reviewboard/webapi/tests/urls.py
+++ b/reviewboard/webapi/tests/urls.py
@@ -654,6 +654,15 @@ def get_review_screenshot_comment_item_url(review, comment_id,
 
 
 #
+# ReviewRequestLastUpdateResource
+#
+def get_review_request_last_update_url(review_request,
+                                       local_site_name=None):
+    return resources.review_request_last_update.get_item_url(
+        local_site_name=local_site_name,
+        review_request_id=review_request.display_id)
+
+#
 # RootResource
 #
 def get_root_url(local_site_name=None):
