Save comment drafts locally if they are abandoned

If the user starts writing a diff comment, but discards it or navigates
away before saving it as a draft, then the text that had been entered
re-appears if the user starts a comment on the same line of the same
file of the same patch-set of the same change.

Achieves this by storing the comment text in localStorage along with a
timestamp whenever the textarea is edited by the user. The entry is
cleared from localStorage if the user saves the comment as a draft. When
a new comment is started, the gr-diff-comment checks localStorage to see
whether a relevant entry exists to use as the initial text.

Adds the gr-storage element as an interface for localStorage. This
element clears away stored comment drafts if they are more than a day
old.

Bug: Issue 3787
Change-Id: I11327a69d463a6a84a0cd8d59f4662a6a4c296a6
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
index 646dfde..c28d0ac 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -14,6 +14,8 @@
 (function() {
   'use strict';
 
+  var STORAGE_DEBOUNCE_INTERVAL = 400;
+
   Polymer({
     is: 'gr-diff-comment',
 
@@ -67,17 +69,26 @@
       projectConfig: Object,
 
       _xhrPromise: Object,  // Used for testing.
-      _editDraft: String,
+      _editDraft: {
+        type: String,
+        observer: '_editDraftChanged',
+      },
     },
 
     ready: function() {
-      this._editDraft = (this.comment && this.comment.message) || '';
-      this.editing = this._editDraft.length == 0;
+      this._loadLocalDraft().then(function(loadedLocal) {
+        this._editDraft = (this.comment && this.comment.message) || '';
+        this.editing = !this._editDraft.length || loadedLocal;
+      }.bind(this));
     },
 
     save: function() {
       this.comment.message = this._editDraft;
       this.disabled = true;
+
+      this.$.localStorage.eraseDraft(this.changeNum, this.patchNum,
+          this.comment.path, this.comment.line);
+
       this._xhrPromise = this._saveDraft(this.comment).then(function(response) {
         this.disabled = false;
         if (!response.ok) { return response; }
@@ -136,6 +147,25 @@
       }
     },
 
+    _editDraftChanged: function(newValue, oldValue) {
+      if (this.comment && this.comment.id) { return; }
+
+      this.debounce('store', function() {
+        var message = this._editDraft;
+
+        // If the draft has been modified to be empty, then erase the storage
+        // entry.
+        if ((!this._editDraft || !this._editDraft.length) && oldValue) {
+          this.$.localStorage.eraseDraft(this.changeNum, this.patchNum,
+              this.comment.path, this.comment.line);
+          return;
+        }
+
+        this.$.localStorage.setDraft(this.changeNum, this.patchNum,
+            this.comment.path, this.comment.line, message);
+      }.bind(this), STORAGE_DEBOUNCE_INTERVAL);
+    },
+
     _handleLinkTap: function(e) {
       e.preventDefault();
       var hash = this._computeLinkToComment(this.comment);
@@ -220,5 +250,29 @@
       return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
           draft);
     },
+
+    _loadLocalDraft: function() {
+      return new Promise(function(resolve) {
+        this.async(function() {
+          // Only apply local drafts to comments that haven't been saved
+          // remotely, and haven't been given a default message already.
+          if (!this.comment || this.comment.id || this.comment.message) {
+            resolve(false);
+            return;
+          }
+
+          var draft = this.$.localStorage.getDraft(this.changeNum,
+              this.patchNum, this.comment.path, this.comment.line);
+
+          if (draft) {
+            this.comment.message = draft.message;
+            resolve(true);
+            return;
+          }
+
+          resolve(false);
+        }.bind(this));
+      }.bind(this));
+    },
   });
 })();