diff --git a/docs/manual/webapi/2.0/resources/action-list.txt b/docs/manual/webapi/2.0/resources/action-list.txt
new file mode 100644
index 0000000000000000000000000000000000000000..5583e0027e28b187b13b62ce2998b60f3c2b3748
--- /dev/null
+++ b/docs/manual/webapi/2.0/resources/action-list.txt
@@ -0,0 +1,5 @@
+.. webapi-resource::
+   :classname: reviewboard.webapi.resources.ActionResource
+   :is-list:
+
+.. comment: vim: ft=rst et ts=3
diff --git a/docs/manual/webapi/2.0/resources/action.txt b/docs/manual/webapi/2.0/resources/action.txt
new file mode 100644
index 0000000000000000000000000000000000000000..e7f7240744c23a850642abee221f4d81069d68da
--- /dev/null
+++ b/docs/manual/webapi/2.0/resources/action.txt
@@ -0,0 +1,4 @@
+.. webapi-resource::
+   :classname: reviewboard.webapi.resources.ActionResource
+
+.. comment: vim: ft=rst et ts=3
diff --git a/docs/manual/webapi/2.0/resources/index.txt b/docs/manual/webapi/2.0/resources/index.txt
index eab5260d36b6d0324ce0ceed07618c781449eccc..fdcde0367ee1ae5251c5251b631a962bb25a707f 100644
--- a/docs/manual/webapi/2.0/resources/index.txt
+++ b/docs/manual/webapi/2.0/resources/index.txt
@@ -5,6 +5,8 @@ Resources
 .. toctree::
    :maxdepth: 1
 
+   action-list
+   action
    change-list
    change
    diff-list
diff --git a/reviewboard/reviews/admin.py b/reviewboard/reviews/admin.py
index 5359ba5d2e16bb410fdd9029be7c8984a53b5416..3f79d2bb4b18391af52c50c078e4d840c0957332 100644
--- a/reviewboard/reviews/admin.py
+++ b/reviewboard/reviews/admin.py
@@ -3,12 +3,18 @@ from django.template.defaultfilters import truncatechars
 from django.utils.translation import ugettext_lazy as _
 
 from reviewboard.reviews.forms import DefaultReviewerForm, GroupForm
-from reviewboard.reviews.models import Comment, DefaultReviewer, Group, \
-                                       Review, ReviewRequest, \
+from reviewboard.reviews.models import Action, Comment, DefaultReviewer, \
+                                       Group, Review, ReviewRequest, \
                                        ReviewRequestDraft, Screenshot, \
                                        ScreenshotComment, FileAttachmentComment
 
 
+class ActionAdmin(admin.ModelAdmin):
+    list_display = ('review_request', 'summary', 'verb', 'submitter',
+                    'timestamp', 'review', 'changedesc', 'local_site',
+                    'local_id')
+
+
 class CommentAdmin(admin.ModelAdmin):
     list_display = ('truncated_text', 'review_request_id', 'first_line',
                     'num_lines', 'timestamp')
@@ -233,7 +239,7 @@ class FileAttachmentCommentAdmin(admin.ModelAdmin):
         return obj.review.get().review_request.id
     review_request_id.short_description = _('Review request ID')
 
-
+admin.site.register(Action, ActionAdmin)
 admin.site.register(Comment, CommentAdmin)
 admin.site.register(DefaultReviewer, DefaultReviewerAdmin)
 admin.site.register(Group, GroupAdmin)
diff --git a/reviewboard/reviews/datagrids.py b/reviewboard/reviews/datagrids.py
index 0e6863c1e9a63a3ddb8176b3fd34c26648f017a8..7cc815b8e9119cd4f8fde801288e3ecac51e8057 100644
--- a/reviewboard/reviews/datagrids.py
+++ b/reviewboard/reviews/datagrids.py
@@ -700,6 +700,9 @@ class DashboardDataGrid(ReviewRequestDataGrid):
             self.queryset = ReviewRequest.objects.to_user(
                 user, user, local_site=self.local_site)
             self.title = _(u"All Incoming Review Requests")
+        elif view == 'action-feed':
+            self.queryset = ReviewRequest.objects.public()
+            self.title = _(u"Latest Changes")
         else:
             raise Http404
 
diff --git a/reviewboard/reviews/management/commands/rb-cleanup.py b/reviewboard/reviews/management/commands/rb-cleanup.py
new file mode 100644
index 0000000000000000000000000000000000000000..00d68954aec2be1e0c5c4c951964711a2a8bc0f0
--- /dev/null
+++ b/reviewboard/reviews/management/commands/rb-cleanup.py
@@ -0,0 +1,30 @@
+from datetime import timedelta
+from django.core.management.base import NoArgsCommand
+from django.db import transaction
+from django.utils import timezone
+from optparse import make_option
+
+from reviewboard.reviews.models import Action
+from reviewboard.accounts.models import ReviewRequestVisit
+
+
+class Command(NoArgsCommand):
+    help = 'Removes old actions and review request visits.'
+    option_list = NoArgsCommand.option_list + (
+        make_option('--days',
+                    action='store',
+                    dest='days',
+                    default=30,
+                    type='int',
+                    help='Delete actions and review request visits older than'
+                    ' days. Default: %default',
+                ),
+    )
+
+    @transaction.commit_on_success
+    def handle_noargs(self, *args, **options):
+        days = options.get('days')
+        delta = timedelta(days)
+        ReviewRequestVisit.objects \
+            .filter(timestamp__lt=timezone.now() - delta).delete()
+        Action.objects.filter(timestamp__lt=timezone.now() - delta)
diff --git a/reviewboard/reviews/managers.py b/reviewboard/reviews/managers.py
index 0a21b70b6fc9ae309ca0d6abcdfaeda877fc7693..5ce533bf6d7cfd405d9aca7d71c9d3fa33420523 100644
--- a/reviewboard/reviews/managers.py
+++ b/reviewboard/reviews/managers.py
@@ -11,6 +11,27 @@ from djblets.util.db import ConcurrencyManager
 from reviewboard.diffviewer.models import DiffSetHistory
 from reviewboard.scmtools.errors import ChangeNumberInUseError
 
