Convert gr-storage to service

Change-Id: Iaf731da59e52eee9d62badcf57f7a5fc76d8933d
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 7fb09df..e0e2e46 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -21,7 +21,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-formatted-text/gr-formatted-text';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../shared/gr-storage/gr-storage';
 import '../../shared/gr-account-list/gr-account-list';
 import '../gr-label-scores/gr-label-scores';
 import '../gr-thread-list/gr-thread-list';
@@ -98,7 +97,6 @@
 import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {GrStorage, StorageLocation} from '../../shared/gr-storage/gr-storage';
 import {isAttentionSetEnabled} from '../../../utils/attention-set-util';
 import {
   CODE_REVIEW,
@@ -114,6 +112,7 @@
 } from '../../../utils/event-util';
 import {ErrorCallback} from '../../../api/rest';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {StorageLocation} from '../../../services/storage/gr-storage';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -374,7 +373,7 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  private readonly storage = new GrStorage();
+  private readonly storage = appContext.storageService;
 
   private readonly jsAPI = appContext.jsApiService;
 
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
index d0d40ca..c1e2564 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -18,7 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import {IronOverlayManager} from '@polymer/iron-overlay-behavior/iron-overlay-manager.js';
 import './gr-reply-dialog.js';
-import {mockPromise} from '../../../test/test-utils.js';
+import {mockPromise, stubStorage} from '../../../test/test-utils.js';
 import {SpecialFilePath} from '../../../constants/constants.js';
 import {appContext} from '../../../services/app-context.js';
 import {addListenerForTest} from '../../../test/test-utils.js';
@@ -113,9 +113,9 @@
       ],
     };
 
-    getDraftCommentStub = sinon.stub(element.storage, 'getDraftComment');
-    setDraftCommentStub = sinon.stub(element.storage, 'setDraftComment');
-    eraseDraftCommentStub = sinon.stub(element.storage, 'eraseDraftComment');
+    getDraftCommentStub = stubStorage('getDraftComment');
+    setDraftCommentStub = stubStorage('setDraftComment');
+    eraseDraftCommentStub = stubStorage('eraseDraftComment');
 
     // sinon.stub(patchSetUtilMockProxy, 'fetchChangeUpdates')
     //     .returns(Promise.resolve({isLatest: true}));
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index 7c4e8d6..fb8c2e0 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -18,7 +18,6 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-editable-label/gr-editable-label';
-import '../../shared/gr-storage/gr-storage';
 import '../gr-default-editor/gr-default-editor';
 import '../../../styles/shared-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -38,7 +37,6 @@
   NumericChangeId,
   EditPatchSetNum,
 } from '../../../types/common';
-import {GrStorage} from '../../shared/gr-storage/gr-storage';
 import {HttpMethod, NotifyType} from '../../../constants/constants';
 import {fireAlert, fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
@@ -117,7 +115,7 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  private readonly storage = new GrStorage();
+  private readonly storage = appContext.storageService;
 
   private storeTask?: DelayedTask;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index a57bdfa..7e67fb8 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
-import '../gr-storage/gr-storage';
 import '../gr-comment/gr-comment';
 import '../../diff/gr-diff/gr-diff';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
@@ -51,7 +50,6 @@
 } from '../../../types/common';
 import {GrComment} from '../gr-comment/gr-comment';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
 import {CustomKeyboardEvent} from '../../../types/events';
 import {LineNumber, FILE} from '../../diff/gr-diff/gr-diff-line';
 import {GrButton} from '../gr-button/gr-button';
@@ -61,6 +59,7 @@
 import {check, assertIsDefined} from '../../../utils/common-util';
 import {waitForEventOnce} from '../../../utils/event-util';
 import {GrSyntaxLayer} from '../../diff/gr-syntax-layer/gr-syntax-layer';
+import {StorageLocation} from '../../../services/storage/gr-storage';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -208,7 +207,7 @@
 
   flagsService = appContext.flagsService;
 
-  readonly storage = new GrStorage();
+  readonly storage = appContext.storageService;
 
   private readonly syntaxLayer = new GrSyntaxLayer();
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 8b15203..35b0d8c 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -44,8 +44,8 @@
   tap,
   pressAndReleaseKeyOn,
 } from '@polymer/iron-test-helpers/mock-interactions';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-import {stubRestApi} from '../../../test/test-utils';
+import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {stubRestApi, stubStorage} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-comment-thread');
 
@@ -652,7 +652,7 @@
           __draft: true,
         },
       ];
