Convert gr-comment to typescript

The change converts the following files to typescript:

* elements/shared/gr-comment/gr-comment.ts

Change-Id: I34975acf43537d9317462eb721d64a82fd21b441
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
index 179caaa..0fa8e31 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
@@ -789,6 +789,9 @@
 
   suite('_recomputeComments', () => {
     setup(() => {
+      element._changeNum = '1';
+      element._change = {_number: '1'};
+      flushAsynchronousOperations();
       // Fake computeDraftCount as its required for ChangeComments,
       // see gr-comment-api#reloadDrafts.
       sinon.stub(element.$.commentAPI, 'reloadDrafts')
@@ -927,7 +930,9 @@
           rev4: {_number: 4, commit: {parents: []}},
         },
         current_revision: 'rev4',
+        _number: '1',
       };
+      element._changeNum = '1';
       element._commentThreads = THREADS;
       const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
       MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[3]);
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 2828cf1..d240f2c 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -14,30 +14,45 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-import '../../../styles/shared-styles.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../gr-button/gr-button.js';
-import '../gr-dialog/gr-dialog.js';
-import '../gr-date-formatter/gr-date-formatter.js';
-import '../gr-formatted-text/gr-formatted-text.js';
-import '../gr-icons/gr-icons.js';
-import '../gr-overlay/gr-overlay.js';
-import '../gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-storage/gr-storage.js';
-import '../gr-textarea/gr-textarea.js';
-import '../gr-tooltip-content/gr-tooltip-content.js';
-import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js';
-import '../gr-account-label/gr-account-label.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-comment_html.js';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {getRootElement} from '../../../scripts/rootElement.js';
-import {appContext} from '../../../services/app-context.js';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../gr-button/gr-button';
+import '../gr-dialog/gr-dialog';
+import '../gr-date-formatter/gr-date-formatter';
+import '../gr-formatted-text/gr-formatted-text';
+import '../gr-icons/gr-icons';
+import '../gr-overlay/gr-overlay';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+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';
+import '../gr-account-label/gr-account-label';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-comment_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {getRootElement} from '../../../scripts/rootElement';
+import {appContext} from '../../../services/app-context';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrTextarea} from '../gr-textarea/gr-textarea';
+import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
+import {GrOverlay} from '../gr-overlay/gr-overlay';
+import {
+  RobotCommentInfo,
+  PatchSetNum,
+  CommentInfo,
+  ConfigInfo,
+  AccountDetailInfo,
+} from '../../../types/common';
+import {GrButton} from '../gr-button/gr-button';
+import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
+import {GrDialog} from '../gr-dialog/gr-dialog';
 
 const STORAGE_DEBOUNCE_INTERVAL = 400;
 const TOAST_DEBOUNCE_INTERVAL = 200;
@@ -59,7 +74,7 @@
 /**
  * All candidates tips to show, will pick randomly.
  */