+from reviewboard.site.models import LocalSite
+
+
+class ActionFeedManager(Manager):
+    """A manager for Action model."""
+    def for_user(self, user, local_site_name, older_than=None):
+        """Returns the action feed for the user."""
+        actions = self.extra(
+            tables=['accounts_reviewrequestvisit'],
+            where=['accounts_reviewrequestvisit.user_id = %s' % user.pk,
+                   'accounts_reviewrequestvisit.review_request_id = '
+                   'reviews_action.review_request_id'])
+        if older_than:
+            actions = actions.filter(timestamp__lt=older_than)
+        try:
+            local_site = LocalSite.objects.get(name=local_site_name)
+            local_site_id = local_site.pk
+        except LocalSite.DoesNotExist:
+            local_site_id = None
+        return actions.filter(local_site_id=local_site_id)
+
 
 class DefaultReviewerManager(Manager):
     """A manager for DefaultReviewer models."""
diff --git a/reviewboard/reviews/models.py b/reviewboard/reviews/models.py
index 37b5edcf8b1c201d01146f86fd40855bdc954cb9..f16b894bb385d79110a813ab68a65bbd98fc2cbd 100644
--- a/reviewboard/reviews/models.py
+++ b/reviewboard/reviews/models.py
@@ -18,7 +18,8 @@ from reviewboard.changedescs.models import ChangeDescription
 from reviewboard.diffviewer.models import DiffSet, DiffSetHistory, FileDiff
 from reviewboard.attachments.models import FileAttachment
 from reviewboard.reviews.errors import PermissionError
-from reviewboard.reviews.managers import DefaultReviewerManager, \
+from reviewboard.reviews.managers import ActionFeedManager, \
+                                         DefaultReviewerManager, \
                                          ReviewGroupManager, \
                                          ReviewRequestManager, \
                                          ReviewManager
@@ -62,7 +63,7 @@ class Group(models.Model):
     objects = ReviewGroupManager()
 
     def is_accessible_by(self, user):
-        "Returns true if the user can access this group."""
+        """Returns true if the user can access this group."""
         if self.local_site and not self.local_site.is_accessible_by(user):
             return False
 
@@ -729,11 +730,6 @@ class ReviewRequest(BaseReviewRequestDetails):
         if update_counts or self.id is None:
             self._update_counts()
 
-        if self.status != self.PENDING_REVIEW:
-            # If this is not a pending review request now, delete any
-            # and all ReviewRequestVisit objects.
-            self.visits.all().delete()
-
         super(ReviewRequest, self).save(**kwargs)
 
     def delete(self, **kwargs):
@@ -799,7 +795,7 @@ class ReviewRequest(BaseReviewRequestDetails):
             self.changedescs.add(changedesc)
             self.status = type
             self.save(update_counts=True)
-
+            action_verb = Action.CLOSED_REQUEST
             review_request_closed.send(sender=self.__class__, user=user,
                                        review_request=self,
                                        type=type)
@@ -809,10 +805,20 @@ class ReviewRequest(BaseReviewRequestDetails):
             changedesc.timestamp = timezone.now()
             changedesc.text = description or ""
             changedesc.save()
-
+            action_verb = Action.UPDATED_SUBMISSION_DESCRIPTION
             # Needed to renew last-update.
             self.save()
 
+        if user:
+            Action.objects.create(review_request=self,
+                                  summary=self.summary,
+                                  verb=action_verb,
+                                  submitter=user,
+                                  review=None,
+                                  changedesc=changedesc,
+                                  local_id=self.local_id,
+                                  local_site=self.local_site)
+
         try:
             draft = self.draft.get()
         except ReviewRequestDraft.DoesNotExist:
@@ -848,6 +854,16 @@ class ReviewRequest(BaseReviewRequestDetails):
             self.status = self.PENDING_REVIEW
             self.save(update_counts=True)
 
+            if user:
+                Action.objects.create(review_request=self,
+                                      summary=self.summary,
+                                      verb=Action.REOPENED_REQUEST,
+                                      submitter=user,
+                                      review=None,
+                                      changedesc=changedesc,
+                                      local_id=self.local_id,
+                                      local_site=self.local_site)
+
         review_request_reopened.send(sender=self.__class__, user=user,
                                      review_request=self)
 
@@ -903,6 +919,15 @@ class ReviewRequest(BaseReviewRequestDetails):
         self.public = True
         self.save(update_counts=True)
 
+        if changes is not None:
+            Action.objects.create(review_request=self,
+                                  summary=self.summary,
+                                  verb=Action.UPDATED_REQUEST,
+                                  submitter=user,
+                                  review=None,
+                                  changedesc=changes,
+                                  local_id=self.local_id,
+                                  local_site=self.local_site)
         review_request_published.send(sender=self.__class__, user=user,
                                       review_request=self,
                                       changedesc=changes)
@@ -1712,12 +1737,23 @@ class Review(models.Model):
             self.review_request.increment_shipit_count()
 
         if self.is_reply():
+            action_verb = Action.PUBLISHED_REPLY
             reply_published.send(sender=self.__class__,
                                  user=user, reply=self)
         else:
+            action_verb = Action.PUBLISHED_REVIEW
             review_published.send(sender=self.__class__,
                                   user=user, review=self)
 
