diff --git a/reviewboard/admin/urls.py b/reviewboard/admin/urls.py
index 8e956b95cda674bd5caa7808f4c26d63532d08f0..048caecb0fa2f682cc6c6ea9a06a13f269e7e25b 100644
--- a/reviewboard/admin/urls.py
+++ b/reviewboard/admin/urls.py
@@ -30,12 +30,86 @@ from django.conf.urls import include, url
 from django.contrib import admin
 from django.views.generic import RedirectView
 from djblets.feedview.views import view_feed
+# TODO: remove below line
+from django.http import HttpResponse
 
 from reviewboard.admin import forms, views
 
 
 NEWS_FEED = 'https://www.reviewboard.org/news/feed/'
 
+
+def one_line_dynamic_test(request):
+    return HttpResponse("""
+    <colgroup>
+  <col class="line">
+  <col class="right">
+ </colgroup>
+ <thead>
+  <tr class="revision-row">
+   <th></th>
+   <th>
+     test.xml
+     (revision 8)
+   </th>
+  </tr>
+ </thead>
+ <tbody>
+  <tr line="1">
+   <th>1</th>
+   <td class="l"><h1>f</h1></td>
+  </tr>
+  <tr line="2">
+   <th>2</th>
+   <td class="l">
+</td>
+ </tbody>
+""")
+
+
+# TODO: remove this
+def dynamic_test(request):
+    # return one_line_dynamic_test(request)
+    return HttpResponse("""
+<colgroup>
+  <col class="line">
+  <col class="right">
+ </colgroup>
+ <thead>
+  <tr class="revision-row">
+   <th></th>
+   <th>
+     test.xml
+     (revision 8)
+   </th>
+  </tr>
+ </thead>
+ <tbody>
+  <tr line="1">
+   <th>1</th>
+   <td class="l"><h1>Updated line 1</h1></td>
+  </tr>
+  <tr line="2">
+   <th>2</th>
+   <td class="l">
+</td>
+  </tr>
+  <tr line="3">
+   <th>3</th>
+   <td class="l"><h1>A newly added line</h1></td>
+  </tr>
+  <tr line="4">
+   <th>4</th>
+   <td class="l">
+</td>
+  </tr>
+  <tr line="5">
+   <th>5</th>
+   <td class="l"><h1>Another newly added line</h1></td>
+  </tr>
+ </tbody>
+""")
+
 urlpatterns = [
     url(r'^$', views.dashboard, name='admin-dashboard'),
 
@@ -56,6 +130,8 @@ urlpatterns = [
         RedirectView.as_view(url=NEWS_FEED, permanent=True)),
 
     url(r'^log/', include('djblets.log.urls')),
+    # TODO: remove below line
+    url(r'^testdynamic/', dynamic_test),
 
     url(r'^security/$', views.security, name='admin-security-checks'),
 
diff --git a/reviewboard/deprecation.py b/reviewboard/deprecation.py
index 13f554ce60763409c93077cec247ab71726fa525..1f69347b1fc0c63423423737fdff78c2a84dd5c4 100644
--- a/reviewboard/deprecation.py
+++ b/reviewboard/deprecation.py
@@ -39,5 +39,16 @@ class RemovedInReviewBoard50Warning(BaseRemovedInReviewBoardVersionWarning):
     """
 
 
+class RemovedInReviewBoard60Warning(BaseRemovedInReviewBoardVersionWarning):
+    """Deprecations for features removed in Review Board 6.0.
+
+    Note that this class will itself be removed in Review Board 6.0. If you
+    need to check against Review Board deprecation warnings, please see
+    :py:class:`BaseRemovedInReviewBoardVersionWarning`. Alternatively, you
+    can use the alias for this class,
+    :py:data:`RemovedInNextReviewBoardVersionWarning`.
+    """
+
+
 #: An alias for the next release of Djblets where features would be removed.
 RemovedInNextReviewBoardVersionWarning = RemovedInReviewBoard50Warning
diff --git a/reviewboard/reviews/tests/test_file_attachment_review_ui.py b/reviewboard/reviews/tests/test_file_attachment_review_ui.py
index ebdf44083bee4ff0d8428da84c0462a2165c0041..51f2bfee49da2a31cce71a3e7a45bcb48f968324 100644
--- a/reviewboard/reviews/tests/test_file_attachment_review_ui.py
+++ b/reviewboard/reviews/tests/test_file_attachment_review_ui.py
@@ -1,9 +1,11 @@
 """Unit tests for reviewboard.reviews.ui.base.FileAttachmentReviewUI."""
 from __future__ import unicode_literals
 
+from django.core.urlresolvers import reverse
+from django.conf.urls import url
+from django.utils.text import slugify
 from djblets.testing.decorators import add_fixtures
 from kgb import SpyAgency
-
 from reviewboard.reviews.ui.base import (FileAttachmentReviewUI,
                                          register_ui,
                                          unregister_ui)
@@ -17,6 +19,7 @@ class MyReviewUI(FileAttachmentReviewUI):
     supports_diffing = True
 
 
+# noinspection PyTypeChecker
 class FileAttachmentReviewUITests(SpyAgency, TestCase):
     """Unit tests for reviewboard.reviews.ui.base.FileAttachmentReviewUI."""
 
@@ -458,3 +461,80 @@ class FileAttachmentReviewUITests(SpyAgency, TestCase):
                     'username': 'dopey',
                 },
             })
+
+    def test_register_review_ui_id_set(self):
+        """Testing register_ui with a review_ui_id set.
+        """
+        class ReviewUIWithId(FileAttachmentReviewUI):
+            supported_mimetypes = ['application/reviewid']
+            review_ui_id = 'test'
+
+        self.spy_on(ReviewUIWithId.__init__,
+                    owner=ReviewUIWithId)
+        register_ui(ReviewUIWithId)
+
+        try:
+            attachment = self.create_file_attachment(
+                self.review_request,
+                mimetype='application/reviewid',
+            )
+            review_ui = attachment.review_ui
+            self.assertIsInstance(review_ui, ReviewUIWithId)
+        finally:
+            unregister_ui(ReviewUIWithId)
+
+    def test_register_review_ui_no_id_set(self):
+        """Testing register_ui with no review_ui_id set.
+        """
+        class ReviewUIWithoutId(FileAttachmentReviewUI):
+            supported_mimetypes = ['application/noreviewid']
+
+        self.spy_on(ReviewUIWithoutId.__init__,
+                    owner=ReviewUIWithoutId)
+        register_ui(ReviewUIWithoutId)
+
+        try:
+            attachment = self.create_file_attachment(
+                self.review_request,
+                mimetype='application/noreviewid',
+            )
+            review_ui = attachment.review_ui
+            expected_review_ui_id = slugify(
+                '%s.%s' % (review_ui.__module__, "ReviewUIWithoutId"))
+            self.assertEqual(review_ui.review_ui_id, expected_review_ui_id)
+            self.assertIsInstance(review_ui, ReviewUIWithoutId)
+        finally:
+            unregister_ui(ReviewUIWithoutId)
+
+    def test_register_review_ui_custom_url(self):
+        """ Testing register_ui with custom URLs.
+        """
+
+        def dummy_view():
+            pass
+
+        class URLPatternReviewUI(FileAttachmentReviewUI):
+            supported_mimetypes = ['application/urlpattern']
+            review_ui_id = 'testid'
+            url_patterns = [
+                url(r'^testpattern/$', dummy_view, name='testpattern')
+            ]
+
+        self.spy_on(URLPatternReviewUI.__init__,
+                    owner=URLPatternReviewUI)
+        register_ui(URLPatternReviewUI)
+
+        attachment = self.create_file_attachment(
+            self.review_request,
+            mimetype='application/urlpattern',
+        )
+        review_ui = attachment.review_ui
+
+        test_url = reverse('testpattern', args=(self.review_request.id,
+                                                attachment.id))
+        expected_url = '/r/%d/file/%d/%s/testpattern/' % \
+                       (self.review_request.id, attachment.id,
+                        review_ui.review_ui_id)
+
+        self.assertEqual(test_url, expected_url)
+        unregister_ui(URLPatternReviewUI)
diff --git a/reviewboard/reviews/ui/base.py b/reviewboard/reviews/ui/base.py
index 69d30dfd4471f1cffc6287a2c352202905baf778..52ac16c572de41bd06dc682fe3676a38d466231f 100644
--- a/reviewboard/reviews/ui/base.py
+++ b/reviewboard/reviews/ui/base.py
@@ -3,27 +3,95 @@ from __future__ import unicode_literals
 import json
 import logging
 import os
+import re
+import warnings
 from uuid import uuid4
 
 import mimeparse
+from django.conf.urls import include, url
 from django.core.exceptions import ObjectDoesNotExist
-from django.http import HttpResponse
+from django.http import HttpResponse, Http404
 from django.utils import six
-from django.utils.safestring import mark_safe
+from django.utils.text import slugify
 from django.utils.translation import ugettext as _
+from django.views.generic.base import View
 from djblets.util.compat.django.template.loader import render_to_string
-
+from reviewboard.accounts.mixins import UserProfileRequiredViewMixin
 from reviewboard.attachments.mimetypes import MIMETYPE_EXTENSIONS, score_match
 from reviewboard.attachments.models import (FileAttachment,
                                             get_latest_file_attachments)
+from reviewboard.deprecation import RemovedInReviewBoard60Warning
 from reviewboard.reviews.context import make_review_request_context
 from reviewboard.reviews.markdown_utils import (markdown_render_conditional,
                                                 normalize_text_for_edit)
-from reviewboard.reviews.models import FileAttachmentComment, Review
+from reviewboard.reviews.models import (FileAttachmentComment, Review,
+                                        ReviewRequest)
+from reviewboard.reviews.views_mixins import ReviewFileAttachmentViewMixin
 from reviewboard.site.urlresolvers import local_site_reverse
 
 
 _file_attachment_review_uis = []
+_file_attachment_url_patterns = {}
+
+
+class BaseReviewUIUtilityView(ReviewFileAttachmentViewMixin,
+                              UserProfileRequiredViewMixin,
+                              View):
+
+    def dispatch(self, request, local_site=None, *args, **kwargs):
+        """Dispatch the view.
+
+
+        Args:
+            request (django.http.HttpRequest):
+                The current HTTP request.
+
+            local_site (reviewboard.site.models.LocalSite):
+                The LocalSite on which the UI is being requested.
+
+            *args (tuple, unused):
+                Ignored positional arguments.
+
+            **kwargs (dict, unused):
+                Ignored keyword arguments.
+
+        Returns:
+            django.http.HttpResponse:
+            The HTTP response for the search.
+        """
+
+        file_attachment_id = kwargs['file_attachment_id']
+
+        if 'file_attachment_diff_id' in kwargs:
+            file_attachment_diff_id = kwargs['file_attachment_diff_id']
+        else:
+            file_attachment_diff_id = None
+
+        review_request_id = kwargs['review_request_id']
+        if local_site:
+            review_request = ReviewRequest.objects.get(
+                local_site=local_site, local_id=review_request_id)
+        else:
+            review_request = ReviewRequest.objects.get(pk=review_request_id)
+
+        file_attachment, file_attachment_revision = self.get_attachments(
+            request, file_attachment_id, review_request,
+            file_attachment_diff_id)
+
+        self.review_ui = self.set_attachment_ui(file_attachment)
+        self.review_ui.review_request = review_request
+
+        if file_attachment_revision:
+            self.review_ui.set_diff_against(file_attachment_revision)
+
+        is_enabled_for = self.set_enabled_for(request, self.review_ui,
+                                              review_request, file_attachment)
+        if self.review_ui and is_enabled_for:
+            return super(BaseReviewUIUtilityView, self).dispatch(request,
+                                                                 *args,
+                                                                 **kwargs)
+        else:
+            raise Http404
 
 
 class ReviewUI(object):
@@ -83,6 +151,13 @@ class ReviewUI(object):
     #: Whether this Review UI supports diffing two objects.
     supports_diffing = False
 
+    #: The id of the file format namespace to use for the Review UI.
+    #: This should be set on all custom Review UIs.
+    review_ui_id = None
+
+    #: URL patterns registered by the subclass. This defaults to None.
+    url_patterns = None
+
     #: A list of CSS bundle names to include on the Review UI's page.
     css_bundle_names = []
 
@@ -884,6 +959,27 @@ def register_ui(review_ui):
         raise TypeError('Only FileAttachmentReviewUI subclasses can be '
                         'registered')
 
+    if not review_ui.review_ui_id:
+        review_ui.review_ui_id = slugify(
+            '%s.%s' % (review_ui.__module__, review_ui.__name__))
+        warnings.warn('%r should set review_ui_id. This will be required '
+                      'in Review Board 6.0. Defaulting the ID Defaulting '
+                      'the ID to "%s".'
+                      % (review_ui, review_ui.review_ui_id),
+                      RemovedInReviewBoard60Warning,
+                      stacklevel=2)
+
+    if review_ui.url_patterns:
+        import reviewboard.reviews.urls as review_request_urls
+        ui_urlpatterns = [
+            url(r'^%s/'
+                % re.escape(review_ui.review_ui_id),
+                include(review_ui.url_patterns)),
+        ]
+
+        _file_attachment_url_patterns[review_ui.review_ui_id] = ui_urlpatterns
+        review_request_urls.dynamic_review_ui_urls.add_patterns(ui_urlpatterns)
+
     _file_attachment_review_uis.append(review_ui)
 
 
@@ -912,6 +1008,13 @@ def unregister_ui(review_ui):
         raise TypeError('Only FileAttachmentReviewUI subclasses can be '
                         'unregistered')
 
+    if review_ui.review_ui_id in _file_attachment_url_patterns:
+        import reviewboard.reviews.urls as review_request_urls
+        ui_urlpatterns = _file_attachment_url_patterns[review_ui.review_ui_id]
+        review_request_urls.dynamic_review_ui_urls\
+            .remove_patterns(ui_urlpatterns)
+        del _file_attachment_url_patterns[review_ui.review_ui_id]
+
     try:
         _file_attachment_review_uis.remove(review_ui)
     except ValueError:
diff --git a/reviewboard/reviews/ui/markdownui.py b/reviewboard/reviews/ui/markdownui.py
index 8ad76c6b1b9b580e845a8aeff10a7eb89a086c1f..c680b5822b41dbd3e04a31231d874911a683a3b2 100644
--- a/reviewboard/reviews/ui/markdownui.py
+++ b/reviewboard/reviews/ui/markdownui.py
@@ -2,14 +2,24 @@ from __future__ import unicode_literals
 
 import logging
 
+from django.conf.urls import url
+from django.http import HttpResponse
+from django.utils.safestring import mark_safe
 from django.utils.translation import ugettext as _
 from djblets.markdown import iter_markdown_lines
+from djblets.util.compat.django.template.loader import render_to_string
 from pygments.lexers import TextLexer
 
 from reviewboard.reviews.chunk_generators import MarkdownDiffChunkGenerator
 from reviewboard.reviews.ui.text import TextBasedReviewUI
 from reviewboard.reviews.markdown_utils import render_markdown
 
+from reviewboard.reviews.ui.text import TextBasedReviewUITextView
+
+
+class MarkdownReviewUITextView(TextBasedReviewUITextView):
+    """Displays a text file attachment with a review UI."""
+
 
 class MarkdownReviewUI(TextBasedReviewUI):
     """A Review UI for markdown files.
@@ -26,6 +36,15 @@ class MarkdownReviewUI(TextBasedReviewUI):
 
     js_view_class = 'RB.MarkdownReviewableView'
 
+    template_name = 'reviews/ui/markdown.html'
+    review_ui_id = 'markdown'
+
+    url_patterns = [
+        url(r'^_render/(?P<render_type>[\w.@+-]+)',
+            MarkdownReviewUITextView.as_view(),
+            name='render_options')
+    ]
+
     def generate_render(self):
         with self.obj.file as f:
             f.open()
diff --git a/reviewboard/reviews/ui/text.py b/reviewboard/reviews/ui/text.py
index f231edf8700873ed02f5e48b827f78a285d2b604..b2de68c091fc56150771ca93720fc3a6d2ae5b35 100644
--- a/reviewboard/reviews/ui/text.py
+++ b/reviewboard/reviews/ui/text.py
@@ -2,6 +2,7 @@ from __future__ import unicode_literals
 
 import logging
 
+from django.http import HttpResponse, Http404
 from django.utils.encoding import force_bytes
 from django.utils.safestring import mark_safe
 from djblets.cache.backend import cache_memoize
@@ -9,12 +10,49 @@ from djblets.util.compat.django.template.loader import render_to_string
 from pygments import highlight
 from pygments.lexers import (ClassNotFound, guess_lexer_for_filename,
                              TextLexer)
-
+from django.conf.urls import url
 from reviewboard.attachments.models import FileAttachment
 from reviewboard.diffviewer.chunk_generator import (NoWrapperHtmlFormatter,
                                                     RawDiffChunkGenerator)
 from reviewboard.diffviewer.diffutils import get_chunks_in_range
-from reviewboard.reviews.ui.base import FileAttachmentReviewUI
+from reviewboard.reviews.ui.base import (FileAttachmentReviewUI,
+                                         BaseReviewUIUtilityView)
+
+
+class TextBasedReviewUITextView(BaseReviewUIUtilityView):
+    """Displays a text file attachment with a review UI."""
+    def get(self, request, *args, **kwargs):
+
+        render_type = kwargs.get('render_type', 'source')
+        text_color = request.GET.get('color', 'black')
+        text_align = request.GET.get('align', 'left')
+
+        if render_type not in ['rendered', 'source']:
+            raise Http404
+
+        try:
+            context = self.review_ui.build_render_context(request, inline=True)
+            context.update(self.review_ui.get_extra_context(request))
+        except Exception as e:
+            context = {}
+            logging.exception('Error when calling get_extra_context for '
+                              '%r: %s', self, e)
+
+        if render_type == 'rendered':
+            template = 'reviews/ui/_text_rendered_table.html'
+            lines = context['rendered_lines']
+        else:
+            template = 'reviews/ui/_text_table.html'
+            lines = context['text_lines']
+
+        context['lines'] = [
+            mark_safe('<span style="color:%s; text-align:%s">%s</span>'
+                      % (text_color, text_align, line))
+            for line in lines
+        ]
+
+        return HttpResponse(render_to_string(template_name=template,
+                                             context=context, request=request))
 
 
 class TextBasedReviewUI(FileAttachmentReviewUI):
@@ -29,6 +67,8 @@ class TextBasedReviewUI(FileAttachmentReviewUI):
         'text/*',
         'application/x-javascript',
     ]
