diff --git a/reviewboard/filemanager/admin.py b/reviewboard/filemanager/admin.py
--- /dev/null
+++ b/reviewboard/filemanager/admin.py
@@ -0,0 +1,33 @@
+from django.contrib import admin
+from django.utils.translation import ugettext_lazy as _
+
+from reviewboard.reviews.forms import DefaultReviewerForm
+from reviewboard.reviews.models import Comment, DefaultReviewer, Group, \
+                                       Review, ReviewRequest, \
+                                       ReviewRequestDraft, Screenshot, \
+                                       ScreenshotComment, UploadedFileComment
+from reviewboard.filemanager.models import UploadedFile 
+
+class UploadedFileAdmin(admin.ModelAdmin):
+    list_display = ('upfile', 'caption', 'review_request_id')
+    list_display_links = ('upfile', 'caption')
+    search_fields = ('caption',)
+
+    def review_request_id(self, obj):
+        return obj.review_request.get().id
+    review_request_id.short_description = _('Review request ID')
+
+
+class UploadedFileCommentAdmin(admin.ModelAdmin):
+    list_display = ('text', 'upfile', 'review_request_id', 'timestamp')
+    list_filter = ('timestamp',)
+    search_fields = ['caption']
+    search_fields = ['upfile']
+    raw_id_fields = ('upfile', 'reply_to')
+
+    def review_request_id(self, obj):
+        return obj.review.get().review_request.id
+    review_request_id.short_description = _('Review request ID')
+
+admin.site.register(UploadedFile, UploadedFileAdmin)
+admin.site.register(UploadedFileComment, UploadedFileCommentAdmin)
diff --git a/reviewboard/filemanager/forms.py b/reviewboard/filemanager/forms.py
--- /dev/null
+++ b/reviewboard/filemanager/forms.py
@@ -0,0 +1,61 @@
+import logging
+import re
+
+from django import forms
+from django.contrib.admin.widgets import FilteredSelectMultiple
+from django.utils.translation import ugettext as _
+from djblets.util.misc import get_object_or_none
+
+from reviewboard.diffviewer import forms as diffviewer_forms
+from reviewboard.diffviewer.models import DiffSet
+from reviewboard.reviews.errors import OwnershipError
+from reviewboard.reviews.models import DefaultReviewer, ReviewRequest, \
+                                       ReviewRequestDraft, UploadedFileComment
+from reviewboard.scmtools.errors import SCMError, ChangeNumberInUseError, \
+                                        InvalidChangeNumberError, \
+                                        ChangeSetError
+from reviewboard.filemanager.models import UploadedFile
+from reviewboard.scmtools.models import Repository
+from reviewboard.site.models import LocalSite
+
+class UploadFileForm(forms.Form):
+    """
+    A form that handles uploading of new files.
+    A file takes a path argument and optionally a caption.
+    """
+    caption = forms.CharField(required=False)
+    path = forms.FileField(required=True)
+
+    def create(self, file, review_request):
+        upFile = UploadedFile(caption=self.cleaned_data['caption'],
+                                draft_caption=self.cleaned_data['caption'])
+        upFile.upfile.save(file.name, file, save=True)
+
+        review_request.files.add(upFile)
+
+        draft = ReviewRequestDraft.create(review_request)
+        draft.files.add(upFile)
+        draft.save()
+
+        return upFile
+
+class CommentFileForm(forms.Form):
+    """
+    A form that handles commenting on a file.
+    """
+    review = forms.CharField(widget=forms.Textarea(attrs={'rows':'8','cols':'70'}))
+
+    def create(self, upfile, review_request):
+
+        comment = UploadedFileComment(text=self.cleaned_data['review'],
+                                upfile=upfile)
+
+        comment.timestamp = datetime.now()
+        comment.save(save=True)
+        review_request.files.add(upFile)
+
+        draft = ReviewRequestDraft.create(review_request)
+        draft.file_comments.add(comment)
+        draft.save()
+
+        return comment
diff --git a/reviewboard/filemanager/models.py b/reviewboard/filemanager/models.py
--- /dev/null
+++ b/reviewboard/filemanager/models.py
@@ -0,0 +1,67 @@
+import os
+import re
+from datetime import datetime
+
+from django.contrib.auth.models import User
+from django.core.urlresolvers import reverse
+from django.db import connection, models, transaction
+from django.db.models import F, Q, permalink
+from django.utils.html import escape
+from django.utils.safestring import mark_safe
+from django.utils.translation import ugettext_lazy as _
+
+from djblets.util.db import ConcurrencyManager
+from djblets.util.decorators import root_url
+from djblets.util.fields import CounterField, ModificationTimestampField
+from djblets.util.misc import get_object_or_none
+from djblets.util.templatetags.djblets_images import crop_image, thumbnail
+
+from reviewboard.changedescs.models import ChangeDescription
+from reviewboard.diffviewer.models import DiffSet, DiffSetHistory, FileDiff
+from reviewboard.reviews.signals import review_request_published, \
+                                        reply_published, review_published
+from reviewboard.reviews.errors import PermissionError
+from reviewboard.scmtools.errors import EmptyChangeSetError, \
+                                        InvalidChangeNumberError
+from reviewboard.scmtools.models import Repository
+from reviewboard.site.models import LocalSite
+
+class UploadedFile(models.Model):
+    """
+    A file associated with a review request.
+
+    Like diffs, a screenshot can have comments associated with it.
+    These comments are of type :model:`reviews.FileComment`.
+    """
+    caption = models.CharField(_("caption"), max_length=256, blank=True)
+    draft_caption = models.CharField(_("draft caption"),
+                                     max_length=256, blank=True)
+    upfile = models.FileField(_("file"),
+                              upload_to=os.path.join('uploaded', 'files',
+                                                     '%Y', '%m', '%d'))
+
+    def get_path(self):
+        """
+        Returns the file path for downloading purposes.
+        """
+        return "%s" % (self.upfile.url)
+
+    def get_title(self):
+        """
+        Returns the file title for display purposes
+        """
+        title = self.upfile.name
+        title = title.split('/')[-1]
+        return "%s" % (title)
+
+    def __unicode__(self):
+        return u"%s" % (self.caption)
+
+    def get_absolute_url(self):
+        try:
+            review = self.review_request.all()[0]
+        except IndexError:
+            review = self.inactive_review_request.all()[0]
+
+        return '%sf/%d/' % (review.get_absolute_url(), self.id)
+
diff --git a/reviewboard/filemanager/views.py b/reviewboard/filemanager/views.py
--- /dev/null
+++ b/reviewboard/filemanager/views.py
@@ -0,0 +1,74 @@
+import logging
+import time
+from datetime import datetime
+
+from django.conf import settings
+from django.contrib.auth.models import User
+from django.contrib.sites.models import Site
+from django.core.urlresolvers import reverse
+from django.db.models import Q
+from django.http import HttpResponse, HttpResponseRedirect, Http404, \
+                        HttpResponseNotModified, HttpResponseServerError, \
+                        HttpResponseForbidden
+from django.shortcuts import get_object_or_404, get_list_or_404, \
+                             render_to_response
+from django.template.context import RequestContext
+from django.template.loader import render_to_string
+from django.utils import simplejson
+from django.utils.http import http_date
+from django.utils.safestring import mark_safe
+from django.utils.translation import ugettext as _
+from django.views.decorators.cache import cache_control
+from django.views.generic.list_detail import object_list
+
+from djblets.auth.util import login_required
+from djblets.siteconfig.models import SiteConfiguration
+from djblets.util.dates import get_latest_timestamp
+from djblets.util.http import set_last_modified, get_modified_since, \
+                              set_etag, etag_if_none_match
+from djblets.util.misc import get_object_or_none
+
+from reviewboard.accounts.decorators import check_login_required, \
+                                            valid_prefs_required
+from reviewboard.accounts.models import ReviewRequestVisit
+from reviewboard.changedescs.models import ChangeDescription
+from reviewboard.diffviewer.diffutils import get_file_chunks_in_range
+from reviewboard.diffviewer.models import DiffSet
+from reviewboard.diffviewer.views import view_diff, view_diff_fragment, \
+                                         exception_traceback_string
+from reviewboard.filemanager.models import UploadedFile
+from reviewboard.reviews.errors import OwnershipError
+from reviewboard.filemanager.forms import CommentFileForm
+from reviewboard.reviews.views import find_review_request
+from reviewboard.reviews.models import Comment, ReviewRequest, \
+                                       ReviewRequestDraft, Review, Group, \
+                                       UploadedFileComment
+from reviewboard.scmtools.core import PRE_CREATION
+from reviewboard.scmtools.errors import SCMError
+from reviewboard.site.models import LocalSite
+
+@login_required
+def delete_file(request,
+                      review_request_id,
+                      file_id,
+                      local_site_name=None):
+    """
+    Deletes a file from a review request and redirects back to the
+    review request page.
+    """
+    review_request, response = \
+        find_review_request(request, review_request_id, local_site_name)
+
+    if not review_request:
+        return response
+
+    s = UploadedFile.objects.get(id=file_id)
+
+    draft = ReviewRequestDraft.create(review_request)
+    draft.files.remove(s)
+    draft.inactive_files.add(s)
+    draft.save()
+
+    return HttpResponseRedirect(review_request.get_absolute_url())
+
+
diff --git a/reviewboard/htdocs/media/rb/css/reviews.css b/reviewboard/htdocs/media/rb/css/reviews.css
--- a/reviewboard/htdocs/media/rb/css/reviews.css
+++ b/reviewboard/htdocs/media/rb/css/reviews.css
@@ -765,6 +765,24 @@
   text-align: center;
 }
 