+            Action.objects.create(review_request=self.review_request,
+                                  summary=self.review_request.summary,
+                                  verb=action_verb,
+                                  submitter=user,
+                                  review=self,
+                                  changedesc=None,
+                                  local_id=self.review_request.local_id,
+                                  local_site=self.review_request.local_site)
+
     def delete(self):
         """
         Deletes this review.
@@ -1747,3 +1783,69 @@ class Review(models.Model):
     class Meta:
         ordering = ['timestamp']
         get_latest_by = 'timestamp'
+
+
+class Action(models.Model):
+    """Action feed storage."""
+
+    CLOSED_REQUEST = "C"
+    PUBLISHED_REPLY = "R"
+    PUBLISHED_REVIEW = "E"
+    REOPENED_REQUEST = "O"
+    UPDATED_REQUEST = "U"
+    UPDATED_SUBMISSION_DESCRIPTION = "S"
+
+    VERBS = (
+        (CLOSED_REQUEST, _('closed the request')),
+        (PUBLISHED_REPLY, _('published a reply')),
+        (PUBLISHED_REVIEW, _('published a review')),
+        (REOPENED_REQUEST, _('reopened the request')),
+        (UPDATED_REQUEST, _('updated the request')),
+        (UPDATED_SUBMISSION_DESCRIPTION,
+         _('updated the submission description')),
+    )
+
+    review_request = models.ForeignKey(ReviewRequest,
+                                       verbose_name=_("review request"))
+    summary = models.CharField(_('summary'), max_length=300, default='')
+    submitter = models.ForeignKey(User, verbose_name=_("submitter"))
+    timestamp = models.DateTimeField(_("timestamp"), default=timezone.now,
+                                     db_index=True)
+    review = models.ForeignKey(Review,
+                               verbose_name=_("review"),
+                               null=True)
+    changedesc = models.ForeignKey(ChangeDescription,
+                                   verbose_name=_("changedesc"),
+                                   null=True)
+    verb = models.CharField(_('verb'), max_length=1, choices=VERBS)
+    local_site = models.ForeignKey(LocalSite, verbose_name=_("local site"),
+                                   blank=True, null=True)
+    local_id = models.IntegerField('site-local ID', blank=True, null=True)
+    objects = ActionFeedManager()
+
+    @property
+    def request_display_id(self):
+        """Gets the ID which should be exposed to the user."""
+        if self.local_site_id:
+            return self.local_id
+        else:
+            return self.review_request_id
+
+    @property
+    def type(self):
+        """Return one of these strings: 'review', 'reply', 'change'"""
+        if self.changedesc_id:
+            return 'change'
+        elif self.verb == self.PUBLISHED_REPLY:
+            return 'reply'
+        else:
+            return 'review'
+
+    @property
+    def display_verb(self):
+        # 'get_verb_display' provided automatically by Django
+        return self.get_verb_display()
+
+    class Meta:
+        ordering = ['-timestamp']
+        get_latest_by = 'timestamp'
diff --git a/reviewboard/reviews/templatetags/reviewtags.py b/reviewboard/reviews/templatetags/reviewtags.py
index e56e5a11ac77ddf3b5c72189e8a3c655a6bf5bc7..6e878745b80c233d1074eedb220c014beb555e16 100644
--- a/reviewboard/reviews/templatetags/reviewtags.py
+++ b/reviewboard/reviews/templatetags/reviewtags.py
@@ -360,6 +360,8 @@ def dashboard_entry(context, level, text, view, param=None):
     elif view == "url":
         url = param
         show_count = False
+    elif view == 'action-feed':
+        show_count = False
     else:
         raise template.TemplateSyntaxError, \
             "Invalid view type '%s' passed to 'dashboard_entry' tag." % view
diff --git a/reviewboard/reviews/views.py b/reviewboard/reviews/views.py
index a2c9734a951c28cc403573c190c5e05199e70af0..350988412779f70c8750400fdd6978dfe6b8a936 100644
--- a/reviewboard/reviews/views.py
+++ b/reviewboard/reviews/views.py
@@ -833,6 +833,7 @@ def dashboard(request,
         * 'watched-groups'
         * 'incoming'
         * 'mine'
+        * 'action-feed'
     """
     view = request.GET.get('view', None)
 
@@ -847,14 +848,34 @@ def dashboard(request,
         # This is special. We want to return a list of groups, not
         # review requests.
         grid = WatchedGroupDataGrid(request, local_site=local_site)
+    elif view == "action-feed":
+        grid = ActionFeed(request, local_site=local_site)
     else:
         grid = DashboardDataGrid(request, local_site=local_site)
 
     return grid.render_to_response(template_name, extra_context={
         'sidebar_hooks': DashboardHook.hooks,
+        'view': view,
     })
 
 
+class ActionFeed(DashboardDataGrid):
+
+    def __init__(self, request, local_site=None):
+        super(ActionFeed, self).__init__(request, local_site)
+
+    def render_to_response(self, template_name, extra_context={}):
+        self.load_state()
+        context = {
+            'user': self.request.user,
+            'local_site': self.local_site,
+            'datagrid': self,
+        }
+        context.update(extra_context)
+        return render_to_response(template_name, RequestContext(self.request,
+                                                                context))
+
+
 @check_login_required
 def group(request,
           name,
diff --git a/reviewboard/settings.py b/reviewboard/settings.py
index 8147971cc5fdf64f387b41bd1e7d99edf6c0260b..3b74f8c56eeb5f7d0e133a092e09979baba316e9 100644
--- a/reviewboard/settings.py
+++ b/reviewboard/settings.py
@@ -340,6 +340,12 @@ PIPELINE_JS = {
         ),
         'output_filename': 'rb/js/repositoryform.min.js',
     },
+    'action-feed': {
+        'source_filenames': (
+            'rb/js/action-feed.js',
+        ),
+        'output_filename': 'rb/js/action-feed.min.js',
+    },
 }
 
 PIPELINE_CSS = {
@@ -376,6 +382,13 @@ PIPELINE_CSS = {
         'output_filename': 'rb/css/admin.min.css',
         'absolute_paths': False,
     },
+    'action-feed': {
+        'source_filenames': (
+            'rb/css/action-feed.less',
+        ),
+        'output_filename': 'rb/css/action-feed.min.css',
+        'absolute_paths': False,
+    },
 }
 
 BLESS_IMPORT_PATHS = ('rb/css/',)