+    review_ui_id = 'text'
+
     template_name = 'reviews/ui/text.html'
     comment_thumbnail_template_name = 'reviews/ui/text_comment_thumbnail.html'
     can_render_text = False
@@ -42,9 +82,19 @@ class TextBasedReviewUI(FileAttachmentReviewUI):
     js_model_class = 'RB.TextBasedReviewable'
     js_view_class = 'RB.TextBasedReviewableView'
 
+    url_patterns = [
+        url(r'^_render/(?P<render_type>[\w.@+-]+)',
+            TextBasedReviewUITextView.as_view(),
+            name='render_options')
+    ]
+
+    def customize_render(self):
+        pass
+
     def get_js_model_data(self):
         data = super(TextBasedReviewUI, self).get_js_model_data()
         data['hasRenderedView'] = self.can_render_text
+        data['reviewUIId'] = self.review_ui_id
 
         if self.can_render_text:
             data['viewMode'] = 'rendered'
@@ -56,7 +106,6 @@ class TextBasedReviewUI(FileAttachmentReviewUI):
     def get_extra_context(self, request):
         context = {}
         diff_type_mismatch = False
-
         if self.diff_against_obj:
             diff_against_review_ui = self.diff_against_obj.review_ui
 
@@ -103,7 +152,6 @@ class TextBasedReviewUI(FileAttachmentReviewUI):
             'num_revisions': num_revisions,
             'diff_type_mismatch': diff_type_mismatch,
         })