-      const storageStub = sinon.stub(element.storage, 'setDraftComment');
+      const storageStub = stubStorage('setDraftComment');
       flush();
 
       const draftEl = element.root?.querySelectorAll('gr-comment')[1];
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 7fb85a4..b7f1bcc 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -24,7 +24,6 @@
 import '../gr-formatted-text/gr-formatted-text';
 import '../gr-icons/gr-icons';
 import '../gr-overlay/gr-overlay';
-import '../gr-storage/gr-storage';
 import '../gr-textarea/gr-textarea';
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
@@ -38,7 +37,6 @@
 import {customElement, observe, property} from '@polymer/decorators';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {GrTextarea} from '../gr-textarea/gr-textarea';
-import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
 import {GrOverlay} from '../gr-overlay/gr-overlay';
 import {
   AccountDetailInfo,
@@ -62,6 +60,7 @@
 import {pluralize} from '../../../utils/string-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {StorageLocation} from '../../../services/storage/gr-storage';
 
 const STORAGE_DEBOUNCE_INTERVAL = 400;
 const TOAST_DEBOUNCE_INTERVAL = 200;
@@ -269,7 +268,7 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  private readonly storage = new GrStorage();
+  private readonly storage = appContext.storageService;
 
   reporting = appContext.reportingService;
 
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
index b5205a6..be647ab 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
@@ -20,7 +20,7 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {__testOnly_UNSAVED_MESSAGE} from './gr-comment.js';
 import {SpecialFilePath, Side} from '../../../constants/constants.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import {stubRestApi, stubStorage} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-comment');
 
