MozReview: Added changes that fix bug 1259237, combined in an attempt to move commits into file structure that is more suitable for merging and keeping track of mozreview specific files. Fixed issue with edit review dropdown not showing offline diff comments. (Bug 1259237). r?davidwalsh draft LocalStorage
authorBrian Birkhauser <bcbirkhauser@gmail.com>
Wed, 07 Sep 2016 15:37:55 -0700
branchLocalStorage
changeset 106 bfab041438b5cd58f56c44034a01fcc5670a3336
parent 105 eec761ad8cec35e7a669fade637aa7bfd36e7b2a
push idunknown
push userunknown
push dateunknown
reviewersdavidwalsh
bugs1259237
MozReview: Added changes that fix bug 1259237, combined in an attempt to move commits into file structure that is more suitable for merging and keeping track of mozreview specific files. Fixed issue with edit review dropdown not showing offline diff comments. (Bug 1259237). r?davidwalsh MozReview-Commit-ID: IskVPf6Atlr *** MozReview: Fixed issue with edit review dropdown not loading offline comments. (Bug 1259237). r?davidwalsh
reviewboard/reviewboard/static/rb/js/diffviewer/models/diffReviewableModel_mozreview.js
reviewboard/reviewboard/static/rb/js/resources/models/baseCommentModel_mozreview.js
reviewboard/reviewboard/static/rb/js/resources/models/diffCommentModel_mozreview.js
reviewboard/reviewboard/static/rb/js/resources/models/localResourceModel_mozreview.js
reviewboard/reviewboard/static/rb/js/resources/models/reviewRequestModel_mozreview.js
reviewboard/reviewboard/static/rb/js/resources/utils/localStore_mozreview.js
reviewboard/reviewboard/static/rb/js/views/reviewDialogView_mozreview.js
reviewboard/reviewboard/staticbundles.py
--- a/reviewboard/reviewboard/static/rb/js/diffviewer/models/diffReviewableModel_mozreview.js
+++ b/reviewboard/reviewboard/static/rb/js/diffviewer/models/diffReviewableModel_mozreview.js
@@ -7,17 +7,48 @@ RB.DiffReviewable = RB.AbstractReviewabl
         fileDiffID: null,
         interFileDiffID: null,
         revision: null,
         interdiffRevision: null
     }, RB.AbstractReviewable.prototype.defaults),
 
     commentBlockModel: RB.DiffCommentBlock,
     defaultCommentBlockFields: ['fileDiffID', 'interFileDiffID'],