+/****************************************************************************
+ * List of Attached Files
+ ****************************************************************************/
+#file-list {
+  margin: 2px;
+  padding: 0.5em;
+}
+
+#file-list.dragover {
+  border: 2px green dashed;
+  display: block;
+  margin: 0;
+}
+
+#file-list h1.drop-indicator {
+  clear: both;
+  text-align: center;
+}
 
 /****************************************************************************
  * Comment detail dialog
diff --git a/reviewboard/htdocs/media/rb/js/datastore.js b/reviewboard/htdocs/media/rb/js/datastore.js
--- a/reviewboard/htdocs/media/rb/js/datastore.js
+++ b/reviewboard/htdocs/media/rb/js/datastore.js
@@ -1,18 +1,29 @@
 RB = {};
 
-RB.DiffComment = function(filediff, interfilediff, beginLineNum, endLineNum,
-                          textOnServer) {
+RB.DiffComment = function(review, id, filediff, interfilediff, beginLineNum,
+                          endLineNum) {
+    this.id = id;
+    this.review = review;
     this.filediff = filediff;
     this.interfilediff = interfilediff;
     this.beginLineNum = beginLineNum;
     this.endLineNum = endLineNum;
-    this.text = textOnServer || "";
-    this.saved = (textOnServer != undefined);
+    this.text = "";
+    this.loaded = false;
+    this.url = null;
 
     return this;
 }
 
 $.extend(RB.DiffComment.prototype, {
+    ready: function(on_ready) {
+        if (this.loaded) {
+            on_ready.apply(this, arguments);
+        } else {
+            this._load(on_ready);
+        }
+    },
+
     /*
      * Sets the current text in the comment block.
      *
@@ -39,21 +50,43 @@ $.extend(RB.DiffComment.prototype, {
         var self = this;
         options = options || {};
 
-        rbApiCall({
-            path: this._getURL(),
-            data: {
-                action: "set",
-                num_lines: this.getNumLines(),
-                text: this.text
-            },
-            success: function() {
-                self.saved = true;
-                $.event.trigger("saved", null, self);
+        self.ready(function() {
+            self.review.ensureCreated(function() {
+                var type;
+                var url;
+                var data = {
+                    text: self.text,
+                    first_line: self.beginLineNum,
+                    num_lines: self.getNumLines()
+                };
 
-                if ($.isFunction(options.success)) {
-                    options.success();
+                if (self.loaded) {
+                    type = "PUT";
+                    url = self.url;
+                } else {
+                    data.filediff_id = self.filediff.id;
+                    url = self.review.links.diff_comments.href;
+
+                    if (self.interfilediff) {
+                        data.interfilediff_id = self.interfilediff_id;
+                    }
                 }
-            }
+
+                rbApiCall({
+                    type: type,
+                    url: url,
+                    data: data,
+                    success: function(rsp) {
+                        self._loadDataFromResponse(rsp);
+
+                        $.event.trigger("saved", null, self);
+
+                        if ($.isFunction(options.success)) {
+                            options.success();
+                        }
+                    }
+                });
+            });
         });
     },
 
@@ -63,24 +96,165 @@ $.extend(RB.DiffComment.prototype, {
     deleteComment: function() {
         var self = this;
 
-        if (this.saved) {
+        self.ready(function() {
+            if (self.loaded) {
+                rbApiCall({
+                    type: "DELETE",
+                    url: self.url,
+                    success: function() {
+                        $.event.trigger("deleted", null, self);
+                        self._deleteAndDestruct();
+                    }
+                });
+            } else {
+                self._deleteAndDestruct();
+            }
+        });
+    },
+
+    deleteIfEmpty: function() {
+        if (this.text == "") {
+            this.deleteComment();
+        }
+    },
+
+    _deleteAndDestruct: function() {
+        $.event.trigger("destroyed", null, this);
+    },
+
+    _load: function(on_done) {
+        var self = this;
+
+        if (!self.id) {
+            on_done.apply(this, arguments);
+            return;
+        }
+
+        self.review.ready(function() {
+            if (!self.review.loaded) {
+                on_done.apply(this, arguments);
+                return;
+            }
+
             rbApiCall({
-                path: this._getURL(),
-                data: {
-                    action: "delete",
-                    num_lines: this.getNumLines()
+                type: "GET",
+                url: self.review.links.diff_comments.href + self.id + "/",
+                success: function(rsp, status) {
+                    if (status != 404) {
+                        self._loadDataFromResponse(rsp);
+                    }
+
+                    on_done.apply(this, arguments);
                 },
-                success: function() {
-                    self.saved = false;
-                    $.event.trigger("deleted", null, self);
-                    self._deleteAndDestruct();
-                }
             });
+        });
+    },
+
+    _loadDataFromResponse: function(rsp) {
+        this.id = rsp.diff_comment.id;
+        this.text = rsp.diff_comment.text;
+        this.beginLineNum = rsp.diff_comment.first_line;
+        this.endLineNum = rsp.diff_comment.num_lines + this.beginLineNum;
+        this.links = rsp.diff_comment.links;
+        this.url = rsp.diff_comment.links.self.href;
+        this.loaded = true;
+    }
+});
+
+
+RB.DiffCommentReply = function(reply, id, reply_to_id) {
+    this.id = id;
+    this.reply = reply;
+    this.text = "";
+    this.reply_to_id = reply_to_id;
+    this.loaded = false;
+    this.url = null;
+
+    return this;
+}
+
+$.extend(RB.DiffCommentReply.prototype, {
+    ready: function(on_ready) {
+        if (this.loaded) {
+            on_ready.apply(this, arguments);
         } else {
-            this._deleteAndDestruct();
+            this._load(on_ready);
         }
     },
 
+    /*
+     * Sets the current text in the comment block.
+     *
+     * @param {string} text  The new text to set.
+     */
+    setText: function(text) {
+        this.text = text;
+        $.event.trigger("textChanged", null, this);
+    },
+
+    /*
+     * Saves the comment on the server.
+     */
+    save: function(options) {
+        var self = this;
+        options = options || {};
+
+        self.ready(function() {
+            self.reply.ensureCreated(function() {
+                var type;
+                var url;
+                var data = {
+                    text: self.text
+                };
+
+                if (self.loaded) {
+                    type = "PUT";
+                    url = self.url;
+                } else {
+                    data.reply_to_id = self.reply_to_id;
+                    url = self.reply.links.diff_comments.href;
+                }
+
+                rbApiCall({
+                    type: type,
+                    url: url,
+                    data: data,
+                    success: function(rsp) {
+                        self._loadDataFromResponse(rsp);
+
+                        $.event.trigger("saved", null, self);
+
+                        if ($.isFunction(options.success)) {
+                            options.success();
+                        }
+                    }
+                });
+            });
+        });
+    },
+
+    /*
+     * Deletes the comment from the server.
+     */
+    deleteComment: function() {
+        var self = this;
+
+        self.ready(function() {
+            if (self.loaded) {
+                rbApiCall({
+                    type: "DELETE",
+                    url: self.url,
+                    success: function() {
+                        $.event.trigger("deleted", null, self);
+                        self._deleteAndDestruct();
+                    }
+                });
+            } else {
+                self._deleteAndDestruct();
+            }
+        });
+    },
+
     deleteIfEmpty: function() {
         if (this.text != "") {
             return;
@@ -93,32 +267,40 @@ $.extend(RB.DiffComment.prototype, {
         $.event.trigger("destroyed", null, this);
     },
 
-    /*
-     * Returns the URL used for API calls.
-     *
-     * @return {string} The URL used for API calls for this comment block.
-     */
-    _getURL: function() {
-        var interfilediff_revision = null;
-        var interfilediff_id = null;
-
-        if (this.interfilediff != null) {
-            interfilediff_revision = this.interfilediff['revision'];
-            interfilediff_id = this.interfilediff['id'];
-        }
-
-        var filediff_revision = this.filediff['revision'];
-        var filediff_id = this.filediff['id'];
-
-        return "/reviewrequests/" + gReviewRequestId + "/diff/" +
-               (interfilediff_revision == null
-                    ? filediff_revision
-                    : filediff_revision + "-" + interfilediff_revision) +
-               "/file/" +
-               (interfilediff_id == null
-                    ? filediff_id
-                    : filediff_id + "-" + interfilediff_id) +
-               "/line/" + this.beginLineNum + "/comments/";
+    _load: function(on_done) {
+        var self = this;
+
+        if (!self.id) {
+            on_done.apply(this, arguments);
+            return;
+        }
+
+        self.reply.ready(function() {
+            if (!self.reply.loaded) {
+                on_done.apply(this, arguments);
+                return;
+            }
+
+            rbApiCall({
+                type: "GET",
+                url: self.reply.links.diff_comments.href + self.id + "/",
+                success: function(rsp, status) {
+                    if (status != 404) {
+                        self._loadDataFromResponse(rsp);
+                    }
+
+                    on_done.apply(this, arguments);
+                },
+            });
+        });
+    },
+
+    _loadDataFromResponse: function(rsp) {
+        this.id = rsp.diff_comment.id;
+        this.text = rsp.diff_comment.text;
+        this.links = rsp.diff_comment.links;
+        this.url = rsp.diff_comment.links.self.href;
+        this.loaded = true;
     }
 });
 
@@ -185,34 +367,38 @@ $.extend(RB.Diff.prototype, {
     },
 
     save: function(options) {
+        var self = this;
+
         options = $.extend(true, {
             success: function() {},
             error: function() {}
         }, options);
 
-        if (this.id != undefined) {
-            options.error("The diff " + this.id + " was already created. " +
+        if (self.id != undefined) {
+            options.error("The diff " + self.id + " was already created. " +
                           "This is a script error. Please report it.");
             return;
         }
 
-        if (!this.form) {
+        if (!self.form) {
             options.error("No data has been set for this diff. This " +
                           "is a script error. Please report it.");
             return;
         }
 
-        rbApiCall({
-            path: '/reviewrequests/' + this.review_request.id + '/diff/new/',
-            form: this.form,
-            buttons: options.buttons,
-            success: function(rsp) {
-                if (rsp.stat == "ok") {
-                    options.success(rsp);
-                } else {
-                    options.error(rsp, rsp.err.msg);
+        self.review_request.ready(function() {
+            rbApiCall({
+                url: self.review_request.links.diffs.href,
+                form: self.form,
+                buttons: options.buttons,
+                success: function(rsp) {
+                    if (rsp.stat == "ok") {
+                        options.success(rsp);
+                    } else {
+                        options.error(rsp, rsp.err.msg);
+                    }
                 }
-            }
+            });
         });
     }
 });
@@ -223,6 +409,8 @@ RB.ReviewRequest = function(id, path) {
     this.path = path;
     this.reviews = {};
     this.draft_review = null;
+    this.links = {};
+    this.loaded = false;
 
     return this;
 }
@@ -254,63 +442,143 @@ $.extend(RB.ReviewRequest.prototype, {
         return this.reviews[review_id];
     },
 
-    createScreenshot: function() {
-        return new RB.Screenshot(this);
+    createScreenshot: function(screenshot_id) {
+        return new RB.Screenshot(this, screenshot_id);
+    },
+
+    createUploadedFile: function() {
+        return new RB.UploadedFile(this);
+    },
+
+    createFileComment: function(file_id) {
+        return new RB.FileComment(this, file_id);
+    },
+
+    /*
+     * Ensures that the review request's state is loaded.
+     *
+     * If it's not loaded, then a request will be made to load the state
+     * before the callback is called.
+     */
+    ready: function(on_ready) {
+        if (this.loaded) {
+            on_ready.apply(this, arguments);
+        } else {
+            var self = this;
+
+            this._apiCall({
+                type: "GET",
+                path: "/",
+                success: function(rsp) {
+                    self.loaded = true;
+                    self.links = rsp.review_request.links;
+                    on_ready.apply(this, arguments);
+                }
+            });
+        }
     },
 
     setDraftField: function(options) {
+        data = {};
+        data[options.field] = options.value;
+
+        if (options.field == "target_people" ||
+            options.field == "target_groups") {
+            data.expand = options.field;
+        }
+
         this._apiCall({
-            path: "/draft/set/" + options.field + "/",
+            type: "PUT",
+            path: "/draft/",
             buttons: options.buttons,
-            data: { value: options.value },
+            data: data,
             success: options.success // XXX
         });
     },
 
     setStarred: function(starred) {
-        this._apiCall({
-            path: (starred ? "/star/" : "/unstar/"),
+        var apiType;
+        var path = "/users/" + gUserName + "/watched/review-requests/";
+        var data = {};
+
+        if (starred) {
+            apiType = "POST";
+            data['object_id'] = this.id;
+        } else {
+            apiType = "DELETE";
+            path += this.id + "/";
+        }
+
+        rbApiCall({
+            type: apiType,
+            path: path,
+            data: data,
             success: function() {}
         });
     },
 
     publish: function(options) {
+        var self = this;
+
         options = $.extend(true, {}, options);
 
-        this._apiCall({
-            path: "/publish/",
-            buttons: options.buttons
+        self.ready(function() {
+            self._apiCall({
+                type: "PUT",
+                url: self.links.draft.href,
+                data: {
+                    public: 1
+                },
+                buttons: options.buttons
+            });
         });
     },
 
     discardDraft: function(options) {
-        options = $.extend(true, {}, options);
+        var self = this;
 
-        this._apiCall({
-            path: "/draft/discard/",
-            buttons: options.buttons
+        self.ready(function() {
+            self._apiCall({
+                type: "DELETE",
+                url: self.links.draft.href,
+                buttons: options.buttons
+            });
         });
     },
 
     close: function(options) {
+        var self = this;
+        var statusType;
+
         if (options.type == RB.ReviewRequest.CLOSE_DISCARDED) {
-            this._apiCall({
-                path: "/close/discarded/",
-                buttons: options.buttons
-            });
+            statusType = "discarded";
         } else if (options.type == RB.ReviewRequest.CLOSE_SUBMITTED) {
-            this._apiCall({
-                path: "/close/submitted/",
+            statusType = "submitted";
+        } else {
+            return;
+        }
+
+        self.ready(function() {
+            self._apiCall({
+                type: "PUT",
+                path: "/",
+                data: {
+                    status: statusType
+                },
                 buttons: options.buttons
             });
-        }
+        });
     },
 
     reopen: function(options) {
         options = $.extend(true, {}, options);
 
         this._apiCall({
-            path: "/reopen/",
+            type: "PUT",
+            path: "/",
+            data: {
+                status: "pending"
+            },
             buttons: options.buttons
         });
     },
@@ -319,7 +587,8 @@ $.extend(RB.ReviewRequest.prototype, {
         options = $.extend(true, {}, options);
 
         this._apiCall({
-            path: "/delete/",
+            type: "DELETE",
+            path: "/",
             buttons: options.buttons,
             success: options.success
         });
@@ -338,29 +607,33 @@ $.extend(RB.ReviewRequest.prototype, {
     _checkForUpdates: function() {
         var self = this;
 
-        this._apiCall({
-            type: "GET",
-            noActivityIndicator: true,
-            path: "/last-update/",
-            success: function(rsp) {
-                if ((self.checkUpdatesType == undefined ||
-                     self.checkUpdatesType == rsp.type) &&
-                    self.lastUpdateTimestamp != rsp.timestamp) {
-                    $.event.trigger("updated", [rsp], self);
-                }
+        self.ready(function() {
+            self._apiCall({
+                type: "GET",
+                noActivityIndicator: true,
+                url: self.links.last_update.href,
+                success: function(rsp) {
+                    var last_update = rsp.last_update;
+
+                    if ((self.checkUpdatesType == undefined ||
+                         self.checkUpdatesType == last_update.type) &&
+                        self.lastUpdateTimestamp != last_update.timestamp) {
+                        $.event.trigger("updated", [last_update], self);
+                    }
 
-                self.lastUpdateTimestamp = rsp.timestamp;
+                    self.lastUpdateTimestamp = last_update.timestamp;
 
-                setTimeout(function() { self._checkForUpdates(); },
-                           RB.ReviewRequest.CHECK_UPDATES_MSECS);
-            }
+                    setTimeout(function() { self._checkForUpdates(); },
+                               RB.ReviewRequest.CHECK_UPDATES_MSECS);
+                }
+            });
         });
     },
 
     _apiCall: function(options) {
         var self = this;
 
-        options.path = "/reviewrequests/" + this.id + options.path;
+        options.path = "/review-requests/" + this.id + options.path;
 
         if (!options.success) {
             options.success = function() { window.location = self.path; };
@@ -375,14 +648,31 @@ RB.Review = function(review_request, id) {
     this.id = id;
     this.review_request = review_request;
     this.draft_reply = null;
-    this.shipit = false;
-    this.body_top = "";
-    this.body_bottom = "";
+    this.ship_it = null;
+    this.body_top = null;
+    this.body_bottom = null;
+    this.url = null;
+    this.loaded = false;
 
     return this;
 }
 
 $.extend(RB.Review.prototype, {
+    createDiffComment: function(id, filediff, interfilediff, beginLineNum,
+                                endLineNum) {
+        return new RB.DiffComment(this, id, filediff, interfilediff,
+                                  beginLineNum, endLineNum);
+    },
+
+    createScreenshotComment: function(id, screenshot_id, x, y, width, height) {
+        return new RB.ScreenshotComment(this, id, screenshot_id, x, y,
+                                        width, height);
+    },
+
+    createFileComment: function(id, file_id) {
+        return new RB.FileComment(this, id, file_id);
+    },
+
     createReply: function() {
         if (this.draft_reply == null) {
             this.draft_reply = new RB.ReviewReply(this);
@@ -391,53 +681,150 @@ $.extend(RB.Review.prototype, {
         return this.draft_reply;
     },
 
+    ready: function(on_done) {
+        if (this.loaded) {
+            on_done.apply(this, arguments);
+        } else {
+            this._load(on_done);
+        }
+    },
+
+    ensureCreated: function(on_done) {
+        var self = this;
+
+        self.ready(function() {
+            if (self.loaded) {
+                on_done.apply(this, arguments);
+            } else {
+                /* The review doesn't exist. Create it. */
+                self.save({
+                    success: function(rsp) {
+                        self.id = rsp.review.id;
+                        self.loaded = true;
+                        on_done.apply(this, arguments);
+                    }
+                });
+            }
+        });
+    },
+
     save: function(options) {
-        this._apiCall({
-            path: "save/",
-            data: {
-                shipit: this.shipit,
-                body_top: this.body_top,
-                body_bottom: this.body_bottom
-            },
-            buttons: options.buttons,
-            success: options.success
+        var data = {};
+
+        if (this.ship_it != null) {
+            data.ship_it = (this.ship_it ? 1 : 0);
+        }
+
+        if (this.body_top != null) {
+            data.body_top = this.body_top;
+        }
+
+        if (this.body_bottom != null) {
+            data.body_bottom = this.body_bottom;
+        }
+
+        if (options.public) {
+            data.public = 1;
+        }
+
+        var self = this;
+
+        this.ready(function() {
+            var type;
+            var url;
+
+            if (self.loaded) {
+                type = "PUT";
+                url = self.url;
+            } else {
+                type = "POST";
+                url = self.review_request.links.reviews.href;
+            }
+
+            self._apiCall({
+                type: type,
+                url: url,
+                data: data,
+                buttons: options.buttons,
+                success: function(rsp) {
+                    self._loadDataFromResponse(rsp);
+
+                    if ($.isFunction(options.success)) {
+                        options.success(rsp);
+                    }
+                }
+            });
         });
     },
 
     publish: function(options) {
-        this._apiCall({
-            path: "publish/",
-            data: {
-                shipit: this.shipit,
-                body_top: this.body_top,
-                body_bottom: this.body_bottom
-            },
-            buttons: options.buttons,
-            success: options.success
-        });
+        this.save($.extend(true, {
+            public: true,
+        }, options));
     },
 
     deleteReview: function(options) {
-        this._apiCall({
-            path: "delete/",
-            buttons: options.buttons,
-            success: options.success
+        var self = this;
+
+        self.ready(function() {
+            if (self.loaded) {
+                self._apiCall({
+                    type: "DELETE",
+                    buttons: options.buttons,
+                    success: options.success
+                });
+            } else if ($.isFunction(options.success)) {
+                options.success();
+            }
         });
     },
 
+    _load: function(on_done) {
+        var self = this;
+
+        self.review_request.ready(function() {
+            rbApiCall({
+                type: "GET",
+                url: self.review_request.links.reviews.href +
+                     (self.id || "draft") + "/",
+                success: function(rsp, status) {
+                    if (status != 404) {
+                        self._loadDataFromResponse(rsp);
+                    }
+
+                    on_done.apply(this, arguments);
+                }
+            });
+        });
+    },
+
+    _loadDataFromResponse: function(rsp) {
+        this.id = rsp.review.id;
+        this.ship_it = rsp.review.ship_it;
+        this.body_top = rsp.review.body_top;
+        this.body_bottom = rsp.review.body_bottom;
+        this.links = rsp.review.links;
+        this.url = rsp.review.links.self.href;
+        this.loaded = true;
+    },
+
     _apiCall: function(options) {
         var self = this;
 
-        options.path = "/reviewrequests/" + this.review_request.id +
-                       "/reviews/draft/" + options.path;
+        self.review_request.ready(function() {
+            if (!options.url) {
+                options.url = self.review_request.links.reviews.href +
+                              self.id + "/" + (options.path || "");
+            }
 
-        if (!options.success) {
-            options.success = function() {
-                window.location = self.review_request.path;
-            };
-        }
+            if (!options.success) {
+                options.success = function() {
+                    window.location = self.review_request.path;
+                };
+            }
 
-        rbApiCall(options);
+            rbApiCall(options);
+        });
     }
 });
 
@@ -450,59 +837,832 @@ RB.ReviewGroup = function(id) {
 
 $.extend(RB.ReviewGroup.prototype, {
     setStarred: function(starred) {
+        var apiType;
+        var path = "/users/" + gUserName + "/watched/review-groups/";
+        var data = {};
+
+        if (starred) {
+            apiType = "POST";
+            data['object_id'] = this.id;
+        } else {
+            apiType = "DELETE";
+            path += this.id + "/";
+        }
+
         rbApiCall({
-            path: "/groups/" + this.id + (starred ? "/star/" : "/unstar/"),
+            type: apiType,
+            path: path,
+            data: data,
             success: function() {}
         });
     }
 });
 
 
-RB.ReviewReply = function(review) {
+RB.ReviewReply = function(review, id) {
     this.review = review;
+    this.id = id;
+    this.body_top = null;
+    this.body_bottom = null;
+    this.url = null;
+    this.loaded = false;
 
     return this;
 }
 
 $.extend(RB.ReviewReply.prototype, {
-    addComment: function(options) {
-        rbApiCall({
-            path: "/reviewrequests/" + this.review.review_request.id +
-                  "/reviews/" + this.review.id + "/replies/draft/",
-            data: {
-                value:     options.text,
-                id:        options.context_id,
-                type:      options.context_type,
-                review_id: this.review.id
-            },
-            buttons: options.buttons,
-            success: options.success
+    ready: function(on_done) {
+        if (this.loaded) {
+            on_done.apply(this, arguments);
+        } else {
+            this._load(on_done);
+        }
+    },
+
+    ensureCreated: function(on_done) {
+        var self = this;
+
+        self.ready(function() {
+            if (self.loaded) {
+                on_done.apply(this, arguments);
+            } else {
+                /* The review doesn't exist. Create it. */
+                self.save({
+                    success: function(rsp) {
+                        self._loadDataFromResponse(rsp);
+                        on_done.apply(this, arguments);
+                    }
+                });
+            }
+        });
+    },
+
+    save: function(options) {
+        var data = {};
+
+        if (this.body_top != null) {
+            data.body_top = this.body_top;
+        }
+
+        if (this.body_bottom != null) {
+            data.body_bottom = this.body_bottom;
+        }
+
+        if (options.public) {
+            data.public = 1;
+        }
+
+        var self = this;
+
+        this.ready(function() {
+            var type;
+            var url;
+
+            if (self.loaded) {
+                type = "PUT";
+                url = self.url;
+            } else {
+                type = "POST";
+                url = self.review.links.replies.href;
+            }
+
+            rbApiCall({
+                type: type,
+                url: url,
+                data: data,
+                buttons: options.buttons,
+                success: function(rsp) {
+                    self._loadDataFromResponse(rsp);
+
+                    if ($.isFunction(options.success)) {
+                        options.success(rsp);
+                    }
+                }
+            });
         });
     },
 
     publish: function(options) {
-        rbApiCall({
-            path: '/reviewrequests/' + this.review.review_request.id +
-                  '/reviews/' + this.review.id + '/replies/draft/save/',
-            buttons: options.buttons,
+        this.save($.extend(true, {
+            public: true,
             errorText: "Saving the reply draft has " +
                        "failed due to a server error:",
-            success: options.success
-        });
+        }, options));
     },
 
     discard: function(options) {
-        rbApiCall({
-            path: '/reviewrequests/' + this.review.review_request.id +
-                  '/reviews/' + this.review.id + '/replies/draft/discard/',
+        var self = this;
+
+        self.ready(function() {
+            if (self.loaded) {
+                rbApiCall($.extend(true, options, {
+                    url: self.url,
+                    type: "DELETE",
+                    errorText: "Discarding the reply draft " +
+                               "has failed due to a server error:",
+                }));
+            } else if ($.isFunction(options.success)) {
+                options.success();
+            }
+        });
+    },
+
+    discardIfEmpty: function(options) {
+        var self = this;
+
+        self.ready(function() {
+            if (self.body_top || self.body_bottom) {
+                return;
+            }
+
+            /* We can only discard if there are on comments of any kind. */
+            rbApiCall({
+                type: "GET",
+                url: self.links.diff_comments.href,
+                success: function(rsp, status) {
+                    if (rsp.diff_comments.length == 0) {
+                        rbApiCall({
+                            type: "GET",
+                            url: self.links.screenshot_comments.href,
+                            success: function(rsp, status) {
+                                if (rsp.screenshot_comments.length == 0) {
+                                        rbApiCall({
+                                            type: "GET",
+                                            url: self.links.file_comments.href,
+                                            success: function(rsp, status) {
+                                                if (rsp.file_comments.length == 0) {
+                                                     self.discard(options);
+                                                }
+                                            }
+                                        });
+                                }
+                            }
+                        });
+                    }
+                }
+            });
+        });
+    },
+
+    _load: function(on_done) {
+        var self = this;
+
+        self.review.ready(function() {
+            rbApiCall({
+                type: "GET",
+                url: self.review.links.replies.href +
+                     (self.id ? self.id : "draft") + "/",
+                success: function(rsp, status) {
+                    if (status != 404) {
+                        self._loadDataFromResponse(rsp);
+                    }
+
+                    on_done.apply(this, arguments);
+                },
+            });
+        });
+    },
+
+    _loadDataFromResponse: function(rsp) {
+        this.id = rsp.reply.id;
+        this.body_top = rsp.reply.body_top;
+        this.body_bottom = rsp.reply.body_bottom;
+        this.links = rsp.reply.links;
+        this.url = rsp.reply.links.self.href;
+        this.loaded = true;
+    }
+});
+
+RB.UploadedFile = function(review_request, id) {
+    this.review_request = review_request;
+    this.id = id;
+    this.caption = null;
+    this.thumbnail_url = null;
+    this.path = null;
+    this.url = null;
+    this.loaded = false;
+
+    return this;
+}
+
+$.extend(RB.UploadedFile.prototype, {
+    setFile: function(file) {
+        this.uploaded_file = file;
+    },
+
+    setForm: function(form) {
+        this.form = form;
+    },
+
+    ready: function(on_done) {
+        if (this.loaded && this.id) {
+            on_done.apply(this, arguments);
+        } else {
+            this._load(on_done);
+        }
+    },
+
+    save: function(options) {
+        options = $.extend(true, {
+            success: function() {},
+            error: function() {}
+        }, options);
+
+        if (this.id) {
+            var data = {};
+
+            if (this.caption != null) {
+                data.caption = this.caption;
+            }
+
+            var self = this;
+            this.ready(function() {
+                rbApiCall({
+                    type: "PUT",
+                    url: self.url,
+                    data: data,
+                    buttons: options.buttons,
+                    success: function(rsp) {
+                        self._loadDataFromResponse(rsp);
+
+                        if ($.isFunction(options.success)) {
+                            options.success(rsp);
+                        }
+                    }
+                });
+            });
+        } else {
+            if (this.form) {
+                this._saveForm(options);
+            } else if (this.file) {
+                this._saveFile(options);
+            } else {
+                options.error("No data has been set for this screenshot. " +
+                              "This is a script error. Please report it.");
+            }
+        }
+    },
+
+    deleteScreenshot: function() {
+        var self = this;
+
+        self.ready(function() {
+            if (self.loaded) {
+                rbApiCall({
+                    type: "DELETE",
+                    url: self.url,
+                    success: function() {
+                        $.event.trigger("deleted", null, self);
+                        self._deleteAndDestruct();
+                    }
+                });
+            }
+        });
+    },
+
+    _load: function(on_done) {
+        if (!this.id) {
+            on_done.apply(this, arguments);
+            return;
+        }
+
+        var self = this;
+
+        self.review_request.ready(function() {
+            rbApiCall({
+                type: "GET",
+                url: self.review_request.links.screenshots.href + self.id + "/",
+                success: function(rsp, status) {
+                    if (status != 404) {
+                        self._loadDataFromResponse(rsp);
+                    }
+
+                    on_done.apply(this, arguments);
+                }
+            });
+        });
+    },
+
+    _loadDataFromResponse: function(rsp) {
+        this.id = rsp.screenshot.id;
+        this.caption = rsp.screenshot.caption;
+        this.thumbnail_url = rsp.screenshot.thumbnail_url;
+        this.path = rsp.screenshot.path;
+        this.url = rsp.screenshot.links.self.href;
+        this.loaded = true;
+    },
+
+    _saveForm: function(options) {
+        this._saveApiCall(options.success, options.error, {
             buttons: options.buttons,
-            errorText: "Discarding the reply draft " +
-                       "has failed due to a server error:",
-            success: options.success
+            form: this.form
         });
+    },
+
+    _saveFile: function(options) {
+        var boundary = "-----multipartformboundary" + new Date().getTime();
+        var blob = "";
+        blob += "--" + boundary + "\r\n";
+        blob += 'Content-Disposition: form-data; name="path"; ' +
+                           'filename="' + this.file.name + '"\r\n';
+        blob += 'Content-Type: application/octet-stream\r\n';
+        blob += '\r\n';
+        blob += this.file.getAsBinary();
+        blob += '\r\n';
+        blob += "--" + boundary + "--\r\n";
+        blob += '\r\n';
+
+        this._saveApiCall(options.success, options.error, {
+            buttons: options.buttons,
+            data: blob,
+            processData: false,
+            contentType: "multipart/form-data; boundary=" + boundary,
+            xhr: function() {
+                var xhr = $.ajaxSettings.xhr()
+                xhr.send = function(data) {
+                    xhr.sendAsBinary(blob);
+                };
+
+                return xhr;
+            }
+        });
+    },
+
+    _saveApiCall: function(onSuccess, onError, options) {
+        var self = this;
+
+        self.review_request.ready(function() {
+            rbApiCall($.extend(options, {
+                url: self.review_request.links.uploaded_files.href,
+                success: function(rsp) {
+                    if (rsp.stat == "ok") {
+                        self._loadDataFromResponse(rsp);
+
+                        if ($.isFunction(onSuccess)) {
+                            onSuccess(rsp, rsp.uploaded_file);
+                        }
+                    } else if ($.isFunction(onError)) {
+                        onError(rsp, rsp.err.msg);
+                    }
+                }
+            }));
+        });
+    },
+
+    _deleteAndDestruct: function() {
+        $.event.trigger("destroyed", null, this);
+    }
+});
+
+RB.ScreenshotComment = function(review, id, screenshot_id, x, y, width,
+                                height) {
+    this.id = id;
+    this.review = review;
+    this.screenshot_id = screenshot_id;
+    this.x = x;
+    this.y = y;
+    this.width = width;
+    this.height = height;
+    this.text = "";
+    this.loaded = false;
+    this.url = null;
+
+    return this;
+}
+
+$.extend(RB.ScreenshotComment.prototype, {
+    ready: function(on_ready) {
+        if (this.loaded) {
+            on_ready();
+        } else {
+            this._load(on_ready);
+        }
+    },
+
+    /*
+     * Sets the current text in the comment block.
+     *
+     * @param {string} text  The new text to set.
+     */
+    setText: function(text) {
+        this.text = text;
+        $.event.trigger("textChanged", null, this);
+    },
+
+    /*
+     * Saves the comment on the server.
+     */
+    save: function(options) {
+        var self = this;
+
+        options = $.extend({
+            success: function() {}
+        }, options);
+
+        self.ready(function() {
+            self.review.ensureCreated(function() {
+                var type;
+                var url;
+                var data = {
+                    text: self.text,
+                    x: self.x,
+                    y: self.y,
+                    w: self.width,
+                    h: self.height
+                };
+
+                if (self.loaded) {
+                    type = "PUT";
+                    url = self.url;
+                } else {
+                    data.screenshot_id = self.screenshot_id;
+                    url = self.review.links.screenshot_comments.href;
+                }
+
+                rbApiCall({
+                    type: type,
+                    url: url,
+                    data: data,
+                    success: function(rsp) {
+                        self._loadDataFromResponse(rsp);
+                        $.event.trigger("saved", null, self);
+                        options.success();
+                    }
+                });
+            });
+        });
+    },
+
+    /*
+     * Deletes the comment from the server.
+     */
+    deleteComment: function() {
+        var self = this;
+
+        self.ready(function() {
+            if (self.loaded) {
+                rbApiCall({
+                    type: "DELETE",
+                    url: self.url,
+                    success: function() {
+                        $.event.trigger("deleted", null, self);
+                        self._deleteAndDestruct();
+                    }
+                });
+            } else {
+                this._deleteAndDestruct();
+            }
+        });
+    },
+
+    deleteIfEmpty: function() {
+        if (this.text != "") {
+            return;
+        }
+
+        this.deleteComment();
+    },
+
+    _deleteAndDestruct: function() {
+        $.event.trigger("destroyed", null, this);
+    },
+
+    _load: function(on_done) {
+        var self = this;
+
+        if (!self.id) {
+            on_done();
+            return;
+        }
+
+        self.review.ready(function() {
+            if (!self.review.loaded) {
+                on_done();
+                return;
+            }
+
+            rbApiCall({
+                type: "GET",
+                url: self.review.links.screenshot_comments.href +
+                     self.id + "/",
+                success: function(rsp, status) {
+                    if (status != 404) {
+                        self._loadDataFromResponse(rsp);
+                    }
+
+                    on_done();
+                },
+            });
+        });
+    },
+
+    _loadDataFromResponse: function(rsp) {
+        this.id = rsp.screenshot_comment.id;
+        this.text = rsp.screenshot_comment.text;
+        this.x = rsp.screenshot_comment.x;
+        this.y = rsp.screenshot_comment.y;
+        this.width = rsp.screenshot_comment.w;
+        this.height = rsp.screenshot_comment.h;
+        this.links = rsp.screenshot_comment.links;
+        this.url = rsp.screenshot_comment.links.self.href;
+        this.loaded = true;
     }
 });
 
+RB.FileComment = function(review, file_id, id) {
+    this.id = id;
+    this.review = review;
+    this.file_id = file_id;
+    this.text = "";
+    this.loaded = false;
+    this.url = null;
+
+    return this;
+}
+
+$.extend(RB.FileComment.prototype, {
+    ready: function(on_ready) {
+        if (this.loaded) {
+            on_ready();
+        } else {
+            this._load(on_ready);
+        }
+    },
+
+    /*
+     * Sets the current text in the comment block.
+     *
+     * @param {string} text  The new text to set.
+     */
+    setText: function(text) {
+        this.text = text;
+        $.event.trigger("textChanged", null, this);
+    },
+
+    setForm: function(form) {
+        this.form = form;
+    },
+
+    _saveForm: function(options) {
+        this._saveApiCall(options.success, options.error, {
+            buttons: options.buttons,
+            form: this.form
+        });
+    },
+
+    /*
+     * Saves the comment on the server.
+     */
+    save: function(options) {
+        var self = this;
+
+        options = $.extend({
+            success: function() {}
+        }, options);
+
+        self.ready(function() {
+            self.review.ensureCreated(function() {
+                var type;
+                var url;
+                var data = {
+                    text: self.text,
+                };
+
+                if (self.loaded) {
+                    type = "PUT";
+                    url = self.url;
+                } else {
+                    data.file_id = self.file_id;
+                    url = self.review.links.file_comments.href;
+                }
+
+                rbApiCall({
+                    type: type,
+                    url: url,
+                    data: data,
+                    success: function(rsp) {
+                        self._loadDataFromResponse(rsp);
+                        $.event.trigger("saved", null, self);
+                        options.success();
+                    }
+                });
+            });
+        });
+    },
+
+    /*
+     * Deletes the comment from the server.
+     */
+    deleteComment: function() {
+        var self = this;
+
+        self.ready(function() {
+            if (self.loaded) {
+                rbApiCall({
+                    type: "DELETE",
+                    url: self.url,
+                    success: function() {
+                        $.event.trigger("deleted", null, self);
+                        self._deleteAndDestruct();
+                    }
+                });
+            } else {
+                this._deleteAndDestruct();
+            }
+        });
+    },
+
+    deleteIfEmpty: function() {
+        if (this.text != "") {
+            return;
+        }
+
+        this.deleteComment();
+    },
+
+    _deleteAndDestruct: function() {
+        $.event.trigger("destroyed", null, this);
+    },
+
+    _load: function(on_done) {
+        var self = this;
+
+        if (!self.id) {
+            on_done();
+            return;
+        }
+
+        self.review.ready(function() {
+            if (!self.review.loaded) {
+                on_done();
+                return;
+            }
+
+            rbApiCall({
+                type: "GET",
+                url: self.review.links.uploaded_file_comments.href +
+                     self.id + "/",
+                success: function(rsp, status) {
+                    if (status != 404) {
+                        self._loadDataFromResponse(rsp);
+                    }
+
+                    on_done();
+                },
+            });
+        });
+    },
+
+    _loadDataFromResponse: function(rsp) {
+        this.id = rsp.file_comment.id;
+        this.text = rsp.file_comment.text;
+        this.links = rsp.file_comment.links;
+        this.url = rsp.file_comment.links.self.href;
+        this.loaded = true;
+    }
+});
+
+RB.UploadedFileCommentReply = function(reply, id, reply_to_id) {
+    this.id = id;
+    this.reply = reply;
+    this.text = "";
+    this.reply_to_id = reply_to_id;
+    this.loaded = false;
+    this.url = null;
+
+    return this;
+}
+
+$.extend(RB.UploadedFileCommentReply.prototype, {
+    ready: function(on_ready) {
+        if (this.loaded) {
+            on_ready();
+        } else {
+            this._load(on_ready);
+        }
+    },
+
+    /*
+     * Sets the current text in the comment block.
+     *
+     * @param {string} text  The new text to set.
+     */
+    setText: function(text) {
+        this.text = text;
+        $.event.trigger("textChanged", null, this);
+    },
+
+    /*
+     * Saves the comment on the server.
+     */
+    save: function(options) {
+        var self = this;
+        options = options || {};
+
+        self.ready(function() {
+            self.reply.ensureCreated(function() {
+                var type;
+                var url;
+                var data = {
+                    text: self.text
+                };
+
+                if (self.loaded) {
+                    type = "PUT";
+                    url = self.url;
+                } else {
+                    data.reply_to_id = self.reply_to_id;
+                    url = self.reply.links.file_comments.href;
+                }
+
+                rbApiCall({
+                    type: type,
+                    url: url,
+                    data: data,
+                    success: function(rsp) {
+                        self._loadDataFromResponse(rsp);
+
+                        $.event.trigger("saved", null, self);
+
+                        if ($.isFunction(options.success)) {
+                            options.success();
+                        }
+                    }
+                });
+            });
+        });
+    },
+
+    /*
+     * Deletes the comment from the server.
+     */
+    deleteComment: function() {
+        var self = this;
+
+        self.ready(function() {
+            if (self.loaded) {
+                rbApiCall({
+                    type: "DELETE",
+                    url: self.url,
+                    success: function() {
+                        $.event.trigger("deleted", null, self);
+                        self._deleteAndDestruct();
+                    }
+                });
+            } else {
+                self._deleteAndDestruct();
+            }
+        });
+    },
+
+    deleteIfEmpty: function() {
+        if (this.text != "") {
+            return;
+        }
+
+        this.deleteComment();
+    },
+
+    _deleteAndDestruct: function() {
+        $.event.trigger("destroyed", null, this);
+    },
+
+    _load: function(on_done) {
+        var self = this;
+
+        if (!self.id) {
+            on_done();
+            return;
+        }
+
+        self.reply.ready(function() {
+            if (!self.reply.loaded) {
+                on_done();
+                return;
+            }
+
+            rbApiCall({
+                type: "GET",
+                url: self.reply.links.file_comments.href + self.id + "/",
+                success: function(rsp, status) {
+                    if (status != 404) {
+                        self._loadDataFromResponse(rsp);
+                    }
+
+                    on_done();
+                },
+            });
+        });
+    },
+
+    _loadDataFromResponse: function(rsp) {
+        this.id = rsp.file_comment.id;
+        this.text = rsp.file_comment.text;
+        this.links = rsp.file_comment.links;
+        this.url = rsp.file_comment.links.self.href;
+        this.loaded = true;
+    }
+});
 
 RB.Screenshot = function(review_request, id) {
     this.review_request = review_request;
@@ -547,7 +1707,6 @@ $.extend(RB.Screenshot.prototype, {
 
     _saveForm: function(options) {
         this._saveApiCall(options.success, options.error, {
-            path: 'new/',
             buttons: options.buttons,
             form: this.form
         });
@@ -567,7 +1726,6 @@ $.extend(RB.Screenshot.prototype, {
         blob += '\r\n';
 
         this._saveApiCall(options.success, options.error, {
-            path: 'new/',
             buttons: options.buttons,
             data: blob,
             processData: false,
@@ -584,37 +1742,50 @@ $.extend(RB.Screenshot.prototype, {
     },
 
     _saveApiCall: function(onSuccess, onError, options) {
-        rbApiCall($.extend(options, {
-            path: '/reviewrequests/' + this.review_request.id +
-                  '/screenshot/' + options.path,
-            success: function(rsp) {
-                if (rsp.stat == "ok") {
-                    if ($.isFunction(onSuccess)) {
-                        onSuccess(rsp, rsp.screenshot);
-                    }
-                } else if ($.isFunction(onError)) {
-                    onError(rsp, rsp.err.msg);
+        var self = this;
+
+        self.review_request.ready(function() {
+            rbApiCall($.extend(options, {
+                url: self.review_request.links.screenshots.href,
+                success: function(rsp) {
+                    if (rsp.stat == "ok") {
+                        if ($.isFunction(onSuccess)) {
+                            onSuccess(rsp, rsp.screenshot);
+                        }
+                    } else if ($.isFunction(onError)) {
+                        onError(rsp, rsp.err.msg);
+                    }
                 }
-            }
-        }));
+            }));
+        });
     }
 });
 
-
-RB.ScreenshotComment = function(screenshot_id, x, y, width, height,
-                                textOnServer) {
+RB.ScreenshotComment = function(review, id, screenshot_id, x, y, width,
+                                height) {
+    this.id = id;
+    this.review = review;
     this.screenshot_id = screenshot_id;
     this.x = x;
     this.y = y;
     this.width = width;
     this.height = height;
-    this.text = textOnServer || "";
-    this.saved = (textOnServer != undefined);
+    this.text = "";
+    this.loaded = false;
+    this.url = null;
 
     return this;
 }
 
 $.extend(RB.ScreenshotComment.prototype, {
+    ready: function(on_ready) {
+        if (this.loaded) {
+            on_ready.apply(this, arguments);
+        } else {
+            this._load(on_ready);
+        }
+    },
+
     /*
      * Sets the current text in the comment block.
      *
@@ -629,23 +1800,43 @@ $.extend(RB.ScreenshotComment.prototype, {
      * Saves the comment on the server.
      */
     save: function(options) {
+        var self = this;
+
         options = $.extend({
             success: function() {}
         }, options);
 
-        var self = this;
+        self.ready(function() {
+            self.review.ensureCreated(function() {
+                var type;
+                var url;
+                var data = {
+                    text: self.text,
+                    x: self.x,
+                    y: self.y,
+                    w: self.width,
+                    h: self.height
+                };
 
-        rbApiCall({
-            path: this._getURL(),
-            data: {
-                action: "set",
-                text: this.text
-            },
-            success: function() {
-                self.saved = true;
-                $.event.trigger("saved", null, self);
-                options.success();
-            }
+                if (self.loaded) {
+                    type = "PUT";
+                    url = self.url;
+                } else {
+                    data.screenshot_id = self.screenshot_id;
+                    url = self.review.links.screenshot_comments.href;
+                }
+
+                rbApiCall({
+                    type: type,
+                    url: url,
+                    data: data,
+                    success: function(rsp) {
+                        self._loadDataFromResponse(rsp);
+                        $.event.trigger("saved", null, self);
+                        options.success();
+                    }
+                });
+            });
         });
     },
 
@@ -655,23 +1846,170 @@ $.extend(RB.ScreenshotComment.prototype, {
     deleteComment: function() {
         var self = this;
 
-        if (this.saved) {
+        self.ready(function() {
+            if (self.loaded) {
+                rbApiCall({
+                    type: "DELETE",
+                    url: self.url,
+                    success: function() {
+                        $.event.trigger("deleted", null, self);
+                        self._deleteAndDestruct();
+                    }
+                });
+            } else {
+                this._deleteAndDestruct();
+            }
+        });
+    },
+
+    deleteIfEmpty: function() {
+        if (this.text != "") {
+            return;
+        }
+
+        this.deleteComment();
+    },
+
+    _deleteAndDestruct: function() {
+        $.event.trigger("destroyed", null, this);
+    },
+
+    _load: function(on_done) {
+        var self = this;
+
+        if (!self.id) {
+            on_done.apply(this, arguments);
+            return;
+        }
+
+        self.review.ready(function() {
+            if (!self.review.loaded) {
+                on_done.apply(this, arguments);
+                return;
+            }
+
             rbApiCall({
-                path: this._getURL(),
-                data: {
-                    action: "delete"
+                type: "GET",
+                url: self.review.links.screenshot_comments.href +
+                     self.id + "/",
+                success: function(rsp, status) {
+                    if (status != 404) {
+                        self._loadDataFromResponse(rsp);
+                    }
+
+                    on_done.apply(this, arguments);
                 },
-                success: function() {
-                    self.saved = false;
-                    $.event.trigger("deleted", null, self);
-                    self._deleteAndDestruct();
-                }
             });
+        });
+    },
+
+    _loadDataFromResponse: function(rsp) {
+        this.id = rsp.screenshot_comment.id;
+        this.text = rsp.screenshot_comment.text;
+        this.x = rsp.screenshot_comment.x;
+        this.y = rsp.screenshot_comment.y;
+        this.width = rsp.screenshot_comment.w;
+        this.height = rsp.screenshot_comment.h;
+        this.links = rsp.screenshot_comment.links;
+        this.url = rsp.screenshot_comment.links.self.href;
+        this.loaded = true;
+    }
+});
+
+
+RB.ScreenshotCommentReply = function(reply, id, reply_to_id) {
+    this.id = id;
+    this.reply = reply;
+    this.text = "";
+    this.reply_to_id = reply_to_id;
+    this.loaded = false;
+    this.url = null;
+
+    return this;
+}
+
+$.extend(RB.ScreenshotCommentReply.prototype, {
+    ready: function(on_ready) {
+        if (this.loaded) {
+            on_ready.apply(this, arguments);
         } else {
-            this._deleteAndDestruct();
+            this._load(on_ready);
         }
     },
 
+    /*
+     * Sets the current text in the comment block.
+     *
+     * @param {string} text  The new text to set.
+     */
+    setText: function(text) {
+        this.text = text;
+        $.event.trigger("textChanged", null, this);
+    },
+
+    /*
+     * Saves the comment on the server.
+     */
+    save: function(options) {
+        var self = this;
+        options = options || {};
+
+        self.ready(function() {
+            self.reply.ensureCreated(function() {
+                var type;
+                var url;
+                var data = {
+                    text: self.text
+                };
+
+                if (self.loaded) {
+                    type = "PUT";
+                    url = self.url;
+                } else {
+                    data.reply_to_id = self.reply_to_id;
+                    url = self.reply.links.screenshot_comments.href;
+                }
+
+                rbApiCall({
+                    type: type,
+                    url: url,
+                    data: data,
+                    success: function(rsp) {
+                        self._loadDataFromResponse(rsp);
+
+                        $.event.trigger("saved", null, self);
+
+                        if ($.isFunction(options.success)) {
+                            options.success();
+                        }
+                    }
+                });
+            });
+        });
+    },
+
+    /*
+     * Deletes the comment from the server.
+     */
+    deleteComment: function() {
+        var self = this;
+
+        self.ready(function() {
+            if (self.loaded) {
+                rbApiCall({
+                    type: "DELETE",
+                    url: self.url,
+                    success: function() {
+                        $.event.trigger("deleted", null, self);
+                        self._deleteAndDestruct();
+                    }
+                });
+            } else {
+                self._deleteAndDestruct();
+            }
+        });
+    },
+
     deleteIfEmpty: function() {
         if (this.text != "") {
             return;
@@ -684,16 +2022,40 @@ $.extend(RB.ScreenshotComment.prototype, {
         $.event.trigger("destroyed", null, this);
     },
 
-    /*
-     * Returns the URL used for API calls.
-     *
-     * @return {string} The URL used for API calls for this comment block.
-     */
-    _getURL: function() {
-        return "/reviewrequests/" + gReviewRequestId + "/s/" +
-               this.screenshot_id + "/comments/" +
-               Math.round(this.width) + "x" + Math.round(this.height) +
-               "+" + Math.round(this.x) + "+" + Math.round(this.y) + "/";
+    _load: function(on_done) {
+        var self = this;
+
+        if (!self.id) {
+            on_done.apply(this, arguments);
+            return;
+        }
+
+        self.reply.ready(function() {
+            if (!self.reply.loaded) {
+                on_done.apply(this, arguments);
+                return;
+            }
+
+            rbApiCall({
+                type: "GET",
+                url: self.reply.links.screenshot_comments.href + self.id + "/",
+                success: function(rsp, status) {
+                    if (status != 404) {
+                        self._loadDataFromResponse(rsp);
+                    }
+
+                    on_done.apply(this, arguments);
+                },
+            });
+        });
+    },
+
+    _loadDataFromResponse: function(rsp) {
+        this.id = rsp.screenshot_comment.id;
+        this.text = rsp.screenshot_comment.text;
+        this.links = rsp.screenshot_comment.links;
+        this.url = rsp.screenshot_comment.links.self.href;
+        this.loaded = true;
     }
 });
 
@@ -720,7 +2082,7 @@ $.extend(RB.ScreenshotComment.prototype, {
  * @param {object} options  The options, listed above.
  */
 function rbApiCall(options) {
-    var url = options.url || (SITE_ROOT + "api/json" + options.path);
+    var url = options.url || (SITE_ROOT + "api" + options.path);
 
     function doCall() {
         if (options.buttons) {
@@ -739,7 +2101,7 @@ function rbApiCall(options) {
 
         var data = $.extend(true, {
             url: url,
-            data: options.data || {dummy: ""},
+            data: options.data,
             dataType: options.dataType || "json",
             error: function(xhr, textStatus, errorThrown) {
                 var rsp = null;
@@ -749,9 +2111,9 @@ function rbApiCall(options) {
                 } catch (e) {
                 }
 
-                if (rsp && rsp.stat) {
+                if ((rsp && rsp.stat) || xhr.status == 204) {
                     if ($.isFunction(options.success)) {
-                        options.success(rsp, textStatus);
+                        options.success(rsp, xhr.status);
                     }
 
                     return;
@@ -804,6 +2166,10 @@ function rbApiCall(options) {
             $.funcQueue("rbapicall").next();
         };
 
+        data.data = $.extend({
+            api_format: 'json'
+        }, data.data || {});
+
         if (options.form) {
             options.form.ajaxSubmit(data);
         } else {
@@ -858,7 +2224,7 @@ function rbApiCall(options) {
 
     options.type = options.type || "POST";
 
-    if (options.type == "POST" || options.type == "PUT") {
+    if (options.type != "GET") {
         $.funcQueue("rbapicall").add(doCall);
         $.funcQueue("rbapicall").start();
     } else {
diff --git a/reviewboard/htdocs/media/rb/js/diffviewer.js b/reviewboard/htdocs/media/rb/js/diffviewer.js
--- a/reviewboard/htdocs/media/rb/js/diffviewer.js
+++ b/reviewboard/htdocs/media/rb/js/diffviewer.js
@@ -161,7 +161,7 @@ function DiffCommentBlock(beginRow, endRow, beginLineNum, endLineNum,
             var comment = comments[i];
 
             if (comment.localdraft) {
-                this._createDraftComment(comment.text);
+                this._createDraftComment(comment.comment_id, comment.text);
             } else {
                 this.comments.push(comment);
             }
@@ -297,16 +297,20 @@ $.extend(DiffCommentBlock.prototype, {
             .close();
     },
 
-    _createDraftComment: function(textOnServer) {
+    _createDraftComment: function(id, text) {
         if (this.draftComment != null) {
             return;
         }
 
         var self = this;
         var el = this.el;
-        var comment = new RB.DiffComment(this.filediff, this.interfilediff,
-                                         this.beginLineNum, this.endLineNum,
-                                         textOnServer);
+        var comment = gReviewRequest.createReview().createDiffComment(
+            id, this.filediff, this.interfilediff, this.beginLineNum,
+            this.endLineNum);
+
+        if (text) {
+            comment.text = text;
+        }
 
         $.event.add(comment, "textChanged", function() {
             self.updateTooltip();
diff --git a/reviewboard/htdocs/media/rb/js/reviews.js b/reviewboard/htdocs/media/rb/js/reviews.js
--- a/reviewboard/htdocs/media/rb/js/reviews.js
+++ b/reviewboard/htdocs/media/rb/js/reviews.js
@@ -185,7 +185,7 @@ function setDraftField(field, value) {
             var func = gEditorCompleteHandlers[field];
 
             if ($.isFunction(func)) {
-                $("#" + field).html(func(rsp[field]));
+                $("#" + field).html(func(rsp['draft'][field]));
             }
 
             gDraftBanner.show();
@@ -396,10 +396,28 @@ $.fn.commentSection = function(review_id, context_id, context_type) {
                 .bind("complete", function(e, value) {
                     self.html(linkifyText(self.text()));
 
-                    review_reply.addComment({
-                        context_id: context_id,
-                        context_type: context_type,
-                        text: value,
+                    if (context_type == "body_top" ||
+                        context_type == "body_bottom") {
+                        review_reply[context_type] = value;
+                        obj = review_reply;
+                    } else if (context_type == "comment") {
+                        obj = new RB.DiffCommentReply(review_reply, null,
+                                                      context_id);
+                        obj.setText(value);
+                    } else if (context_type == "screenshot_comment") {
+                        obj = new RB.ScreenshotCommentReply(review_reply, null,
+                                                            context_id);
+                        obj.setText(value);
+                    } else if (context_type == "file_comment") {
+                        obj = new RB.UploadedFileCommentReply(review_reply, null,
+                                                            context_id);
+                        obj.setText(value);
+                    } else {
+                        /* Shouldn't be reached. */
+                        return;
+                    }
+
+                    obj.save({
                         buttons: bannerButtonsEl,
                         success: function() {
                             removeCommentFormIfEmpty(self);
@@ -433,6 +451,11 @@ $.fn.commentSection = function(review_id, context_id, context_type) {
             }
 
             addCommentLink.fadeIn();
+
+            /* Find out if we need to discard this. */
+            review_reply.discardIfEmpty({
+                buttons: bannerButtonsEl
+            });
         });
     }
 
@@ -629,7 +652,7 @@ $.fn.commentDlg = function() {
         });
     }
 
-    if (!LOGGED_IN) {
+    if (!gUserAuthenticated) {
         textField.attr("disabled", true);
         saveButton.attr("disabled", true);
     }
@@ -782,11 +805,15 @@ $.fn.commentDlg = function() {
         }
 
         comment = newComment;
-        textField.val(comment.text);
-        dirty = false;
 
-        /* Set the initial button states */
-        deleteButton.setVisible(comment.saved);
+        comment.ready(function() {
+            textField.val(comment.text);
+            dirty = false;
+
+            /* Set the initial button states */
+            deleteButton.setVisible(comment.loaded);
+        });
+
         saveButton.attr("disabled", true);
 
         /* Clear the status field. */
@@ -936,7 +963,7 @@ $.reviewForm = function(review) {
     /*
      * Saves the review.
      *
-     * This sets the shipit and body values, and saves all comments.
+     * This sets the ship_it and body values, and saves all comments.
      */
     function saveReview(publish) {
         $.funcQueue("reviewForm").clear();
@@ -956,7 +983,7 @@ $.reviewForm = function(review) {
         });
 
         $.funcQueue("reviewForm").add(function() {
-            review.shipit = $("#id_shipit", dlg)[0].checked ? 1 : 0;
+            review.ship_it = $("#id_shipit", dlg)[0].checked ? 1 : 0;
             review.body_top = $(".body-top", dlg).text();;
             review.body_bottom = $(".body-bottom", dlg).text();;
 
@@ -994,7 +1021,8 @@ $.reviewForm = function(review) {
 /*
  * Adds inline editing capabilities to a comment in the review form.
  *
- * @param {object} comment  A RB.DiffComment or RB.ScreenshotComment instance
+ * @param {object} comment  A RB.DiffComment, RB.UploadedFileComment 
+ *                          or RB.ScreenshotComment instance
  *                          to store the text on and save.
  */
 $.fn.reviewFormCommentEditor = function(comment) {
@@ -1043,7 +1071,45 @@ $.fn.reviewRequestFieldEditor = function() {
 
 
 /*
- * Adds a thumbnail to the thumbnail list.
+ * Handles interaction and events with a screenshot thumbnail.
+
+ * @return {jQuery} The provided screenshot containers.
+ */
+$.fn.screenshotThumbnail = function() {
+    return $(this).each(function() {
+        var self = $(this);
+
+        var screenshot_id = self.attr("data-screenshot-id");
+        var screenshot = gReviewRequest.createScreenshot(screenshot_id);
+        var captionEl = self.find(".screenshot-caption");
+
+        captionEl.find("a.edit")
+            .inlineEditor({
+                cls: this.id + "-editor",
+                editIconPath: MEDIA_URL + "rb/images/edit.png?" + MEDIA_SERIAL,
+                showButtons: false
+            })
+            .bind("complete", function(e, value) {
+                screenshot.ready(function() {
+                    screenshot.caption = value;
+                    screenshot.save()
+                });
+            });
+
+        captionEl.find("a.delete")
+            .click(function() {
+                screenshot.ready(function() {
+                    screenshot.deleteScreenshot()
+                    self.empty();
+                });
+
+                return false;
+            });
+    });
+}
+
+/*
+ * Adds a new, dynamic thumbnail to the thumbnail list.
  *
  * If a screenshot object is given, then this will display actual
  * thumbnail data. Otherwise, this will display a spinner.
@@ -1052,7 +1118,7 @@ $.fn.reviewRequestFieldEditor = function() {
  *
  * @return {jQuery} The root screenshot thumbnail div.
  */
-$.screenshotThumbnail = function(screenshot) {
+$.newScreenshotThumbnail = function(screenshot) {
     var container = $('<div/>')
         .addClass("screenshot-container");
 
@@ -1066,7 +1132,7 @@ $.screenshotThumbnail = function(screenshot) {
 
     if (screenshot) {
         body.append($("<a/>")
-            .attr("href", screenshot.image_url)
+            .attr("href", "s/" + screenshot.id + "/")
             .append($("<img/>")
                 .attr({
                     src: screenshot.thumbnail_url,
@@ -1077,15 +1143,15 @@ $.screenshotThumbnail = function(screenshot) {
 
         captionArea
             .append($("<a/>")
-                .addClass("editable")
-                .addClass("screenshot-editable")
+                .addClass("screenshot-editable edit")
                 .attr({
                     href: screenshot.image_url,
                     id: "screenshot_" + screenshot.id + "_caption"
                 })
             )
             .append($("<a/>")
-                .attr("href", screenshot.image_url + "delete/")
+                .addClass("delete")
+                .attr("href", "#")
                 .append($("<img/>")
                     .attr({
                         src: MEDIA_URL + "rb/images/delete.png?" +
@@ -1095,7 +1161,9 @@ $.screenshotThumbnail = function(screenshot) {
                 )
             );
 
-        container.find(".editable").reviewRequestFieldEditor()
+        container
+            .attr("data-screenshot-id", screenshot.id)
+            .screenshotThumbnail();
     } else {
         body.addClass("loading");
 
@@ -1107,6 +1175,75 @@ $.screenshotThumbnail = function(screenshot) {
     return container.insertBefore(thumbnails.find("br"));
 };
 
+/*
+ * Adds a file to the uploaded files list.
+ *
+ * If an UploadedFile object is given, then this will display the
+ * file data. Otherwise, this will display a placeholder.
+ *
+ * @param {object} uploadedFile  The optional file to display.
+ *
+ * @return {jQuery} The root file-list div.
+ */
+$.fileDisplay = function(uploadedFile) {
+    var container = $("<div/>")
+        .addClass("file-container");
+
+    var body = $("<dd/>")
+        .appendTo(container);
+
+    if (uploadedFile) {
+        
+        var captionArea = $("<label>"+uploadedFile.title+"</label>")
+            .attr({
+                "for": "uploaded_file_"+uploadedFile.id+"_caption"
+            });
+
+        body.append(captionArea);
+        captionArea
+            .append($("<a>Review File</a>")
+                .addClass("file-review")
+                .attr({
+                    href: '#',
+                    id: uploadedFile.id
+                })
+            )
+            .append($("<a>Download File</a>")
+                .attr({
+                    href: uploadedFile.url,
+                })
+            )
+            .append($("<a/>")
+                .attr("href", uploadedFile.file_url + "delete/")
+                .append($("<img/>")
+                    .attr({
+                        src: MEDIA_URL + "rb/images/delete.png?" +
+                             MEDIA_SERIAL,
+                        alt: "Delete File"
+                    })
+                )
+            );
+
+        body.append($("<pre>")
+                .addClass("editable")
+                .addClass("file-editable")
+                .attr({
+                    id: "uploaded_file_"+uploadedFile.id+"_caption"
+                })
+                .append(uploadedFile.caption)
+        );
+
+        container.find(".editable").reviewRequestFieldEditor()
+    } else {
+        body.addClass("loading");
+
+        body.append("&nbsp;");
+    }
+
+    var thumbnails = $("#file-list");
+    $(thumbnails.parent()[0]).show();
+    return container.insertBefore(thumbnails.find("br"));
+};
 
 /*
  * Registers for updates to the review request. This will cause a pop-up
@@ -1383,8 +1520,10 @@ function initScreenshotDnD() {
             thumbnailsContainerVisible = true;
             handleDragExit(null);
         } else {
-            dropIndicator.html("None of the dropped files were valid " +
-                               "images");
+            if (dropIndicator) {
+                dropIndicator.html("None of the dropped files were valid " +
+                                   "images");
+            }
 
             setTimeout(function() {
                 handleDragExit(null);
@@ -1394,7 +1533,7 @@ function initScreenshotDnD() {
 
     function uploadScreenshot(file) {
         /* Create a temporary screenshot thumbnail. */
-        var thumb = $.screenshotThumbnail()
+        var thumb = $.newScreenshotThumbnail()
             .css("opacity", 0)
             .fadeTo(1000, 1);
 
@@ -1403,7 +1542,27 @@ function initScreenshotDnD() {
         screenshot.save({
             buttons: gDraftBannerButtons,
             success: function(rsp, screenshot) {
-                thumb.replaceWith($.screenshotThumbnail(screenshot));
+                thumb.replaceWith($.newScreenshotThumbnail(screenshot));
+                gDraftBanner.show();
+            },
+            error: function(rsp, msg) {
+                thumb.remove();
+            }
+        });
+    }
+
+    function uploadFile(file) {
+        /* Create a temporary file listing. */
+        var thumb = $.fileDisplay()
+            .css("opacity", 0)
+            .fadeTo(1000, 1);
+
+        var uploadedFile = gReviewRequest.createUploadedFile();
+        uploadedFile.setFile(file);
+        uploadedFile.save({
+            buttons: gDraftBannerButtons,
+            success: function(rsp, uploadedFile) {
+                thumb.replaceWith($.fileDisplay(uploadedFile));
                 gDraftBanner.show();
             },
             error: function(rsp, msg) {
@@ -1594,6 +1753,7 @@ $(document).ready(function() {
     if (gUserAuthenticated) {
         if (window["gEditable"]) {
             $(".editable").reviewRequestFieldEditor();
+            $(".screenshot-container").screenshotThumbnail();
 
             var targetGroupsEl = $("#target_groups");
             var targetPeopleEl = $("#target_people");
diff --git a/reviewboard/htdocs/media/rb/js/screenshots.js b/reviewboard/htdocs/media/rb/js/screenshots.js
--- a/reviewboard/htdocs/media/rb/js/screenshots.js
+++ b/reviewboard/htdocs/media/rb/js/screenshots.js
@@ -37,7 +37,7 @@ function CommentBlock(x, y, width, height, container, comments) {
             var comment = comments[i];
 
             if (comment.localdraft) {
-                this._createDraftComment(comment.text);
+                this._createDraftComment(comment.comment_id, comment.text);
             } else {
                 this.comments.push(comment);
             }
@@ -132,16 +132,19 @@ jQuery.extend(CommentBlock.prototype, {
             });
     },
 
-    _createDraftComment: function(textOnServer) {
+    _createDraftComment: function(id, text) {
         if (this.draftComment != null) {
             return;
         }
 
         var self = this;
         var el = this.el;
-        var comment = new RB.ScreenshotComment(gScreenshotId,
-                                               this.x, this.y, this.width,
-                                               this.height, textOnServer);
+        var comment = gReviewRequest.createReview().createScreenshotComment(
+            id, gScreenshotId, this.x, this.y, this.width, this.height);
+
+        if (text) {
+            comment.text = text;
+        }
 
         $.event.add(comment, "textChanged", function() {
             self.updateTooltip();
diff --git a/reviewboard/reviews/evolutions/filemanager.py b/reviewboard/reviews/evolutions/filemanager.py
--- /dev/null
+++ b/reviewboard/reviews/evolutions/filemanager.py
@@ -0,0 +1,12 @@
+from django.db import models
+
+from django_evolution.mutations import AddField
+
+MUTATIONS = [
+    AddField('ReviewRequest', 'files', models.ManyToManyField, related_model='filemanager.Filemanager'),
+    AddField('ReviewRequest', 'inactive_files', models.ManyToManyField, related_model='filemanager.Filemanager'),
+    AddField('Review', 'file_comments', models.ManyToManyField, related_model='filemanager.FilemanagerComment'),
+    AddField('ReviewRequestDraft', 'files', models.ManyToManyField, related_model='filemanager.Filemanager'),
+    AddField('ReviewRequestDraft', 'inactive_files', models.ManyToManyField, related_model='filemanager.Filemanager')
+]
+
diff --git a/reviewboard/reviews/managers.py b/reviewboard/reviews/managers.py
--- a/reviewboard/reviews/managers.py
+++ b/reviewboard/reviews/managers.py
@@ -331,7 +331,7 @@ class ReviewManager(ConcurrencyManager):
                 if (review_value and not getattr(master_review, attname)):
                     setattr(master_review, attname, review_value)
 
-            for attname in ["comments", "screenshot_comments"]:
+            for attname in ["comments", "screenshot_comments","file_comments"]:
                 master_m2m = getattr(master_review, attname)
                 review_m2m = getattr(review, attname)
 
diff --git a/reviewboard/reviews/models.py b/reviewboard/reviews/models.py
--- a/reviewboard/reviews/models.py
+++ b/reviewboard/reviews/models.py
@@ -18,6 +18,7 @@ from djblets.util.templatetags.djblets_images import crop_image, thumbnail
 
 from reviewboard.changedescs.models import ChangeDescription
 from reviewboard.diffviewer.models import DiffSet, DiffSetHistory, FileDiff
+from reviewboard.filemanager.models import UploadedFile
 from reviewboard.reviews.signals import review_request_published, \
                                         reply_published, review_published
 from reviewboard.reviews.errors import PermissionError
@@ -271,6 +272,17 @@ class ReviewRequest(models.Model):
                     "longer associated with this review request."),
         related_name="inactive_review_request",
         blank=True)
+    files = models.ManyToManyField(
+        UploadedFile,
+        related_name="review_request",
+        verbose_name=_("uploaded files"),
+        blank=True)
+    inactive_files = models.ManyToManyField(UploadedFile,
+        verbose_name=_("inactive files"),
+        help_text=_("A list of uploaded files that used to be but are no "
+                    "longer associated with this review request."),
+        related_name="inactive_review_request",
+        blank=True)
 
     changedescs = models.ManyToManyField(ChangeDescription,
         verbose_name=_("change descriptions"),
@@ -792,6 +804,14 @@ class ReviewRequestDraft(models.Model):
         verbose_name=_("inactive screenshots"),
         related_name="inactive_drafts",
         blank=True)
+    files = models.ManyToManyField(UploadedFile,
+                                   related_name="drafts",
+                                   verbose_name=_("uploaded files"),
+                                   blank=True)
+    inactive_files = models.ManyToManyField(UploadedFile,
+        verbose_name=_("inactive files"),
+        related_name="inactive_drafts",
+        blank=True)
 
     submitter = property(lambda self: self.review_request.submitter)
 
@@ -863,6 +883,16 @@ class ReviewRequestDraft(models.Model):
                 screenshot.save()
                 draft.inactive_screenshots.add(screenshot)
 
+            for upfile in review_request.files.all():
+                upfile.draft_caption = upfile.caption
+                upfile.save()
+                draft.files.add(upfile)
+
+            for upfile in review_request.inactive_files.all():
+                upfile.draft_caption = upfile.caption
+                upfile.save()
+                draft.inactive_files.add(upfile)
+
             draft.save();
 
         return draft
@@ -1062,6 +1092,33 @@ class ReviewRequestDraft(models.Model):
         map(review_request.inactive_screenshots.add,
             self.inactive_screenshots.all())
 
+        # Files are treated like screenshots. The list of files can
+        # change, but so can captions within each file.
+        files = self.files.all()
+        caption_changes = {}
+
+        for f in review_request.files.all():
+            if f in files and f.caption != f.draft_caption:
+                caption_changes[f.id] = {
+                    'old': (f.caption,),
+                    'new': (f.draft_caption,),
+                }
+
+                f.caption = f.draft_caption
+                f.save()
+
+        if caption_changes and self.changedesc:
+            self.changedesc.fields_changed['file_captions'] = \
+                caption_changes
+
+        update_list(review_request.files, self.files,
+                    'files', name_field="caption")
+
+        # There's no change notification required for this field.
+        review_request.inactive_files.clear()
+        map(review_request.inactive_files.add,
+            self.inactive_files.all())
+
         if self.diffset:
             if self.changedesc:
                 self.changedesc.fields_changed['diff'] = {
@@ -1252,6 +1309,61 @@ class ScreenshotComment(models.Model):
         ordering = ['timestamp']
 
 
+class UploadedFileComment(models.Model):
+    """
+    A comment on an uploaded file.
+    """
+    upfile = models.ForeignKey(UploadedFile, verbose_name=_('uploaded file'),
+                                   related_name="comments")
+    reply_to = models.ForeignKey('self', blank=True, null=True,
+                                 related_name='replies',
+                                 verbose_name=_("reply to"))
+    timestamp = models.DateTimeField(_('timestamp'), default=datetime.now)
+    text = models.TextField(_('comment text'))
+
+    # Set this up with a ConcurrencyManager to help prevent race conditions.
+    objects = ConcurrencyManager()
+
+    def public_replies(self, user=None):
+        """
+        Returns a list of public replies to this comment, optionally
+        specifying the user replying.
+        """
+        if user:
+            return self.replies.filter(Q(review__public=True) |
+                                       Q(review__user=user))
+        else:
+            return self.replies.filter(review__public=True)
+
+    def get_file(self):
+        """
+        Generates the file referenced by this
+        comment and returns the HTML markup embedding it.
+        """
+        return '<a href="%s" alt="%s" />' % \
+            (self.upfile.upfile, escape(self.text))
+
+    def get_review_url(self):
+        return "%s#scomment%d" % \
+            (self.review.get().review_request.get_absolute_url(), self.id)
+
+    def save(self, **kwargs):
+        super(UploadedFileComment, self).save()
+
+        try:
+            # Update the review timestamp.
+            review = self.review.get()
+            review.timestamp = datetime.now()
+            review.save()
+        except Review.DoesNotExist:
+            pass
+
+    def __unicode__(self):
+        return self.text
+
+    class Meta:
+        ordering = ['timestamp']
+
 class Review(models.Model):
     """
     A review of a review request.
@@ -1302,6 +1414,11 @@ class Review(models.Model):
         verbose_name=_("screenshot comments"),
         related_name="review",
         blank=True)
+    file_comments = models.ManyToManyField(
+        UploadedFileComment,
+        verbose_name=_("uploaded file comments"),
+        related_name="review",
+        blank=True)
 
     # XXX Deprecated. This will be removed in a future release.
     reviewed_diffset = models.ForeignKey(
@@ -1387,6 +1504,10 @@ class Review(models.Model):
             comment.timetamp = self.timestamp
             comment.save()
 
+        for comment in self.file_comments.all():
+            comment.timetamp = self.timestamp
+            comment.save()
+
         # Update the last_updated timestamp on the review request.
         self.review_request.last_review_timestamp = self.timestamp
         self.review_request.save()
@@ -1414,6 +1535,9 @@ class Review(models.Model):
         for comment in self.screenshot_comments.all():
             comment.delete()
 
+        for comment in self.file_comments.all():
+            comment.delete()
+
         super(Review, self).delete()
 
     def get_absolute_url(self):
diff --git a/reviewboard/reviews/templatetags/reviewtags.py b/reviewboard/reviews/templatetags/reviewtags.py
--- a/reviewboard/reviews/templatetags/reviewtags.py
+++ b/reviewboard/reviews/templatetags/reviewtags.py
@@ -11,8 +11,9 @@ from djblets.util.templatetags.djblets_utils import humanize_list
 
 from reviewboard.accounts.models import Profile
 from reviewboard.diffviewer.models import DiffSet
+from reviewboard.filemanager.models import UploadedFile
 from reviewboard.reviews.models import Comment, Group, ReviewRequest, \
-                                       ScreenshotComment
+                                       ScreenshotComment, UploadedFileComment
 
 
 register = template.Library()
@@ -212,8 +213,8 @@ def reply_list(context, review, comment, context_type, context_id):
     to display replies to a type of object. In each case, the replies will
     be rendered using the template :template:`reviews/review_reply.html`.
 
-    If ``context_type`` is ``"comment"`` or ``"screenshot_comment"``,
-    the generated list of replies are to ``comment``.
+    If ``context_type`` is ``"comment"``, ``"screenshot_comment"``
+    or ``"file_comment"``, the generated list of replies are to ``comment``.
 
     If ``context_type`` is ``"body_top"`` or ```"body_bottom"``,
     the generated list of replies are to ``review``. Depending on the
@@ -253,7 +254,8 @@ def reply_list(context, review, comment, context_type, context_id):
 
     s = ""
 
-    if context_type == "comment" or context_type == "screenshot_comment":
+    if context_type == "comment" or context_type == "screenshot_comment" \
+        or context_type == "file_comment":
         for reply_comment in comment.public_replies(user):
             s += generate_reply_html(reply_comment.review.get(),
                                      reply_comment.timestamp,
@@ -291,6 +293,8 @@ def reply_section(context, review, comment, context_type, context_id):
     if comment != "":
         if type(comment) is ScreenshotComment:
             context_id += 's'
+        elif type(comment) is UploadedFileComment:
+            context_id += 'f'
         context_id += str(comment.id)
 
     return {
diff --git a/reviewboard/reviews/urls.py b/reviewboard/reviews/urls.py
--- a/reviewboard/reviews/urls.py
+++ b/reviewboard/reviews/urls.py
@@ -46,9 +46,6 @@ urlpatterns = patterns('reviewboard.reviews.views',
     (r'^(?P<review_request_id>[0-9]+)/s/(?P<screenshot_id>[0-9]+)/$',
      'view_screenshot'),
 
-    (r'^(?P<review_request_id>[0-9]+)/s/(?P<screenshot_id>[0-9]+)/delete/$',
-     'delete_screenshot'),
-
     # E-mail previews
     (r'^(?P<review_request_id>[0-9]+)/preview-email/(?P<format>(text|html))/$',
      'preview_review_request_email'),
@@ -60,3 +57,9 @@ urlpatterns = patterns('reviewboard.reviews.views',
     # Search
     url(r'^search/$', 'search', name="search"),
 )
+
+urlpatterns += patterns('reviewboard.filemanager.views',
+   # Uploaded files
+    (r'^(?P<review_request_id>[0-9]+)/f/(?P<file_id>[0-9]+)/delete/$',
+     'delete_file'),
+)
diff --git a/reviewboard/reviews/views.py b/reviewboard/reviews/views.py
--- a/reviewboard/reviews/views.py
+++ b/reviewboard/reviews/views.py
@@ -36,6 +36,7 @@ from reviewboard.diffviewer.diffutils import get_file_chunks_in_range
 from reviewboard.diffviewer.models import DiffSet
 from reviewboard.diffviewer.views import view_diff, view_diff_fragment, \
                                          exception_traceback_string
+from reviewboard.filemanager.forms import UploadFileForm, CommentFileForm
 from reviewboard.reviews.datagrids import DashboardDataGrid, \
                                           GroupDataGrid, \
                                           ReviewRequestDataGrid, \
@@ -68,7 +69,7 @@ def _render_permission_denied(
     return response
 
 
-def _find_review_request(request, review_request_id, local_site_name):
+def find_review_request(request, review_request_id, local_site_name):
     """
     Find a review request based on an ID and optional LocalSite name.
 
@@ -112,6 +113,8 @@ def _make_review_request_context(review_request, extra_context):
         'review_request': review_request,
         'upload_diff_form': upload_diff_form,
         'upload_screenshot_form': UploadScreenshotForm(),
+        'upload_file_form': UploadFileForm(),
+        'comment_file_form': CommentFileForm(),
         'scmtool': scmtool,
     }, **extra_context)
 
@@ -208,6 +211,8 @@ fields_changed_name_map = {
     'target_people': 'Reviewers (People)',
     'screenshots': 'Screenshots',
     'screenshot_captions': 'Screenshot Captions',
+    'files': 'Uploaded Files',
+    'file_captions': 'Uploaded File Captions',
     'diff': 'Diff',
 }
 
@@ -272,7 +277,7 @@ def review_detail(request,
     # local_site configured to have its own review request ID namespace
     # starting from 1.
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -395,6 +400,8 @@ def review_detail(request,
                         info['new'][0] = mark_safe(info['new'][0])
             elif name == "screenshot_captions":
                 change_type = 'screenshot_captions'
+            elif name == "file_captions":
+                change_type = 'file_captions'
             else:
                 # No clue what this is. Bail.
                 continue
@@ -445,7 +452,7 @@ def review_draft_inline_form(request,
                              template_name,
                              local_site_name=None):
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -634,7 +641,7 @@ def diff(request,
     providing the user's current review of the diff if it exists.
     """
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -693,7 +700,7 @@ def raw_diff(request,
     given review request.
     """
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -734,7 +741,7 @@ def comment_diff_fragments(
     # While we don't actually need the review request, we still want to do this
     # lookup in order to get the permissions checking.
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -787,7 +794,7 @@ def diff_fragment(request,
     diff.
     """
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -823,7 +830,7 @@ def preview_review_request_email(
     This is mainly used for debugging.
     """
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -862,7 +869,7 @@ def preview_review_email(request, review_request_id, review_id, format,
     This is mainly used for debugging.
     """
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -915,7 +922,7 @@ def preview_reply_email(request, review_request_id, review_id, reply_id,
     This is mainly used for debugging.
     """
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -955,32 +962,6 @@ def preview_reply_email(request, review_request_id, review_id, reply_id,
         render_to_string(template_name, RequestContext(request, context)),
         mimetype=mimetype)
 
-
-@login_required
-def delete_screenshot(request,
-                      review_request_id,
-                      screenshot_id,
-                      local_site_name=None):
-    """
-    Deletes a screenshot from a review request and redirects back to the
-    review request page.
-    """
-    review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
-
-    if not review_request:
-        return response
-
-    s = Screenshot.objects.get(id=screenshot_id)
-
-    draft = ReviewRequestDraft.create(review_request)
-    draft.screenshots.remove(s)
-    draft.inactive_screenshots.add(s)
-    draft.save()
-
-    return HttpResponseRedirect(review_request.get_absolute_url())
-
-
 @check_login_required
 def view_screenshot(request,
                     review_request_id,
@@ -991,7 +972,7 @@ def view_screenshot(request,
     Displays a screenshot, along with any comments that were made on it.
     """
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
diff --git a/reviewboard/settings.py b/reviewboard/settings.py
--- a/reviewboard/settings.py
+++ b/reviewboard/settings.py
@@ -103,6 +103,7 @@ INSTALLED_APPS = (
     'reviewboard.admin',
     'reviewboard.changedescs',
     'reviewboard.diffviewer',
+    'reviewboard.filemanager',
     'reviewboard.iphone',
     'reviewboard.notifications',
     'reviewboard.reports',
diff --git a/reviewboard/templates/base.html b/reviewboard/templates/base.html
--- a/reviewboard/templates/base.html
+++ b/reviewboard/templates/base.html
@@ -13,7 +13,13 @@
     var MEDIA_SERIAL = "{{MEDIA_SERIAL}}";
     var MEDIA_URL = "{{MEDIA_URL}}";
     var SITE_ROOT = "{{SITE_ROOT}}";
-    var LOGGED_IN = {% if request.user.is_authenticated %}true{% else %}false{% endif %};
+
+    var gUserURL = "{% url user user %}";
+    var gUserAuthenticated = {{user.is_authenticated|lower}};
+{% if not user.is_anonymous %}
+    var gUserName = "{{user.username}}";
+    var gUserFullName = "{{user|user_displayname}}";
+{% endif %}
 {% block jsconsts %}{% endblock %}
   </script>
   <link rel="icon" type="image/png" href="{{MEDIA_URL}}rb/images/favicon.png?{{MEDIA_SERIAL}}" />
diff --git a/reviewboard/templates/reviews/review_flags.js b/reviewboard/templates/reviews/review_flags.js
--- a/reviewboard/templates/reviews/review_flags.js
+++ b/reviewboard/templates/reviews/review_flags.js
@@ -17,9 +17,3 @@
 {% else %}{# error #}
   var gReviewPending = false;
 {% endif %}{# !error #}
-
-  var gUserURL = "{% url user user %}";
-  var gUserAuthenticated = {{user.is_authenticated|lower}};
-{% if not user.is_anonymous %}
-  var gUserFullName = "{{user|user_displayname}}";
-{% endif %}
diff --git a/reviewboard/templates/reviews/review_request_actions_secondary.html b/reviewboard/templates/reviews/review_request_actions_secondary.html
--- a/reviewboard/templates/reviews/review_request_actions_secondary.html
+++ b/reviewboard/templates/reviews/review_request_actions_secondary.html
@@ -18,6 +18,7 @@
 {% endifuserorperm %}
 {% ifuserorperm review_request.submitter "reviews.can_edit_reviewrequest" %}
  <li><a id="upload-screenshot-link" href="#">{% trans "Add Screenshot" %}</a></li>
+ <li><a id="upload-file-link" href="#">{% trans "Add File" %}</a></li>
 {% if upload_diff_form %}
  <li><a id="upload-diff-link" href="#">{% if review_request_details.diffset or review_request.diffset_history.diffsets.count %}{% trans "Update Diff" %}{% else %}{% trans "Upload Diff" %}{% endif %}</a></li>
 {% endif %}
diff --git a/reviewboard/templates/reviews/review_request_box.html b/reviewboard/templates/reviews/review_request_box.html
--- a/reviewboard/templates/reviews/review_request_box.html
+++ b/reviewboard/templates/reviews/review_request_box.html
@@ -75,12 +75,12 @@
   <label for="images">{% trans "Screenshots" %}:</label>
   <div id="screenshot-thumbnails">
 {% for image in review_request_details.screenshots.all %}
-   <div class="screenshot-container">
+   <div class="screenshot-container" data-screenshot-id="{{image.id}}">
     <div class="screenshot" onclick="javascript:window.location='{{image.get_absolute_url}}'; return false;"><a href="{{image.get_absolute_url}}">{{image.thumb}}</a></div>
     <div class="screenshot-caption">
-     <a href="{{image.get_absolute_url}}" id="screenshot_{{image.id}}_caption" class="editable screenshot-editable">{% if draft %}{{image.draft_caption|default:image.caption}}{% else %}{{image.caption}}{% endif %}</a>
+     <a href="{{image.get_absolute_url}}" class="edit">{% if draft %}{{image.draft_caption|default:image.caption}}{% else %}{{image.caption}}{% endif %}</a>
      {% ifuserorperm review_request.submitter "reviews.delete_screenshot" %}
-      <a href="{{image.get_absolute_url}}delete/"><img src="{{MEDIA_URL}}rb/images/delete.png?{{MEDIA_SERIAL}}" alt="{% trans "Delete Screenshot" %}" /></a>
+      <a href="#" class="delete"><img src="{{MEDIA_URL}}rb/images/delete.png?{{MEDIA_SERIAL}}" alt="{% trans "Delete Screenshot" %}" /></a>
      {% endifuserorperm %}
     </div>
    </div>
@@ -88,3 +88,28 @@
    <br clear="both" />
   </div>
  </div>
+<div class="content clearfix"{% ifequal review_request_details.files.count 0 %} style="display: none;"{% endifequal %}>
+<label for="file-list">{% trans "Uploaded Files" %}:</label>
+ <div id="file-list">
+  {% for upfile in review_request_details.files.all %}
+   <div class="file-container">
+   <dd>
+    <label for="uploaded_file_{{upfile.id}}_caption">{{upfile.get_title}}
+     <a href="#" id="{{upfile.id}}" class="file-review" >{% trans "Review File" %}</a>
+     <a href="{{upfile.get_path}}">{% trans "Download File" %}</a>
+     {% ifuserorperm review_request.submitter "reviews.delete_file" %}
+      <a href="{{upfile.get_absolute_url}}delete/">
+       <img src="{{MEDIA_URL}}rb/images/delete.png?{{MEDIA_SERIAL}}" alt="{% trans "Delete File" %}" />
+      </a>
+     {% endifuserorperm %}
+    </label>
+    <pre id="uploaded_file_{{upfile.id}}_caption" class="editable file-editable">
+     {% if draft %}{{upfile.draft_caption}}{% else %}{{upfile.caption}}{% endif %}
+    </pre>
+   </dd>
+   </div>
+   {% endfor %}
+  <br clear="both" />
+ </div>
+</div>
+</div>
diff --git a/reviewboard/templates/reviews/review_request_dlgs.html b/reviewboard/templates/reviews/review_request_dlgs.html
--- a/reviewboard/templates/reviews/review_request_dlgs.html
+++ b/reviewboard/templates/reviews/review_request_dlgs.html
@@ -31,7 +31,7 @@
         fields: {% form_dialog_fields upload_screenshot_form %},
         success: function(rsp) {
             if (!$("#screenshot-thumbnails").length == 0) {
-                $.screenshotThumbnail(rsp.screenshot);
+                $.newScreenshotThumbnail(rsp.screenshot);
             }
 
             gDraftBanner.show();
@@ -40,6 +40,41 @@
 
       return false;
     });
+    
+    $("#upload-file-link").click(function() {
+      $("<div/>").formDlg({
+        title: "{% trans "Upload File" %}",
+        confirmLabel: "{% trans "Upload" %}",
+        dataStoreObject: gReviewRequest.createUploadedFile(),
+        width: "50em",
+        upload: true,
+        fields: {% form_dialog_fields upload_file_form %},
+        success: function(rsp) {
+            if (!$("#file-list").length == 0) {
+                $.fileDisplay(rsp.uploaded_file);
+            }
+
+            gDraftBanner.show();
+        }
+      });
+
+      return false;
+    });
+
+   $(".file-review").click(function() {
+      $("<div/>").formDlg({
+        title: "{% trans "Review File" %}",
+        confirmLabel: "{% trans "Save" %}",
+        dataStoreObject: gReviewRequest.createReview().createFileComment(this.id),
+        upload: false,
+        fields: {% form_dialog_fields comment_file_form %},
+        success: function(rsp) {
+            gReviewBanner.show();
+        }
+      });
+
+      return false;
+    });
   });
 </script>
 {% endifuserorperm %}
diff --git a/reviewboard/webapi/resources.py b/reviewboard/webapi/resources.py
--- a/reviewboard/webapi/resources.py
+++ b/reviewboard/webapi/resources.py
@@ -36,12 +36,15 @@ from reviewboard import get_version_string, get_package_version, is_release
 from reviewboard.accounts.models import Profile
 from reviewboard.diffviewer.diffutils import get_diff_files
 from reviewboard.diffviewer.forms import EmptyDiffError
+from reviewboard.filemanager.models import UploadedFile
+from reviewboard.filemanager.forms import UploadFileForm
 from reviewboard.reviews.errors import PermissionError
 from reviewboard.reviews.forms import UploadDiffForm, UploadScreenshotForm
 from reviewboard.reviews.models import Comment, DiffSet, FileDiff, Group, \
                                        Repository, ReviewRequest, \
                                        ReviewRequestDraft, Review, \
-                                       ScreenshotComment, Screenshot
+                                       ScreenshotComment, Screenshot, \
+                                       UploadedFileComment
 from reviewboard.scmtools.errors import ChangeNumberInUseError, \
                                         EmptyChangeSetError, \
                                         FileNotFoundError, \
@@ -1971,6 +1974,7 @@ class BaseScreenshotResource(WebAPIResource):
             },
         }
     )
+    @webapi_response_errors(DOES_NOT_EXIST, PERMISSION_DENIED)
     def update(self, request, caption=None, *args, **kwargs):
         """Updates the screenshot's data.
 
@@ -2001,6 +2005,29 @@ class BaseScreenshotResource(WebAPIResource):
             self.item_result_key: screenshot,
         }
 
+    @webapi_login_required
+    @webapi_response_errors(DOES_NOT_EXIST, PERMISSION_DENIED)
+    def delete(self, request, *args, **kwargs):
+        try:
+            review_request = \
+                review_request_resource.get_object(request, *args, **kwargs)
+            screenshot = screenshot_resource.get_object(request, *args,
+                                                        **kwargs)
+        except ObjectDoesNotExist:
+            return DOES_NOT_EXIST
+
+        try:
+            draft = review_request_draft_resource.prepare_draft(request,
+                                                                review_request)
+        except PermissionDenied:
+            return PERMISSION_DENIED
+
+        draft.screenshots.remove(screenshot)
+        draft.inactive_screenshots.add(screenshot)
+        draft.save()
+
+        return 204, {}
+
 
 class DraftScreenshotResource(BaseScreenshotResource):
     """Provides information on new screenshots being added to a draft of
@@ -2089,6 +2116,242 @@ class DraftScreenshotResource(BaseScreenshotResource):
 
 draft_screenshot_resource = DraftScreenshotResource()
 
+class BaseUploadedFileResource(WebAPIResource):
+    """A base resource representing uploaded files."""
+    model = UploadedFile
+    name = 'uploaded_file'
+    fields = {
+        'id': {
+            'type': int,
+            'description': 'The numeric ID of the file.',
+        },
+        'caption': {
+            'type': str,
+            'description': "The file's descriptive caption.",
+        },
+        'title': {
+            'type': str,
+            'description': "The path of the file, "
+                           "relative to the media directory configured "
+                           "on the Review Board server.",
+        },
+        'url': {
+            'type': str,
+            'description': "The URL of the file, for downloading purposes."
+                           "an absolute URL (for example, if it is just a "
+                           "path), then it's relative to the Review Board "
+                           "server's URL.",
+        },
+        'file_url': {
+            'type': str,
+            'description': "The URL of the file object.",
+        },
+    }
+
+    uri_object_key = 'file_id'
+
+    def get_queryset(self, request, review_request_id, *args, **kwargs):
+        return self.model.objects.filter(review_request=review_request_id)
+
+    def serialize_title_field(self, obj):
+        return obj.get_title()
+
+    def serialize_url_field(self, obj):
+        return obj.get_path()
+
+    def serialize_file_url_field(self, obj):
+        return obj.get_absolute_url()
+
+    @webapi_login_required
+    @webapi_response_errors(DOES_NOT_EXIST, PERMISSION_DENIED,
+                            INVALID_FORM_DATA)
+    @webapi_request_fields(
+        required={
+            'path': {
+                'type': file,
+                'description': 'The file to upload.',
+            },
+        },
+        optional={
+            'caption': {
+                'type': str,
+                'description': 'The optional caption describing the '
+                               'file.',
+            },
+        },
+    )
+    def create(self, request, *args, **kwargs):
+        """Creates a new file from an uploaded file.
+
+        This accepts any standard file format (including images) and associates
+        it with a draft of a review request.
+
+        It is expected that the client will send the data as part of a
+        :mimetype:`multipart/form-data` mimetype. The file's name
+        and content should be stored in the ``path`` field. A typical request
+        may look like::
+
+            -- SoMe BoUnDaRy
+            Content-Disposition: form-data; name=path; filename="foo.txt"
+
+            <PNG content here>
+            -- SoMe BoUnDaRy --
+        """
+        try:
+            review_request = \
+                review_request_resource.get_object(request, *args, **kwargs)
+        except ObjectDoesNotExist:
+            return DOES_NOT_EXIST
+
+        if not review_request.is_mutable_by(request.user):
+            return PERMISSION_DENIED
+
+        form_data = request.POST.copy()
+        form = UploadFileForm(form_data, request.FILES)
+
+        if not form.is_valid():
+            return WebAPIResponseFormError(request, form)
+
+        try:
+            upfile = form.create(request.FILES['path'], review_request)
+        except ValueError, e:
+            return INVALID_FORM_DATA, {
+                'fields': {
+                    'path': [str(e)],
+                },
+            }
+
+        return 201, {
+            self.item_result_key: upfile,
+        }
+
+    @webapi_login_required
+    @webapi_request_fields(
+        optional={
+            'caption': {
+                'type': str,
+                'description': 'The new caption for the file.',
+            },
+        }
+    )
+    def update(self, request, caption=None, *args, **kwargs):
+        """Updates the file's data.
+
+        This allows updating the file in a draft. The caption, currently,
+        is the only thing that can be updated.
+        """
+        try:
+            review_request = \
+                review_request_resource.get_object(request, *args, **kwargs)
+            upfile = uploaded_file_resource.get_object(request, *args,
+                                                        **kwargs)
+        except ObjectDoesNotExist:
+            return DOES_NOT_EXIST
+
+        if not review_request.is_mutable_by(request.user):
+            return PERMISSION_DENIED
+
+        try:
+            review_request_draft_resource.prepare_draft(request,
+                                                        review_request)
+        except PermissionDenied:
+            return PERMISSION_DENIED
+
+        upfile.draft_caption = caption
+        upfile.save()
+
+        return 200, {
+            self.item_result_key: screenshot,
+        }
+
+
+class DraftUploadedFileResource(BaseUploadedFileResource):
+    """Provides information on new uploaded files being added to a draft of
+    a review request.
+
+    These are files that will be shown once the pending review request
+    draft is published.
+    """
+    name = 'draft_files'
+    uri_name = 'files'
+    model_parent_key = 'drafts'
+    allowed_methods = ('GET', 'DELETE', 'POST', 'PUT',)
+
+    def get_queryset(self, request, review_request_id, *args, **kwargs):
+        try:
+            draft = review_request_draft_resource.get_object(
+                request, review_request_id, *args, **kwargs)
+
+            inactive_ids = \
+                draft.inactive_files.values_list('pk', flat=True)
+
+            q = Q(review_request=review_request_id) | Q(drafts=draft)
+            query = self.model.objects.filter(q)
+            query = query.exclude(pk__in=inactive_ids)
+            return query
+        except ObjectDoesNotExist:
+            return self.model.objects.none()
+
+    def serialize_caption_field(self, obj):
+        return obj.draft_caption or obj.caption
+
+    @webapi_login_required
+    @augment_method_from(WebAPIResource)
+    def get(self, *args, **kwargs):
+        pass
+
+    @webapi_login_required
+    @augment_method_from(WebAPIResource)
+    def delete(self, *args, **kwargs):
+        """Deletes the file from the draft.
+
+        This will remove the screenshot from the draft review request.
+        This cannot be undone.
+
+        This can be used to remove old files that were previously
+        shown, as well as newly added files that were part of the
+        draft.
+
+        Instead of a payload response on success, this will return :http:`204`.
+        """
+        pass
+
+    @webapi_login_required
+    @augment_method_from(WebAPIResource)
+    def get_list(self, *args, **kwargs):
+        """Returns a list of draft files.
+
+        Each screenshot in this list is an uploaded screenshot that will
+        be shown in the final review request. These may include newly
+        uploaded files or files that were already part of the
+        existing review request. In the latter case, existing files
+        are shown so that their captions can be added.
+        """
+        pass
+
+    def _get_list_impl(self, request, *args, **kwargs):
+        """Returns the list of files on this draft.
+
+        This is a specialized version of the standard get_list function
+        that uses this resource to serialize the children, in order to
+        guarantee that we'll be able to identify them as files that are
+        part of the draft.
+        """
+        return WebAPIResponsePaginated(
+            request,
+            queryset=self.get_queryset(request, is_list=True,
+                                       *args, **kwargs),
+            results_key=self.list_result_key,
+            serialize_object_func=
+                lambda obj: self.serialize_object(obj, request=request,
+                                                  *args, **kwargs),
+            extra_data={
+                'links': self.get_links(self.list_child_resources,
+                                        request=request, *args, **kwargs),
+            })
+
+draft_uploaded_file_resource = DraftUploadedFileResource()
+
 
 class ReviewRequestDraftResource(WebAPIResource):
     """An editable draft of a review request.
@@ -2177,6 +2440,7 @@ class ReviewRequestDraftResource(WebAPIResource):
 
     item_child_resources = [
         draft_screenshot_resource,
+        draft_uploaded_file_resource
     ]
 
     @classmethod
@@ -2616,7 +2880,6 @@ class ScreenshotCommentResource(BaseScreenshotCommentResource):
 
 screenshot_comment_resource = ScreenshotCommentResource()
 
-
 class ReviewScreenshotCommentResource(BaseScreenshotCommentResource):
     """Provides information on screenshots comments made on a review.
 
@@ -2779,7 +3042,6 @@ class ReviewScreenshotCommentResource(BaseScreenshotCommentResource):
 
 review_screenshot_comment_resource = ReviewScreenshotCommentResource()
 
-
 class ReviewReplyScreenshotCommentResource(BaseScreenshotCommentResource):
     """Provides information on replies to screenshot comments made on a
     review reply.
@@ -2936,6 +3198,376 @@ review_reply_screenshot_comment_resource = \
     ReviewReplyScreenshotCommentResource()
 
 
+class BaseFileCommentResource(WebAPIResource):
+    """A base resource for file comments."""
+    model = UploadedFileComment
+    name = 'file_comment'
+    fields = {
+        'id': {
+            'type': int,
+            'description': 'The numeric ID of the comment.',
+        },
+        'upfile': {
+            'type': 'reviewboard.webapi.resources.UploadedFileResource',
+            'description': 'The file the comment was made on.',
+        },
+        'text': {
+            'type': str,
+            'description': 'The comment text.',
+        },
+        'timestamp': {
+            'type': str,
+            'description': 'The date and time that the comment was made '
+                           '(in YYYY-MM-DD HH:MM:SS format).',
+        },
+        'public': {
+            'type': bool,
+            'description': 'Whether or not the comment is part of a public '
+                           'review.',
+        },
+        'user': {
+            'type': 'reviewboard.webapi.resources.UserResource',
+            'description': 'The user who made the comment.',
+        },
+    }
+
+    uri_object_key = 'comment_id'
+
+    allowed_methods = ('GET',)
+
+    def get_queryset(self, request, review_request_id, *args, **kwargs):
+        return self.model.objects.filter(
+            file__review_request=review_request_id,
+            review__isnull=False)
+
+    def serialize_public_field(self, obj):
+        return obj.review.get().public
+
+    def serialize_timesince_field(self, obj):
+        return timesince(obj.timestamp)
+
+    def serialize_user_field(self, obj):
+        return obj.review.get().user
+
+    @augment_method_from(WebAPIResource)
+    def get(self, *args, **kwargs):
+        """Returns information on the comment.
+
+        This contains the comment text, time the comment was made,
+        and the location of the comment region on the screenshot, amongst
+        other information. It can be used to reconstruct the exact
+        position of the comment for use as an overlay on the screenshot.
+        """
+        pass
+
+
+class FileCommentResource(BaseFileCommentResource):
+    """Provides information on filess comments made on a review request.
+
+    The list of comments cannot be modified from this resource. It's meant
+    purely as a way to see existing comments that were made on a diff. These
+    comments will span all public reviews.
+    """
+    model_parent_key = 'uploaded_file'
+    uri_object_key = None
+
+    def get_queryset(self, request, review_request_id, file_id,
+                     *args, **kwargs):
+        q = super(FileCommentResource, self).get_queryset(
+            request, review_request_id, *args, **kwargs)
+        q = q.filter(upfile=file_id)
+        return q
+
+    @augment_method_from(BaseFileCommentResource)
+    def get_list(self, *args, **kwargs):
+        """Returns the list of screenshot comments on a file.
+
+        This list of comments will cover all comments made on this
+        file from all reviews.
+        """
+        pass
+
+file_comment_resource = FileCommentResource()
+
+class ReviewFileCommentResource(BaseFileCommentResource):
+    """Provides information on file comments made on a review.
+
+    If the review is a draft, then comments can be added, deleted, or
+    changed on this list. However, if the review is already published,
+    then no changes can be made.
+    """
+    allowed_methods = ('GET', 'POST', 'PUT', 'DELETE')
+    model_parent_key = 'review'
+
+    def get_queryset(self, request, review_request_id, review_id,
+                     *args, **kwargs):
+        q = super(ReviewFileCommentResource, self).get_queryset(
+            request, review_request_id, *args, **kwargs)
+        return q.filter(review=review_id)
+
+    def has_delete_permissions(self, request, comment, *args, **kwargs):
+        review = comment.review.get()
+        return not review.public and review.user == request.user
+
+    @webapi_login_required
+    @webapi_request_fields(
+        required = {
+            'file_id': {
+                'type': int,
+                'description': 'The ID of the file being commented on.',
+            },
+            'text': {
+                'type': str,
+                'description': 'The comment text.',
+            },
+        },
+    )
+    def create(self, request, file_id, text,
+               *args, **kwargs):
+        """Creates a file comment on a review.
+
+        This will create a new comment on a file as part of a review.
+        The comment contains text and dimensions for the area being commented
+        on.
+        """
+        try:
+            review_request = \
+                review_request_resource.get_object(request, *args, **kwargs)
+            review = review_resource.get_object(request, *args, **kwargs)
+        except ObjectDoesNotExist:
+            return DOES_NOT_EXIST
+
+        if not review_resource.has_modify_permissions(request, review):
+            return PERMISSION_DENIED
+
+        try:
+            upfile = UploadedFile.objects.get(pk=file_id,
+                                                review_request=review_request)
+        except ObjectDoesNotExist:
+            return INVALID_FORM_DATA, {
+                'fields': {
+                    'file_id': ['This is not a valid file ID'],
+                }
+            }
+
+        new_comment = self.model(upfile=upfile, text=text)
+        new_comment.save()
+
+        review.file_comments.add(new_comment)
+        review.save()
+
+        return 201, {
+            self.item_result_key: new_comment,
+        }
+
+    @webapi_login_required
+    @webapi_response_errors(DOES_NOT_EXIST, PERMISSION_DENIED)
+    @webapi_request_fields(
+        optional = {
+            'text': {
+                'type': str,
+                'description': 'The comment text.',
+            },
+        },
+    )
+    def update(self, request, *args, **kwargs):
+        """Updates a file comment.
+
+        This can update the text or region of an existing comment. It
+        can only be done for comments that are part of a draft review.
+        """
+        try:
+            review_request_resource.get_object(request, *args, **kwargs)
+            review = review_resource.get_object(request, *args, **kwargs)
+            file_comment = self.get_object(request, *args, **kwargs)
+        except ObjectDoesNotExist:
+            return DOES_NOT_EXIST
+
+        if not review_resource.has_modify_permissions(request, review):
+            return PERMISSION_DENIED
+
+        for field in ('text'):
+            value = kwargs.get(field, None)
+
+            if value is not None:
+                setattr(file_comment, field, value)
+
+        file_comment.save()
+
+        return 200, {
+            self.item_result_key: file_comment,
+        }
+
+    @augment_method_from(BaseFileCommentResource)
+    def delete(self, *args, **kwargs):
+        """Deletes the comment.
+
+        This will remove the comment from the review. This cannot be undone.
+
+        Only comments on draft reviews can be deleted. Attempting to delete
+        a published comment will return a Permission Denied error.
+
+        Instead of a payload response on success, this will return :http:`204`.
+        """
+        pass
+
+    @augment_method_from(BaseFileCommentResource)
+    def get_list(self, *args, **kwargs):
+        """Returns the list of file comments made on a review."""
+        pass
+
+review_file_comment_resource = ReviewFileCommentResource()
+
+class ReviewReplyFileCommentResource(BaseFileCommentResource):
+    """Provides information on replies to file comments made on a
+    review reply.
+
+    If the reply is a draft, then comments can be added, deleted, or
+    changed on this list. However, if the reply is already published,
+    then no changed can be made.
+    """
+    allowed_methods = ('GET', 'POST', 'PUT', 'DELETE')
+    model_parent_key = 'review'
+    fields = dict({
+        'reply_to': {
+            'type': ReviewFileCommentResource,
+            'description': 'The comment being replied to.',
+        },
+    }, **BaseFileCommentResource.fields)
+
+    def get_queryset(self, request, review_request_id, review_id, reply_id,
+                     *args, **kwargs):
+        q = super(ReviewReplyFileCommentResource, self).get_queryset(
+            request, review_request_id, *args, **kwargs)
+        q = q.filter(review=reply_id, review__base_reply_to=review_id)
+        return q
+
+    @webapi_login_required
+    @webapi_response_errors(DOES_NOT_EXIST, INVALID_FORM_DATA,
+                            PERMISSION_DENIED)
+    @webapi_request_fields(
+        required = {
+            'reply_to_id': {
+                'type': int,
+                'description': 'The ID of the comment being replied to.',
+            },
+            'text': {
+                'type': str,
+                'description': 'The comment text.',
+            },
+        },
+    )
+    def create(self, request, reply_to_id, text, *args, **kwargs):
+        """Creates a reply to a file comment on a review.
+
+        This will create a reply to a file comment on a review.
+        The new comment will contain the same dimensions of the comment
+        being replied to, but may contain new text.
+        """
+        try:
+            review_request_resource.get_object(request, *args, **kwargs)
+            reply = review_reply_resource.get_object(request, *args, **kwargs)
+        except ObjectDoesNotExist:
+            return DOES_NOT_EXIST
+
+        if not review_reply_resource.has_modify_permissions(request, reply):
+            return PERMISSION_DENIED
+
+        try:
+            comment = review_file_comment_resource.get_object(
+                request,
+                comment_id=reply_to_id,
+                *args, **kwargs)
+        except ObjectDoesNotExist:
+            return INVALID_FORM_DATA, {
+                'fields': {
+                    'reply_to_id': ['This is not a valid file '
+                                    'comment ID'],
+                }
+            }
+
+        new_comment = self.model(upfile=comment.upfile,
+                                 text=text)
+        new_comment.save()
+
+        reply.file_comments.add(new_comment)
+        reply.save()
+
+        return 201, {
+            self.item_result_key: new_comment,
+        }
+
+    @webapi_login_required
+    @webapi_response_errors(DOES_NOT_EXIST, PERMISSION_DENIED)
+    @webapi_request_fields(
+        required = {
+            'text': {
+                'type': str,
+                'description': 'The new comment text.',
+            },
+        },
+    )
+    def update(self, request, *args, **kwargs):
+        """Updates a reply to a file comment.
+
+        This can only update the text in the comment. The comment being
+        replied to cannot change.
+        """
+        try:
+            review_request_resource.get_object(request, *args, **kwargs)
+            reply = review_reply_resource.get_object(request, *args, **kwargs)
+            file_comment = self.get_object(request, *args, **kwargs)
+        except ObjectDoesNotExist:
+            return DOES_NOT_EXIST
+
+        if not review_reply_resource.has_modify_permissions(request, reply):
+            return PERMISSION_DENIED
+
+        for field in ('text',):
+            value = kwargs.get(field, None)
+
+            if value is not None:
+                setattr(file_comment, field, value)
+
+        file_comment.save()
+
+        return 200, {
+            self.item_result_key: file_comment,
+        }
+
+    @augment_method_from(BaseFileCommentResource)
+    def delete(self, *args, **kwargs):
+        """Deletes a file comment from a draft reply.
+
+        This will remove the comment from the reply. This cannot be undone.
+
+        Only comments on draft replies can be deleted. Attempting to delete
+        a published comment will return a Permission Denied error.
+
+        Instead of a payload response, this will return :http:`204`.
+        """
+        pass
+
+    @augment_method_from(BaseFileCommentResource)
+    def get(self, *args, **kwargs):
+        """Returns information on a reply to a file comment.
+
+        Much of the information will be identical to that of the comment
+        being replied to.
+        """
+        pass
+
+    @augment_method_from(BaseFileCommentResource)
+    def get_list(self, *args, **kwargs):
+        """Returns the list of replies to file comments made on a
+        review reply.
+        """
+        pass
+
+review_reply_file_comment_resource = \
+    ReviewReplyFileCommentResource()
+
+
 class BaseReviewResource(WebAPIResource):
     """Base class for review resources.
 
@@ -3244,6 +3876,7 @@ class ReviewReplyResource(BaseReviewResource):
     item_child_resources = [
         review_reply_diff_comment_resource,
         review_reply_screenshot_comment_resource,
+        review_reply_file_comment_resource,
     ]
 
     list_child_resources = [
@@ -3453,6 +4086,7 @@ class ReviewResource(BaseReviewResource):
         review_diff_comment_resource,
         review_reply_resource,
         review_screenshot_comment_resource,
+        review_file_comment_resource,
     ]
 
     list_child_resources = [
@@ -3529,6 +4163,85 @@ class ScreenshotResource(BaseScreenshotResource):
         """
         pass
 
+    @augment_method_from(BaseScreenshotResource)
+    def delete(self, *args, **kwargs):
+        """Deletes the screenshot.
+
+        This will remove the screenshot from the draft review request.
+        This cannot be undone.
+
+        Deleting a screenshot will automatically create a new review request
+        draft, if one doesn't already exist. The screenshot won't be actually
+        removed until the review request draft is published.
+
+        This can be used to remove old screenshots that were previously
+        shown, as well as newly added screenshots that were part of the
+        draft.
+
+        Instead of a payload response on success, this will return :http:`204`.
+        """
+        pass
+
+screenshot_resource = ScreenshotResource()
+
+
+class UploadedFileResource(BaseUploadedFileResource):
+    """A resource representing a screenshot on a review request."""
+    model_parent_key = 'review_request'
+
+    item_child_resources = [
+        file_comment_resource,
+    ]
+
+    allowed_methods = ('GET', 'POST', 'PUT', 'DELETE')
+
+    @augment_method_from(BaseUploadedFileResource)
+    def get_list(self, *args, **kwargs):
+        """Returns a list of screenshots on the review request.
+
+        Each screenshot in this list is an uploaded screenshot that is
+        shown on the review request.
+        """
+        pass
+
+    @augment_method_from(BaseUploadedFileResource)
+    def create(self, request, *args, **kwargs):
+        """Creates a new screenshot from an uploaded file.
+
+        This accepts any standard image format (PNG, GIF, JPEG) and associates
+        it with a draft of a review request.
+
+        Creating a new screenshot will automatically create a new review
+        request draft, if one doesn't already exist. This screenshot will
+        be part of that draft, and will be shown on the review request
+        when it's next published.
+
+        It is expected that the client will send the data as part of a
+        :mimetype:`multipart/form-data` mimetype. The screenshot's name
+        and content should be stored in the ``path`` field. A typical request
+        may look like::
+
+            -- SoMe BoUnDaRy
+            Content-Disposition: form-data; name=path; filename="foo.png"
+
+            <PNG content here>
+            -- SoMe BoUnDaRy --
+        """
+        pass
+
+    @augment_method_from(BaseUploadedFileResource)
+    def update(self, request, caption=None, *args, **kwargs):
+        """Updates the screenshot's data.
+
+        This allows updating the screenshot. The caption, currently,
+        is the only thing that can be updated.
+
+        Updating a screenshot will automatically create a new review request
+        draft, if one doesn't already exist. The updates won't be public
+        until the review request draft is published.
+        """
+        pass
+
     @webapi_login_required
     @augment_method_from(WebAPIResource)
     def delete(self, *args, **kwargs):
@@ -3549,7 +4262,7 @@ class ScreenshotResource(BaseScreenshotResource):
         """
         pass
 
-screenshot_resource = ScreenshotResource()
+uploaded_file_resource = UploadedFileResource()
 
 
 class ReviewRequestLastUpdateResource(WebAPIResource):
@@ -3738,6 +4451,7 @@ class ReviewRequestResource(WebAPIResource):
         review_request_last_update_resource,
         review_resource,
         screenshot_resource,
+        uploaded_file_resource
     ]
 
     allowed_methods = ('GET', 'POST', 'PUT', 'DELETE')
@@ -4330,9 +5044,15 @@ register_resource_for_model(
 register_resource_for_model(ReviewRequest, review_request_resource)
 register_resource_for_model(ReviewRequestDraft, review_request_draft_resource)
 register_resource_for_model(Screenshot, screenshot_resource)
+register_resource_for_model(UploadedFile, uploaded_file_resource)
 register_resource_for_model(
     ScreenshotComment,
     lambda obj: obj.review.get().is_reply() and
                 review_reply_screenshot_comment_resource or
                 review_screenshot_comment_resource)
+register_resource_for_model(
+    UploadedFileComment,
+    lambda obj: obj.review.get().is_reply() and
+                review_reply_file_comment_resource or
+                review_file_comment_resource)
 register_resource_for_model(User, user_resource)
diff --git a/webtests/tests.py b/webtests/tests.py
--- a/webtests/tests.py
+++ b/webtests/tests.py
@@ -25,7 +25,8 @@ def create_screenshot(r, caption=""):
 
 
 class SeleniumUnitTest(testcases.SeleniumUnitTest):
-    fixtures = ['test_users', 'test_reviewrequests', 'test_scmtools']
+    fixtures = ['test_users', 'test_reviewrequests', 'test_scmtools',
+                'test_site']
 
     def setUp(self):
         super(SeleniumUnitTest, self).setUp()
@@ -113,12 +114,12 @@ class DiffTests(SeleniumUnitTest):
         f = open(diff_filename, "r")
         self.client.login(username="grumpy", password="grumpy")
         response = self.client.post(
-            '/api/json/reviewrequests/%s/diff/new/' % r.id, {
+            '/api/review-requests/%s/diffs/' % r.id, {
                 'path': f,
                 'basedir': '/trunk',
             }
         )
-        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.status_code, 201)
         self.assertTrue('"ok"' in response.content)
         f.close()
 
@@ -174,6 +175,7 @@ class DiffCommentTests(SeleniumUnitTest):
         self.open_comment_box(file.id, first_line, last_line)
         self.selenium.type_keys('comment_text', comment_text)
         self.selenium.click('comment_save')
+        self.wait_for_visible('review-banner')
         self.selenium.click('review-banner-publish')
         self.selenium.wait_for_page_to_load("6000")
 
@@ -266,7 +268,7 @@ class DiffCommentTests(SeleniumUnitTest):
         self.wait_for_ajax_finish()
         time.sleep(0.25) # It will be animating, so wait.
 
-        self.assertEqual(r.reviews.count(), 0)
+        self.assertEqual(r.reviews.count(), 1)
 
     def open_comment_box(self, file_id, first_line, last_line):
         first_line_locator = self.build_line_locator(file_id, first_line)
@@ -400,7 +402,7 @@ class ReviewRequestTests(SeleniumUnitTest):
 
         summary = 'My new summary'
         branch = 'mybranch'
-        bugs_closed = '123, 789'
+        bugs_closed = '123,789'
         target_groups = 'devgroup'
         target_people = 'grumpy'
         description = 'My new description'
@@ -539,8 +541,10 @@ class ReviewRequestTests(SeleniumUnitTest):
     # This is a test for bug #1586
     def test_linkified_text_for_non_editable_description(self):
         """Testing linkified text in non-editable description"""
-        r = ReviewRequest.objects.filter(public=True, status='P')\
-            .exclude(submitter=self.user)[0]
+        q = ReviewRequest.objects.filter(public=True, status='P',
+                                         local_site=None)
+        q = q.exclude(submitter=self.user)
+        r = q[0]
         r.description = "Testing linkified text\n\n/r/123"
         r.save()
         transaction.commit()
@@ -741,7 +745,8 @@ class ReviewTests(SeleniumUnitTest):
         self.assertTrue(self.selenium.is_visible('review-banner'))
 
     def _click_discard(self):
-        self._click_dlg_button('Discard Review', reloads_page=True)
+        self._click_dlg_button('Discard Review')
+        self.assertFalse(self.selenium.is_visible('review-banner'))
 
 
 class ReviewGroupTests(SeleniumUnitTest):
@@ -963,7 +968,7 @@ class ScreenshotTests(SeleniumUnitTest):
         self.selenium.open(r.get_absolute_url())
         self.selenium.click('css=.screenshot-caption '
                             'img[alt="Delete Screenshot"]')
-        self.selenium.wait_for_page_to_load("6000")
+        self.wait_for_ajax_finish()
 
         draft = r.get_draft(self.user)
         self.assertNotEqual(draft, None)
@@ -1022,7 +1027,7 @@ class ScreenshotCommentTests(SeleniumUnitTest):
         self.selenium.click('comment_delete')
         self.wait_for_ajax_finish()
 
-        self.assertEqual(r.reviews.count(), 0)
+        self.assertEqual(r.reviews.count(), 1)
 
     def _get_review_request(self):
         r = ReviewRequest.objects.filter(public=True, status='P',