diff --git a/reviewboard/static/rb/css/action-feed.less b/reviewboard/static/rb/css/action-feed.less
new file mode 100644
index 0000000000000000000000000000000000000000..dd018c59e3d8eb819d6205726bad28cc2168df26
--- /dev/null
+++ b/reviewboard/static/rb/css/action-feed.less
@@ -0,0 +1,209 @@
+@import "defs.less";
+
+.container-action-feed {
+  padding: 3px;
+}
+
+.changedesc {
+  background-color: #FEFADF;
+  background-image: url('../images/review_request_box_bottom_bg.png');
+
+  .box-inner {
+    background-image: url('../images/review_request_box_bottom_bg.png');
+  }
+}
+
+.review {
+  background-color: #E9EDF5;
+  background-image: url('../images/review_box_top_bg.png');
+
+  .box-inner {
+    background-image: url('../images/review_box_bottom_bg.png');
+  }
+}
+
+.action_feed {
+  .action-summary {
+    display: block;
+    margin-bottom: 10px;
+  }
+
+  .reviewer {
+    float: left;
+    font-weight: bold;
+
+    a {
+      position: relative;
+    }
+  }
+
+  .posted_time {
+    text-align: right;
+  }
+
+  .header {
+    padding: 2px 4px;
+
+    a {
+      color: #0000CC;
+      text-decoration: none;
+    }
+  }
+
+  .body {
+    background-color: #FAFAFA;
+    border: 1px #AAAAAA solid;
+    margin: 5px;
+    padding: 10px;
+
+    .body_top, .body_bottom {
+      margin: 0;
+    }
+
+    ul {
+      padding-left: 2em;
+      margin-bottom: 10px;
+
+      li {
+        label {
+          color: #575012;
+          font-weight: bold;
+        }
+
+        pre {
+          border: 1px #b8b5a0 solid;
+          padding: 10px;
+          font-size: 9pt;
+        }
+      }
+
+      pre {
+        .pre-wrap;
+      }
+
+       textarea {
+          border: 1px #b8b5a0 solid;
+	  height: 1.5em;
+	  overflow: hidden;
+	  width: 100%;
+       }
+    }
+  }
+}
+
+.box {
+  &.collapsed {
+    .collapse-button {
+      .ui-collapse-icon {
+        background-image: url('../images/expand-review.png');
+      }
+    }
+
+    .body {
+      display: none;
+    }
+
+    .banner {
+      margin-bottom: 0;
+    }
+  }
+
+  .collapse-button {
+    border: 1px #333333 solid;
+    cursor: pointer;
+    float: left;
+    margin-right: 0.5em;
+    padding: 0;
+
+    .ui-collapse-icon {
+      background: url('../images/collapse-review.png') no-repeat;
+      width: 18px;
+      height: 18px;
+    }
+  }
+
+  .load-more {
+    margin-left: auto;
+    margin-right: auto;
+    width: 30%;
+
+    .load-more-button {
+      width: 20em;
+      border-width: 1px;
+      border-style: solid;
+      text-align: center;
+      font-size: 1.3em;
+      background-color: #C6DCF3;
+    }
+  }
+}
+
+.issue-state {
+  @issue-state-height: 26px;
+
+  font-weight: bold;
+  line-height: @issue-state-height;
+  min-height: @issue-state-height;
+  padding: 0.3em 0.6em 0.3em 2.5em;
+  margin-bottom: 10px;
+  position: relative;
+
+  input, .back-to-issue-summary {
+    /*
+     * This keeps a consistency with the browser. By default, Chrome uses
+     * a margin of 2px, and changing it to 0 actually makes things look
+     * off-center.
+     */
+    margin: 2px 2px 2px 0.2em;
+  }
+
+  .issue-container {
+    position: relative;
+  }
+
+  &.dropped {
+    background: url('../images/closed_issue.png') @issue-discarded-bg no-repeat 5px 50%;
+    border: 1px solid @issue-discarded-border-color;
+
+    .back-to-issue-summary, .back-to-issue-summary:visited {
+      color: @issue-discarded-link-color;
+    }
+  }
+
+  &.open {
+    background: url('../images/open_issue.png') @issue-opened-bg no-repeat 5px 50%;
+    border: 1px solid @issue-opened-border-color;
+
+    .back-to-issue-summary, .back-to-issue-summary:visited {
+      color: @issue-opened-link-color;
+    }
+  }
+
+  &.resolved {
+    background: url('../images/tick.png') @issue-resolved-bg no-repeat 5px 50%;
+    border: 1px solid @issue-resolved-border-color;
+
+    .back-to-issue-summary, .back-to-issue-summary:visited {
+      color: @issue-resolved-link-color;
+    }
+  }
+
+  .back-to-issue-summary {
+    font-size: 13px;
+    font-weight: normal;
+    text-decoration: none;
+    padding: 0 0.5em;
+    position: absolute;
+    right: 0;
+    height: 24px;
+    line-height: 24px;
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+
+  .issue-message {
+    margin-right: 0.5em;
+  }
+}
\ No newline at end of file
diff --git a/reviewboard/static/rb/js/action-feed.js b/reviewboard/static/rb/js/action-feed.js
new file mode 100644
index 0000000000000000000000000000000000000000..dd77c709dce53dda76be45837fee7cc92fa30549
--- /dev/null
+++ b/reviewboard/static/rb/js/action-feed.js
@@ -0,0 +1,451 @@
+'use strict';
+
+function status_to_string(stat) {
+    switch (stat) {
+        case 'P':
+            return 'pending';
+        case 'S':
+            return 'submitted';
+        case 'D':
+            return 'discarded';
+        default:
+            return 'all';
+    }
+}
+
+var field_name_map = {
+    'summary': 'Summary',
+    'description': 'Description',
+    'testing_done': 'Testing Done',
+    'bugs_closed': 'Bugs Closed',
+    'branch': 'Branch',
+    'target_groups': 'Reviewers (Groups)',
+    'target_people': 'Reviereturn some defaultwers (People)',
+    'screenshots': 'Screenshots',
+    'screenshot_captions': 'Screenshot Captions',
+    'files': 'Uploaded Files',
+    'file_captions': 'Uploaded File Captions',
+    'diff': 'Diff',
+}
+
+function with_change(collapse_button, change, callback) {
+    var body = collapse_button.parent().siblings('.body'),
+        request_url = collapse_button.parent().find('a').attr('href'),
+        field, j;
+
+    var changeitems = $('<ul/>').appendTo(body);
+    for (field in change.fields_changed) {
+        var field_changed = change.fields_changed[field],
+            changeitem = $('<li>'),
+            field_name;
+
+        field_name = field_name_map[field] || field;
+        changeitem.append($('<label/>').text(field_name));
+
+        if (field_changed.hasOwnProperty('added') ||
+            field_changed.hasOwnProperty('removed')) {
+            var list = $('<ul>');
+
+            ['added', 'removed'].forEach(function(type) {
+                var field_value = field_changed[type],
+                    url_prefix, url_key, name_key, i,
+                    field_value_length, li;
+
+                if (!field_value ||
+                    (Array.isArray(field_value) && !field_value.length)) {
+                    return;
+                }
+
+                li = $('<li/>')
+                     .text(type + ' ')
+                     .appendTo(list);
+
+                if (field === 'diff') {
+                    li.append(
+                        $('<a />').attr('href', request_url + 'diff/' +
+                            field_value.revision)
+                                 .text('Diff r' +  field_value.revision));
+                } else {
+                    url_prefix = '';
+                    url_key = 'url';
+                    name_key = null;
+
+                    switch (field) {
+                        case 'target_people':
+                            name_key = 'username';
+                            break;
+                        case 'target_groups':
+                            name_key = 'display_name';
+                            break;
+                        case 'bugs_closed':
+                            url_key = null;
+                            name_key = null;
+                            break;
+                        case 'files':
+                            url_key = 1;
+                            name_key = 0;
+                            break;
+                        case 'screenshots':
+                            url_prefix = request_url + 's/'
+                            name_key = 'caption';
+                            url_key = 'id';
+                            break;
+                    }
+
+                    field_value_length = field_value.length;
+                    for (i = 0; i < field_value_length; i++) {
+                        if (url_key !== null && name_key !== null) {
+                            if (field_value[i].hasOwnProperty(url_key)) {
+                                $('<a/>')
+                                    .attr('href', url_prefix +
+                                                  field_value[i][url_key])
+                                    .text(field_value[i][name_key])
+                                    .appendTo(li)
+
+                                if (i !== field_value_length - 1) {
+                                    li.append(',')
+                                }
+                            } else if (field_value[i].hasOwnProperty(name_key)) {
+                                li.append(field_changed[type][i][name_key]);
+                            }
+                        } else {
+                            li.append(field_value[i]);
+                        }
+                    }
+                }
+            });
+
+            changeitem.append(list);
+        } else if (field_changed.hasOwnProperty('old') ||
+                   field_changed.hasOwnProperty('new')) {
+            var multiline = (field === 'description') ||
+		            (field === 'testing_done'),
+                oldValue = field_changed.old,
+                newValue = field_changed.new;
+
+            if (field === 'status') {
+                oldValue = status_to_string(oldValue);
+                newValue = status_to_string(newValue);
+            }
+
+            if (!multiline) {
+                changeitem.append(' changed from ');
+                changeitem.append($('<i/>').text(oldValue));
+                changeitem.append(' to ');
+                changeitem.append($('<i/>').text(newValue));
+            } else {
+                $('</p>')
+                    .append($('<label>').text('Changed from:'))
+                    .appendTo(changeitem);
+                changeitem.append($('<pre/>').text(oldValue));
+                $('</p>')
+                    .append($('<label>').text('Changed to:'))
+                    .appendTo(changeitem);
+                changeitem.append($('<pre/>').text(newValue));
+            }
+        } else if (field === 'screenshot_captions' ||
+                   field === 'file_captions') {
+            var list = $('<ul>'),
+                length = field_changed.length;
+
+            for (j = 0; j < length; j++) {
+                list.append($('<li/>')
+                        .append('changed from ')
+                        .append($('<i/>').text(field_changed[j].old))
+                        .append(' to ')
+                        .append($('<i/>').text(field_changed[j].new)));
+            }
+
+            changeitem.append(list);
+        } else {
+            console.log("Invalid field", field_changed);
+        }
+
+        changeitems.append(changeitem);
+    }
+
+    if (change.text) {
+        $('<label>')
+            .text('Description: ')
+            .appendTo(body);
+        $('<pre>')
+            .addClass('changedesc-text')
+            .text(change.text)
+            .appendTo(body);
+    }
+
+    callback();
+}
+
+function get_comment_text(comment) {
+    var dd = $('<dd/>')
+            .append($('<pre/>')
+                .addClass('comment-text')
+                .attr('id', comment.anchor_prefix + comment.id)
+                .text(comment.text));
+
+    if (comment.issue_opened) {
+      dd.append(
+         $("<div/>")
+            .addClass('issue-indicator')
+            .attr('id', 'comment-' + comment.id + '-issue')
+            .append($('<div/>')
+                .addClass('issue-state open')
+                .append($('<span/>')
+                    .addClass('issue-message')
+                    .text('An issue was opened.')
+                )
+            )
+        )
+    }
+    return dd;
+}
+
+function get_comment_anchor(comment) {
+    return $('<a/>')
+        .addClass("comment-anchor")
+        .attr('name', comment.anchor_prefix + comment.id);
+}
+
+function with_review(collapse_button, review, callback) {
+    var body = collapse_button.parent().siblings('.body'),
+        request_url = collapse_button.parent().find('a').attr('href');
+
+    $('<pre/>')
+        .addClass('body_top reviewtext')
+        .text(review.body_top)
+        .appendTo(body);
+
+    if (review.diff_comments || review.screenshot_comments ||
+        review.file_attachment_comments) {
+        var i, length,
+            comment_body,
+            review_comments = $('<dl/>')
+                .addClass('diff-comments')
+                .appendTo(body);
+
+	length = 0;
+	if (review.screenshot_comments) {
+            length = review.screenshot_comments.length;
+	}
+
+        for (i = 0; i < length; i++) {
+            var comment = review.screenshot_comments[i],
+                screenshot = comment.links.screenshot,
+                id = screenshot.href.match(/\d+(?!.*\d+)/),
+                title = screenshot.title.match(/\w+/).toString();
+            $('<dt/>')
+                .append(get_comment_anchor(comment))
+                .append($('<div/>')
+                    .addClass('screenshot')
+                    .text('Screenshot: ')
+                    .append($('<a/>')
+                        .attr('href', request_url + 's/' + id)
+                        .text(title)
+                    )
+                )
+                .appendTo(review_comments);
+            get_comment_text(comment).appendTo(review_comments);
+        }
+
+	if (review.file_attachment_comments) {
+            length = review.file_attachment_comments.length;
+	}
+        for (i = 0; i < length; i++) {
+            var comment = review.file_attachment_comments[i],
+                file_attachment = comment.links.file_attachment,
+                title = file_attachment.title;
+            $('<dt/>')
+                .append(get_comment_anchor(comment))
+                .append($('<div/>')
+                    .addClass('file-attachment')
+                    .text('File attachment: ' + title)
+                )
+                .appendTo(review_comments);
+            get_comment_text(comment).appendTo(review_comments);
+        }
+
+	if (review.diff_comments) {
+            length = review.diff_comments.length;
+	}
+
+        for (i = 0; i < length; i++) {
+            var comment = review.diff_comments[i],
+                filediff = comment.links.filediff,
+                title = filediff.title.match(/^[^ ]*/)[0],
+                ids = filediff.href.match('diffs/\\d+/files/\\d+')[0],
+                file_id = ids.match(/\d+(?!.*\d+)/),
+                diff_id = ids.match(/\d/),
+                filediff_name = 'diff/' + diff_id + '/?file=' + file_id +
+                         '#file' + file_id + 'line' + comment.first_line;
+
+            $('<dt/>')
+                .append(get_comment_anchor(comment))
+                .append($('<div/>')
+                    .attr('id', 'comment_cotainer_' + comment.id)
+                    .append($('<div/>')
+                        .addClass('file_attachment')
+                        .text('Filediff: ')
+                        .append($('<a/>')
+                            .attr('href', request_url + filediff_name)
+                            .text(title)
+                        )
+                    )
+                    .append(get_comment_text(comment))
+                )
+                .appendTo(review_comments)
+        }
+    }
+
+    if (review.body_bottom) {
+        $('<pre/>')
+            .addClass('body_bottom')
+            .addClass('reviewtext')
+            .text(review.body_bottom)
+           .appendTo(body);
+    }
+
+    callback();
+}
+
+function append_actions(action_list) {
+    var container = $('#action-feed-content'),
+        site_prefix = SITE_ROOT + gSitePrefix,
+        button = $('#load-more'),
+        last_id = button.data('last_id'),
+        i, action_list_length = action_list.actions.length,
+        action, box_inner, action_summary, collapse_button, reviewer,
+        posted_time, header, posted_text;
+
+    for (i = 0; i < action_list_length; i++) {
+        action = action_list.actions[i];
+        box_inner = $('<div/>')
+            .addClass('box-inner')
+            .appendTo($('<div/>')
+                .addClass('box')
+                .addClass('action_feed')
+                .addClass('collapsed')
+                .addClass(action.type)
+                .appendTo($('<div/>')
+                    .addClass('box-container')
+                    .appendTo(container)
+                )
+            );
+        action_summary = $('<div/>')
+            .addClass('action-summary')
+            .text('Review Request ')
+            .append($('<a/>')
+                .attr('href', site_prefix + 'r/' + action.request_display_id +
+                      '/')
+                .text('#' + action.request_display_id)
+            )
+            .append(': ')
+            .append($('<b/>')
+                .text(action.summary)
+            );
+        collapse_button = $('<div/>')
+            .addClass('collapse-button')
+            .click(on_collapse)
+            .attr('data-object-type', action.type)
+            .attr('data-action-id', action.id)
+            .attr('data-review-display-id', action.request_display_id)
+	    .append($('<div/>')
+		    .addClass('ui-collapse-icon'));
+        reviewer = $('<div/>')
+            .append($('<a/>')
+                .addClass('user')
+                .attr('href', site_prefix + action.submitter.url.substring(1))
+                .text(action.submitter.username)
+            )
+            .append(' ' + action.display_verb);
+
+        if (action.type === 'change') {
+            posted_text = 'Updated';
+        } else {
+            posted_text = 'Posted';
+        }
+
+        posted_time = $('<div/>')
+            .addClass('posted_time')
+            .text(action.timesince + ' ago');
+        header = $('<div/>')
+            .addClass('header')
+            .append(action_summary)
+            .append(collapse_button)
+            .append(reviewer)
+            .append(posted_time);
+        $('<div/>')
+            .addClass('main')
+            .append(header)
+            .append($('<div/>')
+                .addClass('body')
+            )
+            .appendTo(box_inner);
+        last_id = action.id;
+    }
+
+    button.data('last_id', last_id);
+}
+
+function load_more() {
+    var button = $('#load-more'),
+        last_id = button.data('last_id'),
+        action_list;
+
+    if (!last_id) {
+        last_id = 0;
+        button.data('last_id', 0);
+    }
+
+    action_list = new RB.ActionList(last_id, 'submitter', button, gSitePrefix);
+    action_list.ready(function() {
+        append_actions(action_list);
+    });
+    return false;
+}
+
+function on_collapse(){
+    var self = $(this),
+        action_id = self.attr('data-action-id'),
+        action_type = self.attr('data-object-type'),
+        action, expand;
+
+    function toggle_collapsed() {
+        $(self).closest('.box').toggleClass('collapsed');
+    }
+
+    if (action_type === 'review') {
+        expand = 'review,diff_comments,file_attachment_comments,' +
+                 'screenshot_comments';
+    } else {
+        expand = 'changedesc';
+    }
+
+    action = self.data(action_id);
+
+    if (!action) {
+        action = new RB.Action(action_id, expand, gSitePrefix);
+        action.ready(function() {
+            self.data(action_id, action);
+                if (action_type === 'review') {
+                    with_review(self, action.review, toggle_collapsed);
+                } else {
+                    with_change(self, action.changedesc, toggle_collapsed);
+                }
+            });
+    } else {
+        toggle_collapsed();
+    }
+};
+
+
+$(document).ready(function() {
+    load_more();
+
+    $('#load-more').click(function(){
+        load_more();
+    });
+});
+
+
+// vim: set et:sw=4:
diff --git a/reviewboard/static/rb/js/datastore.js b/reviewboard/static/rb/js/datastore.js
index a835436f8e43654c777c43c171d803357191120f..e965bb5db6f4a9c70430096553c006088e6c3047 100644
--- a/reviewboard/static/rb/js/datastore.js
+++ b/reviewboard/static/rb/js/datastore.js
@@ -1,3 +1,101 @@
+RB.Action = function(action_id, expand, prefix) {
+    this.prefix = prefix;
+    this.id = action_id;
+    this.expand = expand;
+    this.loaded = false;
+    return this;
+};
+
+$.extend(RB.Action.prototype, {
+    ready: function(on_ready) {
+        if (this.loaded) {
+            on_ready.apply(this, arguments);
+        } else {
+            this._load(on_ready);
+        }
+    },
+
+    _load: function(on_done) {
+        var self = this;
+
+        if (!self.id) {
+            on_done.apply(this, arguments);
+            return;
+        }
+
+        RB.apiCall({
+            type: "GET",
+            prefix: self.prefix,
+            path: "/session/actions/" + this.id + "?expand=" + this.expand,
+            success: function(rsp, status) {
+                if (status != 404) {
+                    self._loadDataFromResponse(rsp);
+                }
+                on_done.apply(this, arguments);
+            }
+        });
+    },
+
+    _loadDataFromResponse: function(rsp) {
+        this.id = rsp.action.id;
+        this.links = rsp.action.links;
+        this.changedesc = rsp.action.changedesc;
+        this.review = rsp.action.review;
+        this.timestamp = rsp.action.timestamp;
+        this.type = rsp.action.type;
+        this.verb = rsp.action.verb;
+        this.loaded = true;
+    }
+});
+
+RB.ActionList = function(last_timestamp, expand, disable, prefix) {
+    this.prefix = prefix;
+    this.last_timestamp = last_timestamp;
+    this.expand = expand;
+    this.loaded = false;
+    this.disable = disable;
+    this.max_results = 15;
+    return this;
+};
+
+$.extend(RB.ActionList.prototype, {
+    ready: function(on_ready) {
+        if (this.loaded) {
+            on_ready.apply(this, arguments);
+        } else {
+            this._load(on_ready);
+        }
+    },
+
+    _load: function(on_done) {
+        var self = this,
+            path = "/session/actions?&max-results=" + self.max_results +
+                   "&expand=" + this.expand;
+
+        if (self.last_timestamp) {
+            path += '&timestamp-to=' + self.last_timestamp;
+        }
+        RB.apiCall({
+            type: "GET",
+            buttons: self.disable,
+            prefix: self.prefix,
+            path: path,
+            success: function(rsp, status) {
+                if (status != 404) {
+                    self._loadDataFromResponse(rsp);
+                }
+
+                on_done.apply(this, arguments);
+            }
+        });
+    },
+
+    _loadDataFromResponse: function(rsp) {
+        this.actions = rsp.actions;
+        this.loaded = true;
+    }
+});
+
 RB.DiffComment = function(review, id, filediff, interfilediff, beginLineNum,
                           endLineNum) {
     this.id = id;
diff --git a/reviewboard/templates/reviews/dashboard.html b/reviewboard/templates/reviews/dashboard.html
index c57bcfe80d5fdeef75ec2e269ff6caadec665de6..4e20a33beff5b7a659f117e986894bf71445f5f7 100755
--- a/reviewboard/templates/reviews/dashboard.html
+++ b/reviewboard/templates/reviews/dashboard.html
@@ -1,4 +1,5 @@
 {% extends "base.html" %}
+{% load compressed %}
 {% load djblets_deco %}
 {% load i18n %}
 {% load reviewtags %}
@@ -10,13 +11,25 @@
 <meta http-equiv="refresh" content="300" />
 {% endblock %}
 
+{% if view == 'action-feed' %}
+{%  block jsconsts %}
+  var gSitePrefix = "{% if local_site %}s/{{local_site}}/{% endif %}";
+{%  endblock %}
+{% endif %}
+
 {% block css %}
 {{block.super}}
 <link rel="stylesheet" type="text/css" href="{% static "djblets/css/datagrid.css" %}" />
+{%  if view == 'action-feed' %}
+{%   compressed_css "action-feed" %}
+{%  endif %}
 {% endblock %}
 
 {% block scripts-post %}
 <script type="text/javascript" src="{% static "djblets/js/datagrid.js" %}"></script>
+{%  if view == 'action-feed' %}
+{%   compressed_js "action-feed" %}
+{%  endif %}
 {% endblock %}
 
 {% block content %}
@@ -28,6 +41,7 @@
    <col class="count" />
   </colgroup>
   <tbody>
+{% dashboard_entry "main-item" "Action Feed"     "action-feed" %}
 {% dashboard_entry "main-item" "Starred Reviews"  "starred" %}
 {% dashboard_entry "main-item" "Outgoing Reviews" "outgoing" %}
 {% dashboard_entry "main-item" "Incoming Reviews" "incoming" %}
@@ -53,7 +67,16 @@
   </tbody>
  </table>
  <div id="dashboard-main" class="clearfix">
+{%  if view == 'action-feed' %}
+ <div class="container-action-feed">
+  <div id="action-feed-content"></div>
+  <div class="load-more-actions" align="center">
+   <input type="button" class="load-more-button" id="load-more-actions" value="Load more"/>
+  </div>
+ </div>
+{%  else %}
 {{datagrid.render_listview}}
+{%  endif %}
  </div>
 </div>
 {% endbox %}
diff --git a/reviewboard/webapi/resources.py b/reviewboard/webapi/resources.py
index 27cb2e5d8d7eb0f64b71f2e3870938b6c4f44187..426b108984e05c9df6d46c9fc80e224766669dd3 100644
--- a/reviewboard/webapi/resources.py
+++ b/reviewboard/webapi/resources.py
@@ -53,7 +53,7 @@ from reviewboard.hostingsvcs.models import HostingServiceAccount
 from reviewboard.hostingsvcs.service import get_hosting_service
 from reviewboard.reviews.errors import PermissionError
 from reviewboard.reviews.forms import UploadDiffForm, UploadScreenshotForm
-from reviewboard.reviews.models import BaseComment, Comment, DiffSet, \
+from reviewboard.reviews.models import Action, BaseComment, Comment, DiffSet, \
                                        FileDiff, Group, Repository, \
                                        ReviewRequest, ReviewRequestDraft, \
                                        Review, ScreenshotComment, Screenshot, \
@@ -219,6 +219,115 @@ class WebAPIResource(DjbletsWebAPIResource):
                                kwargs=href_kwargs))
 
 