-const RESPECTFUL_REVIEW_TIPS= [
+const RESPECTFUL_REVIEW_TIPS = [
   'Assume competence.',
   'Provide rationale or context.',
   'Consider how comments may be interpreted.',
@@ -68,14 +83,39 @@
   'When disagreeing, explain the advantage of your approach.',
 ];
 
-/**
- * @extends PolymerElement
- */
-class GrComment extends KeyboardShortcutMixin(GestureEventListeners(
-    LegacyElementMixin(PolymerElement))) {
-  static get template() { return htmlTemplate; }
+interface Draft {
+  collapsed?: boolean;
+  __editing?: boolean;
+  __otherEditing?: boolean;
+  __draft?: boolean;
+  __draftID?: number;
+  __commentSide?: string;
+}
 
-  static get is() { return 'gr-comment'; }
+export type Comment = Draft & CommentInfo;
+export type RobotComment = Draft & RobotCommentInfo;
+
+interface CommentOverlays {
+  confirmDelete?: GrOverlay | null;
+  confirmDiscard?: GrOverlay | null;
+}
+
+export interface GrComment {
+  $: {
+    restAPI: RestApiService & Element;
+    storage: GrStorage;
+    container: HTMLDivElement;
+    resolvedCheckbox: HTMLInputElement;
+  };
+}
+@customElement('gr-comment')
+export class GrComment extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
   /**
    * Fired when the create fix comment action is triggered.
    *
@@ -118,138 +158,132 @@
    * @event comment-anchor-tap
    */
 
-  static get properties() {
-    return {
-      changeNum: String,
-      /** @type {!Gerrit.Comment} */
-      comment: {
-        type: Object,
-        notify: true,
-        observer: '_commentChanged',
-      },
-      comments: {
-        type: Array,
-      },
-      isRobotComment: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      disabled: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      draft: {
-        type: Boolean,
-        value: false,
-        observer: '_draftChanged',
-      },
-      editing: {
-        type: Boolean,
-        value: false,
-        observer: '_editingChanged',
-      },
-      discarding: {
-        type: Boolean,
-        value: false,
-        reflectToAttribute: true,
-      },
-      hasChildren: Boolean,
-      patchNum: String,
-      showActions: Boolean,
-      _showHumanActions: Boolean,
-      _showRobotActions: Boolean,
-      collapsed: {
-        type: Boolean,
-        value: true,
-        reflectToAttribute: true,
-        observer: '_toggleCollapseClass',
-      },
-      /** @type {?} */
-      projectConfig: Object,
-      robotButtonDisabled: Boolean,
-      _hasHumanReply: Boolean,
-      _isAdmin: {
-        type: Boolean,
-        value: false,
-      },
+  @property({type: Number})
+  changeNum!: number;
 
-      _xhrPromise: Object, // Used for testing.
-      _messageText: {
-        type: String,
-        value: '',
-        observer: '_messageTextChanged',
-      },
-      commentSide: String,
-      side: String,
+  @property({type: Object, notify: true, observer: '_commentChanged'})
+  comment!: Comment | RobotComment;
 
-      resolved: Boolean,
+  @property({type: Array})
+  comments?: (Comment | RobotComment)[];
 
-      _numPendingDraftRequests: {
-        type: Object,
-        value:
-          {number: 0}, // Intentional to share the object across instances.
-      },
+  @property({type: Boolean, reflectToAttribute: true})
+  isRobotComment = false;
 
-      _enableOverlay: {
-        type: Boolean,
-        value: false,
-      },
+  @property({type: Boolean, reflectToAttribute: true})
+  disabled = false;
 
-      /**
-       * Property for storing references to overlay elements. When the overlays
-       * are moved to getRootElement() to be shown they are no-longer
-       * children, so they can't be queried along the tree, so they are stored
-       * here.
-       */
-      _overlays: {
-        type: Object,
-        value: () => { return {}; },
-      },
+  @property({type: Boolean, observer: '_draftChanged'})
+  draft = false;
 
-      _showRespectfulTip: {
-        type: Boolean,
-        value: false,
-      },
-      showPatchset: {
-        type: Boolean,
-        value: true,
-      },
-      _respectfulReviewTip: String,
-      _respectfulTipDismissed: {
-        type: Boolean,
-        value: false,
-      },
-      _unableToSave: {
-        type: Boolean,
-        value: false,
-      },
-      _selfAccount: Object,
-    };
-  }
+  @property({type: Boolean, observer: '_editingChanged'})
+  editing = false;
 
-  static get observers() {
-    return [
-      '_commentMessageChanged(comment.message)',
-      '_loadLocalDraft(changeNum, patchNum, comment)',
-      '_isRobotComment(comment)',
-      '_calculateActionstoShow(showActions, isRobotComment)',
-      '_computeHasHumanReply(comment, comments.*)',
-      '_onEditingChange(editing)',
-    ];
-  }
+  @property({type: Boolean, reflectToAttribute: true})
+  discarding = false;
+
+  @property({type: Boolean})
+  hasChildren?: boolean;
+
+  @property({type: String})
+  patchNum!: PatchSetNum;
+
+  @property({type: Boolean})
+  showActions?: boolean;
+
+  @property({type: Boolean})
+  _showHumanActions?: boolean;
+
+  @property({type: Boolean})
+  _showRobotActions?: boolean;
+
+  @property({
+    type: Boolean,
+    reflectToAttribute: true,
+    observer: '_toggleCollapseClass',
+  })
+  collapsed = true;
+
+  @property({type: Object})
+  projectConfig?: ConfigInfo;
+
+  @property({type: Boolean})
+  robotButtonDisabled?: boolean;
+
+  @property({type: Boolean})
+  _hasHumanReply?: boolean;
+
+  @property({type: Boolean})
+  _isAdmin = false;
+
+  @property({type: Object})
+  _xhrPromise?: Promise<any>; // Used for testing.
+
+  @property({type: String, observer: '_messageTextChanged'})
+  _messageText = '';
+
+  @property({type: String})
+  commentSide?: string;
+
+  @property({type: String})
+  side?: string;
+
+  @property({type: Boolean})
+  resolved?: boolean;
+
+  // Intentional to share the object across instances.
+  @property({type: Object})
+  _numPendingDraftRequests: {number: number} = {number: 0};
+
+  @property({type: Boolean})
+  _enableOverlay = false;
+
+  /**
+   * Property for storing references to overlay elements. When the overlays
+   * are moved to getRootElement() to be shown they are no-longer
+   * children, so they can't be queried along the tree, so they are stored
+   * here.
+   */
+  @property({type: Object})
+  _overlays: CommentOverlays = {};
+
+  @property({type: Boolean})
+  _showRespectfulTip = false;
+
+  @property({type: Boolean})
+  showPatchset = true;
+
+  @property({type: String})
+  _respectfulReviewTip?: string;
+
+  @property({type: Boolean})
+  _respectfulTipDismissed = false;
+
+  @property({type: Boolean})
+  _unableToSave = false;
+
+  @property({type: Object})
+  _selfAccount?: AccountDetailInfo;
 
   get keyBindings() {
     return {
       'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
-      'esc': '_handleEsc',
+      esc: '_handleEsc',
     };
   }
 
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
+  reporting = appContext.reportingService;
+
+  /** @override */
+  ready() {
+    super.ready();
+    if (
+      this.changeNum === undefined ||
+      this.patchNum === undefined ||
+      this.comment === undefined
+    ) {
+      throw new Error('Not all required properties are defined.');
+    }
   }
 
   /** @override */
@@ -261,10 +295,10 @@
     if (this.editing) {
       this.collapsed = false;
     } else if (this.comment) {
-      this.collapsed = this.comment.collapsed;
+      this.collapsed = !!this.comment.collapsed;
     }
     this._getIsAdmin().then(isAdmin => {
-      this._isAdmin = isAdmin;
+      this._isAdmin = !!isAdmin;
     });
   }
 
@@ -277,51 +311,51 @@
     }
   }
 