@@ -115,7 +115,7 @@
     });
 
     test('message is not retrieved from storage when other edits', done => {
-      const storageStub = sinon.stub(element.storage, 'getDraftComment');
+      const storageStub = stubStorage('getDraftComment');
       const loadSpy = sinon.spy(element, '_loadLocalDraft');
 
       element.changeNum = 1;
@@ -135,7 +135,7 @@
     });
 
     test('message is retrieved from storage when no other edits', done => {
-      const storageStub = sinon.stub(element.storage, 'getDraftComment');
+      const storageStub = stubStorage('getDraftComment');
       const loadSpy = sinon.spy(element, '_loadLocalDraft');
 
       element.changeNum = 1;
@@ -1022,8 +1022,8 @@
 
     test('cancelling an unsaved draft discards, persists in storage', () => {
       const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = sinon.stub(element.storage, 'setDraftComment');
-      const eraseStub = sinon.stub(element.storage, 'eraseDraftComment');
+      const storeStub = stubStorage('setDraftComment');
+      const eraseStub = stubStorage('eraseDraftComment');
       element._messageText = 'test text';
       flush();
       element.storeTask.flush();
@@ -1038,7 +1038,7 @@
     test('cancelling edit on a saved draft does not store', () => {
       element.comment.id = 'foo';
       const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = sinon.stub(element.storage, 'setDraftComment');
+      const storeStub = stubStorage('setDraftComment');
       element._messageText = 'test text';
       flush();
       if (element.storeTask) element.storeTask.flush();
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 12f07ba..096697f 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -16,9 +16,7 @@
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
-import '../gr-storage/gr-storage';
 import '../gr-button/gr-button';
-import {GrStorage} from '../gr-storage/gr-storage';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-editable-content_html';
@@ -111,7 +109,7 @@
   @property({type: Boolean})
   _isNewChangeSummaryUiEnabled = false;
 
-  private readonly storage = new GrStorage();
+  private readonly storage = appContext.storageService;
 
   private readonly flagsService = appContext.flagsService;
 
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 0369ccf..479ab88 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -23,6 +23,7 @@
 import {ChangeService} from './change/change-service';
 import {ChecksService} from './checks/checks-service';
 import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
+import {GrStorageService} from './storage/gr-storage_impl';
 
 type ServiceName = keyof AppContext;
 type ServiceCreator<T> = () => T;
@@ -73,5 +74,6 @@
     changeService: () => new ChangeService(),
     checksService: () => new ChecksService(),
     jsApiService: () => new GrJsApiInterface(),
+    storageService: () => new GrStorageService(),
   });
 }
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 1f618fd..cf186f0 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -22,6 +22,7 @@
 import {ChangeService} from './change/change-service';
 import {ChecksService} from './checks/checks-service';
 import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
+import {StorageService} from './storage/gr-storage';
 
 export interface AppContext {
   flagsService: FlagsService;
@@ -32,6 +33,7 @@
   changeService: ChangeService;
   checksService: ChecksService;
   jsApiService: JsApiService;
+  storageService: StorageService;
 }
 
 /**
diff --git a/polygerrit-ui/app/services/storage/gr-storage.ts b/polygerrit-ui/app/services/storage/gr-storage.ts
new file mode 100644
index 0000000..08a3387
--- /dev/null
+++ b/polygerrit-ui/app/services/storage/gr-storage.ts
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {CommentRange, PatchSetNum} from '../../types/common';
+
+export interface StorageLocation {
+  changeNum: number;
+  patchNum: PatchSetNum | '@change';
+  path?: string;
+  line?: number;
+  range?: CommentRange;
+}
+
+export interface StorageObject {
+  message?: string;
+  updated: number;
+}
+
+export interface StorageService {
+  getDraftComment(location: StorageLocation): StorageObject | null;
+
+  setDraftComment(location: StorageLocation, message: string): void;
+
+  eraseDraftComment(location: StorageLocation): void;
+
+  getEditableContentItem(key: string): StorageObject | null;
+
+  setEditableContentItem(key: string, message: string): void;
+
+  getRespectfulTipVisibility(): StorageObject | null;
+
+  setRespectfulTipVisibility(delayDays?: number): void;
+
+  eraseEditableContentItem(key: string): void;
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
similarity index 72%
rename from polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
rename to polygerrit-ui/app/services/storage/gr-storage_impl.ts
index a86d8f2..0c0d151 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
@@ -14,22 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {CommentRange, PatchSetNum} from '../../../types/common';
 
-export interface StorageLocation {
-  changeNum: number;
-  patchNum: PatchSetNum | '@change';
-  path?: string;
-  line?: number;
-  range?: CommentRange;
-}
+import {StorageLocation, StorageObject, StorageService} from './gr-storage';
 
-export interface StorageObject {
-  message?: string;
-  updated: number;
-}
-
-const DURATION_DAY = 24 * 60 * 60 * 1000;
+export const DURATION_DAY = 24 * 60 * 60 * 1000;
 
 // Clean up old entries no more frequently than one day.
 const CLEANUP_THROTTLE_INTERVAL = DURATION_DAY;
@@ -39,7 +27,7 @@
 CLEANUP_PREFIXES_MAX_AGE_MAP.set('draft', DURATION_DAY);
 CLEANUP_PREFIXES_MAX_AGE_MAP.set('editablecontent', DURATION_DAY);
 
-export class GrStorage {
+export class GrStorageService implements StorageService {
   private lastCleanup = 0;
 
   private readonly storage = window.localStorage;
@@ -47,49 +35,49 @@
   private exceededQuota = false;
 
   getDraftComment(location: StorageLocation): StorageObject | null {
-    this._cleanupItems();
-    return this._getObject(this._getDraftKey(location));
+    this.cleanupItems();
+    return this.getObject(this.getDraftKey(location));
   }
 
   setDraftComment(location: StorageLocation, message: string) {
-    const key = this._getDraftKey(location);
-    this._setObject(key, {message, updated: Date.now()});
+    const key = this.getDraftKey(location);
+    this.setObject(key, {message, updated: Date.now()});
   }
 
   eraseDraftComment(location: StorageLocation) {
-    const key = this._getDraftKey(location);
+    const key = this.getDraftKey(location);
     this.storage.removeItem(key);
   }
 
   getEditableContentItem(key: string): StorageObject | null {
-    this._cleanupItems();
-    return this._getObject(this._getEditableContentKey(key));
+    this.cleanupItems();
+    return this.getObject(this.getEditableContentKey(key));
   }
 
   setEditableContentItem(key: string, message: string) {
-    this._setObject(this._getEditableContentKey(key), {
+    this.setObject(this.getEditableContentKey(key), {
       message,
       updated: Date.now(),
     });
   }
 
   getRespectfulTipVisibility(): StorageObject | null {
-    this._cleanupItems();
-    return this._getObject('respectfultip:visibility');
+    this.cleanupItems();
+    return this.getObject('respectfultip:visibility');
   }
 
   setRespectfulTipVisibility(delayDays = 0) {
-    this._cleanupItems();
-    this._setObject('respectfultip:visibility', {
+    this.cleanupItems();
+    this.setObject('respectfultip:visibility', {
       updated: Date.now() + delayDays * DURATION_DAY,
     });
   }
 
   eraseEditableContentItem(key: string) {
-    this.storage.removeItem(this._getEditableContentKey(key));
+    this.storage.removeItem(this.getEditableContentKey(key));
   }
 
-  _getDraftKey(location: StorageLocation): string {
+  private getDraftKey(location: StorageLocation): string {
     const range = location.range
       ? `${location.range.start_line}-${location.range.start_character}` +
         `-${location.range.end_character}-${location.range.end_line}`
@@ -107,11 +95,11 @@
     return key;
   }
 
-  _getEditableContentKey(key: string): string {
+  private getEditableContentKey(key: string): string {
     return `editablecontent:${key}`;
   }
 
-  _cleanupItems() {
+  private cleanupItems() {
     // Throttle cleanup to the throttle interval.
     if (
       this.lastCleanup &&
@@ -125,7 +113,7 @@
       const entries = CLEANUP_PREFIXES_MAX_AGE_MAP.entries();
       for (const [prefix, expiration] of entries) {
         if (key.startsWith(prefix)) {
-          const item = this._getObject(key);
+          const item = this.getObject(key);
           if (!item || Date.now() - item.updated > expiration) {
             this.storage.removeItem(key);
           }
@@ -134,7 +122,7 @@
     });
   }
 
-  _getObject(key: string): StorageObject | null {
+  private getObject(key: string): StorageObject | null {
     const serial = this.storage.getItem(key);
     if (!serial) {
       return null;
@@ -142,7 +130,7 @@
     return JSON.parse(serial) as StorageObject;
   }
 
-  _setObject(key: string, obj: StorageObject) {
+  private setObject(key: string, obj: StorageObject) {
     if (this.exceededQuota) {
       return;
     }
diff --git a/polygerrit-ui/app/services/storage/gr-storage_mock.ts b/polygerrit-ui/app/services/storage/gr-storage_mock.ts
new file mode 100644
index 0000000..02215a8
--- /dev/null
+++ b/polygerrit-ui/app/services/storage/gr-storage_mock.ts
@@ -0,0 +1,91 @@
+/**
+ * @license
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {StorageLocation, StorageObject, StorageService} from './gr-storage';
+import {DURATION_DAY} from './gr-storage_impl';
+
+const storage = new Map();
+
+const getDraftKey = (location: StorageLocation): string => {
+  const range = location.range
+    ? `${location.range.start_line}-${location.range.start_character}` +
+      `-${location.range.end_character}-${location.range.end_line}`
+    : null;
+  let key = [
+    'draft',
+    location.changeNum,
+    location.patchNum,
+    location.path,
+    location.line || '',
+  ].join(':');
+  if (range) {
+    key = key + ':' + range;
+  }
+  return key;
+};
+
+const getEditableContentKey = (key: string): string => {
+  return `editablecontent:${key}`;
+};
+
+export function cleanUpStorage() {
+  storage.clear();
+}
+
+export const grStorageMock: StorageService = {
+  getDraftComment(location: StorageLocation): StorageObject | null {
+    return storage.get(getDraftKey(location));
+  },
+
+  setDraftComment(location: StorageLocation, message: string) {
+    const key = getDraftKey(location);
+    storage.set(key, {message, updated: Date.now()});
+  },
+
+  eraseDraftComment(location: StorageLocation) {
+    const key = getDraftKey(location);
+    storage.delete(key);
+  },
+
+  getEditableContentItem(key: string): StorageObject | null {
+    return storage.get(getEditableContentKey(key));
+  },
+
+  setEditableContentItem(key: string, message: string): void {
+    storage.set(
+      getEditableContentKey(key),
+      JSON.stringify({
+        message,
+        updated: Date.now(),
+      })
+    );
+  },
+
+  getRespectfulTipVisibility(): StorageObject | null {
+    return storage.get('respectfultip:visibility');
+  },
+
+  setRespectfulTipVisibility(delayDays = 0): void {
+    storage.set('respectfultip:visibility', {
+      updated: Date.now() + delayDays * DURATION_DAY,
+    });
+  },
+
+  eraseEditableContentItem(key: string): void {
+    storage.delete(getEditableContentKey(key));
+  },
+};
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js b/polygerrit-ui/app/services/storage/gr-storage_test.js
similarity index 87%
rename from polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js
rename to polygerrit-ui/app/services/storage/gr-storage_test.js
index 64d3750..6cbfacf 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.js
+++ b/polygerrit-ui/app/services/storage/gr-storage_test.js
@@ -15,8 +15,8 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import {GrStorage} from './gr-storage.js';
+import '../../test/common-test-setup-karma.js';
+import {GrStorageService} from './gr-storage_impl.js';
 
 suite('gr-storage tests', () => {
   let grStorage;
@@ -34,7 +34,7 @@
   }
 
   setup(() => {
-    grStorage = new GrStorage();
+    grStorage = new GrStorageService();
     grStorage.storage = mockStorage();
   });
 
@@ -51,7 +51,7 @@
     };
 
     // The key is in the expected format.
-    const key = grStorage._getDraftKey(location);
+    const key = grStorage.getDraftKey(location);
     assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
 
     // There should be no draft initially.
@@ -82,12 +82,12 @@
       line,
     };
 
-    const key = grStorage._getDraftKey(location);
+    const key = grStorage.getDraftKey(location);
 
     // Make sure that the call to cleanup doesn't get throttled.
     grStorage.lastCleanup = 0;
 
-    const cleanupSpy = sinon.spy(grStorage, '_cleanupItems');
+    const cleanupSpy = sinon.spy(grStorage, 'cleanupItems');
 
     // Create a message with a timestamp that is a second behind the max age.
     grStorage.storage.setItem(key, JSON.stringify({
@@ -103,7 +103,7 @@
     assert.isNotOk(grStorage.storage.getItem(key));
   });
 
-  test('_getDraftKey', () => {
+  test('getDraftKey', () => {
     const changeNum = 1234;
     const patchNum = 5;
     const path = 'my_source_file.js';
@@ -115,7 +115,7 @@
       line,
     };
     let expectedResult = 'draft:1234:5:my_source_file.js:123';
-    assert.equal(grStorage._getDraftKey(location), expectedResult);
+    assert.equal(grStorage.getDraftKey(location), expectedResult);
     location.range = {
       start_character: 1,
       start_line: 1,
@@ -123,7 +123,7 @@
       end_line: 2,
     };
     expectedResult = 'draft:1234:5:my_source_file.js:123:1-1-1-2';
-    assert.equal(grStorage._getDraftKey(location), expectedResult);
+    assert.equal(grStorage.getDraftKey(location), expectedResult);
   });
 
   test('exceeded quota disables storage', () => {
@@ -140,16 +140,16 @@
       path,
       line,
     };
-    const key = grStorage._getDraftKey(location);
+    const key = grStorage.getDraftKey(location);
     grStorage.setDraftComment(location, 'my comment');
     assert.isTrue(grStorage.exceededQuota);
     assert.isNotOk(grStorage.storage.getItem(key));
   });
 
   test('editable content items', () => {
-    const cleanupStub = sinon.stub(grStorage, '_cleanupItems');
+    const cleanupStub = sinon.stub(grStorage, 'cleanupItems');
     const key = 'testKey';
-    const computedKey = grStorage._getEditableContentKey(key);
+    const computedKey = grStorage.getEditableContentKey(key);
     // Key correctly computed.
     assert.equal(computedKey, 'editablecontent:testKey');
 
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 848eeaf..34a13ba 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -43,6 +43,7 @@
   installPolymerResin,
 } from '../scripts/polymer-resin-install';
 import {_testOnly_allTasks} from '../utils/async-util';
+import {cleanUpStorage} from '../services/storage/gr-storage_mock';
 
 declare global {
   interface Window {
@@ -204,6 +205,7 @@
   checkGlobalSpace();
   removeIronOverlayBackdropStyleEl();
   cancelAllTasks();
+  cleanUpStorage();
   const testTeardownTimestampMs = new Date().getTime();
   const elapsedMs = testTeardownTimestampMs - testSetupTimestampMs;
   if (elapsedMs > 1000) {
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 57afd8a..d74a9c1 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -20,6 +20,7 @@
 import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
 import {AppContext, appContext} from '../services/app-context';
 import {grRestApiMock} from './mocks/gr-rest-api_mock';
+import {grStorageMock} from '../services/storage/gr-storage_mock';
 
 export function _testOnlyInitAppContext() {
   initAppContext();
@@ -36,4 +37,5 @@
   }
   setMock('reportingService', grReportingMock);
   setMock('restApiService', grRestApiMock);
+  setMock('storageService', grStorageMock);
 }
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 50f465f..26b99ac 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -24,6 +24,7 @@
 import {appContext} from '../services/app-context';
 import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 import {SinonSpy} from 'sinon/pkg/sinon-esm';
+import {StorageService} from '../services/storage/gr-storage';
 
 export interface MockPromise extends Promise<unknown> {
   resolve: (value?: unknown) => void;
@@ -165,6 +166,10 @@
   return sinon.spy(appContext.restApiService, method);
 }
 
+export function stubStorage<K extends keyof StorageService>(method: K) {
+  return sinon.stub(appContext.storageService, method);
+}
+
 export type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
   Parameters<F>,
   ReturnType<F>