+    initialize: function() {
+         _super(this).initialize.call(this);
 
+        var _model = this;
+        var diffComment = new RB.DiffComment({parentObject: this.get('review')});
+        diffComment.syncDirtyAndDestroyed({success: function(resp) {
+            _.each(resp, function(cb) {
+                //force the saved comment to act like it the other serialized comments.
+                var comment = cb.toJSON();
+                comment.user = {
+                    name: RB.UserSession.instance.get('name'),
+                    username: RB.UserSession.instance.get('username')
+                };
+                comment.html = cb.get('html');
+                comment.url = '/r/' + _model.get('reviewRequest').id + '/#comment' + cb.get('id');
+                comment.localdraft = true;
+                comment.issue_status = "open";
+                comment.review_id = _model.get('review').id;
+                comment.line = comment.first_line;
+                comment.comment_id = cb.get('id');
+                _model.createCommentBlock({
+                    reviewRequest: _model.get('reviewRequest'),
+                    review: _model.get('review'),
+                    fileDiffID: _model.get('fileDiffID'),
+                    interFileDiffID: _model.get('interFileDiffID'),
+                    beginLineNum: cb.get('beginLineNum'),
+                    endLineNum: cb.get('endLineNum'),
+                    serializedComments: [comment] || []
+                });
+            });
+        }});
+    },
     /*
      * Adds comment blocks for the serialized comment blocks passed to the
      * reviewable.
      */
     loadSerializedCommentBlock: function(serializedCommentBlock) {
         this.createCommentBlock({
             reviewRequest: this.get('reviewRequest'),
             review: this.get('review'),
--- a/reviewboard/reviewboard/static/rb/js/resources/models/baseCommentModel_mozreview.js
+++ b/reviewboard/reviewboard/static/rb/js/resources/models/baseCommentModel_mozreview.js
@@ -1,16 +1,16 @@
 /*
  * The base model for a comment.
  *
  * This provides all the common properties, serialization, deserialization,
  * validation, and other functionality of comments. It's meant to be
  * subclassed by more specific implementations.
  */
-RB.BaseComment = RB.BaseResource.extend({
+RB.BaseComment = RB.LocalResource.extend({
     defaults: function() {
         return _.defaults({
             /*
              * The text format type to request for text in all responses.
              */
             forceTextType: null,
 
             /*
@@ -118,16 +118,39 @@ RB.BaseComment = RB.BaseResource.extend(
      */
     destroyIfEmpty: function(options, context) {
         if (!this.get('text')) {
             this.destroy(options, context);
         }
     },
 
     /*
+     * Adds some of the data that the server normally responds with, while in offline mode
+     *
+     * This must be overloaded by subclasses, and the parent version called.
+     */
+    parseOfflineData: function(rsp) {
+        var data = RB.LocalResource.prototype.parseOfflineData.call(this, rsp);
+        var types = [];
+        if(rsp.include_text_types) {
+            types = rsp.include_text_types.split(',');
+        }
+        _.each(types, function(type) {
+            var txt_field = type + '_text_fields';
+            if(!rsp[txt_field]) {
+                //should do some better parsing like in mix_ins.py
+                data[txt_field] = {
+                    text: rsp.text
+                };
+            }
+        }, this);
+
+        return data;
+    },
+    /*
      * Deserializes comment data from an API payload.
      *
      * This must be overloaded by subclasses, and the parent version called.
      */
     parseResourceData: function(rsp) {
         var rawTextFields = rsp.raw_text_fields || rsp,
             data = RB.BaseResource.prototype.parseResourceData.call(this, rsp);
 
--- a/reviewboard/reviewboard/static/rb/js/resources/models/diffCommentModel_mozreview.js
+++ b/reviewboard/reviewboard/static/rb/js/resources/models/diffCommentModel_mozreview.js
@@ -27,17 +27,19 @@ RB.DiffComment = RB.BaseComment.extend({
 
             /* The optional FileDiff at the end of an interdiff range. */
             interFileDiff: null,
 
             /*
              * The ID of the optional FileDiff specifying the end of an
              * interdiff range.
              */
-            interFileDiffID: null
+            interFileDiffID: null,
+            /* the name used for the local store  and to create the model in localResourceModel*/
+            storeName: 'DiffComment'
         }, parentProto.defaults());
     },
 
     rspNamespace: 'diff_comment',
     expandedFields: ['filediff', 'interfilediff'],
 
     attrToJsonMap: _.defaults({
         fileDiffID: 'filediff_id',
@@ -70,16 +72,24 @@ RB.DiffComment = RB.BaseComment.extend({
     /*
      * Returns the total number of lines the comment spans.
      */
     getNumLines: function() {
         return this.get('endLineNum') - this.get('beginLineNum') + 1;
     },
 
     /*
+     * Adds some of the data that the server normally responds with, while in offline mode
+     */
+    parseOfflineData: function(rsp) {
+        var result = parentProto.parseOfflineData.call(this, rsp);
+        return result;
+    },
+
+    /*
      * Deserializes comment data from an API payload.
      */
     parseResourceData: function(rsp) {
         var result = parentProto.parseResourceData.call(this, rsp);
 
         result.endLineNum = rsp.num_lines + result.beginLineNum - 1;
 
         result.fileDiff = new RB.FileDiff(rsp.filediff, {
new file mode 100644
--- /dev/null
+++ b/reviewboard/reviewboard/static/rb/js/resources/models/localResourceModel_mozreview.js
@@ -0,0 +1,577 @@
+var indexOf = [].indexOf || function(item) {
+  for (var i = 0, l = this.length; i < l; i++) {
+    if (i in this && this[i] === item) {
+      return i;
+    }
+  }
+  return -1;
+};
+
+/**
+ * The base model for all Dual Storage resource models. It extends the BaseResource Model.
+ *
+ *  This will default to using the REST Api, except when the user is offline.
+ *	The user can continue to make changes and they will be synced when they are back online.
+ *
+ *  Any model needing offline support are expected to extend this.
+ *
+ *  Based off of https://github.com/nilbus/Backbone.dualStorage
+ */
+
+RB.modelUpdatedWithResponse = function(model, response) {
+  var modelClone;
+  var storeName = model.get('storeName');
+  modelClone = new RB[storeName]();
+  modelClone.idAttribute = model.idAttribute;
+  modelClone.set(model.attributes);
+  modelClone.set(model.parse(response));
+  return modelClone;
+};
+RB.parseRemoteResponse = function(object, response) {
+  if (!(object && object.parseBeforeLocalSave)) {
+    return response;
+  }
+  if (_.isFunction(object.parseBeforeLocalSave)) {
+    return object.parseBeforeLocalSave(response);
+  }
+};
+
+RB.getModelFromLocalStore = function(storeName, data, parentObject) {
+  var model = new RB[storeName](data);
+  model.set('parentObject', parentObject);
+  return model;
+};
+
+RB.LocalResource = RB.BaseResource.extend({
+  defaults: function() {
+    return _.defaults({
+      remote: true,
+      local: true
+    }, RB.BaseResource.prototype.defaults());
+  },
+  offlineStatusCodes: [408, 502],
+  hasTempId: function() {
+    return _.isString(this.id) && this.id.length === 36 && this.id.indexOf('t') === 0;
+  },
+  getStoreName: function(model) {
+    return _.result(model.attributes, 'storeName') || _.result(model, 'urlRoot') || _.result(model, 'url');
+  },
+  callbackTranslator: {
+    needsTranslation: Backbone.VERSION === '0.9.10',
+    forBackboneCaller: function(callback) {
+      if (this.needsTranslation) {
+        return function(model, resp, options) {
+          return callback.call(null, resp, options);
+        };
+      } else {
+        return callback;
+      }
+    },
+    forDualstorageCaller: function(callback, model, options) {
+      if (this.needsTranslation) {
+        return function(resp) {
+          return callback.call(null, model, resp, options);
+        };
+      } else {
+        return callback;
+      }
+    }
+  },
+  syncDirty: function(options) {
+    if(!options) {
+      options = {};
+    }
+    var success = options.success ? options.success: function() {};
+    var error = options.error ? options.error: function() {};
+
+    var model = this;
+    var ids, ref, results, store, dirty;
+    store = localStorage.getItem((model.getStoreName(model)) + '_dirty');
+    ids = (store && store.split(',')) || [];
+    results = [];
+    dirty = [];
+    _.each(ids, function(id, i) {
+      var data = JSON.parse(localStorage.getItem(model.getStoreName(model) + id));
+      var parsedData = {};
+      if (_.isFunction(model.parseResourceData)) {
+        parsedData = model.parseResourceData.call(model, data);
+      }
+
+      _.each(model.attrToJsonMap, function(val, key) {
+        if (data[val]) {
+          parsedData[key] = data[val];
+          delete data[val];
+        }
+      });
+      _.extend(parsedData, data);
+
+      ref = RB.getModelFromLocalStore(model.getStoreName(model), parsedData, model.get('parentObject'));
+      var storeName = model.getStoreName(model) + id;
+      options.success = function(args) {
+         if(_.isFunction(success)) {
+          success.call(model, [args]);
+        }
+        localStorage.removeItem(storeName);
+      };
+      options.error = function() {
+        dirty.push(id);
+      };
+      options.dirty = true;
+      results.push(ref !== null ? ref.save(options) : void 0);
+    });
+
+    if (!dirty.length) {
+      localStorage.removeItem(model.getStoreName(model));
+      localStorage.removeItem(model.getStoreName(model) + '_dirty');
+    } else {
+      localStorage.setItem(model.getStoreName(model) + '_dirty', dirty);
+    }
+    return results;
+  },
+  syncDestroyed: function(options) {
+    if(!options) {
+      options = {};
+    }
+    var ids, model, results, store;
+    model = this;
+    store = localStorage.getItem((model.getStoreName(model)) + '_destroyed');
+    ids = (store && store.split(',')) || [];
+    results = [];
+    _.each(ids, function(id, i) {
+      model = RB.getModelFromLocalStore(model.getStoreName(model), {
+        id: id
+      }, model.get('parentObject'));
+      model.set(model.idAttribute, id);
+      var destroyed = model.destroy(options);
+      results.push(destroyed);
+      if (destroyed) {
+        var parentObject = model.get('parentObject');
+        if (parentObject) {
+          parentObject.trigger('destroy');
+        } else {
+          model.trigger('destroy');
+        }
+      }
+    });
+    // return results;
+  },
+  destroyedModelIds: function() {
+    var store;
+    store = localStorage.getItem((this.getStoreName(this)) + '_destroyed');
+    return (store && store.split(',')) || [];
+  },
+  syncDirtyAndDestroyed: function(options) {
+    this.syncDirty(options);
+    return this.syncDestroyed(options);
+  },
+  /*
+   * Adds some of the data that the server normally responds with, while in offline mode
+   *
+   * This can be overridden by subclasses.
+   */
+  parseOfflineData: function(data) {
+    return data;
+  },
+  parseAndTriggerSave: function(model, options) {
+    var data = JSON.parse(localStorage.getItem(model.getStoreName(model) + model.id));
+    data = model.parseOfflineData.call(model, data);
+    data = model.parseResourceData.call(model, data);
+    model.set(data);
+    return model.trigger('saved', options);
+  },
+  localsync: function(method, model, options) {
+    var preExisting, response, store;
+    store = new RB.Store(options.storeName);
+    response = (function() {
+      switch (method) {
+        case 'read':
+          if (model instanceof Backbone.Model) {
+            return store.find(model);
+          } else {
+            return store.findAll();
+          }
+          break;
+        case 'hasDirtyOrDestroyed':
+          return store.hasDirtyOrDestroyed();
+        case 'clear':
+          return store.clear();
+        case 'create':
+          if (options.add && !options.merge && (preExisting = store.find(model))) {
+            return preExisting;
+          } else {
+            var url = _.result(model, 'url');
+            model.url = url;
+            model = store.create(model, options);
+            if (options.dirty) {
+              store.dirty(model);
+            }
+            model.parseAndTriggerSave(model, options);
+            return model;
+          }
+          break;
+        case 'update':
+          store.update(model, options);
+          model.parseAndTriggerSave(model, options);
+          if (options.dirty) {
+            return store.dirty(model);
+          } else {
+            return store.clean(model, 'dirty');
+          }
+          break;
+        case 'delete':
+          var _id = model.id;
+          var exists = localStorage.getItem((model.getStoreName(model)) + _id);
+          if (exists) {
+            store.destroy(model);
+            if (options.dirty && !model.hasTempId()) {
+              return store.destroyed(model);
+            } else {
+              store.clean(model, 'dirty');
+              return store.clean(model, 'destroyed');
+            }
+          } else {
+            console.log('does not exist');
+          }
+          break;
+      }
+    })();
+    if (response) {
+      if (response.toJSON) {
+        response = response.toJSON(options);
+      }
+      if (response.attributes) {
+        response = response.attributes;
+      }
+    }
+    if (!options.ignoreCallbacks) {
+      if (response) {
+        if (options.context) {
+          options.success.call(options.context, response);
+        } else {
+          options.success.call(model, response);
+        }
+      } else {
+        if (options.context) {
+          options.error.call(options.context, 'Record not found');
+        } else {
+          options.error.call(model, 'Record not found');
+        }
+      }
+    }
+    return response;
+  },
+  hasOfflineStatusCode: function(xhr) {
+    var offlineStatusCodes = this.offlineStatusCodes;
+    var ref;
+    if (!xhr) {
+      return true;
+    }
+    if (_.isFunction(offlineStatusCodes)) {
+      offlineStatusCodes = offlineStatusCodes(xhr);
+    }
+    return xhr.status === 0 || (ref = xhr.status, indexOf.call(offlineStatusCodes, ref) >= 0);
+  },
+  useOfflineStorage: function(model, method, options, success, error, context) {
+    options.dirty = true;
+    options.ignoreCallbacks = false;
+    options.success = success;
+    options.error = error;
+    options.context = context;
+    return model.localsync(method, model, options);
+  },
+  relayErrorCallback: function(model, method, options, xhr, success, error, context) {
+    var online;
+    online = !this.hasOfflineStatusCode(xhr);
+    if (online) {
+      return error.call(context, xhr);
+    } else {
+      return this.useOfflineStorage(model, method, options, success, error, context);
+    }
+  },
+  /**
+   * Delete the object's resource on the server.
+   *
+   * An object must either be loaded or have a parent resource linking to
+   * this object's list resource URL for an object to be deleted.
+   *
+   * Args:
+   *     options (object):
+   *         Object with success and error callbacks.
+   *
+   *     context (object):
+   *         Context to bind when executing callbacks.
+   */
+  destroy: function(options, context) {
+    options = options || {};
+    var model, error, success;
+    var parentObject = this.get('parentObject');
+    model = _.clone(this);
+
+    options.storeName = this.getStoreName(this);
+
+    options.storeExists = RB.Store.exists(options.storeName);
+
+    error = (options.error) ? options.error : function() {};
+    success = (options.success) ? options.success : function() {};
+
+    var offlineSuccess = function(args) {
+      model.set(_.defaults({
+          id: null,
+          parentObject: parentObject
+        },
+        _.result(model, 'defaults')));
+      model.trigger('destroy', options);
+
+      if (_.isFunction(success)) {
+        success.call(context, args);
+      }
+    };
+    if (this.hasTempId()) {
+      options.ignoreCallbacks = false;
+      return this.localsync('delete', model, options);
+    } else {
+      if (!model.id) {
+        //we need the id set, so get it from the url.
+        var url = model.url();
+        var urlparts = (url) ? url.split('/') : [];
+
+        var _id = (urlparts[urlparts.length - 1] !== '') ? urlparts[urlparts.length - 1] : urlparts[urlparts.length - 2];
+        if (parseInt(_id, 10)) {
+          model.set('id', _id);
+        } else {
+          return false;
+        }
+      }
+      options.success = function(resp, _status, _xhr) {
+        if (model.hasOfflineStatusCode(_xhr)) {
+          return model.useOfflineStorage(model, 'delete', options, offlineSuccess, error, context);
+        }
+        options.ignoreCallbacks = true;
+        model.localsync('delete', model, options);
+        return success.call(context, resp, _status, _xhr);
+      };
+      options.error = function(xhr) {
+        return model.relayErrorCallback(model, 'delete', options, xhr, offlineSuccess, error, context);
+      };
+      return RB.BaseResource.prototype.destroy.call(this, options, context);
+    }
+  },
+  /**
+   * Fetch the object's data from the server.
+   *
+   * An object must have an ID before it can be fetched. Otherwise,
+   * options.error() will be called.
+   *
+   * If this has a parent resource object, we'll ensure that's ready before
+   * fetching this resource.
+   *
+   * The resource must override the parse() function to determine how
+   * the returned resource data is parsed and what data is stored in
+   * this object.
+   *
+   * If we successfully fetch the resource, options.success() will be
+   * called.
+   *
+   * If we fail to fetch the resource, options.error() will be called.
+   *
+   * Args:
+   *     options (object):
+   *         Object with success and error callbacks.
+   *
+   *     context (object):
+   *         Context to bind when executing callbacks.
+   */
+  fetch: function(options, context) {
+    options = options || {};
+    var error, success;
+    options.storeName = this.getStoreName(this);
+
+    options.storeExists = RB.Store.exists(options.storeName);
+
+    error = (options.error) ? options.error : function() {};
+    success = (options.success) ? options.success : function() {};
+
+    if (this.localsync('hasDirtyOrDestroyed', this, {
+        ignoreCallbacks: true
+      })) {
+      return this.useOfflineStorage(this, 'fetch', options, success, error, context);
+    } else {
+      var model = this;
+      options.success = function(resp, _status, _xhr) {
+        var responseModel;
+        options.ignoreCallbacks = true;
+        if (model.hasOfflineStatusCode(_xhr)) {
+          return model.useOfflineStorage(model, 'read', options, success, error, context);
+        }
+        resp = RB.parseRemoteResponse(model, resp);
+        responseModel = RB.modelUpdatedWithResponse(model, resp);
+
+        model.localsync('update', responseModel, options);
+
+        return success.call(context, resp, _status, _xhr);
+      };
+      options.error = function(xhr) {
+        model.localsync('update', model, options);
+        return model.relayErrorCallback(model, 'read', options, xhr, success, error, context);
+      };
+      // return options.xhr = onlineSync(method, model, options);
+      var parentObject = this.get('parentObject');
+
+      if (parentObject) {
+        parentObject.ready({
+          ready: function() { Backbone.Model.prototype.fetch.call(this, options, context); },
+          error: options.error
+        }, this);
+      } else {
+        Backbone.Model.prototype.fetch.call(this, options, context);
+      }
+    }
+  },
+  /**
+   * Save the object's data to the server or locally if offline.
+   *
+   * If the object has an ID already, it will be saved to its known
+   * URL using HTTP PUT. If it doesn't have an ID, it will be saved
+   * to its parent list resource using HTTP POST
+   *
+   * If this has a parent resource object, we'll ensure that's created
+   * before saving this resource.
+   *
+   * An object must either be loaded or have a parent resource linking to
+   * this object's list resource URL for an object to be saved.
+   *
+   * The resource must override the toJSON() function to determine what
+   * data is saved to the server.
+   *
+   * If we successfully save the resource, options.success() will be
+   * called, and the "saved" event will be triggered.
+   *
+   * If we fail to save the resource, options.error() will be called.
+   *
+   * Args:
+   *     options (object):
+   *         Object with success and error callbacks.
+   *
+   *     context (object):
+   *         Context to bind when executing callbacks.
+   */
+  save: function(options, context) {
+    if(!options) {
+      options = {};
+    }
+    options.storeName = this.getStoreName(this);
+
+    options.storeExists = RB.Store.exists(options.storeName);
+
+    if (!this.id) {
+      //creating a new record
+      return this.__createObject(options, context);
+    } else {
+      //updating a record.
+      return this.__saveObject(options, context);
+    }
+  },
+  __createObject: function(options, context) {
+    var error, success;
+    var model = this;
+
+    error = (options.error) ? options.error : function() {};
+    success = (options.success) ? options.success : function() {};
+
+    var offlineSuccess = function(args) {
+      model.trigger('saved', options);
+
+      if (_.isFunction(success)) {
+        success.call(context, args);
+      }
+    };
+    options.success = function(resp, _status, _xhr) {
+      var updatedModel;
+      if (model.hasOfflineStatusCode(_xhr)) {
+        return model.useOfflineStorage(model, 'create', options, offlineSuccess, error, context);
+      } else {
+        //we are online, so check to sync any items that haven't been saved
+        if (!options.dirty) {
+          model.syncDirtyAndDestroyed();
+        }
+        updatedModel = RB.modelUpdatedWithResponse(model, resp);
+        options.dirty = false;
+        options.ignoreCallbacks = true;
+        model.localsync('update', updatedModel, options);
+        return success.call(context, resp, _status, _xhr);
+      }
+    };
+    options.error = function(context, xhr) {
+      return model.relayErrorCallback(model, 'create', options, xhr, offlineSuccess, error, context);
+    };
+    return RB.BaseResource.prototype.save.call(this, options, context);
+  },
+  __saveObject: function(options, context) {
+    var model = this;
+    var success, error, temporaryId;
+    error = (options.error) ? options.error : function() {};
+    success = (options.success) ? options.success : function() {};
+
+    var parentObject = model.get('parentObject');
+
+    var offlineSuccess = function(args) {
+      model.trigger('saved', options);
+      parentObject.trigger('saved', options);
+      if (_.isFunction(success)) {
+        success.call(context, args);
+      }
+    };
+    if (this.hasTempId()) {
+      temporaryId = this.id;
+      options.success = function(resp, _status, _xhr) {
+        var updatedModel;
+        model.set(model.idAttribute, temporaryId, {
+          silent: true
+        });
+        if (model.hasOfflineStatusCode(_xhr)) {
+          return model.useOfflineStorage(model, 'update', options, offlineSuccess, error, context);
+        }
+
+        //we are online, so check to sync any items that haven't been saved
+        if (!options.dirty) {
+          model.syncDirtyAndDestroyed();
+        }
+        updatedModel = RB.modelUpdatedWithResponse(model, resp);
+        options.dirty = false;
+        options.ignoreCallbacks = true;
+        model.localsync('update', updatedModel, options);
+        return success.call(context, resp, _status, _xhr);
+      };
+      options.error = function(context, xhr) {
+        model.set(model.idAttribute, temporaryId, {
+          silent: true
+        });
+        return model.relayErrorCallback(model, 'update', options, xhr, offlineSuccess, error, context);
+      };
+      this.set(this.idAttribute, null, {
+        silent: true
+      });
+      return RB.BaseResource.prototype.save.call(this, options, context);
+    } else {
+      options.success = function(resp, _status, _xhr) {
+        var updatedModel;
+        if (model.hasOfflineStatusCode(_xhr)) {
+          return model.useOfflineStorage(model, 'update', options, offlineSuccess, error, context);
+        }
+        //we are online, so check to sync any items that haven't been saved
+        if (!options.dirty) {
+          model.syncDirtyAndDestroyed();
+        }
+        updatedModel = RB.modelUpdatedWithResponse(model, resp);
+        options.dirty = false;
+        options.ignoreCallbacks = true;
+        model.localsync('update', updatedModel, options);
+        return success.call(context, resp, _status, _xhr);
+      };
+      options.error = function(context, xhr) {
+        return model.relayErrorCallback(model, 'update', options, xhr, offlineSuccess, error, context);
+      };
+      return RB.BaseResource.prototype.save.call(this, options, context);
+    }
+  }
+});
\ No newline at end of file
--- a/reviewboard/reviewboard/static/rb/js/resources/models/reviewRequestModel_mozreview.js
+++ b/reviewboard/reviewboard/static/rb/js/resources/models/reviewRequestModel_mozreview.js
@@ -170,17 +170,16 @@ RB.ReviewRequest = RB.BaseResource.exten
             if (!review) {
                 review = new RB.Review({
                     parentObject: this,
                     id: reviewID
                 });
                 this.reviews.add(review);
             }
         }
-
         return review;
     },
 
     /*
      * Creates a Screenshot object for this review request.
      */
     createScreenshot: function(screenshotID) {
         return new RB.Screenshot({
new file mode 100644
--- /dev/null
+++ b/reviewboard/reviewboard/static/rb/js/resources/utils/localStore_mozreview.js
@@ -0,0 +1,134 @@
+'use strict';
+
+RB.S4 = function() {
+  return ((1 + Math.random()) * 0x10000 | 0).toString(16).substring(1);
+};
+/**
+ * Store handles saving resources to local storage.  It keeps track of the record data, and any items that are dirty or destroyed.
+ */
+
+RB.Store = function() {
+  Store.prototype.sep = '';
+
+  function Store(name) {
+    this.name = name;
+    this.records = this.recordsOn(this.name);
+  }
+
+  Store.prototype.generateId = function() {
+    return 't' + RB.S4().substring(1) + RB.S4() + '-' + RB.S4() + '-' + RB.S4() + '-' + RB.S4() + '-' + RB.S4() + RB.S4() + RB.S4();
+  };
+
+  Store.prototype.save = function() {
+    return localStorage.setItem(this.name, this.records.join(','));
+  };
+
+  Store.prototype.recordsOn = function(key) {
+    var store;
+    store = localStorage.getItem(key);
+    return store && store.split(',') || [];
+  };
+
+  Store.prototype.dirty = function(model) {
+    var dirtyRecords;
+    dirtyRecords = this.recordsOn(this.name + '_dirty');
+    if (!_.include(dirtyRecords, model.id.toString())) {
+      dirtyRecords.push(model.id);
+      localStorage.setItem(this.name + '_dirty', dirtyRecords.join(','));
+    }
+    return model;
+  };
+
+  Store.prototype.clean = function(model, from) {
+    var dirtyRecords, store;
+    store = this.name + '_' + from;
+    dirtyRecords = this.recordsOn(store);
+    if (_.include(dirtyRecords, model.id.toString())) {
+      localStorage.setItem(store, _.without(dirtyRecords, model.id.toString()).join(','));
+    }
+    return model;
+  };
+
+  Store.prototype.destroyed = function(model) {
+    var destroyedRecords;
+    destroyedRecords = this.recordsOn(this.name + '_destroyed');
+    if (!_.include(destroyedRecords, model.id.toString())) {
+      destroyedRecords.push(model.id);
+      localStorage.setItem(this.name + '_destroyed', destroyedRecords.join(','));
+    }
+    return model;
+  };
+
+  Store.prototype.create = function(model, options) {
+    if (!_.isObject(model)) {
+      return model;
+    }
+    if (!model.id) {
+      model.set(model.idAttribute, this.generateId());
+    }
+    localStorage.setItem(this.name + this.sep + model.id, JSON.stringify(model.toJSON ? model.toJSON(options) : model));
+    this.records.push(model.id.toString());
+    this.save();
+    return model;
+  };
+
+  Store.prototype.update = function(model, options) {
+    localStorage.setItem(this.name + this.sep + model.id, JSON.stringify(model.toJSON ? model.toJSON(options) : model));
+    if (!_.include(this.records, model.id.toString())) {
+      this.records.push(model.id.toString());
+    }
+    this.save();
+    return model;
+  };
+
+  Store.prototype.clear = function() {
+    var store, ref;
+    store = this;
+    ref = this.records;
+    ref.map(function(id, i) {
+      id = ref[i];
+      localStorage.removeItem(store.name + store.sep + id);
+    });
+    this.records = [];
+    return this.save();
+  };
+
+  Store.prototype.hasDirtyOrDestroyed = function() {
+    return !_.isEmpty(localStorage.getItem(this.name + '_dirty')) || !_.isEmpty(localStorage.getItem(this.name + '_destroyed'));
+  };
+
+  Store.prototype.find = function(model) {
+    var modelAsJson;
+    modelAsJson = localStorage.getItem(this.name + this.sep + model.id);
+    if (modelAsJson === null) {
+      return null;
+    }
+    return JSON.parse(modelAsJson);
+  };
+
+  Store.prototype.findAll = function() {
+    var store, ref, results;
+    store = this;
+    ref = this.records;
+    results = [];
+    ref.map(function(id, i) {
+      results.push(JSON.parse(localStorage.getItem(store.name + store.sep + id)));
+    });
+    return results;
+  };
+
+  Store.prototype.destroy = function(model) {
+    localStorage.removeItem(this.name + this.sep + model.id);
+    this.records = _.reject(this.records, function(record_id) {
+      return record_id === model.id.toString();
+    });
+    this.save();
+    return model;
+  };
+
+  return Store;
+}();
+
+RB.Store.exists = function(storeName) {
+  return localStorage.getItem(storeName) !== null;
+};
\ No newline at end of file
--- a/reviewboard/reviewboard/static/rb/js/views/reviewDialogView_mozreview.js
+++ b/reviewboard/reviewboard/static/rb/js/views/reviewDialogView_mozreview.js
@@ -777,16 +777,20 @@ RB.ReviewDialogView = Backbone.View.exte
     /*
      * Loads the comments from the server.
      *
      * This will begin chaining together the loads of each set of
      * comment types. Each loaded comment will be rendered to the
      * dialog once loaded.
      */
     _loadComments: function() {
+        //sync the offline comments to ensure all comments are loaded correctly.
+        var diffComment = new RB.DiffComment({parentObject: this.model});
+        diffComment.syncDirtyAndDestroyed();
+
         var collections = [
             this._screenshotCommentsCollection,
             this._fileAttachmentCommentsCollection,
             this._diffCommentsCollection
         ];
 
         this._loadCommentsFromCollection(collections, function() {
             this._$spinner.remove();
--- a/reviewboard/reviewboard/staticbundles.py
+++ b/reviewboard/reviewboard/staticbundles.py
@@ -114,16 +114,18 @@ PIPELINE_JS = dict({
             'rb/js/collections/filteredCollection.js',
             'rb/js/extensions/models/aliases.js',
             'rb/js/extensions/models/commentDialogHookModel.js',
             'rb/js/extensions/models/reviewDialogCommentHookModel.js',
             'rb/js/extensions/models/reviewDialogHookModel.js',
             'rb/js/pages/models/pageManagerModel.js',
             'rb/js/resources/utils/serializers.js',
             'rb/js/resources/models/baseResourceModel.js',
+            'rb/js/resources/utils/localStore_mozreview.js',
+            'rb/js/resources/models/localResourceModel_mozreview.js',
             'rb/js/resources/models/apiTokenModel.js',
             'rb/js/resources/models/repositoryBranchModel.js',
             'rb/js/resources/models/repositoryCommitModel.js',
             'rb/js/resources/models/draftResourceChildModelMixin.js',
             'rb/js/resources/models/draftResourceModelMixin.js',
             'rb/js/resources/models/draftReviewRequestModel.js',
             'rb/js/resources/models/reviewModel.js',
             'rb/js/resources/models/draftReviewModel_mozreview.js',