-  _getAuthor(comment) {
+  _getAuthor(comment: Comment) {
     return comment.author || this._selfAccount;
   }
 
-  _onEditingChange(editing) {
-    this.dispatchEvent(new CustomEvent('comment-editing-changed', {
-      detail: !!editing,
-      bubbles: true,
-      composed: true,
-    }));
+  @observe('editing')
+  _onEditingChange(editing?: boolean) {
+    this.dispatchEvent(
+      new CustomEvent('comment-editing-changed', {
+        detail: !!editing,
+        bubbles: true,
+        composed: true,
+      })
+    );
     if (!editing) return;
     // visibility based on cache this will make sure we only and always show
     // a tip once every Math.max(a day, period between creating comments)
-    const cachedVisibilityOfRespectfulTip =
-      this.$.storage.getRespectfulTipVisibility();
+    const cachedVisibilityOfRespectfulTip = this.$.storage.getRespectfulTipVisibility();
     if (!cachedVisibilityOfRespectfulTip) {
       // we still want to show the tip with a probability of 30%
       if (this.getRandomNum(0, 3) >= 1) return;
       this._showRespectfulTip = true;
       const randomIdx = this.getRandomNum(0, RESPECTFUL_REVIEW_TIPS.length);
       this._respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
-      this.reporting.reportInteraction(
-          'respectful-tip-appeared',
-          {tip: this._respectfulReviewTip}
-      );
+      this.reporting.reportInteraction('respectful-tip-appeared', {
+        tip: this._respectfulReviewTip,
+      });
       // update cache
       this.$.storage.setRespectfulTipVisibility();
     }
   }
 
   /** Set as a separate method so easy to stub. */
-  getRandomNum(min, max) {
+  getRandomNum(min: number, max: number) {
     return Math.floor(Math.random() * (max - min) + min);
   }
 
-  _computeVisibilityOfTip(showTip, tipDismissed) {
+  _computeVisibilityOfTip(showTip: boolean, tipDismissed: boolean) {
     return showTip && !tipDismissed;
   }
 
   _dismissRespectfulTip() {
     this._respectfulTipDismissed = true;
-    this.reporting.reportInteraction(
-        'respectful-tip-dismissed',
-        {tip: this._respectfulReviewTip}
-    );
+    this.reporting.reportInteraction('respectful-tip-dismissed', {
+      tip: this._respectfulReviewTip,
+    });
     // add a 14-day delay to the tip cache
     this.$.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
   }
@@ -330,16 +364,17 @@
     this.reporting.reportInteraction('respectful-read-more-clicked');
   }
 
-  get textarea() {
-    return this.shadowRoot.querySelector('#editTextarea');
+  get textarea(): GrTextarea | null {
+    return this.shadowRoot?.querySelector('#editTextarea') as GrTextarea | null;
   }
 
   get confirmDeleteOverlay() {
     if (!this._overlays.confirmDelete) {
       this._enableOverlay = true;
       flush();
-      this._overlays.confirmDelete = this.shadowRoot
-          .querySelector('#confirmDeleteOverlay');
+      this._overlays.confirmDelete = this.shadowRoot?.querySelector(
+        '#confirmDeleteOverlay'
+      ) as GrOverlay | null;
     }
     return this._overlays.confirmDelete;
   }
@@ -348,21 +383,23 @@
     if (!this._overlays.confirmDiscard) {
       this._enableOverlay = true;
       flush();
-      this._overlays.confirmDiscard = this.shadowRoot
-          .querySelector('#confirmDiscardOverlay');
+      this._overlays.confirmDiscard = this.shadowRoot?.querySelector(
+        '#confirmDiscardOverlay'
+      ) as GrOverlay | null;
     }
     return this._overlays.confirmDiscard;
   }
 
-  _computeShowHideIcon(collapsed) {
+  _computeShowHideIcon(collapsed: boolean) {
     return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
   }
 
-  _computeShowHideAriaLabel(collapsed) {
+  _computeShowHideAriaLabel(collapsed: boolean) {
     return collapsed ? 'Expand' : 'Collapse';
   }
 
-  _calculateActionstoShow(showActions, isRobotComment) {
+  @observe('showActions', 'isRobotComment')
+  _calculateActionstoShow(showActions?: boolean, isRobotComment?: boolean) {
     // Polymer 2: check for undefined
     if ([showActions, isRobotComment].includes(undefined)) {
       return;
@@ -372,7 +409,8 @@
     this._showRobotActions = showActions && isRobotComment;
   }
 
-  _isRobotComment(comment) {
+  @observe('comment')
+  _isRobotComment(comment: RobotComment) {
     this.isRobotComment = !!comment.robot_id;
   }
 
@@ -384,20 +422,18 @@
     return this.$.restAPI.getIsAdmin();
   }
 
-  _computeDraftTooltip(unableToSave) {
-    return unableToSave ? `Unable to save draft. Please try to save again.` :
-      `This draft is only visible to you. To publish drafts, click the 'Reply'`
-    + `or 'Start review' button at the top of the change or press the 'A' key.`;
+  _computeDraftTooltip(unableToSave: boolean) {
+    return unableToSave
+      ? 'Unable to save draft. Please try to save again.'
+      : "This draft is only visible to you. To publish drafts, click the 'Reply'" +
+          "or 'Start review' button at the top of the change or press the 'A' key.";
   }
 
-  _computeDraftText(unableToSave) {
+  _computeDraftText(unableToSave: boolean) {
     return 'DRAFT' + (unableToSave ? '(Failed to save)' : '');
   }
 
-  /**
-   * @param {*=} opt_comment
-   */
-  save(opt_comment) {
+  save(opt_comment?: Comment) {
     let comment = opt_comment;
     if (!comment) {
       comment = this.comment;
@@ -411,29 +447,32 @@
       return this._discardDraft();
     }
 
-    this._xhrPromise = this._saveDraft(comment).then(response => {
-      this.disabled = false;
-      if (!response.ok) { return response; }
-
-      this._eraseDraftComment();
-      return this.$.restAPI.getResponseObject(response).then(obj => {
-        const resComment = obj;
-        resComment.__draft = true;
-        // Maintain the ephemeral draft ID for identification by other
-        // elements.
-        if (this.comment.__draftID) {
-          resComment.__draftID = this.comment.__draftID;
+    this._xhrPromise = this._saveDraft(comment)
+      .then(response => {
+        this.disabled = false;
+        if (!response.ok) {
+          return;
         }
-        resComment.__commentSide = this.commentSide;
-        this.comment = resComment;
-        this._fireSave();
-        return obj;
-      });
-    })
-        .catch(err => {
-          this.disabled = false;
-          throw err;
+
+        this._eraseDraftComment();
+        return this.$.restAPI.getResponseObject(response).then(obj => {
+          const resComment = (obj as unknown) as Comment;
+          resComment.__draft = true;
+          // Maintain the ephemeral draft ID for identification by other
+          // elements.
+          if (this.comment.__draftID) {
+            resComment.__draftID = this.comment.__draftID;
+          }
+          resComment.__commentSide = this.commentSide;
+          this.comment = resComment;
+          this._fireSave();
+          return obj;
         });
+      })
+      .catch(err => {
+        this.disabled = false;
+        throw err;
+      });
 
     return this._xhrPromise;
   }
@@ -443,6 +482,8 @@
     // prior to it being saved.
     this.cancelDebouncer('store');
 
+    if (!this.comment.path || this.comment.line === undefined)
+      throw new Error('Cannot erase Draft Comment');
     this.$.storage.eraseDraftComment({
       changeNum: this.changeNum,
       patchNum: this._getPatchNum(),
@@ -452,57 +493,62 @@
     });
   }
 
-  _commentChanged(comment) {
+  _commentChanged(comment: Comment) {
     this.editing = !!comment.__editing;
     this.resolved = !comment.unresolved;
-    if (this.editing) { // It's a new draft/reply, notify.
+    if (this.editing) {
+      // It's a new draft/reply, notify.
       this._fireUpdate();
     }
   }
 
+  @observe('comment', 'comments.*')
   _computeHasHumanReply() {
     if (!this.comment || !this.comments) return;
     // hide please fix button for robot comment that has human reply
-    this._hasHumanReply = this.comments
-        .some(c => c.in_reply_to && c.in_reply_to === this.comment.id &&
-          !c.robot_id);
+    this._hasHumanReply = this.comments.some(
+      c =>
+        c.in_reply_to &&
+        c.in_reply_to === this.comment.id &&
+        !(c as RobotComment).robot_id
+    );
   }
 
-  /**
-   * @param {!Object=} opt_mixin
-   *
-   * @return {!Object}
-   */
-  _getEventPayload(opt_mixin) {
-    return {...opt_mixin, comment: this.comment,
-      patchNum: this.patchNum};
+  _getEventPayload(opt_mixin?: Record<string, any>) {
+    return {...opt_mixin, comment: this.comment, patchNum: this.patchNum};
   }
 
   _fireSave() {
-    this.dispatchEvent(new CustomEvent('comment-save', {
-      detail: this._getEventPayload(),
-      composed: true, bubbles: true,
-    }));
+    this.dispatchEvent(
+      new CustomEvent('comment-save', {
+        detail: this._getEventPayload(),
+        composed: true,
+        bubbles: true,
+      })
+    );
   }
 
   _fireUpdate() {
     this.debounce('fire-update', () => {
-      this.dispatchEvent(new CustomEvent('comment-update', {
-        detail: this._getEventPayload(),
-        composed: true, bubbles: true,
-      }));
+      this.dispatchEvent(
+        new CustomEvent('comment-update', {
+          detail: this._getEventPayload(),
+          composed: true,
+          bubbles: true,
+        })
+      );
     });
   }
 
-  _computeAccountLabelClass(draft) {
+  _computeAccountLabelClass(draft: boolean) {
     return draft ? 'draft' : '';
   }
 
-  _draftChanged(draft) {
+  _draftChanged(draft: boolean) {
     this.$.container.classList.toggle('draft', draft);
   }
 
-  _editingChanged(editing, previousValue) {
+  _editingChanged(editing?: boolean, previousValue?: boolean) {
     // Polymer 2: observer fires when at least one property is defined.
     // Do nothing to prevent comment.__editing being overwritten
     // if previousValue is undefined
@@ -510,7 +556,9 @@
 
     this.$.container.classList.toggle('editing', editing);
     if (this.comment && this.comment.id) {
-      const cancelButton = this.shadowRoot.querySelector('.cancel');
+      const cancelButton = this.shadowRoot?.querySelector(
+        '.cancel'
+      ) as GrButton | null;
       if (cancelButton) {
         cancelButton.hidden = !editing;
       }
@@ -518,7 +566,7 @@
     if (this.comment) {
       this.comment.__editing = this.editing;
     }
-    if (editing != !!previousValue) {
+    if (!!editing !== !!previousValue) {
       // To prevent event firing on comment creation.
       this._fireUpdate();
     }
@@ -530,27 +578,28 @@
     }
   }
 
-  _computeDeleteButtonClass(isAdmin, draft) {
+  _computeDeleteButtonClass(isAdmin: boolean, draft: boolean) {
     return isAdmin && !draft ? 'showDeleteButtons' : '';
   }
 
-  _computeSaveDisabled(draft, comment, resolved) {
+  _computeSaveDisabled(draft: string, comment: Comment, resolved?: boolean) {
     // If resolved state has changed and a msg exists, save should be enabled.
-    if (!comment || comment.unresolved === resolved && draft) {
+    if (!comment || (comment.unresolved === resolved && draft)) {
       return false;
     }
     return !draft || draft.trim() === '';
   }
 
-  _handleSaveKey(e) {
-    if (!this._computeSaveDisabled(this._messageText, this.comment,
-        this.resolved)) {
+  _handleSaveKey(e: Event) {
+    if (
+      !this._computeSaveDisabled(this._messageText, this.comment, this.resolved)
+    ) {
       e.preventDefault();
       this._handleSave(e);
     }
   }
 
-  _handleEsc(e) {
+  _handleEsc(e: Event) {
     if (!this._messageText.length) {
       e.preventDefault();
       this._handleCancel(e);
@@ -561,7 +610,7 @@
     this.collapsed = !this.collapsed;
   }
 
-  _toggleCollapseClass(collapsed) {
+  _toggleCollapseClass(collapsed: boolean) {
     if (collapsed) {
       this.$.container.classList.add('collapsed');
     } else {
@@ -569,77 +618,92 @@
     }
   }
 
-  _commentMessageChanged(message) {
+  @observe('comment.message')
+  _commentMessageChanged(message: string) {
     this._messageText = message || '';
   }
 
-  _messageTextChanged(newValue, oldValue) {
+  _messageTextChanged(_: string, oldValue: string) {
     if (!this.comment || (this.comment && this.comment.id)) {
       return;
     }
 
-    this.debounce('store', () => {
-      const message = this._messageText;
-      const commentLocation = {
-        changeNum: this.changeNum,
-        patchNum: this._getPatchNum(),
-        path: this.comment.path,
-        line: this.comment.line,
-        range: this.comment.range,
-      };
+    this.debounce(
+      'store',
+      () => {
+        const message = this._messageText;
+        if (!this.comment.path || this.comment.line === undefined)
+          throw new Error('missing path or line in comment');
+        const commentLocation: StorageLocation = {
+          changeNum: this.changeNum,
+          patchNum: this._getPatchNum(),
+          path: this.comment.path,
+          line: this.comment.line,
+          range: this.comment.range,
+        };
 
-      if ((!this._messageText || !this._messageText.length) && oldValue) {
-        // If the draft has been modified to be empty, then erase the storage
-        // entry.
-        this.$.storage.eraseDraftComment(commentLocation);
-      } else {
-        this.$.storage.setDraftComment(commentLocation, message);
-      }
-    }, STORAGE_DEBOUNCE_INTERVAL);
+        if ((!this._messageText || !this._messageText.length) && oldValue) {
+          // If the draft has been modified to be empty, then erase the storage
+          // entry.
+          this.$.storage.eraseDraftComment(commentLocation);
+        } else {
+          this.$.storage.setDraftComment(commentLocation, message);
+        }
+      },
+      STORAGE_DEBOUNCE_INTERVAL
+    );
   }
 
-  _handleAnchorClick(e) {
+  _handleAnchorClick(e: Event) {
     e.preventDefault();
     if (!this.comment.line) {
       return;
     }
-    this.dispatchEvent(new CustomEvent('comment-anchor-tap', {
-      bubbles: true,
-      composed: true,
-      detail: {
-        number: this.comment.line || FILE,
-        side: this.side,
-      },
-    }));
+    this.dispatchEvent(
+      new CustomEvent('comment-anchor-tap', {
+        bubbles: true,
+        composed: true,
+        detail: {
+          number: this.comment.line || FILE,
+          side: this.side,
+        },
+      })
+    );
   }
 
-  _handleEdit(e) {
+  _handleEdit(e: Event) {
     e.preventDefault();
+    if (!this.comment.message) throw new Error('message undefined');
     this._messageText = this.comment.message;
     this.editing = true;
     this.reporting.recordDraftInteraction();
   }
 
-  _handleSave(e) {
+  _handleSave(e: Event) {
     e.preventDefault();
 
     // Ignore saves started while already saving.
     if (this.disabled) {
       return;
     }
-    const timingLabel = this.comment.id ?
-      REPORT_UPDATE_DRAFT : REPORT_CREATE_DRAFT;
+    const timingLabel = this.comment.id
+      ? REPORT_UPDATE_DRAFT
+      : REPORT_CREATE_DRAFT;
     const timer = this.reporting.getTimer(timingLabel);
     this.set('comment.__editing', false);
-    return this.save().then(() => { timer.end(); });
+    return this.save().then(() => {
+      timer.end();
+    });
   }
 
-  _handleCancel(e) {
+  _handleCancel(e: Event) {
     e.preventDefault();
 
-    if (!this.comment.message ||
-        this.comment.message.trim().length === 0 ||
-        !this.comment.id) {
+    if (
+      !this.comment.message ||
+      this.comment.message.trim().length === 0 ||
+      !this.comment.id
+    ) {
       this._fireDiscard();
       return;
     }
@@ -649,33 +713,40 @@
 
   _fireDiscard() {
     this.cancelDebouncer('fire-update');
-    this.dispatchEvent(new CustomEvent('comment-discard', {
-      detail: this._getEventPayload(),
-      composed: true, bubbles: true,
-    }));
+    this.dispatchEvent(
+      new CustomEvent('comment-discard', {
+        detail: this._getEventPayload(),
+        composed: true,
+        bubbles: true,
+      })
+    );
   }
 
   _handleFix() {
-    this.dispatchEvent(new CustomEvent('create-fix-comment', {
-      bubbles: true,
-      composed: true,
-      detail: this._getEventPayload(),
-    }));
+    this.dispatchEvent(
+      new CustomEvent('create-fix-comment', {
+        bubbles: true,
+        composed: true,
+        detail: this._getEventPayload(),
+      })
+    );
   }
 
   _handleShowFix() {
-    this.dispatchEvent(new CustomEvent('open-fix-preview', {
-      bubbles: true,
-      composed: true,
-      detail: this._getEventPayload(),
-    }));
+    this.dispatchEvent(
+      new CustomEvent('open-fix-preview', {
+        bubbles: true,
+        composed: true,
+        detail: this._getEventPayload(),
+      })
+    );
   }
 
-  _hasNoFix(comment) {
-    return !comment || !comment.fix_suggestions;
+  _hasNoFix(comment: Comment) {
+    return !comment || !(comment as RobotComment).fix_suggestions;
   }
 
-  _handleDiscard(e) {
+  _handleDiscard(e: Event) {
     e.preventDefault();
     this.reporting.recordDraftInteraction();
 
@@ -685,21 +756,25 @@
     }
 
     this._openOverlay(this.confirmDiscardOverlay).then(() => {
-      this.confirmDiscardOverlay.querySelector('#confirmDiscardDialog')
-          .resetFocus();
+      const dialog = this.confirmDiscardOverlay?.querySelector(
+        '#confirmDiscardDialog'
+      ) as GrDialog | null;
+      if (dialog) dialog.resetFocus();
     });
   }
 
-  _handleConfirmDiscard(e) {
+  _handleConfirmDiscard(e: Event) {
     e.preventDefault();
     const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
     this._closeConfirmDiscardOverlay();
-    return this._discardDraft().then(() => { timer.end(); });
+    return this._discardDraft().then(() => {
+      timer.end();
+    });
   }
 
   _discardDraft() {
     if (!this.comment.__draft) {
-      throw Error('Cannot discard a non-draft comment.');
+      return Promise.reject(new Error('Cannot discard a non-draft comment.'));
     }
     this.discarding = true;
     this.editing = false;
@@ -709,22 +784,23 @@
     if (!this.comment.id) {
       this.disabled = false;
       this._fireDiscard();
-      return;
+      return Promise.resolve();
     }
 
-    this._xhrPromise = this._deleteDraft(this.comment).then(response => {
-      this.disabled = false;
-      if (!response.ok) {
-        this.discarding = false;
-        return response;
-      }
+    this._xhrPromise = this._deleteDraft(this.comment)
+      .then(response => {
+        this.disabled = false;
+        if (!response.ok) {
+          this.discarding = false;
+        }
 
-      this._fireDiscard();
-    })
-        .catch(err => {
-          this.disabled = false;
-          throw err;
-        });
+        this._fireDiscard();
+        return response;
+      })
+      .catch(err => {
+        this.disabled = false;
+        throw err;
+      });
 
     return this._xhrPromise;
   }
@@ -733,7 +809,7 @@
     this._closeOverlay(this.confirmDiscardOverlay);
   }
 
-  _getSavingMessage(numPending, requestFailed) {
+  _getSavingMessage(numPending: number, requestFailed?: boolean) {
     if (requestFailed) {
       return UNSAVED_MESSAGE;
     }
@@ -763,19 +839,30 @@
     // Cancel the debouncer so that error toasts from the error-manager will
     // not be overridden.
     this.cancelDebouncer('draft-toast');
-    this._updateRequestToast(this._numPendingDraftRequests.number,
-        /* requestFailed=*/true);
+    this._updateRequestToast(
+      this._numPendingDraftRequests.number,
+      /* requestFailed=*/ true
+    );
   }
 
-  _updateRequestToast(numPending, requestFailed) {
+  _updateRequestToast(numPending: number, requestFailed?: boolean) {
     const message = this._getSavingMessage(numPending, requestFailed);
-    this.debounce('draft-toast', () => {
-      // Note: the event is fired on the body rather than this element because
-      // this element may not be attached by the time this executes, in which
-      // case the event would not bubble.
-      document.body.dispatchEvent(new CustomEvent(
-          'show-alert', {detail: {message}, bubbles: true, composed: true}));
-    }, TOAST_DEBOUNCE_INTERVAL);
+    this.debounce(
+      'draft-toast',
+      () => {
+        // Note: the event is fired on the body rather than this element because
+        // this element may not be attached by the time this executes, in which
+        // case the event would not bubble.
+        document.body.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {message},
+            bubbles: true,
+            composed: true,
+          })
+        );
+      },
+      TOAST_DEBOUNCE_INTERVAL
+    );
   }
 
   _handleDraftFailure() {
@@ -784,43 +871,51 @@
     this._handleFailedDraftRequest();
   }
 
-  _saveDraft(draft) {
+  _saveDraft(draft: Comment) {
     this._showStartRequest();
-    return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft)
-        .then(result => {
-          if (result.ok) { // remove
-            this._unableToSave = false;
-            this.$.container.classList.remove('unableToSave');
-            this._showEndRequest();
-          } else {
-            this._handleDraftFailure();
-          }
-          return result;
-        })
-        .catch(err => {
+    return this.$.restAPI
+      .saveDiffDraft(this.changeNum, this.patchNum, draft)
+      .then(result => {
+        if (result.ok) {
+          // remove
+          this._unableToSave = false;
+          this.$.container.classList.remove('unableToSave');
+          this._showEndRequest();
+        } else {
           this._handleDraftFailure();
-          throw (err);
-        });
+        }
+        return result;
+      })
+      .catch(err => {
+        this._handleDraftFailure();
+        throw err;
+      });
   }
 
-  _deleteDraft(draft) {
+  _deleteDraft(draft: Comment) {
     this._showStartRequest();
-    return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
-        draft).then(result => {
-      if (result.ok) {
-        this._showEndRequest();
-      } else {
-        this._handleFailedDraftRequest();
-      }
-      return result;
-    });
+    return this.$.restAPI
+      .deleteDiffDraft(this.changeNum, this.patchNum, draft)
+      .then(result => {
+        if (result.ok) {
+          this._showEndRequest();
+        } else {
+          this._handleFailedDraftRequest();
+        }
+        return result;
+      });
   }
 
-  _getPatchNum() {
-    return this.isOnParent() ? 'PARENT' : this.patchNum;
+  _getPatchNum(): PatchSetNum {
+    return this.isOnParent() ? ('PARENT' as PatchSetNum) : this.patchNum;
   }
 
-  _loadLocalDraft(changeNum, patchNum, comment) {
+  @observe('changeNum', 'patchNum', 'comment')
+  _loadLocalDraft(
+    changeNum: number,
+    patchNum?: PatchSetNum,
+    comment?: Comment
+  ) {
     // Polymer 2: check for undefined
     if ([changeNum, patchNum, comment].includes(undefined)) {
       return;
@@ -831,8 +926,16 @@
     //
     // Don't get local draft if there is another comment that is currently
     // in an editing state.
-    if (!comment || comment.id || comment.message || comment.__otherEditing) {
-      delete comment.__otherEditing;
+    if (
+      !comment ||
+      comment.id ||
+      comment.message ||
+      comment.__otherEditing ||
+      !comment.path ||
+      !comment.line ||
+      !comment.range
+    ) {
+      if (comment) delete comment.__otherEditing;
       return;
     }
 
@@ -856,10 +959,13 @@
     // the parent by ref.
     const payload = this._getEventPayload();
     payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
-    this.dispatchEvent(new CustomEvent('comment-update', {
-      detail: payload,
-      composed: true, bubbles: true,
-    }));
+    this.dispatchEvent(
+      new CustomEvent('comment-update', {
+        detail: payload,
+        composed: true,
+        bubbles: true,
+      })
+    );
     if (!this.editing) {
       // Save the resolved state immediately.
       this.save(payload.comment);
@@ -874,31 +980,49 @@
     this._closeOverlay(this.confirmDeleteOverlay);
   }
 
-  _openOverlay(overlay) {
+  _openOverlay(overlay?: GrOverlay | null) {
+    if (!overlay) {
+      return Promise.reject(new Error('undefined overlay'));
+    }
     getRootElement().appendChild(overlay);
     return overlay.open();
   }
 
-  _computeHideRunDetails(comment, collapsed) {
+  _computeHideRunDetails(comment: RobotComment, collapsed: boolean) {
     if (!comment) return true;
     return !(comment.robot_id && comment.url && !collapsed);
   }
 
-  _closeOverlay(overlay) {
-    getRootElement().removeChild(overlay);
-    overlay.close();
+  _closeOverlay(overlay?: GrOverlay | null) {
+    if (overlay) {
+      getRootElement().removeChild(overlay);
+      overlay.close();
+    }
   }
 
   _handleConfirmDeleteComment() {
-    const dialog =
-        this.confirmDeleteOverlay.querySelector('#confirmDeleteComment');
-    this.$.restAPI.deleteComment(
-        this.changeNum, this.patchNum, this.comment.id, dialog.message)
-        .then(newComment => {
-          this._handleCancelDeleteComment();
-          this.comment = newComment;
-        });
+    const dialog = this.confirmDeleteOverlay?.querySelector(
+      '#confirmDeleteComment'
+    ) as GrConfirmDeleteCommentDialog | null;
+    if (!dialog || !dialog.message) {
+      throw new Error('missing confirm delete dialog');
+    }
+    this.$.restAPI
+      .deleteComment(
+        this.changeNum,
+        this.patchNum,
+        this.comment.id,
+        dialog.message
+      )
+      .then(newComment => {
+        this._handleCancelDeleteComment();
+        this.comment = newComment;
+      });
   }
 }
 
-customElements.define(GrComment.is, GrComment);
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-comment': GrComment;
+  }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index f4111e7..3dfe105 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -3226,7 +3226,7 @@
     commentID: UrlEncodedCommentId,
     reason: string
   ) {
-    return this._getChangeURLAndSend({
+    return (this._getChangeURLAndSend({
       changeNum,
       method: HttpMethod.POST,
       patchNum,
@@ -3234,7 +3234,7 @@
       body: {reason},
       parseResponse: true,
       anonymizedEndpoint: '/comments/*/delete',
-    });
+    }) as unknown) as Promise<CommentInfo>;
   }
 
   /**
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
index 176f6c9..15914c5 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
@@ -25,7 +25,7 @@
   patchNum: PatchSetNum;
   path: string;
   line: number;
-  range: CommentRange;
+  range?: CommentRange;
 }
 
 export interface StorageObject {
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index e8fc3f9..05500ec 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -78,6 +78,7 @@
   GroupAuditEventInfo,
   EncodedGroupId,
   Base64FileContent,
+  UrlEncodedCommentId,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {HttpMethod} from '../../../constants/constants';
@@ -604,6 +605,18 @@
     label: string
   ): Promise<Response>;
 
+  deleteComment(
+    changeNum: ChangeNum,
+    patchNum: PatchSetNum,
+    commentID: UrlEncodedCommentId,
+    reason: string
+  ): Promise<CommentInfo>;
+  deleteDiffDraft(
+    changeNum: ChangeNum,
+    patchNum: PatchSetNum,
+    draft: {id: UrlEncodedCommentId}
+  ): Promise<Response>;
+
   deleteChangeCommitMessage(
     changeNum: ChangeNum,
     messageId: ChangeMessageId
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index cc74ef1..eec0fc3 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -1039,7 +1039,7 @@
  * The CommentInfo entity contains information about an inline comment.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
  */
-export interface CommentInfo {
+export interface CommentInfo extends CommentInput {
   patch_set?: PatchSetNum;
   id: UrlEncodedCommentId;
   path?: string;
@@ -1047,9 +1047,9 @@
   parent?: number;
   line?: number;
   range?: CommentRange;
-  in_reply_to?: string;
+  in_reply_to?: UrlEncodedCommentId;
   message?: string;
-  updated: string;
+  updated: Timestamp;
   author?: AccountInfo;
   tag?: string;
   unresolved?: boolean;