Merge changes from topic "gr-comment-to-ts"

* changes:
  Convert gr-comment to typescript
  Rename files to preserve history
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.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
deleted file mode 100644
index 2828cf1..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
+++ /dev/null
@@ -1,904 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 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 '@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';
-
-const STORAGE_DEBOUNCE_INTERVAL = 400;
-const TOAST_DEBOUNCE_INTERVAL = 200;
-
-const SAVING_MESSAGE = 'Saving';
-const DRAFT_SINGULAR = 'draft...';
-const DRAFT_PLURAL = 'drafts...';
-const SAVED_MESSAGE = 'All changes saved';
-const UNSAVED_MESSAGE = 'Unable to save draft';
-
-const REPORT_CREATE_DRAFT = 'CreateDraftComment';
-const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
-const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
-
-const FILE = 'FILE';
-
-export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
-
-/**
- * All candidates tips to show, will pick randomly.
- */
-const RESPECTFUL_REVIEW_TIPS= [
-  'Assume competence.',
-  'Provide rationale or context.',
-  'Consider how comments may be interpreted.',
-  'Avoid harsh language.',
-  'Make your comments specific and actionable.',
-  'When disagreeing, explain the advantage of your approach.',
-];
-
-/**
- * @extends PolymerElement
- */
-class GrComment extends KeyboardShortcutMixin(GestureEventListeners(
-    LegacyElementMixin(PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-comment'; }
-  /**
-   * Fired when the create fix comment action is triggered.
-   *
-   * @event create-fix-comment
-   */
-
-  /**
-   * Fired when the show fix preview action is triggered.
-   *
-   * @event open-fix-preview
-   */
-
-  /**
-   * Fired when this comment is discarded.
-   *
-   * @event comment-discard
-   */
-
-  /**
-   * Fired when this comment is saved.
-   *
-   * @event comment-save
-   */
-
-  /**
-   * Fired when this comment is updated.
-   *
-   * @event comment-update
-   */
-
-  /**
-   * Fired when editing status changed.
-   *
-   * @event comment-editing-changed
-   */
-
-  /**
-   * Fired when the comment's timestamp is tapped.
-   *
-   * @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,
-      },
-
-      _xhrPromise: Object, // Used for testing.
-      _messageText: {
-        type: String,
-        value: '',
-        observer: '_messageTextChanged',
-      },
-      commentSide: String,
-      side: String,
-
-      resolved: Boolean,
-
-      _numPendingDraftRequests: {
-        type: Object,
-        value:
-          {number: 0}, // Intentional to share the object across instances.
-      },
-
-      _enableOverlay: {
-        type: Boolean,
-        value: 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 {}; },
-      },
-
-      _showRespectfulTip: {
-        type: Boolean,
-        value: false,
-      },
-      showPatchset: {
-        type: Boolean,
-        value: true,
-      },
-      _respectfulReviewTip: String,
-      _respectfulTipDismissed: {
-        type: Boolean,
-        value: false,
-      },
-      _unableToSave: {
-        type: Boolean,
-        value: false,
-      },
-      _selfAccount: Object,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_commentMessageChanged(comment.message)',
-      '_loadLocalDraft(changeNum, patchNum, comment)',
-      '_isRobotComment(comment)',
-      '_calculateActionstoShow(showActions, isRobotComment)',
-      '_computeHasHumanReply(comment, comments.*)',
-      '_onEditingChange(editing)',
-    ];
-  }
-
-  get keyBindings() {
-    return {
-      'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
-      'esc': '_handleEsc',
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this.$.restAPI.getAccount().then(account => {
-      this._selfAccount = account;
-    });
-    if (this.editing) {
-      this.collapsed = false;
-    } else if (this.comment) {
-      this.collapsed = this.comment.collapsed;
-    }
-    this._getIsAdmin().then(isAdmin => {
-      this._isAdmin = isAdmin;
-    });
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.cancelDebouncer('fire-update');
-    if (this.textarea) {
-      this.textarea.closeDropdown();
-    }
-  }
-
-  _getAuthor(comment) {
-    return comment.author || this._selfAccount;
-  }
-
-  _onEditingChange(editing) {
-    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();
-    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}
-      );
-      // update cache
-      this.$.storage.setRespectfulTipVisibility();
-    }
-  }
-
-  /** Set as a separate method so easy to stub. */
-  getRandomNum(min, max) {
-    return Math.floor(Math.random() * (max - min) + min);
-  }
-
-  _computeVisibilityOfTip(showTip, tipDismissed) {
-    return showTip && !tipDismissed;
-  }
-
-  _dismissRespectfulTip() {
-    this._respectfulTipDismissed = true;
-    this.reporting.reportInteraction(
-        'respectful-tip-dismissed',
-        {tip: this._respectfulReviewTip}
-    );
-    // add a 14-day delay to the tip cache
-    this.$.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
-  }
-
-  _onRespectfulReadMoreClick() {
-    this.reporting.reportInteraction('respectful-read-more-clicked');
-  }
-
-  get textarea() {
-    return this.shadowRoot.querySelector('#editTextarea');
-  }
-
-  get confirmDeleteOverlay() {
-    if (!this._overlays.confirmDelete) {
-      this._enableOverlay = true;
-      flush();
-      this._overlays.confirmDelete = this.shadowRoot
-          .querySelector('#confirmDeleteOverlay');
-    }
-    return this._overlays.confirmDelete;
-  }
-
-  get confirmDiscardOverlay() {
-    if (!this._overlays.confirmDiscard) {
-      this._enableOverlay = true;
-      flush();
-      this._overlays.confirmDiscard = this.shadowRoot
-          .querySelector('#confirmDiscardOverlay');
-    }
-    return this._overlays.confirmDiscard;
-  }
-
-  _computeShowHideIcon(collapsed) {
-    return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
-  }
-
-  _computeShowHideAriaLabel(collapsed) {
-    return collapsed ? 'Expand' : 'Collapse';
-  }
-
-  _calculateActionstoShow(showActions, isRobotComment) {
-    // Polymer 2: check for undefined
-    if ([showActions, isRobotComment].includes(undefined)) {
-      return;
-    }
-
-    this._showHumanActions = showActions && !isRobotComment;
-    this._showRobotActions = showActions && isRobotComment;
-  }
-
-  _isRobotComment(comment) {
-    this.isRobotComment = !!comment.robot_id;
-  }
-
-  isOnParent() {
-    return this.side === 'PARENT';
-  }
-
-  _getIsAdmin() {
-    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.`;
-  }
-
-  _computeDraftText(unableToSave) {
-    return 'DRAFT' + (unableToSave ? '(Failed to save)' : '');
-  }
-
-  /**
-   * @param {*=} opt_comment
-   */
-  save(opt_comment) {
-    let comment = opt_comment;
-    if (!comment) {
-      comment = this.comment;
-    }
-
-    this.set('comment.message', this._messageText);
-    this.editing = false;
-    this.disabled = true;
-
-    if (!this._messageText) {
-      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;
-        }
-        resComment.__commentSide = this.commentSide;
-        this.comment = resComment;
-        this._fireSave();
-        return obj;
-      });
-    })
-        .catch(err => {
-          this.disabled = false;
-          throw err;
-        });
-
-    return this._xhrPromise;
-  }
-
-  _eraseDraftComment() {
-    // Prevents a race condition in which removing the draft comment occurs
-    // prior to it being saved.
-    this.cancelDebouncer('store');
-
-    this.$.storage.eraseDraftComment({
-      changeNum: this.changeNum,
-      patchNum: this._getPatchNum(),
-      path: this.comment.path,
-      line: this.comment.line,
-      range: this.comment.range,
-    });
-  }
-
-  _commentChanged(comment) {
-    this.editing = !!comment.__editing;
-    this.resolved = !comment.unresolved;
-    if (this.editing) { // It's a new draft/reply, notify.
-      this._fireUpdate();
-    }
-  }
-
-  _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);
-  }
-
-  /**
-   * @param {!Object=} opt_mixin
-   *
-   * @return {!Object}
-   */
-  _getEventPayload(opt_mixin) {
-    return {...opt_mixin, comment: this.comment,
-      patchNum: this.patchNum};
-  }
-
-  _fireSave() {
-    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,
-      }));
-    });
-  }
-
-  _computeAccountLabelClass(draft) {
-    return draft ? 'draft' : '';
-  }
-
-  _draftChanged(draft) {
-    this.$.container.classList.toggle('draft', draft);
-  }
-
-  _editingChanged(editing, previousValue) {
-    // Polymer 2: observer fires when at least one property is defined.
-    // Do nothing to prevent comment.__editing being overwritten
-    // if previousValue is undefined
-    if (previousValue === undefined) return;
-
-    this.$.container.classList.toggle('editing', editing);
-    if (this.comment && this.comment.id) {
-      const cancelButton = this.shadowRoot.querySelector('.cancel');
-      if (cancelButton) {
-        cancelButton.hidden = !editing;
-      }
-    }
-    if (this.comment) {
-      this.comment.__editing = this.editing;
-    }
-    if (editing != !!previousValue) {
-      // To prevent event firing on comment creation.
-      this._fireUpdate();
-    }
-    if (editing) {
-      this.async(() => {
-        flush();
-        this.textarea && this.textarea.putCursorAtEnd();
-      }, 1);
-    }
-  }
-
-  _computeDeleteButtonClass(isAdmin, draft) {
-    return isAdmin && !draft ? 'showDeleteButtons' : '';
-  }
-
-  _computeSaveDisabled(draft, comment, resolved) {
-    // If resolved state has changed and a msg exists, save should be enabled.
-    if (!comment || comment.unresolved === resolved && draft) {
-      return false;
-    }
-    return !draft || draft.trim() === '';
-  }
-
-  _handleSaveKey(e) {
-    if (!this._computeSaveDisabled(this._messageText, this.comment,
-        this.resolved)) {
-      e.preventDefault();
-      this._handleSave(e);
-    }
-  }
-
-  _handleEsc(e) {
-    if (!this._messageText.length) {
-      e.preventDefault();
-      this._handleCancel(e);
-    }
-  }
-
-  _handleToggleCollapsed() {
-    this.collapsed = !this.collapsed;
-  }
-
-  _toggleCollapseClass(collapsed) {
-    if (collapsed) {
-      this.$.container.classList.add('collapsed');
-    } else {
-      this.$.container.classList.remove('collapsed');
-    }
-  }
-
-  _commentMessageChanged(message) {
-    this._messageText = message || '';
-  }
-
-  _messageTextChanged(newValue, oldValue) {
-    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,
-      };
-
-      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) {
-    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,
-      },
-    }));
-  }
-
-  _handleEdit(e) {
-    e.preventDefault();
-    this._messageText = this.comment.message;
-    this.editing = true;
-    this.reporting.recordDraftInteraction();
-  }
-
-  _handleSave(e) {
-    e.preventDefault();
-
-    // Ignore saves started while already saving.
-    if (this.disabled) {
-      return;
-    }
-    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(); });
-  }
-
-  _handleCancel(e) {
-    e.preventDefault();
-
-    if (!this.comment.message ||
-        this.comment.message.trim().length === 0 ||
-        !this.comment.id) {
-      this._fireDiscard();
-      return;
-    }
-    this._messageText = this.comment.message;
-    this.editing = false;
-  }
-
-  _fireDiscard() {
-    this.cancelDebouncer('fire-update');
-    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(),
-    }));
-  }
-
-  _handleShowFix() {
-    this.dispatchEvent(new CustomEvent('open-fix-preview', {
-      bubbles: true,
-      composed: true,
-      detail: this._getEventPayload(),
-    }));
-  }
-
-  _hasNoFix(comment) {
-    return !comment || !comment.fix_suggestions;
-  }
-
-  _handleDiscard(e) {
-    e.preventDefault();
-    this.reporting.recordDraftInteraction();
-
-    if (!this._messageText) {
-      this._discardDraft();
-      return;
-    }
-
-    this._openOverlay(this.confirmDiscardOverlay).then(() => {
-      this.confirmDiscardOverlay.querySelector('#confirmDiscardDialog')
-          .resetFocus();
-    });
-  }
-
-  _handleConfirmDiscard(e) {
-    e.preventDefault();
-    const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
-    this._closeConfirmDiscardOverlay();
-    return this._discardDraft().then(() => { timer.end(); });
-  }
-
-  _discardDraft() {
-    if (!this.comment.__draft) {
-      throw Error('Cannot discard a non-draft comment.');
-    }
-    this.discarding = true;
-    this.editing = false;
-    this.disabled = true;
-    this._eraseDraftComment();
-
-    if (!this.comment.id) {
-      this.disabled = false;
-      this._fireDiscard();
-      return;
-    }
-
-    this._xhrPromise = this._deleteDraft(this.comment).then(response => {
-      this.disabled = false;
-      if (!response.ok) {
-        this.discarding = false;
-        return response;
-      }
-
-      this._fireDiscard();
-    })
-        .catch(err => {
-          this.disabled = false;
-          throw err;
-        });
-
-    return this._xhrPromise;
-  }
-
-  _closeConfirmDiscardOverlay() {
-    this._closeOverlay(this.confirmDiscardOverlay);
-  }
-
-  _getSavingMessage(numPending, requestFailed) {
-    if (requestFailed) {
-      return UNSAVED_MESSAGE;
-    }
-    if (numPending === 0) {
-      return SAVED_MESSAGE;
-    }
-    return [
-      SAVING_MESSAGE,
-      numPending,
-      numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
-    ].join(' ');
-  }
-
-  _showStartRequest() {
-    const numPending = ++this._numPendingDraftRequests.number;
-    this._updateRequestToast(numPending);
-  }
-
-  _showEndRequest() {
-    const numPending = --this._numPendingDraftRequests.number;
-    this._updateRequestToast(numPending);
-  }
-
-  _handleFailedDraftRequest() {
-    this._numPendingDraftRequests.number--;
-
-    // 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);
-  }
-
-  _updateRequestToast(numPending, requestFailed) {
-    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);
-  }
-
-  _handleDraftFailure() {
-    this.$.container.classList.add('unableToSave');
-    this._unableToSave = true;
-    this._handleFailedDraftRequest();
-  }
-
-  _saveDraft(draft) {
-    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 => {
-          this._handleDraftFailure();
-          throw (err);
-        });
-  }
-
-  _deleteDraft(draft) {
-    this._showStartRequest();
-    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;
-  }
-
-  _loadLocalDraft(changeNum, patchNum, comment) {
-    // Polymer 2: check for undefined
-    if ([changeNum, patchNum, comment].includes(undefined)) {
-      return;
-    }
-
-    // Only apply local drafts to comments that haven't been saved
-    // remotely, and haven't been given a default message already.
-    //
-    // 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;
-      return;
-    }
-
-    const draft = this.$.storage.getDraftComment({
-      changeNum,
-      patchNum: this._getPatchNum(),
-      path: comment.path,
-      line: comment.line,
-      range: comment.range,
-    });
-
-    if (draft) {
-      this.set('comment.message', draft.message);
-    }
-  }
-
-  _handleToggleResolved() {
-    this.reporting.recordDraftInteraction();
-    this.resolved = !this.resolved;
-    // Modify payload instead of this.comment, as this.comment is passed from
-    // 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,
-    }));
-    if (!this.editing) {
-      // Save the resolved state immediately.
-      this.save(payload.comment);
-    }
-  }
-
-  _handleCommentDelete() {
-    this._openOverlay(this.confirmDeleteOverlay);
-  }
-
-  _handleCancelDeleteComment() {
-    this._closeOverlay(this.confirmDeleteOverlay);
-  }
-
-  _openOverlay(overlay) {
-    getRootElement().appendChild(overlay);
-    return overlay.open();
-  }
-
-  _computeHideRunDetails(comment, collapsed) {
-    if (!comment) return true;
-    return !(comment.robot_id && comment.url && !collapsed);
-  }
-
-  _closeOverlay(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;
-        });
-  }
-}
-
-customElements.define(GrComment.is, GrComment);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
new file mode 100644
index 0000000..d240f2c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -0,0 +1,1028 @@
+/**
+ * @license
+ * Copyright (C) 2015 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 '@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;
+
+const SAVING_MESSAGE = 'Saving';
+const DRAFT_SINGULAR = 'draft...';
+const DRAFT_PLURAL = 'drafts...';
+const SAVED_MESSAGE = 'All changes saved';
+const UNSAVED_MESSAGE = 'Unable to save draft';
+
+const REPORT_CREATE_DRAFT = 'CreateDraftComment';
+const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
+const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
+
+const FILE = 'FILE';
+
+export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
+
+/**
+ * All candidates tips to show, will pick randomly.
+ */
+const RESPECTFUL_REVIEW_TIPS = [
+  'Assume competence.',
+  'Provide rationale or context.',
+  'Consider how comments may be interpreted.',
+  'Avoid harsh language.',
+  'Make your comments specific and actionable.',
+  'When disagreeing, explain the advantage of your approach.',
+];
+
+interface Draft {
+  collapsed?: boolean;
+  __editing?: boolean;
+  __otherEditing?: boolean;
+  __draft?: boolean;
+  __draftID?: number;
+  __commentSide?: string;
+}
+
+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.
+   *
+   * @event create-fix-comment
+   */
+
+  /**
+   * Fired when the show fix preview action is triggered.
+   *
+   * @event open-fix-preview
+   */
+
+  /**
+   * Fired when this comment is discarded.
+   *
+   * @event comment-discard
+   */
+
+  /**
+   * Fired when this comment is saved.
+   *
+   * @event comment-save
+   */
+
+  /**
+   * Fired when this comment is updated.
+   *
+   * @event comment-update
+   */
+
+  /**
+   * Fired when editing status changed.
+   *
+   * @event comment-editing-changed
+   */
+
+  /**
+   * Fired when the comment's timestamp is tapped.
+   *
+   * @event comment-anchor-tap
+   */
+
+  @property({type: Number})
+  changeNum!: number;
+
+  @property({type: Object, notify: true, observer: '_commentChanged'})
+  comment!: Comment | RobotComment;
+
+  @property({type: Array})
+  comments?: (Comment | RobotComment)[];
+
+  @property({type: Boolean, reflectToAttribute: true})
+  isRobotComment = false;
+
+  @property({type: Boolean, reflectToAttribute: true})
+  disabled = false;
+
+  @property({type: Boolean, observer: '_draftChanged'})
+  draft = false;
+
+  @property({type: Boolean, observer: '_editingChanged'})
+  editing = false;
+
+  @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',
+    };
+  }
+
+  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 */
+  attached() {
+    super.attached();
+    this.$.restAPI.getAccount().then(account => {
+      this._selfAccount = account;
+    });
+    if (this.editing) {
+      this.collapsed = false;
+    } else if (this.comment) {
+      this.collapsed = !!this.comment.collapsed;
+    }
+    this._getIsAdmin().then(isAdmin => {
+      this._isAdmin = !!isAdmin;
+    });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.cancelDebouncer('fire-update');
+    if (this.textarea) {
+      this.textarea.closeDropdown();
+    }
+  }
+
+  _getAuthor(comment: Comment) {
+    return comment.author || this._selfAccount;
+  }
+
+  @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();
+    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,
+      });
+      // update cache
+      this.$.storage.setRespectfulTipVisibility();
+    }
+  }
+
+  /** Set as a separate method so easy to stub. */
+  getRandomNum(min: number, max: number) {
+    return Math.floor(Math.random() * (max - min) + min);
+  }
+
+  _computeVisibilityOfTip(showTip: boolean, tipDismissed: boolean) {
+    return showTip && !tipDismissed;
+  }
+
+  _dismissRespectfulTip() {
+    this._respectfulTipDismissed = true;
+    this.reporting.reportInteraction('respectful-tip-dismissed', {
+      tip: this._respectfulReviewTip,
+    });
+    // add a 14-day delay to the tip cache
+    this.$.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
+  }
+
+  _onRespectfulReadMoreClick() {
+    this.reporting.reportInteraction('respectful-read-more-clicked');
+  }
+
+  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'
+      ) as GrOverlay | null;
+    }
+    return this._overlays.confirmDelete;
+  }
+
+  get confirmDiscardOverlay() {
+    if (!this._overlays.confirmDiscard) {
+      this._enableOverlay = true;
+      flush();
+      this._overlays.confirmDiscard = this.shadowRoot?.querySelector(
+        '#confirmDiscardOverlay'
+      ) as GrOverlay | null;
+    }
+    return this._overlays.confirmDiscard;
+  }
+
+  _computeShowHideIcon(collapsed: boolean) {
+    return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
+  }
+
+  _computeShowHideAriaLabel(collapsed: boolean) {
+    return collapsed ? 'Expand' : 'Collapse';
+  }
+
+  @observe('showActions', 'isRobotComment')
+  _calculateActionstoShow(showActions?: boolean, isRobotComment?: boolean) {
+    // Polymer 2: check for undefined
+    if ([showActions, isRobotComment].includes(undefined)) {
+      return;
+    }
+
+    this._showHumanActions = showActions && !isRobotComment;
+    this._showRobotActions = showActions && isRobotComment;
+  }
+
+  @observe('comment')
+  _isRobotComment(comment: RobotComment) {
+    this.isRobotComment = !!comment.robot_id;
+  }
+
+  isOnParent() {
+    return this.side === 'PARENT';
+  }
+
+  _getIsAdmin() {
+    return this.$.restAPI.getIsAdmin();
+  }
+
+  _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: boolean) {
+    return 'DRAFT' + (unableToSave ? '(Failed to save)' : '');
+  }
+
+  save(opt_comment?: Comment) {
+    let comment = opt_comment;
+    if (!comment) {
+      comment = this.comment;
+    }
+
+    this.set('comment.message', this._messageText);
+    this.editing = false;
+    this.disabled = true;
+
+    if (!this._messageText) {
+      return this._discardDraft();
+    }
+
+    this._xhrPromise = this._saveDraft(comment)
+      .then(response => {
+        this.disabled = false;
+        if (!response.ok) {
+          return;
+        }
+
+        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;
+  }
+
+  _eraseDraftComment() {
+    // Prevents a race condition in which removing the draft comment occurs
+    // 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(),
+      path: this.comment.path,
+      line: this.comment.line,
+      range: this.comment.range,
+    });
+  }
+
+  _commentChanged(comment: Comment) {
+    this.editing = !!comment.__editing;
+    this.resolved = !comment.unresolved;
+    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 as RobotComment).robot_id
+    );
+  }
+
+  _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,
+      })
+    );
+  }
+
+  _fireUpdate() {
+    this.debounce('fire-update', () => {
+      this.dispatchEvent(
+        new CustomEvent('comment-update', {
+          detail: this._getEventPayload(),
+          composed: true,
+          bubbles: true,
+        })
+      );
+    });
+  }
+
+  _computeAccountLabelClass(draft: boolean) {
+    return draft ? 'draft' : '';
+  }
+
+  _draftChanged(draft: boolean) {
+    this.$.container.classList.toggle('draft', draft);
+  }
+
+  _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
+    if (previousValue === undefined) return;
+
+    this.$.container.classList.toggle('editing', editing);
+    if (this.comment && this.comment.id) {
+      const cancelButton = this.shadowRoot?.querySelector(
+        '.cancel'
+      ) as GrButton | null;
+      if (cancelButton) {
+        cancelButton.hidden = !editing;
+      }
+    }
+    if (this.comment) {
+      this.comment.__editing = this.editing;
+    }
+    if (!!editing !== !!previousValue) {
+      // To prevent event firing on comment creation.
+      this._fireUpdate();
+    }
+    if (editing) {
+      this.async(() => {
+        flush();
+        this.textarea && this.textarea.putCursorAtEnd();
+      }, 1);
+    }
+  }
+
+  _computeDeleteButtonClass(isAdmin: boolean, draft: boolean) {
+    return isAdmin && !draft ? 'showDeleteButtons' : '';
+  }
+
+  _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)) {
+      return false;
+    }
+    return !draft || draft.trim() === '';
+  }
+
+  _handleSaveKey(e: Event) {
+    if (
+      !this._computeSaveDisabled(this._messageText, this.comment, this.resolved)
+    ) {
+      e.preventDefault();
+      this._handleSave(e);
+    }
+  }
+
+  _handleEsc(e: Event) {
+    if (!this._messageText.length) {
+      e.preventDefault();
+      this._handleCancel(e);
+    }
+  }
+
+  _handleToggleCollapsed() {
+    this.collapsed = !this.collapsed;
+  }
+
+  _toggleCollapseClass(collapsed: boolean) {
+    if (collapsed) {
+      this.$.container.classList.add('collapsed');
+    } else {
+      this.$.container.classList.remove('collapsed');
+    }
+  }
+
+  @observe('comment.message')
+  _commentMessageChanged(message: string) {
+    this._messageText = message || '';
+  }
+
+  _messageTextChanged(_: string, oldValue: string) {
+    if (!this.comment || (this.comment && this.comment.id)) {
+      return;
+    }
+
+    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
+    );
+  }
+
+  _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,
+        },
+      })
+    );
+  }
+
+  _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: 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 timer = this.reporting.getTimer(timingLabel);
+    this.set('comment.__editing', false);
+    return this.save().then(() => {
+      timer.end();
+    });
+  }
+
+  _handleCancel(e: Event) {
+    e.preventDefault();
+
+    if (
+      !this.comment.message ||
+      this.comment.message.trim().length === 0 ||
+      !this.comment.id
+    ) {
+      this._fireDiscard();
+      return;
+    }
+    this._messageText = this.comment.message;
+    this.editing = false;
+  }
+
+  _fireDiscard() {
+    this.cancelDebouncer('fire-update');
+    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(),
+      })
+    );
+  }
+
+  _handleShowFix() {
+    this.dispatchEvent(
+      new CustomEvent('open-fix-preview', {
+        bubbles: true,
+        composed: true,
+        detail: this._getEventPayload(),
+      })
+    );
+  }
+
+  _hasNoFix(comment: Comment) {
+    return !comment || !(comment as RobotComment).fix_suggestions;
+  }
+
+  _handleDiscard(e: Event) {
+    e.preventDefault();
+    this.reporting.recordDraftInteraction();
+
+    if (!this._messageText) {
+      this._discardDraft();
+      return;
+    }
+
+    this._openOverlay(this.confirmDiscardOverlay).then(() => {
+      const dialog = this.confirmDiscardOverlay?.querySelector(
+        '#confirmDiscardDialog'
+      ) as GrDialog | null;
+      if (dialog) dialog.resetFocus();
+    });
+  }
+
+  _handleConfirmDiscard(e: Event) {
+    e.preventDefault();
+    const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
+    this._closeConfirmDiscardOverlay();
+    return this._discardDraft().then(() => {
+      timer.end();
+    });
+  }
+
+  _discardDraft() {
+    if (!this.comment.__draft) {
+      return Promise.reject(new Error('Cannot discard a non-draft comment.'));
+    }
+    this.discarding = true;
+    this.editing = false;
+    this.disabled = true;
+    this._eraseDraftComment();
+
+    if (!this.comment.id) {
+      this.disabled = false;
+      this._fireDiscard();
+      return Promise.resolve();
+    }
+
+    this._xhrPromise = this._deleteDraft(this.comment)
+      .then(response => {
+        this.disabled = false;
+        if (!response.ok) {
+          this.discarding = false;
+        }
+
+        this._fireDiscard();
+        return response;
+      })
+      .catch(err => {
+        this.disabled = false;
+        throw err;
+      });
+
+    return this._xhrPromise;
+  }
+
+  _closeConfirmDiscardOverlay() {
+    this._closeOverlay(this.confirmDiscardOverlay);
+  }
+
+  _getSavingMessage(numPending: number, requestFailed?: boolean) {
+    if (requestFailed) {
+      return UNSAVED_MESSAGE;
+    }
+    if (numPending === 0) {
+      return SAVED_MESSAGE;
+    }
+    return [
+      SAVING_MESSAGE,
+      numPending,
+      numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
+    ].join(' ');
+  }
+
+  _showStartRequest() {
+    const numPending = ++this._numPendingDraftRequests.number;
+    this._updateRequestToast(numPending);
+  }
+
+  _showEndRequest() {
+    const numPending = --this._numPendingDraftRequests.number;
+    this._updateRequestToast(numPending);
+  }
+
+  _handleFailedDraftRequest() {
+    this._numPendingDraftRequests.number--;
+
+    // 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
+    );
+  }
+
+  _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
+    );
+  }
+
+  _handleDraftFailure() {
+    this.$.container.classList.add('unableToSave');
+    this._unableToSave = true;
+    this._handleFailedDraftRequest();
+  }
+
+  _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 => {
+        this._handleDraftFailure();
+        throw err;
+      });
+  }
+
+  _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;
+      });
+  }
+
+  _getPatchNum(): PatchSetNum {
+    return this.isOnParent() ? ('PARENT' as PatchSetNum) : this.patchNum;
+  }
+
+  @observe('changeNum', 'patchNum', 'comment')
+  _loadLocalDraft(
+    changeNum: number,
+    patchNum?: PatchSetNum,
+    comment?: Comment
+  ) {
+    // Polymer 2: check for undefined
+    if ([changeNum, patchNum, comment].includes(undefined)) {
+      return;
+    }
+
+    // Only apply local drafts to comments that haven't been saved
+    // remotely, and haven't been given a default message already.
+    //
+    // 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 ||
+      !comment.path ||
+      !comment.line ||
+      !comment.range
+    ) {
+      if (comment) delete comment.__otherEditing;
+      return;
+    }
+
+    const draft = this.$.storage.getDraftComment({
+      changeNum,
+      patchNum: this._getPatchNum(),
+      path: comment.path,
+      line: comment.line,
+      range: comment.range,
+    });
+
+    if (draft) {
+      this.set('comment.message', draft.message);
+    }
+  }
+
+  _handleToggleResolved() {
+    this.reporting.recordDraftInteraction();
+    this.resolved = !this.resolved;
+    // Modify payload instead of this.comment, as this.comment is passed from
+    // 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,
+      })
+    );
+    if (!this.editing) {
+      // Save the resolved state immediately.
+      this.save(payload.comment);
+    }
+  }
+
+  _handleCommentDelete() {
+    this._openOverlay(this.confirmDeleteOverlay);
+  }
+
+  _handleCancelDeleteComment() {
+    this._closeOverlay(this.confirmDeleteOverlay);
+  }
+
+  _openOverlay(overlay?: GrOverlay | null) {
+    if (!overlay) {
+      return Promise.reject(new Error('undefined overlay'));
+    }
+    getRootElement().appendChild(overlay);
+    return overlay.open();
+  }
+
+  _computeHideRunDetails(comment: RobotComment, collapsed: boolean) {
+    if (!comment) return true;
+    return !(comment.robot_id && comment.url && !collapsed);
+  }
+
+  _closeOverlay(overlay?: GrOverlay | null) {
+    if (overlay) {
+      getRootElement().removeChild(overlay);
+      overlay.close();
+    }
+  }
+
+  _handleConfirmDeleteComment() {
+    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;
+      });
+  }
+}
+
+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;