+class ActionResource(WebAPIResource):
+    """Provides information on an action performed by a user on a
+    specified review request.
+
+    Each action includes the summary and a reference to the review
+    request it was performed on, the user performed it, a verb
+    describing the action and the action type. The action can either
+    be a review or a change description and it contains references to
+    either one of these objects.
+    """
+    name = 'action'
+    model = Action
+    fields = {
+        'id': {
+            'type': int,
+            'description': 'The numeric ID of the action.',
+        },
+        'review_request': {
+            'type': 'reviewboard.webapi.resources.ReviewRequestResource',
+            'description': 'The review request.',
+        },
+        'summary': {
+            'type': str,
+            'description': 'Review request summary.',
+        },
+        'submitter': {
+            'type': 'reviewboard.webapi.resources.UserResource',
+            'description': 'The user that performed the action.',
+        },
+        'review': {
+            'type': 'reviewboard.webapi.resources.ReviewResource',
+            'description': 'The review that was posted.',
+        },
+        'changedesc': {
+            'type': 'reviewboard.webapi.resources.ChangeResource',
+            'description': 'The change description.',
+        },
+        'request_display_id': {
+            'type': int,
+            'description': 'The local site review request id.',
+        },
+        'type': {
+            'type': str,
+            'description': 'The type of the action (change, review or reply)',
+        },
+        'verb': {
+            'type': str,
+            'description': 'The verb used for describing the action.'
+        },
+        'display_verb': {
+            'type': str,
+            'description': 'Human-readable version of the verb.'
+        },
+        'timestamp': {
+            'type': str,
+            'description': 'The date and time that the change was made '
+                           '(in YYYY-MM-DD HH:MM:SS format).',
+        },
+        'timesince': {
+            'type': str,
+            'descriptions': 'The timestamp in timesince format.',
+        }
+    }
+    uri_object_key = 'action_id'
+    last_modified_field = 'timestamp'
+    allowed_methods = ('GET',)
+
+    @webapi_request_fields(
+        optional={
+            'timestamp-to': {
+                'type': str,
+                'description': 'timestamp separator when loading'
+            },
+        },
+        allow_unknown=True
+    )
+    @webapi_login_required
+    @webapi_check_local_site
+    @augment_method_from(WebAPIResource)
+    def get_list(self, *args, **kwargs):
+        """Returns a list of changes made on a review request."""
+        pass
+
+    @webapi_login_required
+    @webapi_check_local_site
+    @augment_method_from(WebAPIResource)
+    def get(self, *args, **kwargs):
+        """Returns the information on a change to a review request."""
+        pass
+
+    def get_queryset(self, request, is_list=False, local_site_name=None,
+                     *args, **kwargs):
+        older_than = None
+        try:
+            if 'timestamp-to' in request.GET:
+                older_than = dateutil.parser.parse(request.GET.get(
+                    'timestamp-to'))
+        except ValueError:
+            older_than = None
+
+        return self.model.objects.for_user(request.user, local_site_name,
+                                           older_than)
+
+    def serialize_timesince_field(self, obj, **kwargs):
+        return timesince(obj.timestamp)
+
+action_resource = ActionResource()
+
+
 class BaseCommentResource(WebAPIResource):
     """Base class for comment resources.
 
@@ -5493,6 +5602,10 @@ class ReviewReplyResource(BaseReviewResource):
     name = 'reply'
     name_plural = 'replies'
     fields = {
+        'base_reply_to': {
+            'type': 'reviewboard.webapi.resources.BaseReviewResource',
+            'description': 'The review that this is replying to.',
+        },
         'body_bottom': {
             'type': str,
             'description': 'The response to the review content below '
@@ -6857,6 +6970,9 @@ class SessionResource(WebAPIResource):
     """
     name = 'session'
     singleton = True
+    item_child_resources = [
+        action_resource,
+    ]
 
     @webapi_check_local_site
     @webapi_check_login_required
@@ -6943,7 +7059,7 @@ class RootResource(DjbletsRootResource):
 
 root_resource = RootResource()
 
-
+register_resource_for_model(Action, action_resource)
 register_resource_for_model(ChangeDescription, change_resource)
 register_resource_for_model(
     Comment,