-
         return context
 
     def get_text(self):
diff --git a/reviewboard/reviews/urls.py b/reviewboard/reviews/urls.py
index 1b9fe7d5ee658a33f95f46248420c1fc898c40fb..81db63aff5c41b77f259c980213a056d7fc806bc 100644
--- a/reviewboard/reviews/urls.py
+++ b/reviewboard/reviews/urls.py
@@ -1,9 +1,11 @@
 from __future__ import unicode_literals
 
 from django.conf.urls import include, url
+from djblets.urls.resolvers import DynamicURLResolver
 
 from reviewboard.reviews import views
 
+dynamic_review_ui_urls = DynamicURLResolver()
 
 download_diff_urls = [
     url(r'^orig/$',
@@ -94,15 +96,20 @@ review_request_urls = [
         views.CommentDiffFragmentsView.as_view(),
         name='diff-comment-fragments'),
 
+
     # File attachments
-    url(r'^file/(?P<file_attachment_id>\d+)/$',
-        views.ReviewFileAttachmentView.as_view(),
-        name='file-attachment'),
+    url(r'^file/(?P<file_attachment_id>\d+)/', include([
+        dynamic_review_ui_urls,
+        url('$', views.ReviewFileAttachmentView.as_view(),
+            name='file-attachment'),
+    ])),
 
     url(r'^file/(?P<file_attachment_diff_id>\d+)'
-        r'-(?P<file_attachment_id>\d+)/$',
-        views.ReviewFileAttachmentView.as_view(),
-        name='file-attachment'),
+        r'-(?P<file_attachment_id>\d+)/', include([
+            url('$', views.ReviewFileAttachmentView.as_view(),
+                name='file-attachment'),
+            dynamic_review_ui_urls,
+        ])),
 
     # Screenshots
     url(r'^s/(?P<screenshot_id>\d+)/$',
diff --git a/reviewboard/reviews/views.py b/reviewboard/reviews/views.py
index 809b4207d14179aadeaba8f2760f2263385a7338..0509a0f121a2dea53f244cd03d663067bc9b52ed 100644
--- a/reviewboard/reviews/views.py
+++ b/reviewboard/reviews/views.py
@@ -15,13 +15,11 @@ from django.http import (Http404,
                          HttpResponse,
                          HttpResponseBadRequest,
                          HttpResponseNotFound)
-from django.shortcuts import get_object_or_404, get_list_or_404, render
-from django.template.defaultfilters import date
+from django.shortcuts import get_object_or_404, get_list_or_404
 from django.utils import six, timezone
-from django.utils.formats import localize
 from django.utils.html import escape, format_html, strip_tags
 from django.utils.safestring import mark_safe
-from django.utils.timezone import is_aware, localtime, make_aware, utc
+from django.utils.timezone import is_aware, make_aware, utc
 from django.utils.translation import ugettext_lazy as _, ugettext
 from django.views.generic.base import (ContextMixin, RedirectView,
                                        TemplateView, View)
@@ -29,15 +27,12 @@ from djblets.siteconfig.models import SiteConfiguration
 from djblets.util.compat.django.template.loader import render_to_string
 from djblets.util.dates import get_latest_timestamp
 from djblets.util.http import set_last_modified
-from djblets.views.generic.base import (CheckRequestMethodViewMixin,
-                                        PrePostDispatchViewMixin)
 from djblets.views.generic.etag import ETagViewMixin
 
 from reviewboard.accounts.mixins import (CheckLoginRequiredViewMixin,
                                          LoginRequiredViewMixin,
                                          UserProfileRequiredViewMixin)
 from reviewboard.accounts.models import ReviewRequestVisit, Profile
-from reviewboard.admin.decorators import check_read_only
 from reviewboard.admin.mixins import CheckReadOnlyViewMixin
 from reviewboard.admin.read_only import is_site_read_only_for
 from reviewboard.attachments.models import (FileAttachment,
@@ -72,271 +67,14 @@ from reviewboard.reviews.models import (Comment,
                                         Review,
                                         ReviewRequest,
                                         Screenshot)
-from reviewboard.reviews.ui.base import FileAttachmentReviewUI
+from reviewboard.reviews.views_mixins import (ReviewRequestViewMixin,
+                                              ReviewFileAttachmentViewMixin)
 from reviewboard.scmtools.errors import FileNotFoundError
 from reviewboard.scmtools.models import Repository
 from reviewboard.site.mixins import CheckLocalSiteAccessViewMixin
 from reviewboard.site.urlresolvers import local_site_reverse
 
 
-class ReviewRequestViewMixin(CheckRequestMethodViewMixin,
-                             CheckLoginRequiredViewMixin,
-                             CheckLocalSiteAccessViewMixin,
-                             PrePostDispatchViewMixin):
-    """Common functionality for all review request-related pages.
-
-    This performs checks to ensure that the user has access to the page,
-    returning an error page if not. It also provides common functionality
-    for fetching a review request for the given page, returning suitable
-    context for the template, and generating an image used to represent
-    the site when posting to social media sites.
-    """
-
-    permission_denied_template_name = \
-        'reviews/review_request_permission_denied.html'
-
-    def pre_dispatch(self, request, review_request_id, *args, **kwargs):
-        """Look up objects and permissions before dispatching the request.
-
-        This will first look up the review request, returning an error page
-        if it's not accessible. It will then store the review request before
-        calling the handler for the HTTP request.
-
-        Args:
-            request (django.http.HttpRequest):
-                The HTTP request from the client.
-
-            review_request_id (int):
-                The ID of the review request being accessed.
-
-            *args (tuple):
-                Positional arguments to pass to the handler.
-
-            **kwargs (dict):
-                Keyword arguments to pass to the handler.
-
-                These will be arguments provided by the URL pattern.
-
-        Returns:
-            django.http.HttpResponse:
-            The resulting HTTP response to send to the client, if there's
-            a Permission Denied.
-        """
-        self.review_request = self.get_review_request(
-            review_request_id=review_request_id,
-            local_site=self.local_site)
-
-        if not self.review_request.is_accessible_by(request.user):
-            return self.render_permission_denied(request)
-
-        return None
-
-    def render_permission_denied(self, request):
-        """Render a Permission Denied page.
-
-        This will be shown to the user if they're not able to view the
-        review request.
-
-        Args:
-            request (django.http.HttpRequest):
-                The HTTP request from the client.
-
-        Returns:
-            django.http.HttpResponse:
-            The resulting HTTP response to send to the client.
-        """
-        return render(request,
-                      self.permission_denied_template_name,
-                      status=403)
-
-    def get_review_request(self, review_request_id, local_site=None):
-        """Return the review request for the given display ID.
-
-        Args:
-            review_request_id (int):
-                The review request's display ID.
-
-            local_site (reviewboard.site.models.LocalSite):
-                The Local Site the review request is on.
-
-        Returns:
-            reviewboard.reviews.models.review_request.ReviewRequest:
-            The review request for the given display ID and Local Site.
-
-        Raises:
-            django.http.Http404:
-                The review request could not be found.
-        """
-        q = ReviewRequest.objects.all()
-
-        if local_site:
-            q = q.filter(local_site=local_site,
-                         local_id=review_request_id)
-        else:
-            q = q.filter(pk=review_request_id)
-
-        q = q.select_related('submitter', 'repository')
-
-        return get_object_or_404(q)
-
-    def get_diff(self, revision=None, draft=None):
-        """Return a diff on the review request matching the given criteria.
-
-        If a draft is provided, and ``revision`` is either ``None`` or matches
-        the revision on the draft's DiffSet, that DiffSet will be returned.
-
-        Args:
-            revision (int, optional):
-                The revision of the diff to retrieve. If not provided, the
-                latest DiffSet will be returned.
-
-            draft (reviewboard.reviews.models.review_request_draft.
-                   ReviewRequestDraft, optional):
-                The draft of the review request.
-
-        Returns:
-            reviewboard.diffviewer.models.diffset.DiffSet:
-            The resulting DiffSet.
-
-        Raises:
-            django.http.Http404:
-                The diff does not exist.
-        """
-        # Normalize the revision, since it might come in as a string.
-        if revision:
-            revision = int(revision)
-
-        # This will try to grab the diff associated with a draft if the review
-        # request has an associated draft and is either the revision being
-        # requested or no revision is being requested.
-        if (draft and draft.diffset_id and
-            (revision is None or draft.diffset.revision == revision)):
-            return draft.diffset
-
-        query = Q(history=self.review_request.diffset_history_id)
-
-        # Grab a revision if requested.
-        if revision is not None:
-            query = query & Q(revision=revision)
-
-        try:
-            return DiffSet.objects.filter(query).latest()
-        except DiffSet.DoesNotExist:
-            raise Http404
-
-    def get_social_page_image_url(self, file_attachments):
-        """Return the URL to an image used for social media sharing.
-
-        This will look for the first attachment in a list of attachments that
-        can be used to represent the review request on social media sites and
-        chat services. If a suitable attachment is found, its URL will be
-        returned.
-
-        Args:
-            file_attachments (list of reviewboard.attachments.models.
-                              FileAttachment):
-                A list of file attachments used on a review request.
-
-        Returns:
-            unicode:
-            The URL to the first image file attachment, if found, or ``None``
-            if no suitable attachments were found.
-        """
-        for file_attachment in file_attachments:
-            if file_attachment.mimetype.startswith('image/'):
-                return file_attachment.get_absolute_url()
-
-        return None
-
-    def get_review_request_status_html(self, review_request_details,
-                                       close_info, extra_info=[]):
-        """Return HTML describing the current status of a review request.
-
-        This will return a description of the submitted, discarded, or open
-        state for the review request, for use in the rendering of the page.
-
-        Args:
-            review_request_details (reviewboard.reviews.models
-                                    .base_review_request_details
-                                    .BaseReviewRequestDetails):
-                The review request or draft being viewed.
-
-            close_info (dict):
-                A dictionary of information on the closed state of the
-                review request.
-
-            extra_info (list of dict):
-                A list of dictionaries showing additional status information.
-                Each must have a ``text`` field containing a format string
-                using ``{keyword}``-formatted variables, a ``timestamp`` field
-                (which will be normalized to the local timestamp), and an
-                optional ``extra_vars`` for the format string.
-
-        Returns:
-            unicode:
-            The status text as HTML for the page.
-        """
-        review_request = self.review_request
-        status = review_request.status
-        review_request_details = review_request_details
-
-        if status == ReviewRequest.SUBMITTED:
-            timestamp = close_info['timestamp']
-
-            if timestamp:
-                text = ugettext('Created {created_time} and submitted '
-                                '{timestamp}')
-            else:
-                text = ugettext('Created {created_time} and submitted')
-        elif status == ReviewRequest.DISCARDED:
-            timestamp = close_info['timestamp']
-
-            if timestamp:
-                text = ugettext('Created {created_time} and discarded '
-                                '{timestamp}')
-            else:
-                text = ugettext('Created {created_time} and discarded')
-        elif status == ReviewRequest.PENDING_REVIEW:
-            text = ugettext('Created {created_time} and updated {timestamp}')
-            timestamp = review_request_details.last_updated
-        else:
-            logging.error('Unexpected review request status %r for '
-                          'review request %s',
-                          status, review_request.display_id,
-                          request=self.request)
-
-            return ''
-
-        parts = [
-            {
-                'text': text,
-                'timestamp': timestamp,
-                'extra_vars': {
-                    'created_time': date(localtime(review_request.time_added)),
-                },
-            },
-        ] + extra_info
-
-        html_parts = []
-
-        for part in parts:
-            if part['timestamp']:
-                timestamp = localtime(part['timestamp'])
-                timestamp_html = format_html(
-                    '<time class="timesince" datetime="{0}">{1}</time>',
-                    timestamp.isoformat(),
-                    localize(timestamp))
-            else:
-                timestamp_html = ''
-
-            html_parts.append(format_html(
-                part['text'],
-                timestamp=timestamp_html,
-                **part.get('extra_vars', {})))
-
-        return mark_safe(' &mdash; '.join(html_parts))
-
-
 #
 # Helper functions
 #
@@ -2004,6 +1742,7 @@ class PreviewReplyEmailView(ReviewRequestViewMixin, BasePreviewEmailView):
 
 
 class ReviewFileAttachmentView(ReviewRequestViewMixin,
+                               ReviewFileAttachmentViewMixin,
                                UserProfileRequiredViewMixin,
                                View):
     """Displays a file attachment with a review UI."""
@@ -2033,44 +1772,18 @@ class ReviewFileAttachmentView(ReviewRequestViewMixin,
             The resulting HTTP response from the handler.
         """
         review_request = self.review_request
-        draft = review_request.get_draft(request.user)
 
-        # Make sure the attachment returned is part of either the review request
-        # or an accessible draft.
-        review_request_q = (Q(review_request=review_request) |
-                            Q(inactive_review_request=review_request))
+        file_attachment, file_attachment_revision = self.get_attachments(
+            request, file_attachment_id, review_request,
+            file_attachment_diff_id)
 
-        if draft:
-            review_request_q |= Q(drafts=draft) | Q(inactive_drafts=draft)
+        review_ui = self.set_attachment_ui(file_attachment)
 
-        file_attachment = get_object_or_404(
-            FileAttachment,
-            Q(pk=file_attachment_id) & review_request_q)
-
-        review_ui = file_attachment.review_ui
-
-        if not review_ui:
-            review_ui = FileAttachmentReviewUI(review_request, file_attachment)
-
-        if file_attachment_diff_id:
-            file_attachment_revision = get_object_or_404(
-                FileAttachment,
-                Q(pk=file_attachment_diff_id) &
-                Q(attachment_history=file_attachment.attachment_history) &
-                review_request_q)
+        if file_attachment_revision:
             review_ui.set_diff_against(file_attachment_revision)
 
-        try:
-            is_enabled_for = review_ui.is_enabled_for(
-                user=request.user,
-                review_request=review_request,
-                file_attachment=file_attachment)
-        except Exception as e:
-            logging.error('Error when calling is_enabled_for for '
-                          'FileAttachmentReviewUI %r: %s',
-                          review_ui, e, exc_info=1)
-            is_enabled_for = False
-
+        is_enabled_for = self.set_enabled_for(request, review_ui,
+                                              review_request, file_attachment)
         if review_ui and is_enabled_for:
             return review_ui.render_to_response(request)
         else:
diff --git a/reviewboard/reviews/views_mixins.py b/reviewboard/reviews/views_mixins.py
new file mode 100644
index 0000000000000000000000000000000000000000..b6feec91d7eee0a042d925de71839e8989956b64
--- /dev/null
+++ b/reviewboard/reviews/views_mixins.py
@@ -0,0 +1,332 @@
+from __future__ import unicode_literals
+
+import logging
+
+from django.db.models import Q
+from django.http import Http404
+from django.shortcuts import get_object_or_404, render
+from django.template.defaultfilters import date
+from django.utils.formats import localize
+from django.utils.html import format_html
+from django.utils.safestring import mark_safe
+from django.utils.timezone import localtime
+from django.utils.translation import ugettext
+from djblets.views.generic.base import (CheckRequestMethodViewMixin,
+                                        PrePostDispatchViewMixin)
+from reviewboard.accounts.mixins import CheckLoginRequiredViewMixin
+from reviewboard.attachments.models import FileAttachment
+from reviewboard.diffviewer.models import DiffSet
+from reviewboard.reviews.models import ReviewRequest
+# from reviewboard.reviews.ui.base import FileAttachmentReviewUI
+from reviewboard.site.mixins import CheckLocalSiteAccessViewMixin
+
+
+class ReviewRequestViewMixin(CheckRequestMethodViewMixin,
+                             CheckLoginRequiredViewMixin,
+                             CheckLocalSiteAccessViewMixin,
+                             PrePostDispatchViewMixin):
+    """Common functionality for all review request-related pages.
+
+    This performs checks to ensure that the user has access to the page,
+    returning an error page if not. It also provides common functionality
+    for fetching a review request for the given page, returning suitable
+    context for the template, and generating an image used to represent
+    the site when posting to social media sites.
+    """
+
+    permission_denied_template_name = \
+        'reviews/review_request_permission_denied.html'
+
+    def pre_dispatch(self, request, review_request_id, *args, **kwargs):
+        """Look up objects and permissions before dispatching the request.
+
+        This will first look up the review request, returning an error page
+        if it's not accessible. It will then store the review request before
+        calling the handler for the HTTP request.
+
+        Args:
+            request (django.http.HttpRequest):
+                The HTTP request from the client.
+
+            review_request_id (int):
+                The ID of the review request being accessed.
+
+            *args (tuple):
+                Positional arguments to pass to the handler.
+
+            **kwargs (dict):
+                Keyword arguments to pass to the handler.
+
+                These will be arguments provided by the URL pattern.
+
+        Returns:
+            django.http.HttpResponse:
+            The resulting HTTP response to send to the client, if there's
+            a Permission Denied.
+        """
+        self.review_request = self.get_review_request(
+            review_request_id=review_request_id,
+            local_site=self.local_site)
+
+        if not self.review_request.is_accessible_by(request.user):
+            return self.render_permission_denied(request)
+
+        return None
+
+    def render_permission_denied(self, request):
+        """Render a Permission Denied page.
+
+        This will be shown to the user if they're not able to view the
+        review request.
+
+        Args:
+            request (django.http.HttpRequest):
+                The HTTP request from the client.
+
+        Returns:
+            django.http.HttpResponse:
+            The resulting HTTP response to send to the client.
+        """
+        return render(request,
+                      self.permission_denied_template_name,
+                      status=403)
+
+    def get_review_request(self, review_request_id, local_site=None):
+        """Return the review request for the given display ID.
+
+        Args:
+            review_request_id (int):
+                The review request's display ID.
+
+            local_site (reviewboard.site.models.LocalSite):
+                The Local Site the review request is on.
+
+        Returns:
+            reviewboard.reviews.models.review_request.ReviewRequest:
+            The review request for the given display ID and Local Site.
+
+        Raises:
+            django.http.Http404:
+                The review request could not be found.
+        """
+        q = ReviewRequest.objects.all()
+
+        if local_site:
+            q = q.filter(local_site=local_site,
+                         local_id=review_request_id)
+        else:
+            q = q.filter(pk=review_request_id)
+
+        q = q.select_related('submitter', 'repository')
+
+        return get_object_or_404(q)
+
+    def get_diff(self, revision=None, draft=None):
+        """Return a diff on the review request matching the given criteria.
+
+        If a draft is provided, and ``revision`` is either ``None`` or matches
+        the revision on the draft's DiffSet, that DiffSet will be returned.
+
+        Args:
+            revision (int, optional):
+                The revision of the diff to retrieve. If not provided, the
+                latest DiffSet will be returned.
+
+            draft (reviewboard.reviews.models.review_request_draft.
+                   ReviewRequestDraft, optional):
+                The draft of the review request.
+
+        Returns:
+            reviewboard.diffviewer.models.diffset.DiffSet:
+            The resulting DiffSet.
+
+        Raises:
+            django.http.Http404:
+                The diff does not exist.
+        """
+        # Normalize the revision, since it might come in as a string.
+        if revision:
+            revision = int(revision)
+
+        # This will try to grab the diff associated with a draft if the review
+        # request has an associated draft and is either the revision being
+        # requested or no revision is being requested.
+        if (draft and draft.diffset_id and
+            (revision is None or draft.diffset.revision == revision)):
+            return draft.diffset
+
+        query = Q(history=self.review_request.diffset_history_id)
+
+        # Grab a revision if requested.
+        if revision is not None:
+            query = query & Q(revision=revision)
+
+        try:
+            return DiffSet.objects.filter(query).latest()
+        except DiffSet.DoesNotExist:
+            raise Http404
+
+    def get_social_page_image_url(self, file_attachments):
+        """Return the URL to an image used for social media sharing.
+
+        This will look for the first attachment in a list of attachments that
+        can be used to represent the review request on social media sites and
+        chat services. If a suitable attachment is found, its URL will be
+        returned.
+
+        Args:
+            file_attachments (list of reviewboard.attachments.models.
+                              FileAttachment):
+                A list of file attachments used on a review request.
+
+        Returns:
+            unicode:
+            The URL to the first image file attachment, if found, or ``None``
+            if no suitable attachments were found.
+        """
+        for file_attachment in file_attachments:
+            if file_attachment.mimetype.startswith('image/'):
+                return file_attachment.get_absolute_url()
+
+        return None
+
+    def get_review_request_status_html(self, review_request_details,
+                                       close_info, extra_info=[]):
+        """Return HTML describing the current status of a review request.
+
+        This will return a description of the submitted, discarded, or open
+        state for the review request, for use in the rendering of the page.
+
+        Args:
+            review_request_details (reviewboard.reviews.models
+                                    .base_review_request_details
+                                    .BaseReviewRequestDetails):
+                The review request or draft being viewed.
+
+            close_info (dict):
+                A dictionary of information on the closed state of the
+                review request.
+
+            extra_info (list of dict):
+                A list of dictionaries showing additional status information.
+                Each must have a ``text`` field containing a format string
+                using ``{keyword}``-formatted variables, a ``timestamp`` field
+                (which will be normalized to the local timestamp), and an
+                optional ``extra_vars`` for the format string.
+
+        Returns:
+            unicode:
+            The status text as HTML for the page.
+        """
+        review_request = self.review_request
+        status = review_request.status
+        review_request_details = review_request_details
+
+        if status == ReviewRequest.SUBMITTED:
+            timestamp = close_info['timestamp']
+
+            if timestamp:
+                text = ugettext('Created {created_time} and submitted '
+                                '{timestamp}')
+            else:
+                text = ugettext('Created {created_time} and submitted')
+        elif status == ReviewRequest.DISCARDED:
+            timestamp = close_info['timestamp']
+
+            if timestamp:
+                text = ugettext('Created {created_time} and discarded '
+                                '{timestamp}')
+            else:
+                text = ugettext('Created {created_time} and discarded')
+        elif status == ReviewRequest.PENDING_REVIEW:
+            text = ugettext('Created {created_time} and updated {timestamp}')
+            timestamp = review_request_details.last_updated
+        else:
+            logging.error('Unexpected review request status %r for '
+                          'review request %s',
+                          status, review_request.display_id,
+                          request=self.request)
+
+            return ''
+
+        parts = [
+            {
+                'text': text,
+                'timestamp': timestamp,
+                'extra_vars': {
+                    'created_time': date(localtime(review_request.time_added)),
+                },
+            },
+        ] + extra_info
+
+        html_parts = []
+
+        for part in parts:
+            if part['timestamp']:
+                timestamp = localtime(part['timestamp'])
+                timestamp_html = format_html(
+                    '<time class="timesince" datetime="{0}">{1}</time>',
+                    timestamp.isoformat(),
+                    localize(timestamp))
+            else:
+                timestamp_html = ''
+
+            html_parts.append(format_html(
+                part['text'],
+                timestamp=timestamp_html,
+                **part.get('extra_vars', {})))
+
+        return mark_safe(' &mdash; '.join(html_parts))
+
+
+class ReviewFileAttachmentViewMixin(CheckLoginRequiredViewMixin):
+
+    def get_attachments(self, request, file_attachment_id, review_request,
+                        file_attachment_diff_id=None):
+        # Make sure the attachment returned is part of either the review
+        # request or an accessible draft.
+        draft = review_request.get_draft(request.user)
+        review_request_q = (Q(review_request=review_request) |
+                            Q(inactive_review_request=review_request))
+
+        if draft:
+            review_request_q |= Q(drafts=draft) | Q(inactive_drafts=draft)
+
+        file_attachment = get_object_or_404(
+            FileAttachment,
+            Q(pk=file_attachment_id) & review_request_q)
+
+        if file_attachment_diff_id:
+            file_attachment_revision = get_object_or_404(
+                FileAttachment,
+                Q(pk=file_attachment_diff_id) &
+                Q(attachment_history=file_attachment.attachment_history) &
+                review_request_q)
+        else:
+            file_attachment_revision = None
+
+        return file_attachment, file_attachment_revision
+
+    def set_attachment_ui(self, file_attachment):
+        review_ui = file_attachment.review_ui
+
+        # if not review_ui:
+        #   review_ui = FileAttachmentReviewUI(self.review_request,
+        #   file_attachment)
+
+        return review_ui
+
+    def set_enabled_for(self, request, review_ui, review_request,
+                        file_attachment):
+        try:
+            # print(request.user)
+            is_enabled_for = review_ui.is_enabled_for(
+                user=request.user,
+                review_request=review_request,
+                file_attachment=file_attachment)
+        except Exception as e:
+            logging.error('Error when calling is_enabled_for for '
+                          'FileAttachmentReviewUI %r: %s',
+                          review_ui, e, exc_info=1)
+            is_enabled_for = False
+        return is_enabled_for
diff --git a/reviewboard/static/rb/css/common.less b/reviewboard/static/rb/css/common.less
index d9fab7776a7f3940c66b270b47a65b3c1ddef892..b9d224cbfb2c6ff452cc09cfffe4aea214533c3f 100644
--- a/reviewboard/static/rb/css/common.less
+++ b/reviewboard/static/rb/css/common.less
@@ -510,4 +510,11 @@ html[xmlns] .clearfix {
   height: 1%;
 }
 
+/**
+ * Disables interaction with an element and any of it's children
+ */
+.rb-u-disabled-container {
+  pointer-events: none;
+}
+
 // vim: set et ts=2 sw=2:
diff --git a/reviewboard/static/rb/css/pages/text-review-ui.less b/reviewboard/static/rb/css/pages/text-review-ui.less
index 57adde1f721e0be8c3033c91335b388d8a208990..e527d330a2113ad34af1a39dffc80a5dbe7421b3 100644
--- a/reviewboard/static/rb/css/pages/text-review-ui.less
+++ b/reviewboard/static/rb/css/pages/text-review-ui.less
@@ -135,7 +135,7 @@
   }
 }
 
-#attachment_revision_selector, #revision_label {
+#attachment_revision_selector, #revision_label, .render-options {
   padding-top: 4px;
   padding-left: 12px;
 }
diff --git a/reviewboard/static/rb/js/views/abstractReviewableView.es6.js b/reviewboard/static/rb/js/views/abstractReviewableView.es6.js
index f83d63a48df30b914db7f8d284948c7c597a67d4..8c65a426d12493bd355ba90482d2f5de95c8ec34 100644
--- a/reviewboard/static/rb/js/views/abstractReviewableView.es6.js
+++ b/reviewboard/static/rb/js/views/abstractReviewableView.es6.js
@@ -33,6 +33,7 @@ RB.AbstractReviewableView = Backbone.View.extend({
         this.commentDlg = null;
         this._activeCommentBlock = null;
         this.renderedInline = options.renderedInline || false;
+        this.commentBlockViews = [];
     },
 
     /**
@@ -47,11 +48,16 @@ RB.AbstractReviewableView = Backbone.View.extend({
      */
     render() {
         this.renderContent();
-
+        this.renderCommentBlocks();
+        return this;
+    },
+    /**
+     * Renders all comment blocks into the view.
+     */
+    renderCommentBlocks() {
         this.model.commentBlocks.each(this._addCommentBlockView, this);
         this.model.commentBlocks.on('add', this._addCommentBlockView, this);
 
-        return this;
     },
 
     /**
@@ -127,6 +133,25 @@ RB.AbstractReviewableView = Backbone.View.extend({
         });
     },
 
+    /**
+     * Removes all comments from the view, and re-attaches them
+     */
+    refreshComments() {
+        this._hideCommentDlg();
+        this._disposeComments();
+        this.renderCommentBlocks();
+    },
+    _hideCommentDlg() {
+        if (this.commentDlg) {
+            this.commentDlg.close();
+        }
+    },
+    _disposeComments() {
+        this.commentBlockViews.forEach((view) => {
+            view.dispose();
+        });
+        this.commentBlockViews = [];
+    },
     /**
      * Add a CommentBlockView for the given CommentBlock.
      *
@@ -145,6 +170,7 @@ RB.AbstractReviewableView = Backbone.View.extend({
 
         commentBlockView.on('clicked', () => this.showCommentDlg(commentBlockView));
         commentBlockView.render();
+        this.commentBlockViews.push(commentBlockView);
         this.trigger('commentBlockViewAdded', commentBlockView);
     },
 });
diff --git a/reviewboard/static/rb/js/views/markdownReviewableView.es6.js b/reviewboard/static/rb/js/views/markdownReviewableView.es6.js
index d5a8e1d21edc003e78b3acc38da5df350adbf526..80cb835d1a4cc6d69cf651bd0f808b0f6d96a4d1 100644
--- a/reviewboard/static/rb/js/views/markdownReviewableView.es6.js
+++ b/reviewboard/static/rb/js/views/markdownReviewableView.es6.js
@@ -3,4 +3,29 @@
  */
 RB.MarkdownReviewableView = RB.TextBasedReviewableView.extend({
     className: 'markdown-review-ui',
+
+    events() {
+        return _.extend({}, RB.TextBasedReviewableView.prototype.events, {
+            'click #center_text': '_onCenteredCheckbox',
+        });
+    },
+
+    initialize(options) {
+        RB.TextBasedReviewableView.prototype.initialize.apply(this, options);
+        this._$centeredCheckbox = null;
+    },
+
+    renderContent() {
+        RB.TextBasedReviewableView.prototype.renderContent.apply(this);
+        this._$centeredCheckbox = this.$('.render-options input[name="center_text"]');
+    },
+
+    _onCenteredCheckbox() {
+        this.options.align = 'left';
+        if (this._$centeredCheckbox.prop("checked")) {
+            this.options.align = 'center';
+        }
+        this.reloadContentFromServer('source', this.options,
+            this._$textTable);
+    },
 });
diff --git a/reviewboard/static/rb/js/views/tests/textBasedReviewableViewTests.es6.js b/reviewboard/static/rb/js/views/tests/textBasedReviewableViewTests.es6.js
index 33324fd6b4c8deda983d9a5412d0f33c06fe6989..750fa80b1ab35a1bf3cfd66c87c66659c6f4c7ce 100644
--- a/reviewboard/static/rb/js/views/tests/textBasedReviewableViewTests.es6.js
+++ b/reviewboard/static/rb/js/views/tests/textBasedReviewableViewTests.es6.js
@@ -11,9 +11,19 @@ suite('rb/views/TextBasedReviewableView', function() {
        </div>
        <table class="text-review-ui-rendered-table"></table>
        <table class="text-review-ui-text-table"></table>
+       <div class="render-options"></div>
       </div>
     `;
 
+    function getMostRecentApiCallOptions() {
+        return RB.apiCall.calls.mostRecent().args[0];
+    }
+    function spyAndForceAjaxSuccess(responseBody = '') {
+        spyOn($, 'ajax').and.callFake(request => {
+            request.success(responseBody);
+            request.complete();
+        });
+    }
     let $container;
     let reviewRequest;
     let model;
@@ -56,6 +66,7 @@ suite('rb/views/TextBasedReviewableView', function() {
                 Backbone.history.loadUrl(url);
             }
         });
+        spyOn(RB, 'apiCall').and.callThrough();
 
         view.render();
     });
@@ -83,4 +94,55 @@ suite('rb/views/TextBasedReviewableView', function() {
         expect($container.find('.active').attr('data-view-mode')).toBe('rendered');
         expect(model.get('viewMode')).toBe('rendered');
     });
+        it('reloadContentFromServer disables render options during the request',
+        function() {
+            const $renderOptions = $('.render-options');
+            view.reloadContentFromServer(
+                'rendered', {}, view._$renderedTable);
+            expect($renderOptions.hasClass('rb-u-disabled-container')).toEqual(true);
+        }
+    );
+    it('reloadContentFromServer re-enables render options after the request',
+        function() {
+            spyAndForceAjaxSuccess();
+            const $renderOptions = $('.render-options');
+            view.reloadContentFromServer(
+                'rendered', {}, view._$renderedTable);
+            expect($renderOptions.hasClass('rb-u-disabled-container')).toEqual(false);
+        }
+    );
+    it('reloadContentFromServer properly combines extra render option data',
+        function() {
+            view.reloadContentFromServer(
+                'rendered', {
+                    sortKeys: false
+                }, view._$renderedTable);
+            const options = getMostRecentApiCallOptions();
+            expect(options.data).toEqual({
+                type: 'rendered',
+                sortKeys: false
+            });
+        }
+    );
+    it('reloadContentFromServer should emit contentReloaded on success',
+        function() {
+            spyAndForceAjaxSuccess();
+            let contentReloaded = false;
+            view.on('contentReloaded', () => {
+                contentReloaded = true;
+            });
+            view.reloadContentFromServer(
+                'rendered', {}, view._$renderedTable);
+            expect(contentReloaded).toEqual(true);
+        }
+    );
+    it('reloadContentFromServer should update the contents of the element',
+        function() {
+            const contents = '<div>new table contents</div>';
+            spyAndForceAjaxSuccess(contents);
+            view.reloadContentFromServer(
+                'rendered', {}, view._$renderedTable);
+            expect(view._$renderedTable.html()).toEqual(contents);
+        }
+    );
 });
diff --git a/reviewboard/static/rb/js/views/textBasedReviewableView.es6.js b/reviewboard/static/rb/js/views/textBasedReviewableView.es6.js
index 2efaa3b4d5ab3c2ff91c5862523cbe63b0a63d2a..f2aa851a7b68973f893e80a382a116e838bc0c92 100644
--- a/reviewboard/static/rb/js/views/textBasedReviewableView.es6.js
+++ b/reviewboard/static/rb/js/views/textBasedReviewableView.es6.js
@@ -8,7 +8,10 @@
  */
 RB.TextBasedReviewableView = RB.FileAttachmentReviewableView.extend({
     commentBlockView: RB.TextBasedCommentBlockView,
-
+    events: {
+        'click #turn_blue': '_onBlueCheckbox',
+        'click #turn_red': '_onRedCheckbox',
+    },
     /**
      * Initialize the view.
      *
@@ -25,9 +28,9 @@ RB.TextBasedReviewableView = RB.FileAttachmentReviewableView.extend({
         this._$renderedTable = null;
         this._textSelector = null;
         this._renderedSelector = null;
+        this._$blueCheckbox = null;
 
         this.on('commentBlockViewAdded', this._placeCommentBlockView, this);
-
         this.router = new Backbone.Router({
             routes: {
                 ':viewMode(/line:lineNum)': 'viewMode',
@@ -54,6 +57,7 @@ RB.TextBasedReviewableView = RB.FileAttachmentReviewableView.extend({
                 this._scrollToLine(lineNum);
             }
         });
+        this.options = {};
     },
 
     /**
@@ -66,14 +70,68 @@ RB.TextBasedReviewableView = RB.FileAttachmentReviewableView.extend({
         this._renderedSelector.remove();
     },
 
+    /**
+     * Gets the endpoint to hit to reload content for the file
+     * from the server.
+     *
+     * Returns:
+     *     String:
+     *     The endpoint to hit.
+     */
+    getReloadContentEndpoint(renderType) {
+        return this.model.get('reviewRequest').get('reviewURL')
+            + 'file/' + this.model.get('fileAttachmentID') + '/'
+            + this.model.get('reviewUIId') + '/_render/' + renderType;
+    },
+
+    /**
+     * Updates the specified element with by reloading it's text content
+     * from the server.
+     *
+     * Args:
+     *     renderType (string):
+     *         The type of the content that should be reloaded.
+     *     options (object):
+     *         Extra details to pass to the endpoint as query parameters.
+     *     $elementToUpdate (jQuery):
+     *         The DOM element to update.
+     */
+    reloadContentFromServer(renderType, options, $elementToUpdate) {
+        const $renderOptions = $('.render-options');
+        $renderOptions.addClass('rb-u-disabled-container');
+        $elementToUpdate.html('');
+        RB.apiCall({
+            url: this.getReloadContentEndpoint(renderType),
+            type: 'GET',
+            data: $.extend(true, options, {
+                type: renderType
+            }),
+            dataType: 'html',
+            success: (response) => {
+                $elementToUpdate.html(response);
+                this.trigger('contentReloaded', {
+                    renderType,
+                    options,
+                    $el: $elementToUpdate
+                });
+                // TODO: Move this to 'complete: ()' when rr #10745 is live
+                $renderOptions.removeClass('rb-u-disabled-container');
+            },
+        });
+        console.log(this.getReloadContentEndpoint(renderType));
+    },
+
     /**
      * Render the view.
      */
     renderContent() {
         this._$viewTabs = this.$('.text-review-ui-views li');
+        this._$blueCheckbox = this.$('.render-options input[name="turn_blue"]');
+        this._$redCheckbox = this.$('.render-options input[name="turn_red"]');
 
         // Set up the source text table.
         this._$textTable = this.$('.text-review-ui-text-table');
+        this._$textOptions = this.$('.source-view-options');
 
         this._textSelector = new RB.TextCommentRowSelector({
             el: this._$textTable,
@@ -84,12 +142,15 @@ RB.TextBasedReviewableView = RB.FileAttachmentReviewableView.extend({
         if (this.model.get('hasRenderedView')) {
             // Set up the rendered table.
             this._$renderedTable = this.$('.text-review-ui-rendered-table');
+            this._$renderedOptions = this.$('.rendered-view-options');
 
             this._renderedSelector = new RB.TextCommentRowSelector({
                 el: this._$renderedTable,
                 reviewableView: this
             });
             this._renderedSelector.render();
+
+            this._$textOptions.setVisible(false);
         }
 
         this.listenTo(this.model, 'change:viewMode', this._onViewChanged);
@@ -119,6 +180,11 @@ RB.TextBasedReviewableView = RB.FileAttachmentReviewableView.extend({
         Backbone.history.start({
             root: `${reviewURL}file/${attachmentID}/`,
         });
+        // // TODO: remove this
+        // setTimeout(() => {
+        //     this.reloadContentFromServer(
+        //         'source', {}, this._$textTable);
+        // }, 1000);
     },
 
     /**
@@ -201,6 +267,28 @@ RB.TextBasedReviewableView = RB.FileAttachmentReviewableView.extend({
         }
     },
 
+    /**
+     * Return the table element for the given view mode.
+     *
+     * Args:
+     *     viewMode (string):
+     *         The view mode to show.
+     *
+     * Returns:
+     *     jQuery:
+     *     The table element corresponding to the requested view mode.
+     */
+    _getOptionForViewMode(viewMode) {
+        if (viewMode === 'source') {
+            return this._$textOptions;
+        } else if (viewMode === 'rendered' &&
+                   this.model.get('hasRenderedView')) {
+            return this._$renderedOptions;
+        } else {
+            console.assert(false, 'Unexpected viewMode ' + viewMode);
+            return null;
+        }
+    },
     /**
      * Return the row selector for the given view mode.
      *
@@ -287,9 +375,30 @@ RB.TextBasedReviewableView = RB.FileAttachmentReviewableView.extend({
                 .addClass('active');
 
         this._$textTable.setVisible(viewMode === 'source');
+        this._$textOptions.setVisible(viewMode === 'source');
         this._$renderedTable.setVisible(viewMode === 'rendered');
+        this._$renderedOptions.setVisible(viewMode === 'rendered');
 
         /* Cause all comments to recalculate their sizes. */
         $(window).triggerHandler('resize');
     },
+
+    _onBlueCheckbox() {
+        this.options.color = 'black';
+        if (this._$blueCheckbox.prop("checked")) {
+            this.options.color = 'blue';
+            this.options.align = 'left';
+        }
+        this.reloadContentFromServer('rendered', this.options,
+            this._$renderedTable);
+    },
+
+    _onRedCheckbox() {
+        this.options.color = 'black';
+        if (this._$redCheckbox.prop("checked")) {
+            this.options.color = 'red';
+        }
+        this.reloadContentFromServer('source', this.options,
+            this._$textTable);
+    },
 });
diff --git a/reviewboard/static/rb/js/views/textCommentRowSelector.es6.js b/reviewboard/static/rb/js/views/textCommentRowSelector.es6.js
index eca8deaa855f1f365a0b695dbdd517823ec479c6..20e2789b7f989c7d43d6a3fe7cf05a71d7393127 100644
--- a/reviewboard/static/rb/js/views/textCommentRowSelector.es6.js
+++ b/reviewboard/static/rb/js/views/textCommentRowSelector.es6.js
@@ -60,6 +60,12 @@ RB.TextCommentRowSelector = Backbone.View.extend({
 
         this._$ghostCommentFlag = null;
         this._$ghostCommentFlagCell = null;
+        options.reviewableView.on('contentReloaded', (context) => {
+            if (context.$el === options.el) {
+                this._reset();
+                options.reviewableView.refreshComments();
+            }
+        });
     },
 
     /**
diff --git a/reviewboard/templates/reviews/ui/_text_table.html b/reviewboard/templates/reviews/ui/_text_table.html
index f9ee31d4fe6e24768c779621859c1f0343d4dcfd..94e1baf3f7807b06d4eb663f78b939ddaa6a26e3 100644
--- a/reviewboard/templates/reviews/ui/_text_table.html
+++ b/reviewboard/templates/reviews/ui/_text_table.html
@@ -1,5 +1,4 @@
 {% load difftags djblets_utils i18n %}
-
 {% definevar 'line_fmt' %}
   <tr line="%(linenum_row)s"%(row_class_attr)s>
 {%  if not file.is_new_file %}
diff --git a/reviewboard/templates/reviews/ui/markdown.html b/reviewboard/templates/reviews/ui/markdown.html
new file mode 100644
index 0000000000000000000000000000000000000000..edcdef5829411160ae1309020d8796f6ba23cb74
--- /dev/null
+++ b/reviewboard/templates/reviews/ui/markdown.html
@@ -0,0 +1,18 @@
+{% extends "reviews/ui/text.html" %}
+{% load i18n %}
+
+ {% block render_options %}
+     {{ block.super }}
+
+ {% endblock %}
+ {% block source_options %}
+     <div class="source-view-options">
+    <div class="checkbox-row">
+    <label for="center_text">
+        <input type="checkbox" id="center_text" name="center_text" value="center">
+        {% trans "Center text" %}
+    </label>
+    </div>
+    </div>
+     {{ block.super }}
+ {% endblock %}
\ No newline at end of file
diff --git a/reviewboard/templates/reviews/ui/text.html b/reviewboard/templates/reviews/ui/text.html
index 96ae64ec2e7a09380a7ff8723b8bb336bd5ceab9..db1b0a55910d561b0c9e38c5958eaaa0e8efb161 100644
--- a/reviewboard/templates/reviews/ui/text.html
+++ b/reviewboard/templates/reviews/ui/text.html
@@ -13,6 +13,29 @@
     <div id="attachment_revision_selector"></div>
 {%  endif %}
 
+<div class="render-options">
+{%  if review_ui.can_render_text and not diff_type_mismatch %}
+ {% block render_options %}
+    <div class="rendered-view-options">
+    <div class="checkbox-row">
+        <input type="checkbox" id="turn_blue" name="turn_blue" value="blue">
+        <label for="turn_blue">{% trans "Make the text blue" %}</label>
+    </div>
+    </div>
+ {% endblock %}
+{%  endif %}
+ {% block source_options %}
+    <div class="source-view-options">
+    <div class="checkbox-row">
+    <label for="turn_red">
+        <input type="checkbox" id="turn_red" name="turn_red" value="red">
+        {% trans "Make the text red" %}
+    </label>
+    </div>
+    </div>
+ {% endblock %}
+</div>
+
 {%  if review_ui.can_render_text and not diff_type_mismatch %}
     <div class="text-review-ui-views">
      <ul>
