Convert GrReplyDialog to Lit

Release-Notes: skip
Change-Id: I2e923e357749e87d8c254148477edc3aa1303dad
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index c7b1982..f76725a 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -27,7 +27,6 @@
 import '../gr-label-scores/gr-label-scores';
 import '../gr-thread-list/gr-thread-list';
 import '../../../styles/shared-styles';
-import {htmlTemplate} from './gr-reply-dialog_html';
 import {
   GrReviewerSuggestionsProvider,
   SUGGESTIONS_PROVIDERS_USERS_TYPES,
@@ -47,11 +46,11 @@
 } from '../../../utils/account-util';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {TargetElement} from '../../../api/plugin';
-import {customElement, observe, property} from '@polymer/decorators';
 import {FixIronA11yAnnouncer} from '../../../types/types';
 import {
   AccountAddition,
   AccountInfoInput,
+  AccountInput,
   AccountInputDetail,
   GrAccountList,
   GroupInfoInput,
@@ -70,9 +69,7 @@
   isReviewerGroupSuggestion,
   ParsedJSON,
   PatchSetNum,
-  ProjectInfo,
   ReviewerInput,
-  Reviewers,
   ReviewInput,
   ReviewResult,
   ServerInfo,
@@ -82,7 +79,6 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {
   areSetsEqual,
   assertIsDefined,
@@ -113,10 +109,15 @@
 import {getReplyByReason} from '../../../utils/attention-set-util';
 import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
-import {LabelNameToValuesMap} from '../../../api/rest-api';
-import {ValueChangedEvent} from '../../../types/events';
+import {ConfigInfo, LabelNameToValuesMap} from '../../../api/rest-api';
+import {css, html, PropertyValues, LitElement} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {when} from 'lit/directives/when';
+import {classMap} from 'lit/directives/class-map';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {customElement, property, state, query} from 'lit/decorators';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -152,24 +153,8 @@
 
 const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
 
-export interface GrReplyDialog {
-  $: {
-    reviewers: GrAccountList;
-    ccs: GrAccountList;
-    cancelButton: GrButton;
-    sendButton: GrButton;
-    labelScores: GrLabelScores;
-    textarea: GrTextarea;
-    reviewerConfirmationOverlay: GrOverlay;
-  };
-}
-
 @customElement('gr-reply-dialog')
-export class GrReplyDialog extends DIPolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrReplyDialog extends LitElement {
   /**
    * Fired when a reply is successfully sent.
    *
@@ -229,131 +214,132 @@
   @property({type: Boolean})
   canBeStarted = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   disabled = false;
 
-  @property({
-    type: Boolean,
-    computed: '_computeHasDrafts(draft, draftCommentThreads.*)',
-  })
-  hasDrafts = false;
-
-  @property({type: String, observer: '_draftChanged'})
-  draft = '';
-
-  @property({type: Object})
-  filterReviewerSuggestion: (input: Suggestion) => boolean;
-
-  @property({type: Object})
-  filterCCSuggestion: (input: Suggestion) => boolean;
+  @property({type: Array})
+  draftCommentThreads: CommentThread[] | undefined;
 
   @property({type: Object})
   permittedLabels?: LabelNameToValuesMap;
 
   @property({type: Object})
-  projectConfig?: ProjectInfo;
+  projectConfig?: ConfigInfo;
 
   @property({type: Object})
   serverConfig?: ServerInfo;
 
-  @property({type: String})
+  @query('#reviewers') reviewersList?: GrAccountList;
+
+  @query('#ccs') ccsList?: GrAccountList;
+
+  @query('#cancelButton') cancelButton?: GrButton;
+
+  @query('#sendButton') sendButton?: GrButton;
+
+  @query('#labelScores') labelScores?: GrLabelScores;
+
+  @query('#textarea') textarea?: GrTextarea;
+
+  @query('#reviewerConfirmationOverlay')
+  reviewerConfirmationOverlay?: GrOverlay;
+
+  @state()
+  hasDrafts = false;
+
+  @state()
+  draft = '';
+
+  @state()
+  filterReviewerSuggestion: (input: Suggestion) => boolean;
+
+  @state()
+  filterCCSuggestion: (input: Suggestion) => boolean;
+
+  @state()
   knownLatestState?: LatestPatchState;
 
-  @property({type: Boolean})
+  @state()
   underReview = true;
 
-  @property({type: Object})
-  _account?: AccountInfo;
+  @state()
+  account?: AccountInfo;
 
-  @property({type: Array})
-  _ccs: (AccountInfo | GroupInfo)[] = [];
+  @state()
+  ccs: (AccountInfoInput | GroupInfoInput)[] = [];
 
-  @property({type: Number})
-  _attentionCcsCount = 0;
+  @state()
+  attentionCcsCount = 0;
 
-  @property({type: Object, observer: '_reviewerPendingConfirmationUpdated'})
-  _ccPendingConfirmation: SuggestedReviewerGroupInfo | null = null;
+  @state()
+  ccPendingConfirmation: SuggestedReviewerGroupInfo | null = null;
 
-  @property({
-    type: String,
-    computed: '_computeMessagePlaceholder(canBeStarted)',
-  })
-  _messagePlaceholder?: string;
+  @state()
+  messagePlaceholder?: string;
 
-  @property({type: Object})
-  _owner?: AccountInfo;
+  @state()
+  owner?: AccountInfo;
 
-  @property({type: Object, computed: '_computeUploader(change)'})
-  _uploader?: AccountInfo;
+  @state()
+  uploader?: AccountInfo;
 
-  @property({type: Object})
-  _pendingConfirmationDetails: SuggestedReviewerGroupInfo | null = null;
+  @state()
+  pendingConfirmationDetails: SuggestedReviewerGroupInfo | null = null;
 
-  @property({type: Boolean})
-  _includeComments = true;
+  @state()
+  includeComments = true;
 
-  @property({type: Array})
-  _reviewers: (AccountInfo | GroupInfo)[] = [];
+  @state() reviewers: AccountInput[] = [];
 
-  @property({type: Object, observer: '_reviewerPendingConfirmationUpdated'})
-  _reviewerPendingConfirmation: SuggestedReviewerGroupInfo | null = null;
+  @state()
+  reviewerPendingConfirmation: SuggestedReviewerGroupInfo | null = null;
 
-  @property({type: Boolean, observer: '_handleHeightChanged'})
-  _previewFormatting = false;
+  @state()
+  previewFormatting = false;
 
-  @property({type: String, computed: '_computeSendButtonLabel(canBeStarted)'})
-  _sendButtonLabel?: string;
+  @state()
+  sendButtonLabel?: string;
 
-  @property({type: Boolean})
-  _savingComments = false;
+  @state()
+  savingComments = false;
 
-  @property({type: Boolean})
-  _reviewersMutated = false;
+  @state()
+  reviewersMutated = false;
 
   /**
    * Signifies that the user has changed their vote on a label or (if they have
    * not yet voted on a label) if a selected vote is different from the default
    * vote.
    */
-  @property({type: Boolean})
-  _labelsChanged = false;
+  @state()
+  labelsChanged = false;
 
-  @property({type: String})
-  readonly _saveTooltip: string = ButtonTooltips.SAVE;
+  @state()
+  readonly saveTooltip: string = ButtonTooltips.SAVE;
 
-  @property({type: String})
-  _pluginMessage = '';
+  @state()
+  pluginMessage = '';
 
-  @property({type: Boolean})
-  _commentEditing = false;
+  @state()
+  commentEditing = false;
 
-  @property({type: Boolean})
-  _attentionExpanded = false;
+  @state()
+  attentionExpanded = false;
 
-  @property({type: Object})
-  _currentAttentionSet: Set<AccountId> = new Set();
+  @state()
+  currentAttentionSet: Set<AccountId> = new Set();
 
-  @property({type: Object})
-  _newAttentionSet: Set<AccountId> = new Set();
+  @state()
+  newAttentionSet: Set<AccountId> = new Set();
 
-  @property({
-    type: Boolean,
-    computed:
-      '_computeSendButtonDisabled(canBeStarted, ' +
-      'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' +
-      '_includeComments, disabled, _commentEditing, change, _account)',
-    observer: '_sendDisabledChanged',
-  })
-  _sendDisabled?: boolean;
+  @state()
+  sendDisabled?: boolean;
 
-  @property({type: Array, observer: '_handleHeightChanged'})
-  draftCommentThreads: CommentThread[] | undefined;
+  @state()
+  isResolvedPatchsetLevelComment = true;
 
-  @property({type: Boolean})
-  _isResolvedPatchsetLevelComment = true;
-
-  @property({type: Array, computed: '_computeAllReviewers(_reviewers.*)'})
-  _allReviewers: (AccountInfo | GroupInfo)[] = [];
+  @state()
+  allReviewers: (AccountInfo | GroupInfo)[] = [];
 
   private readonly restApiService: RestApiService =
     getAppContext().restApiService;
@@ -362,16 +348,312 @@
 
   private readonly jsAPI = getAppContext().jsApiService;
 
-  private storeTask?: DelayedTask;
+  storeTask?: DelayedTask;
 
   /** Called in disconnectedCallback. */
   private cleanups: (() => void)[] = [];
 
+  static override styles = [
+    sharedStyles,
+    css`
+      :host {
+        background-color: var(--dialog-background-color);
+        display: block;
+        max-height: 90vh;
+      }
+      :host([disabled]) {
+        pointer-events: none;
+      }
+      :host([disabled]) .container {
+        opacity: 0.5;
+      }
+      section {
+        border-top: 1px solid var(--border-color);
+        flex-shrink: 0;
+        padding: var(--spacing-m) var(--spacing-xl);
+        width: 100%;
+      }
+      section.labelsContainer {
+        /* We want the :hover highlight to extend to the border of the dialog. */
+        padding: var(--spacing-m) 0;
+      }
+      .stickyBottom {
+        background-color: var(--dialog-background-color);
+        box-shadow: 0px 0px 8px 0px rgba(60, 64, 67, 0.15);
+        margin-top: var(--spacing-s);
+        bottom: 0;
+        position: sticky;
+        /* @see Issue 8602 */
+        z-index: 1;
+      }
+      .stickyBottom.newReplyDialog {
+        margin-top: unset;
+      }
+      .actions {
+        display: flex;
+        justify-content: space-between;
+      }
+      .actions .right gr-button {
+        margin-left: var(--spacing-l);
+      }
+      .peopleContainer,
+      .labelsContainer {
+        flex-shrink: 0;
+      }
+      .peopleContainer {
+        border-top: none;
+        display: table;
+      }
+      .peopleList {
+        display: flex;
+      }
+      .peopleListLabel {
+        color: var(--deemphasized-text-color);
+        margin-top: var(--spacing-xs);
+        min-width: 6em;
+        padding-right: var(--spacing-m);
+      }
+      gr-account-list {
+        display: flex;
+        flex-wrap: wrap;
+        flex: 1;
+      }
+      #reviewerConfirmationOverlay {
+        padding: var(--spacing-l);
+        text-align: center;
+      }
+      .reviewerConfirmationButtons {
+        margin-top: var(--spacing-l);
+      }
+      .groupName {
+        font-weight: var(--font-weight-bold);
+      }
+      .groupSize {
+        font-style: italic;
+      }
+      .textareaContainer {
+        min-height: 12em;
+        position: relative;
+      }
+      .newReplyDialog.textareaContainer {
+        min-height: unset;
+      }
+      textareaContainer,
+      #textarea,
+      gr-endpoint-decorator[name='reply-text'] {
+        display: flex;
+        width: 100%;
+      }
+      .newReplyDialog .textareaContainer,
+      #textarea,
+      gr-endpoint-decorator[name='reply-text'] {
+        display: block;
+        width: unset;
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size-code);
+        line-height: calc(var(--font-size-code) + var(--spacing-s));
+        font-weight: var(--font-weight-normal);
+      }
+      .newReplyDialog#textarea {
+        padding: var(--spacing-m);
+      }
+      gr-endpoint-decorator[name='reply-text'] {
+        flex-direction: column;
+      }
+      #textarea {
+        flex: 1;
+      }
+      .previewContainer {
+        border-top: none;
+      }
+      .previewContainer gr-formatted-text {
+        background: var(--table-header-background-color);
+        padding: var(--spacing-l);
+      }
+      #checkingStatusLabel,
+      #notLatestLabel {
+        margin-left: var(--spacing-l);
+      }
+      #checkingStatusLabel {
+        color: var(--deemphasized-text-color);
+        font-style: italic;
+      }
+      #notLatestLabel,
+      #savingLabel {
+        color: var(--error-text-color);
+      }
+      #savingLabel {
+        display: none;
+      }
+      #savingLabel.saving {
+        display: inline;
+      }
+      #pluginMessage {
+        color: var(--deemphasized-text-color);
+        margin-left: var(--spacing-l);
+        margin-bottom: var(--spacing-m);
+      }
+      #pluginMessage:empty {
+        display: none;
+      }
+      .preview-formatting {
+        margin-left: var(--spacing-m);
+      }
+      .attention-icon {
+        width: 14px;
+        height: 14px;
+        vertical-align: top;
+        position: relative;
+        top: 3px;
+        --iron-icon-height: 24px;
+        --iron-icon-width: 24px;
+      }
+      .attention .edit-attention-button {
+        vertical-align: top;
+        --gr-button-padding: 0px 4px;
+      }
+      .attention .edit-attention-button iron-icon {
+        color: inherit;
+      }
+      .attention a,
+      .attention-detail a {
+        text-decoration: none;
+      }
+      .attentionSummary {
+        display: flex;
+        justify-content: space-between;
+      }
+      .attentionSummary {
+        /* The account label for selection is misbehaving currently: It consumes
+          26px height instead of 20px, which is the default line-height and thus
+          the max that can be nicely fit into an inline layout flow. We
+          acknowledge that using a fixed 26px value here is a hack and not a
+          great solution. */
+        line-height: 26px;
+      }
+      .attentionSummary gr-account-label,
+      .attention-detail gr-account-label {
+        --account-max-length: 120px;
+        display: inline-block;
+        padding: var(--spacing-xs) var(--spacing-m);
+        user-select: none;
+        --label-border-radius: 8px;
+      }
+      .attentionSummary gr-account-label {
+        margin: 0 var(--spacing-xs);
+        line-height: var(--line-height-normal);
+        vertical-align: top;
+      }
+      .attention-detail .peopleListValues {
+        line-height: calc(var(--line-height-normal) + 10px);
+      }
+      .attention-detail gr-account-label {
+        line-height: var(--line-height-normal);
+      }
+      .attentionSummary gr-account-label:focus,
+      .attention-detail gr-account-label:focus {
+        outline: none;
+      }
+      .attentionSummary gr-account-label:hover,
+      .attention-detail gr-account-label:hover {
+        box-shadow: var(--elevation-level-1);
+        cursor: pointer;
+      }
+      .attention-detail .attentionDetailsTitle {
+        display: flex;
+        justify-content: space-between;
+      }
+      .attention-detail .selectUsers {
+        color: var(--deemphasized-text-color);
+        margin-bottom: var(--spacing-m);
+      }
+      .attentionTip {
+        padding: var(--spacing-m);
+        border: 1px solid var(--border-color);
+        border-radius: var(--border-radius);
+        margin-top: var(--spacing-m);
+        background-color: var(--assignee-highlight-color);
+      }
+      .attentionTip div iron-icon {
+        margin-right: var(--spacing-s);
+      }
+      .patchsetLevelContainer {
+        width: 80ch;
+        border-radius: var(--border-radius);
+        box-shadow: var(--elevation-level-2);
+      }
+      .patchsetLevelContainer.resolved {
+        background-color: var(--comment-background-color);
+      }
+      .patchsetLevelContainer.unresolved {
+        background-color: var(--unresolved-comment-background-color);
+      }
+      .labelContainer {
+        padding-left: var(--spacing-m);
+        padding-bottom: var(--spacing-m);
+      }
+    `,
+  ];
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (
+      changedProperties.has('draft') ||
+      changedProperties.has('draftCommentThreads')
+    ) {
+      this.computeHasDrafts();
+    }
+    if (changedProperties.has('draft')) {
+      this.draftChanged(changedProperties.get('draft') as string);
+    }
+    if (changedProperties.has('ccPendingConfirmation')) {
+      this.pendingConfirmationUpdated(this.ccPendingConfirmation);
+    }
+    if (changedProperties.has('reviewerPendingConfirmation')) {
+      this.pendingConfirmationUpdated(this.reviewerPendingConfirmation);
+    }
+    if (changedProperties.has('change')) {
+      this.computeUploader();
+      this.changeUpdated();
+    }
+    if (changedProperties.has('canBeStarted')) {
+      this.computeMessagePlaceholder();
+      this.computeSendButtonLabel();
+    }
+    if (changedProperties.has('reviewFormatting')) {
+      this.handleHeightChanged();
+    }
+    if (changedProperties.has('draftCommentThreads')) {
+      this.handleHeightChanged();
+    }
+    if (changedProperties.has('reviewers')) {
+      this.computeAllReviewers();
+    }
+    if (changedProperties.has('sendDisabled')) {
+      this.sendDisabledChanged();
+    }
+    if (changedProperties.has('attentionExpanded')) {
+      this.onAttentionExpandedChange();
+    }
+    if (
+      changedProperties.has('account') ||
+      changedProperties.has('reviewers') ||
+      changedProperties.has('ccs') ||
+      changedProperties.has('change') ||
+      changedProperties.has('draftCommentThreads') ||
+      changedProperties.has('includeComments') ||
+      changedProperties.has('labelsChanged') ||
+      changedProperties.has('hasDrafts')
+    ) {
+      this.computeNewAttention();
+    }
+  }
+
   constructor() {
     super();
     this.filterReviewerSuggestion =
-      this._filterReviewerSuggestionGenerator(false);
-    this.filterCCSuggestion = this._filterReviewerSuggestionGenerator(true);
+      this.filterReviewerSuggestionGenerator(false);
+    this.filterCCSuggestion = this.filterReviewerSuggestionGenerator(true);
+    this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
   }
 
   override connectedCallback() {
@@ -379,23 +661,23 @@
     (
       IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
     ).requestAvailability();
-    this._getAccount().then(account => {
-      if (account) this._account = account;
+    this.restApiService.getAccount().then(account => {
+      if (account) this.account = account;
     });
 
     this.cleanups.push(
       addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
-        this._submit()
+        this.submit()
       )
     );
     this.cleanups.push(
       addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
-        this._submit()
+        this.submit()
       )
     );
     this.cleanups.push(addShortcut(this, {key: Key.ESC}, _ => this.cancel()));
     this.addEventListener('comment-editing-changed', e => {
-      this._commentEditing = (e as CustomEvent).detail;
+      this.commentEditing = (e as CustomEvent).detail;
     });
 
     // Plugins on reply-reviewers endpoint can take advantage of these
@@ -404,22 +686,17 @@
     this.addEventListener('add-reviewer', e => {
       // Only support account type, see more from:
       // elements/shared/gr-account-list/gr-account-list.js#addAccountItem
-      this.$.reviewers.addAccountItem({
+      this.reviewersList?.addAccountItem({
         account: (e as CustomEvent).detail.reviewer,
         count: 1,
       });
     });
 
     this.addEventListener('remove-reviewer', e => {
-      this.$.reviewers.removeAccount((e as CustomEvent).detail.reviewer);
+      this.reviewersList?.removeAccount((e as CustomEvent).detail.reviewer);
     });
   }
 
-  override ready() {
-    super.ready();
-    this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
-  }
-
   override disconnectedCallback() {
     this.storeTask?.cancel();
     for (const cleanup of this.cleanups) cleanup();
@@ -427,6 +704,505 @@
     super.disconnectedCallback();
   }
 
+  override render() {
+    if (!this.change) return;
+    this.sendDisabled = this.computeSendButtonDisabled();
+    return html`
+      <div tabindex="-1">
+        <section class="peopleContainer">
+          <gr-endpoint-decorator name="reply-reviewers">
+            <gr-endpoint-param
+              .name=${'change'}
+              .value=${this.change}
+            ></gr-endpoint-param>
+            <gr-endpoint-param .name=${'reviewers'} .value=${this.allReviewers}>
+            </gr-endpoint-param>
+            ${this.renderReviewerList()}
+            <gr-endpoint-slot name="below"></gr-endpoint-slot>
+          </gr-endpoint-decorator>
+          ${this.renderCCList()} ${this.renderReviewConfirmation()}
+        </section>
+        <section class="labelsContainer">${this.renderLabels()}</section>
+        <section class="newReplyDialog textareaContainer">
+          ${this.renderReplyText()}
+        </section>
+        ${when(
+          this.previewFormatting,
+          () => html`
+            <section class="previewContainer">
+              <gr-formatted-text
+                .content=${this.draft}
+                .config=${this.projectConfig?.commentlinks}
+              ></gr-formatted-text>
+            </section>
+          `
+        )}
+        ${this.renderDraftsSection()}
+        <div class="stickyBottom newReplyDialog">
+          <gr-endpoint-decorator name="reply-bottom">
+            <gr-endpoint-param
+              .name=${'change'}
+              .value=${this.change}
+            ></gr-endpoint-param>
+            ${this.renderAttentionSummarySection()}
+            ${this.renderAttentionDetailsSection()}
+            <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
+            ${this.renderActionsSection()}
+          </gr-endpoint-decorator>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderReviewerList() {
+    return html`
+      <div class="peopleList">
+        <div class="peopleListLabel">Reviewers</div>
+        <gr-account-list
+          id="reviewers"
+          .accounts=${this.getAccountListCopy(this.reviewers)}
+          @account-added=${this.accountAdded}
+          @accounts-changed=${this.handleReviewersChanged}
+          .removableValues=${this.change?.removable_reviewers}
+          .filter=${this.filterReviewerSuggestion}
+          .pendingConfirmation=${this.reviewerPendingConfirmation}
+          @pending-confirmation-changed=${this
+            .handleReviewersConfirmationChanged}
+          .placeholder=${'Add reviewer...'}
+          @account-text-changed=${this.handleAccountTextEntry}
+          .suggestionsProvider=${this.getReviewerSuggestionsProvider(
+            this.change
+          )}
+        >
+        </gr-account-list>
+        <gr-endpoint-slot name="right"></gr-endpoint-slot>
+      </div>
+    `;
+  }
+
+  private renderCCList() {
+    return html`
+      <div class="peopleList">
+        <div class="peopleListLabel">CC</div>
+        <gr-account-list
+          id="ccs"
+          .accounts=${this.getAccountListCopy(this.ccs)}
+          @account-added=${this.accountAdded}
+          @accounts-changed=${this.handleCcsChanged}
+          .removableValues=${this.change?.removable_reviewers}
+          .filter=${this.filterCCSuggestion}
+          .pendingConfirmation=${this.ccPendingConfirmation}
+          @pending-confirmation-changed=${this.handleCcsConfirmationChanged}
+          allow-any-input
+          .placeholder=${'Add CC...'}
+          @account-text-changed=${this.handleAccountTextEntry}
+          .suggestionsProvider=${this.getCcSuggestionsProvider(this.change)}
+        >
+        </gr-account-list>
+      </div>
+    `;
+  }
+
+  private renderReviewConfirmation() {
+    return html`
+      <gr-overlay
+        id="reviewerConfirmationOverlay"
+        @iron-overlay-canceled=${this.cancelPendingReviewer}
+      >
+        <div class="reviewerConfirmation">
+          Group
+          <span class="groupName">
+            ${this.pendingConfirmationDetails?.group.name}
+          </span>
+          has
+          <span class="groupSize">
+            ${this.pendingConfirmationDetails?.count}
+          </span>
+          members.
+          <br />
+          Are you sure you want to add them all?
+        </div>
+        <div class="reviewerConfirmationButtons">
+          <gr-button @click=${this.confirmPendingReviewer}>Yes</gr-button>
+          <gr-button @click=${this.cancelPendingReviewer}>No</gr-button>
+        </div>
+      </gr-overlay>
+    `;
+  }
+
+  private renderLabels() {
+    if (!this.change || !this.account || !this.permittedLabels) return;
+    return html`
+      <gr-endpoint-decorator name="reply-label-scores">
+        <gr-label-scores
+          id="labelScores"
+          .account=${this.account}
+          .change=${this.change}
+          @labels-changed=${this._handleLabelsChanged}
+          .permittedLabels=${this.permittedLabels}
+        ></gr-label-scores>
+        <gr-endpoint-param
+          .name=${'change'}
+          .value=${this.change}
+        ></gr-endpoint-param>
+      </gr-endpoint-decorator>
+      <div id="pluginMessage">${this.pluginMessage}</div>
+    `;
+  }
+
+  private renderReplyText() {
+    if (!this.change) return;
+    return html`
+      <div
+        class=${classMap({
+          patchsetLevelContainer: true,
+          [this.getUnresolvedPatchsetLevelClass(
+            this.isResolvedPatchsetLevelComment
+          )]: true,
+        })}
+      >
+        <gr-endpoint-decorator name="reply-text">
+          <gr-textarea
+            id="textarea"
+            class="message newReplyDialog"
+            .autocomplete=${'on'}
+            .placeholder=${this.messagePlaceholder}
+            monospace
+            ?disabled=${this.disabled}
+            .rows=${4}
+            .text=${this.draft}
+            @bind-value-changed=${(e: BindValueChangeEvent) => {
+              // Setting the value of this.draft in ReplyDialog triggers the
+              // bind-value-changed observer which ends up calling draftChanged
+              // with the same oldValue as the current oldValue messing up the
+              // debounced storage clearence as the first call with the correct
+              // oldValue is ignored in favor of this second call.
+              // TODO(dhruvsri): find a better solution to this
+              if (this.draft === e.detail.value) return;
+              this.draft = e.detail.value;
+              this.handleHeightChanged;
+            }}
+          >
+          </gr-textarea>
+          <gr-endpoint-param .name=${'change'} .value=${this.change}>
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+        <div class="labelContainer">
+          <label>
+            <input
+              id="resolvedPatchsetLevelCommentCheckbox"
+              type="checkbox"
+              ?checked=${this.isResolvedPatchsetLevelComment}
+              @change=${this.handleResolvedPatchsetLevelCommentCheckboxChanged}
+            />
+            Resolved
+          </label>
+          <label class="preview-formatting">
+            <input
+              type="checkbox"
+              ?checked=${this.previewFormatting}
+              @change=${this.handlePreviewFormattingChanged}
+            />
+            Preview formatting
+          </label>
+        </div>
+      </div>
+    `;
+  }
+
+  private renderDraftsSection() {
+    if (this.computeHideDraftList(this.draftCommentThreads)) return;
+    return html`
+      <section class="draftsContainer">
+        <div class="includeComments">
+          <input
+            type="checkbox"
+            id="includeComments"
+            @change=${this.handleIncludeCommentsChanged}
+            ?checked=${this.includeComments}
+          />
+          <label for="includeComments"
+            >Publish ${this.computeDraftsTitle(this.draftCommentThreads)}</label
+          >
+        </div>
+        ${when(
+          this.includeComments,
+          () => html`
+            <gr-thread-list
+              id="commentList"
+              .threads=${this.draftCommentThreads!}
+              hide-dropdown
+            >
+            </gr-thread-list>
+          `
+        )}
+        <span
+          id="savingLabel"
+          class=${this.computeSavingLabelClass(this.savingComments)}
+        >
+          Saving comments...
+        </span>
+      </section>
+    `;
+  }
+
+  private renderAttentionSummarySection() {
+    if (this.attentionExpanded) return;
+    return html`
+      <section class="attention">
+        <div class="attentionSummary">
+          <div>
+            ${when(
+              this.computeShowNoAttentionUpdate(),
+              () => html` <span>${this.computeDoNotUpdateMessage()}</span> `
+            )}
+            ${when(
+              !this.computeShowNoAttentionUpdate(),
+              () => html`
+                <span>Bring to attention of</span>
+                ${this.computeNewAttentionAccounts().map(
+                  account => html`
+                    <gr-account-label
+                      .account=${account}
+                      .forceAttention=${this.computeHasNewAttention(account)}
+                      .selected=${this.computeHasNewAttention(account)}
+                      .hideHovercard=${true}
+                      .selectionChipStyle=${true}
+                      @click=${this.handleAttentionClick}
+                    ></gr-account-label>
+                  `
+                )}
+              `
+            )}
+            <gr-tooltip-content
+              has-tooltip
+              title=${this.computeAttentionButtonTitle()}
+            >
+              <gr-button
+                class="edit-attention-button"
+                @click=${this.handleAttentionModify}
+                ?disabled=${this.sendDisabled}
+                link
+                position-below
+                data-label="Edit"
+                data-action-type="change"
+                data-action-key="edit"
+                role="button"
+                tabindex="0"
+              >
+                <iron-icon icon="gr-icons:edit"></iron-icon>
+                Modify
+              </gr-button>
+            </gr-tooltip-content>
+          </div>
+          <div>
+            <a
+              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+              target="_blank"
+            >
+              <iron-icon
+                icon="gr-icons:help-outline"
+                title="read documentation"
+              ></iron-icon>
+            </a>
+          </div>
+        </div>
+      </section>
+    `;
+  }
+
+  private renderAttentionDetailsSection() {
+    if (!this.attentionExpanded) return;
+    return html`
+      <section class="attention-detail">
+        <div class="attentionDetailsTitle">
+          <div>
+            <span>Modify attention to</span>
+          </div>
+          <div></div>
+          <div>
+            <a
+              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+              target="_blank"
+            >
+              <iron-icon
+                icon="gr-icons:help-outline"
+                title="read documentation"
+              ></iron-icon>
+            </a>
+          </div>
+        </div>
+        <div class="selectUsers">
+          <span
+            >Select chips to set who will be in the attention set after sending
+            this reply</span
+          >
+        </div>
+        <div class="peopleList">
+          <div class="peopleListLabel">Owner</div>
+          <div class="peopleListValues">
+            <gr-account-label
+              .account=${this.owner}
+              ?forceAttention=${this.computeHasNewAttention(this.owner)}
+              .selected=${this.computeHasNewAttention(this.owner)}
+              .hideHovercard=${true}
+              .selectionChipStyle=${true}
+              @click=${this.handleAttentionClick}
+            >
+            </gr-account-label>
+          </div>
+        </div>
+        ${when(
+          this.uploader,
+          () => html`
+            <div class="peopleList">
+              <div class="peopleListLabel">Uploader</div>
+              <div class="peopleListValues">
+                <gr-account-label
+                  .account=${this.uploader}
+                  ?forceAttention=${this.computeHasNewAttention(this.uploader)}
+                  .selected=${this.computeHasNewAttention(this.uploader)}
+                  .hideHovercard=${true}
+                  .selectionChipStyle=${true}
+                  @click=${this.handleAttentionClick}
+                >
+                </gr-account-label>
+              </div>
+            </div>
+          `
+        )}
+        <div class="peopleList">
+          <div class="peopleListLabel">Reviewers</div>
+          <div class="peopleListValues">
+            ${this.removeServiceUsers(this.reviewers).map(
+              account => html`
+                <gr-account-label
+                  .account=${account}
+                  ?forceAttention=${this.computeHasNewAttention(account)}
+                  .selected=${this.computeHasNewAttention(account)}
+                  .hideHovercard=${true}
+                  .selectionChipStyle=${true}
+                  @click=${this.handleAttentionClick}
+                >
+                </gr-account-label>
+              `
+            )}
+          </div>
+        </div>
+
+        ${when(
+          this.attentionCcsCount,
+          () => html`
+            <div class="peopleList">
+              <div class="peopleListLabel">CC</div>
+              <div class="peopleListValues">
+                ${this.removeServiceUsers(this.ccs).map(
+                  account => html`
+                    <gr-account-label
+                      .account=${account}
+                      ?forceAttention=${this.computeHasNewAttention(account)}
+                      .selected=${this.computeHasNewAttention(account)}
+                      .hideHovercard=${true}
+                      .selectionChipStyle=${true}
+                      @click=${this.handleAttentionClick}
+                    >
+                    </gr-account-label>
+                  `
+                )}
+              </div>
+            </div>
+          `
+        )}
+        ${when(
+          this.computeShowAttentionTip(
+            this.account,
+            this.owner,
+            this.currentAttentionSet,
+            this.newAttentionSet
+          ),
+          () => html`
+            <div class="attentionTip">
+              <iron-icon
+                class="pointer"
+                icon="gr-icons:lightbulb-outline"
+              ></iron-icon>
+              Be mindful of requiring attention from too many users.
+            </div>
+          `
+        )}
+      </section>
+    `;
+  }
+
+  private renderActionsSection() {
+    return html`
+      <section class="actions">
+        <div class="left">
+          ${when(
+            this.knownLatestState === LatestPatchState.CHECKING,
+            () => html`
+              <span id="checkingStatusLabel">
+                Checking whether patch ${this.patchNum} is latest...
+              </span>
+            `
+          )}
+          ${when(
+            this.knownLatestState === LatestPatchState.NOT_LATEST,
+            () => html`
+              <span id="notLatestLabel">
+                ${this.computePatchSetWarning()}
+                <gr-button link @click=${this._reload}>Reload</gr-button>
+              </span>
+            `
+          )}
+        </div>
+        <div class="right">
+          <gr-button
+            link
+            id="cancelButton"
+            class="action cancel"
+            @click=${this.cancelTapHandler}
+            >Cancel</gr-button
+          >
+          ${when(
+            this.canBeStarted,
+            () => html`
+              <!-- Use 'Send' here as the change may only about reviewers / ccs
+            and when this button is visible, the next button will always
+            be 'Start review' -->
+              <gr-tooltip-content has-tooltip title=${this.saveTooltip}>
+                <gr-button
+                  link
+                  ?disabled=${this.knownLatestState ===
+                  LatestPatchState.NOT_LATEST}
+                  class="action save"
+                  @click=${this.saveClickHandler}
+                  >Send As WIP</gr-button
+                >
+              </gr-tooltip-content>
+            `
+          )}
+          <gr-tooltip-content
+            has-tooltip
+            title=${this.computeSendButtonTooltip(
+              this.canBeStarted,
+              this.commentEditing
+            )}
+          >
+            <gr-button
+              id="sendButton"
+              primary
+              ?disabled=${this.sendDisabled}
+              class="action send"
+              @click=${this.sendTapHandler}
+              >${this.sendButtonLabel}
+            </gr-button>
+          </gr-tooltip-content>
+        </div>
+      </section>
+    `;
+  }
+
   /**
    * Note that this method is not actually *opening* the dialog. Opening and
    * showing the dialog is dealt with by the overlay. This method is used by the
@@ -444,48 +1220,57 @@
           : LatestPatchState.NOT_LATEST;
       });
 
-    this._focusOn(focusTarget);
+    this.focusOn(focusTarget);
     if (quote?.length) {
       // If a reply quote has been provided, use it.
       this.draft = quote;
     } else {
       // Otherwise, check for an unsaved draft in localstorage.
-      this.draft = this._loadStoredDraft();
+      this.draft = this.loadStoredDraft();
     }
     if (this.restApiService.hasPendingDiffDrafts()) {
-      this._savingComments = true;
+      this.savingComments = true;
       this.restApiService.awaitPendingDiffDrafts().then(() => {
         fireEvent(this, 'comment-refresh');
-        this._savingComments = false;
+        this.savingComments = false;
       });
     }
   }
 
-  _computeHasDrafts(
-    draft: string,
-    draftCommentThreads: PolymerDeepPropertyChange<
-      CommentThread[] | undefined,
-      CommentThread[] | undefined
-    >
-  ) {
-    if (draftCommentThreads.base === undefined) return false;
-    return draft.length > 0 || draftCommentThreads.base.length > 0;
+  computeHasDrafts() {
+    if (this.draftCommentThreads === undefined) return false;
+    return this.draft.length > 0 || this.draftCommentThreads.length > 0;
   }
 
   override focus() {
-    this._focusOn(FocusTarget.ANY);
+    this.focusOn(FocusTarget.ANY);
   }
 
   getFocusStops() {
-    const end = this._sendDisabled ? this.$.cancelButton : this.$.sendButton;
-    if (!this.$.reviewers.focusStart) return undefined;
+    const end = this.sendDisabled ? this.cancelButton : this.sendButton;
+    if (!this.reviewersList?.focusStart || !end) return undefined;
     return {
-      start: this.$.reviewers.focusStart,
+      start: this.reviewersList.focusStart,
       end,
     };
   }
 
-  setLabelValue(label: string, value: string) {
+  private handleResolvedPatchsetLevelCommentCheckboxChanged(e: Event) {
+    if (!(e.target instanceof HTMLInputElement)) return;
+    this.isResolvedPatchsetLevelComment = e.target.checked;
+  }
+
+  private handlePreviewFormattingChanged(e: Event) {
+    if (!(e.target instanceof HTMLInputElement)) return;
+    this.previewFormatting = e.target.checked;
+  }
+
+  private handleIncludeCommentsChanged(e: Event) {
+    if (!(e.target instanceof HTMLInputElement)) return;
+    this.includeComments = e.target.checked;
+  }
+
+  setLabelValue(label: string, value: string): void {
     const selectorEl =
       this.getLabelScores().shadowRoot?.querySelector<GrLabelScoreRow>(
         `gr-label-score-row[name="${label}"]`
@@ -509,14 +1294,14 @@
         ? ReviewerType.CC
         : ReviewerType.REVIEWER;
     const isReviewer = ReviewerType.REVIEWER === reviewerType;
-    const array = isReviewer ? this._ccs : this._reviewers;
+    const array = isReviewer ? this.ccs : this.reviewers;
     const index = array.findIndex(
       reviewer => accountOrGroupKey(reviewer) === key
     );
     if (index >= 0) {
       // Remove any accounts that already exist as a CC for reviewer
       // or vice versa.
-      this.splice(isReviewer ? '_ccs' : '_reviewers', index, 1);
+      array.splice(index, 1);
       const moveFrom = isReviewer ? 'CC' : 'reviewer';
       const moveTo = isReviewer ? 'reviewer' : 'CC';
       const id = account.name || key;
@@ -541,33 +1326,27 @@
         reviewers.push(reviewer);
       });
     };
-    addToReviewInput(this.$.reviewers.additions(), ReviewerState.REVIEWER);
-    addToReviewInput(this.$.ccs.additions(), ReviewerState.CC);
+    addToReviewInput(this.reviewersList!.additions(), ReviewerState.REVIEWER);
+    addToReviewInput(this.ccsList!.additions(), ReviewerState.CC);
     addToReviewInput(
-      this.$.reviewers.removals().filter(
+      this.reviewersList!.removals().filter(
         r =>
           isReviewerOrCC(change, r) &&
           // ignore removal from reviewer request if being added to CC
-          !this.$.ccs
-            .additions()
-            .some(
-              account =>
-                mapReviewer(account).reviewer === mapReviewer(r).reviewer
-            )
+          !this.ccsList!.additions().some(
+            account => mapReviewer(account).reviewer === mapReviewer(r).reviewer
+          )
       ),
       ReviewerState.REMOVED
     );
     addToReviewInput(
-      this.$.ccs.removals().filter(
+      this.ccsList!.removals().filter(
         r =>
           isReviewerOrCC(change, r) &&
           // ignore removal from CC request if being added as reviewer
-          !this.$.reviewers
-            .additions()
-            .some(
-              account =>
-                mapReviewer(account).reviewer === mapReviewer(r).reviewer
-            )
+          !this.reviewersList!.additions().some(
+            account => mapReviewer(account).reviewer === mapReviewer(r).reviewer
+          )
       ),
       ReviewerState.REMOVED
     );
@@ -589,23 +1368,23 @@
       reviewInput.ready = true;
     }
 
-    const reason = getReplyByReason(this._account, this.serverConfig);
+    const reason = getReplyByReason(this.account, this.serverConfig);
 
     reviewInput.ignore_automatic_attention_set_rules = true;
     reviewInput.add_to_attention_set = [];
-    for (const user of this._newAttentionSet) {
-      if (!this._currentAttentionSet.has(user)) {
+    for (const user of this.newAttentionSet) {
+      if (!this.currentAttentionSet.has(user)) {
         reviewInput.add_to_attention_set.push({user, reason});
       }
     }
     reviewInput.remove_from_attention_set = [];
-    for (const user of this._currentAttentionSet) {
-      if (!this._newAttentionSet.has(user)) {
+    for (const user of this.currentAttentionSet) {
+      if (!this.newAttentionSet.has(user)) {
         reviewInput.remove_from_attention_set.push({user, reason});
       }
     }
     this.reportAttentionSetChanges(
-      this._attentionExpanded,
+      this.attentionExpanded,
       reviewInput.add_to_attention_set,
       reviewInput.remove_from_attention_set
     );
@@ -613,7 +1392,7 @@
     if (this.draft) {
       const comment: CommentInput = {
         message: this.draft,
-        unresolved: !this._isResolvedPatchsetLevelComment,
+        unresolved: !this.isResolvedPatchsetLevelComment,
       };
       reviewInput.comments = {
         [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [comment],
@@ -624,8 +1403,8 @@
     reviewInput.reviewers = this.computeReviewers(this.change);
     this.disabled = true;
 
-    const errFn = (r?: Response | null) => this._handle400Error(r);
-    return this._saveReview(reviewInput, errFn)
+    const errFn = (r?: Response | null) => this.handle400Error(r);
+    return this.saveReview(reviewInput, errFn)
       .then(response => {
         if (!response) {
           // Null or undefined response indicates that an error handler
@@ -638,7 +1417,7 @@
         }
 
         this.draft = '';
-        this._includeComments = true;
+        this.includeComments = true;
         this.dispatchEvent(
           new CustomEvent('send', {
             composed: true,
@@ -658,31 +1437,31 @@
       });
   }
 
-  _focusOn(section?: FocusTarget) {
+  focusOn(section?: FocusTarget) {
     // Safeguard- always want to focus on something.
     if (!section || section === FocusTarget.ANY) {
-      section = this._chooseFocusTarget();
+      section = this.chooseFocusTarget();
     }
     if (section === FocusTarget.BODY) {
       const textarea = queryAndAssert<GrTextarea>(this, 'gr-textarea');
       setTimeout(() => textarea.getNativeTextarea().focus());
     } else if (section === FocusTarget.REVIEWERS) {
-      const reviewerEntry = this.$.reviewers.focusStart;
+      const reviewerEntry = this.reviewersList?.focusStart;
       setTimeout(() => reviewerEntry?.focus());
     } else if (section === FocusTarget.CCS) {
-      const ccEntry = this.$.ccs.focusStart;
+      const ccEntry = this.ccsList?.focusStart;
       setTimeout(() => ccEntry?.focus());
     }
   }
 
-  _chooseFocusTarget() {
+  chooseFocusTarget() {
     // If we are the owner and the reviewers field is empty, focus on that.
     if (
-      this._account &&
+      this.account &&
       this.change &&
       this.change.owner &&
-      this._account._account_id === this.change.owner._account_id &&
-      (!this._reviewers || this._reviewers.length === 0)
+      this.account._account_id === this.change.owner._account_id &&
+      (!this.reviewers || this.reviewers?.length === 0)
     ) {
       return FocusTarget.REVIEWERS;
     }
@@ -691,15 +1470,15 @@
     return FocusTarget.BODY;
   }
 
-  _isOwner(account?: AccountInfo, change?: ChangeInfo) {
+  isOwner(account?: AccountInfo, change?: ChangeInfo) {
     if (!account || !change || !change.owner) return false;
     return account._account_id === change.owner._account_id;
   }
 
-  _handle400Error(r?: Response | null) {
+  handle400Error(r?: Response | null) {
     if (!r) throw new Error('Response is empty.');
     let response: Response = r;
-    // A call to _saveReview could fail with a server error if erroneous
+    // A call to saveReview could fail with a server error if erroneous
     // reviewers were requested. This is signalled with a 400 Bad Request
     // status. The default gr-rest-api error handling would result in a large
     // JSON response body being displayed to the user in the gr-error-manager
@@ -734,45 +1513,42 @@
     });
   }
 
-  _computeHideDraftList(draftCommentThreads?: CommentThread[]) {
+  computeHideDraftList(draftCommentThreads?: CommentThread[]) {
     return !draftCommentThreads || draftCommentThreads.length === 0;
   }
 
-  _computeDraftsTitle(draftCommentThreads?: CommentThread[]) {
+  computeDraftsTitle(draftCommentThreads?: CommentThread[]) {
     const total = draftCommentThreads ? draftCommentThreads.length : 0;
     return pluralize(total, 'Draft');
   }
 
-  _computeMessagePlaceholder(canBeStarted: boolean) {
-    return canBeStarted
+  computeMessagePlaceholder() {
+    this.messagePlaceholder = this.canBeStarted
       ? 'Add a note for your reviewers...'
       : 'Say something nice...';
   }
 
-  @observe('change.reviewers.*', 'change.owner')
-  _changeUpdated(
-    changeRecord: PolymerDeepPropertyChange<Reviewers, Reviewers>,
-    owner: AccountInfo
-  ) {
-    if (changeRecord === undefined || owner === undefined) return;
-    this._rebuildReviewerArrays(changeRecord.base, owner);
+  changeUpdated() {
+    if (this.change === undefined) return;
+    this.rebuildReviewerArrays();
   }
 
-  _rebuildReviewerArrays(changeReviewers: Reviewers, owner: AccountInfo) {
-    this._owner = owner;
+  rebuildReviewerArrays() {
+    if (!this.change?.owner || !this.change?.reviewers) return;
+    this.owner = this.change.owner;
 
     const reviewers = [];
     const ccs = [];
 
-    if (changeReviewers) {
-      for (const key of Object.keys(changeReviewers)) {
+    if (this.change.reviewers) {
+      for (const key of Object.keys(this.change.reviewers)) {
         if (key !== 'REVIEWER' && key !== 'CC') {
           this.reporting.error(new Error(`Unexpected reviewer state: ${key}`));
           continue;
         }
-        if (!changeReviewers[key]) continue;
-        for (const entry of changeReviewers[key]!) {
-          if (entry._account_id === owner._account_id) {
+        if (!this.change.reviewers[key]) continue;
+        for (const entry of this.change.reviewers[key]!) {
+          if (entry._account_id === this.owner._account_id) {
             continue;
           }
           switch (key) {
@@ -787,172 +1563,141 @@
       }
     }
 
-    this._ccs = ccs;
-    this._reviewers = reviewers;
+    this.ccs = ccs;
+    this.reviewers = reviewers;
   }
 
-  _handleAttentionModify() {
-    this._attentionExpanded = true;
+  handleAttentionModify() {
+    this.attentionExpanded = true;
   }
 
-  @observe('_attentionExpanded')
-  _onAttentionExpandedChange() {
+  onAttentionExpandedChange() {
     // If the attention-detail section is expanded without dispatching this
     // event, then the dialog may expand beyond the screen's bottom border.
     fireEvent(this, 'iron-resize');
   }
 
-  _showAttentionSummary(attentionExpanded?: boolean) {
-    return !attentionExpanded;
-  }
-
-  _showAttentionDetails(attentionExpanded?: boolean) {
-    return attentionExpanded;
-  }
-
-  _computeAttentionButtonTitle(sendDisabled?: boolean) {
+  computeAttentionButtonTitle(sendDisabled?: boolean) {
     return sendDisabled
       ? 'Modify the attention set by adding a comment or use the account ' +
           'hovercard in the change page.'
       : 'Edit attention set changes';
   }
 
-  _handleAttentionClick(e: Event) {
+  handleAttentionClick(e: Event) {
     const id = (e.target as GrAccountChip)?.account?._account_id;
     if (!id) return;
 
-    const selfId = (this._account && this._account._account_id) || -1;
+    const selfId = (this.account && this.account._account_id) || -1;
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
     const self = id === selfId ? '_SELF' : '';
-    const role = id === ownerId ? '_OWNER' : '_REVIEWER';
+    const role = id === ownerId ? 'OWNER' : '_REVIEWER';
 
-    if (this._newAttentionSet.has(id)) {
-      this._newAttentionSet.delete(id);
+    if (this.newAttentionSet.has(id)) {
+      this.newAttentionSet.delete(id);
       this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, {
         action: `REMOVE${self}${role}`,
       });
     } else {
-      this._newAttentionSet.add(id);
+      this.newAttentionSet.add(id);
       this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, {
         action: `ADD${self}${role}`,
       });
     }
 
     // Ensure that Polymer picks up the change.
-    this._newAttentionSet = new Set(this._newAttentionSet);
+    this.newAttentionSet = new Set(this.newAttentionSet);
   }
 
-  _computeHasNewAttention(
-    account?: AccountInfo,
-    newAttention?: Set<AccountId>
-  ) {
-    return (
-      newAttention &&
+  computeHasNewAttention(account?: AccountInfo) {
+    return !!(
       account &&
       account._account_id &&
-      newAttention.has(account._account_id)
+      this.newAttentionSet?.has(account._account_id)
     );
   }
 
-  @observe(
-    '_account',
-    '_reviewers.*',
-    '_ccs.*',
-    'change',
-    'draftCommentThreads',
-    '_includeComments',
-    '_labelsChanged',
-    'hasDrafts'
-  )
-  _computeNewAttention(
-    currentUser?: AccountInfo,
-    reviewers?: PolymerDeepPropertyChange<
-      AccountInfoInput[],
-      AccountInfoInput[]
-    >,
-    ccs?: PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>,
-    change?: ChangeInfo,
-    draftCommentThreads?: CommentThread[],
-    includeComments?: boolean,
-    _labelsChanged?: boolean,
-    hasDrafts?: boolean
-  ) {
+  computeNewAttention() {
     if (
-      currentUser === undefined ||
-      currentUser._account_id === undefined ||
-      reviewers === undefined ||
-      ccs === undefined ||
-      change === undefined ||
-      draftCommentThreads === undefined ||
-      includeComments === undefined
+      this.account?._account_id === undefined ||
+      this.change === undefined ||
+      this.includeComments === undefined ||
+      this.draftCommentThreads === undefined
     ) {
       return;
     }
     // The draft comments are only relevant for the attention set as long as the
     // user actually plans to publish their drafts.
-    draftCommentThreads = includeComments ? draftCommentThreads : [];
-    const hasVote = !!_labelsChanged;
-    const isOwner = this._isOwner(currentUser, change);
-    const isUploader = this._uploader?._account_id === currentUser._account_id;
-    this._attentionCcsCount = removeServiceUsers(ccs.base).length;
-    this._currentAttentionSet = new Set(
-      Object.keys(change.attention_set || {}).map(id => Number(id) as AccountId)
+    const draftCommentThreads = this.includeComments
+      ? this.draftCommentThreads
+      : [];
+    const hasVote = !!this.labelsChanged;
+    const isOwner = this.isOwner(this.account, this.change);
+    const isUploader = this.uploader?._account_id === this.account._account_id;
+    this.attentionCcsCount = removeServiceUsers(this.ccs).length;
+    this.currentAttentionSet = new Set(
+      Object.keys(this.change.attention_set || {}).map(
+        id => Number(id) as AccountId
+      )
     );
-    const newAttention = new Set(this._currentAttentionSet);
-    if (change.status === ChangeStatus.NEW) {
+    const newAttention = new Set(this.currentAttentionSet);
+    if (this.change.status === ChangeStatus.NEW) {
       // Add everyone that the user is replying to in a comment thread.
-      this._computeCommentAccounts(draftCommentThreads).forEach(id =>
+      this.computeCommentAccounts(draftCommentThreads).forEach(id =>
         newAttention.add(id)
       );
       // Remove the current user.
-      newAttention.delete(currentUser._account_id);
+      newAttention.delete(this.account._account_id);
       // Add all new reviewers, but not the current reviewer, if they are also
       // sending a draft or a label vote.
       const notIsReviewerAndHasDraftOrLabel = (r: AccountInfo) =>
-        !(r._account_id === currentUser._account_id && (hasDrafts || hasVote));
-      reviewers.base
-        .filter(r => r._account_id)
+        !(
+          r._account_id === this.account!._account_id &&
+          (this.hasDrafts || hasVote)
+        );
+      this.reviewers
+        .filter(r => isAccount(r))
         .filter(r => r._pendingAdd || (this.canBeStarted && isOwner))
         .filter(notIsReviewerAndHasDraftOrLabel)
-        .forEach(r => newAttention.add(r._account_id!));
+        .forEach(r => newAttention.add((r as AccountInfo)._account_id!));
       // Add owner and uploader, if someone else replies.
-      if (hasDrafts || hasVote) {
-        if (this._uploader?._account_id && !isUploader) {
-          newAttention.add(this._uploader._account_id);
+      if (this.hasDrafts || hasVote) {
+        if (this.uploader?._account_id && !isUploader) {
+          newAttention.add(this.uploader._account_id);
         }
-        if (change.owner?._account_id && !isOwner) {
-          newAttention.add(change.owner._account_id);
+        if (this.change.owner?._account_id && !isOwner) {
+          newAttention.add(this.change.owner._account_id);
         }
       }
     } else {
       // The only reason for adding someone to the attention set for merged or
       // abandoned changes is that someone makes a comment thread unresolved.
       const hasUnresolvedDraft = draftCommentThreads.some(isUnresolved);
-      if (change.owner && hasUnresolvedDraft) {
-        // A change owner must have an _account_id.
-        newAttention.add(change.owner._account_id!);
+      if (this.change.owner && hasUnresolvedDraft) {
+        // A change owner must have an account_id.
+        newAttention.add(this.change.owner._account_id!);
       }
       // Remove the current user.
-      newAttention.delete(currentUser._account_id);
+      newAttention.delete(this.account._account_id);
     }
     // Finally make sure that everyone in the attention set is still active as
     // owner, reviewer or cc.
-    const allAccountIds = this._allAccounts()
+    const allAccountIds = this.allAccounts()
       .map(a => a._account_id)
       .filter(id => !!id);
-    this._newAttentionSet = new Set(
+    this.newAttentionSet = new Set(
       [...newAttention].filter(id => allAccountIds.includes(id))
     );
-    this._attentionExpanded = this._computeShowAttentionTip(
-      currentUser,
-      change.owner,
-      this._currentAttentionSet,
-      this._newAttentionSet
+    this.attentionExpanded = this.computeShowAttentionTip(
+      this.account,
+      this.change.owner,
+      this.currentAttentionSet,
+      this.newAttentionSet
     );
   }
 
-  _computeShowAttentionTip(
+  computeShowAttentionTip(
     currentUser?: AccountInfo,
     owner?: AccountInfo,
     currentAttentionSet?: Set<AccountId>,
@@ -967,7 +1712,7 @@
     return isOwner && addedIds.length > 2;
   }
 
-  _computeCommentAccounts(threads: CommentThread[]) {
+  computeCommentAccounts(threads: CommentThread[]) {
     const crLabel = this.change?.labels?.[StandardLabels.CODE_REVIEW];
     const maxCrVoteAccountIds = getMaxAccounts(crLabel).map(a => a._account_id);
     const accountIds = new Set<AccountId>();
@@ -975,7 +1720,7 @@
       const unresolved = isUnresolved(thread);
       thread.comments.forEach(comment => {
         if (comment.author) {
-          // A comment author must have an _account_id.
+          // A comment author must have an account_id.
           const authorId = comment.author._account_id!;
           const hasGivenMaxReviewVote = maxCrVoteAccountIds.includes(authorId);
           if (unresolved || !hasGivenMaxReviewVote) accountIds.add(authorId);
@@ -985,110 +1730,93 @@
     return accountIds;
   }
 
-  _computeShowNoAttentionUpdate(
-    config?: ServerInfo,
-    currentAttentionSet?: Set<AccountId>,
-    newAttentionSet?: Set<AccountId>,
-    sendDisabled?: boolean
-  ) {
-    return (
-      sendDisabled ||
-      this._computeNewAttentionAccounts(
-        config,
-        currentAttentionSet,
-        newAttentionSet
-      ).length === 0
-    );
+  computeShowNoAttentionUpdate() {
+    return this.sendDisabled || this.computeNewAttentionAccounts().length === 0;
   }
 
-  _computeDoNotUpdateMessage(
-    currentAttentionSet?: Set<AccountId>,
-    newAttentionSet?: Set<AccountId>,
-    sendDisabled?: boolean
-  ) {
-    if (!currentAttentionSet || !newAttentionSet) return '';
-    if (sendDisabled || areSetsEqual(currentAttentionSet, newAttentionSet)) {
+  computeDoNotUpdateMessage() {
+    if (!this.currentAttentionSet || !this.newAttentionSet) return '';
+    if (
+      this.sendDisabled ||
+      areSetsEqual(this.currentAttentionSet, this.newAttentionSet)
+    ) {
       return 'No changes to the attention set.';
     }
-    if (containsAll(currentAttentionSet, newAttentionSet)) {
+    if (containsAll(this.currentAttentionSet, this.newAttentionSet)) {
       return 'No additions to the attention set.';
     }
     this.reporting.error(
       new Error(
-        '_computeDoNotUpdateMessage()' +
+        'computeDoNotUpdateMessage()' +
           'should not be called when users were added to the attention set.'
       )
     );
     return '';
   }
 
-  _computeNewAttentionAccounts(
-    _?: ServerInfo,
-    currentAttentionSet?: Set<AccountId>,
-    newAttentionSet?: Set<AccountId>
-  ) {
-    if (currentAttentionSet === undefined || newAttentionSet === undefined) {
+  computeNewAttentionAccounts(): AccountInfo[] {
+    if (
+      this.currentAttentionSet === undefined ||
+      this.newAttentionSet === undefined
+    ) {
       return [];
     }
-    return [...newAttentionSet]
-      .filter(id => !currentAttentionSet.has(id))
-      .map(id => this._findAccountById(id))
-      .filter(account => !!account);
+    return [...this.newAttentionSet]
+      .filter(id => !this.currentAttentionSet.has(id))
+      .map(id => this.findAccountById(id))
+      .filter(account => !!account) as AccountInfo[];
   }
 
-  _findAccountById(accountId: AccountId) {
-    return this._allAccounts().find(r => r._account_id === accountId);
+  findAccountById(accountId: AccountId) {
+    return this.allAccounts().find(r => r._account_id === accountId);
   }
 
-  _allAccounts() {
+  allAccounts() {
     let allAccounts: (AccountInfoInput | GroupInfoInput)[] = [];
     if (this.change && this.change.owner) allAccounts.push(this.change.owner);
-    if (this._uploader) allAccounts.push(this._uploader);
-    if (this._reviewers) allAccounts = [...allAccounts, ...this._reviewers];
-    if (this._ccs) allAccounts = [...allAccounts, ...this._ccs];
+    if (this.uploader) allAccounts.push(this.uploader);
+    if (this.reviewers) allAccounts = [...allAccounts, ...this.reviewers];
+    if (this.ccs) allAccounts = [...allAccounts, ...this.ccs];
     return removeServiceUsers(allAccounts.filter(isAccount));
   }
 
-  /**
-   * The newAttentionSet param is only used to force re-computation.
-   */
-  _removeServiceUsers(accounts: AccountInfo[], _: Set<AccountId>) {
+  removeServiceUsers(accounts: AccountInfo[]) {
     return removeServiceUsers(accounts);
   }
 
-  _computeUploader(change: ChangeInfo) {
+  computeUploader() {
     if (
-      !change ||
-      !change.current_revision ||
-      !change.revisions ||
-      !change.revisions[change.current_revision]
+      !this.change?.current_revision ||
+      !this.change?.revisions?.[this.change.current_revision]
     ) {
-      return undefined;
+      this.uploader = undefined;
+      return;
     }
-    const rev = change.revisions[change.current_revision];
+    const rev = this.change.revisions[this.change.current_revision];
 
     if (
       !rev.uploader ||
-      change.owner._account_id === rev.uploader._account_id
+      this.change?.owner._account_id === rev.uploader._account_id
     ) {
-      return undefined;
+      this.uploader = undefined;
+      return;
     }
-    return rev.uploader;
+    this.uploader = rev.uploader;
   }
 
   /**
    * Generates a function to filter out reviewer/CC entries. When isCCs is
-   * truthy, the function filters out entries that already exist in this._ccs.
-   * When falsy, the function filters entries that exist in this._reviewers.
+   * truthy, the function filters out entries that already exist in this.ccs.
+   * When falsy, the function filters entries that exist in this.reviewers.
    */
-  _filterReviewerSuggestionGenerator(
+  filterReviewerSuggestionGenerator(
     isCCs: boolean
   ): (input: Suggestion) => boolean {
     return suggestion => {
       let entry: AccountInfo | GroupInfo;
       if (isReviewerAccountSuggestion(suggestion)) {
         entry = suggestion.account;
-        if (entry._account_id === this._owner?._account_id) {
+        if (entry._account_id === this.owner?._account_id) {
           return false;
         }
       } else if (isReviewerGroupSuggestion(suggestion)) {
@@ -1104,24 +1832,20 @@
       const finder = (entry: AccountInfo | GroupInfo) =>
         accountOrGroupKey(entry) === key;
       if (isCCs) {
-        return this._ccs.find(finder) === undefined;
+        return this.ccs.find(finder) === undefined;
       }
-      return this._reviewers.find(finder) === undefined;
+      return this.reviewers.find(finder) === undefined;
     };
   }
 
-  _getAccount() {
-    return this.restApiService.getAccount();
-  }
-
-  _cancelTapHandler(e: Event) {
+  cancelTapHandler(e: Event) {
     e.preventDefault();
     this.cancel();
   }
 
   cancel() {
     assertIsDefined(this.change, 'change');
-    if (!this._owner) throw new Error('missing required _owner property');
+    if (!this.owner) throw new Error('missing required owner property');
     this.dispatchEvent(
       new CustomEvent('cancel', {
         composed: true,
@@ -1129,36 +1853,36 @@
       })
     );
     queryAndAssert<GrTextarea>(this, 'gr-textarea').closeDropdown();
-    this.$.reviewers.clearPendingRemovals();
-    this._rebuildReviewerArrays(this.change.reviewers, this._owner);
+    this.reviewersList?.clearPendingRemovals();
+    this.rebuildReviewerArrays();
   }
 
-  _saveClickHandler(e: Event) {
+  saveClickHandler(e: Event) {
     e.preventDefault();
-    if (!this.$.ccs.submitEntryText()) {
+    if (!this.ccsList?.submitEntryText()) {
       // Do not proceed with the save if there is an invalid email entry in
       // the text field of the CC entry.
       return;
     }
-    this.send(this._includeComments, false);
+    this.send(this.includeComments, false);
   }
 
-  _sendTapHandler(e: Event) {
+  sendTapHandler(e: Event) {
     e.preventDefault();
-    this._submit();
+    this.submit();
   }
 
-  _submit() {
-    if (!this.$.ccs.submitEntryText()) {
+  submit() {
+    if (!this.ccsList?.submitEntryText()) {
       // Do not proceed with the send if there is an invalid email entry in
       // the text field of the CC entry.
       return;
     }
-    if (this._sendDisabled) {
+    if (this.sendDisabled) {
       fireAlert(this, EMPTY_REPLY_MESSAGE);
       return;
     }
-    return this.send(this._includeComments, this.canBeStarted).catch(err => {
+    return this.send(this.includeComments, this.canBeStarted).catch(err => {
       this.dispatchEvent(
         new CustomEvent('show-error', {
           bubbles: true,
@@ -1169,7 +1893,7 @@
     });
   }
 
-  _saveReview(review: ReviewInput, errFn?: ErrorCallback) {
+  saveReview(review: ReviewInput, errFn?: ErrorCallback) {
     assertIsDefined(this.change, 'change');
     assertIsDefined(this.patchNum, 'patchNum');
     return this.restApiService.saveChangeReview(
@@ -1180,43 +1904,43 @@
     );
   }
 
-  _reviewerPendingConfirmationUpdated(reviewer: RawAccountInput | null) {
+  pendingConfirmationUpdated(reviewer: RawAccountInput | null) {
     if (reviewer === null) {
-      this.$.reviewerConfirmationOverlay.close();
+      this.reviewerConfirmationOverlay?.close();
     } else {
-      this._pendingConfirmationDetails =
-        this._ccPendingConfirmation || this._reviewerPendingConfirmation;
-      this.$.reviewerConfirmationOverlay.open();
+      this.pendingConfirmationDetails =
+        this.ccPendingConfirmation || this.reviewerPendingConfirmation;
+      this.reviewerConfirmationOverlay?.open();
     }
   }
 
-  _confirmPendingReviewer() {
-    if (this._ccPendingConfirmation) {
-      this.$.ccs.confirmGroup(this._ccPendingConfirmation.group);
-      this._focusOn(FocusTarget.CCS);
+  confirmPendingReviewer() {
+    if (this.ccPendingConfirmation) {
+      this.ccsList?.confirmGroup(this.ccPendingConfirmation.group);
+      this.focusOn(FocusTarget.CCS);
       return;
     }
-    if (this._reviewerPendingConfirmation) {
-      this.$.reviewers.confirmGroup(this._reviewerPendingConfirmation.group);
-      this._focusOn(FocusTarget.REVIEWERS);
+    if (this.reviewerPendingConfirmation) {
+      this.reviewersList?.confirmGroup(this.reviewerPendingConfirmation.group);
+      this.focusOn(FocusTarget.REVIEWERS);
       return;
     }
     this.reporting.error(
-      new Error('_confirmPendingReviewer called without pending confirm')
+      new Error('confirmPendingReviewer called without pending confirm')
     );
   }
 
-  _cancelPendingReviewer() {
-    this._ccPendingConfirmation = null;
-    this._reviewerPendingConfirmation = null;
+  cancelPendingReviewer() {
+    this.ccPendingConfirmation = null;
+    this.reviewerPendingConfirmation = null;
 
-    const target = this._ccPendingConfirmation
+    const target = this.ccPendingConfirmation
       ? FocusTarget.CCS
       : FocusTarget.REVIEWERS;
-    this._focusOn(target);
+    this.focusOn(target);
   }
 
-  _getStorageLocation(): StorageLocation {
+  getStorageLocation(): StorageLocation {
     assertIsDefined(this.change, 'change');
     return {
       changeNum: this.change._number,
@@ -1225,77 +1949,73 @@
     };
   }
 
-  _loadStoredDraft() {
-    const draft = this.storage.getDraftComment(this._getStorageLocation());
+  loadStoredDraft() {
+    const draft = this.storage.getDraftComment(this.getStorageLocation());
     return draft?.message ?? '';
   }
 
-  _handleAccountTextEntry() {
+  handleAccountTextEntry() {
     // When either of the account entries has input added to the autocomplete,
     // it should trigger the save button to enable/
     //
     // Note: if the text is removed, the save button will not get disabled.
-    this._reviewersMutated = true;
+    this.reviewersMutated = true;
   }
 
-  _draftChanged(newDraft: string, oldDraft?: string) {
+  draftChanged(oldDraft: string) {
     this.storeTask = debounce(
       this.storeTask,
       () => {
-        if (!newDraft.length && oldDraft) {
+        if (!this.draft.length && oldDraft) {
           // If the draft has been modified to be empty, then erase the storage
           // entry.
-          this.storage.eraseDraftComment(this._getStorageLocation());
-        } else if (newDraft.length) {
-          this.storage.setDraftComment(this._getStorageLocation(), this.draft);
+          this.storage.eraseDraftComment(this.getStorageLocation());
+        } else if (this.draft.length) {
+          this.storage.setDraftComment(this.getStorageLocation(), this.draft);
         }
       },
       STORAGE_DEBOUNCE_INTERVAL_MS
     );
   }
 
-  _handleHeightChanged() {
+  handleHeightChanged() {
     fireEvent(this, 'autogrow');
   }
 
-  getLabelScores() {
-    return this.$.labelScores || queryAndAssert(this, 'gr-label-scores');
+  getLabelScores(): GrLabelScores {
+    return this.labelScores || queryAndAssert(this, 'gr-label-scores');
   }
 
   _handleLabelsChanged() {
-    this._labelsChanged =
+    this.labelsChanged =
       Object.keys(this.getLabelScores().getLabelValues(false)).length !== 0;
   }
 
   // To decouple account-list and reply dialog
-  _getAccountListCopy(list: (AccountInfo | GroupInfo)[]) {
+  getAccountListCopy(list: (AccountInfo | GroupInfo)[]) {
     return list.slice();
   }
 
-  _handleReviewersChanged(e: ValueChangedEvent<(AccountInfo | GroupInfo)[]>) {
-    this._reviewers = e.detail.value.slice();
-    this._reviewersMutated = true;
+  handleReviewersChanged(e: ValueChangedEvent<(AccountInfo | GroupInfo)[]>) {
+    this.reviewers = e.detail.value.slice();
+    this.reviewersMutated = true;
   }
 
-  _handleCcsChanged(e: ValueChangedEvent<(AccountInfo | GroupInfo)[]>) {
-    this._ccs = e.detail.value.slice();
-    this._reviewersMutated = true;
+  handleCcsChanged(e: ValueChangedEvent<(AccountInfo | GroupInfo)[]>) {
+    this.ccs = e.detail.value.slice();
+    this.reviewersMutated = true;
   }
 
-  _handleReviewersConfirmationChanged(
+  handleReviewersConfirmationChanged(
     e: ValueChangedEvent<SuggestedReviewerGroupInfo | null>
   ) {
-    this._reviewerPendingConfirmation = e.detail.value;
+    this.reviewerPendingConfirmation = e.detail.value;
   }
 
-  _handleCcsConfirmationChanged(
+  handleCcsConfirmationChanged(
     e: ValueChangedEvent<SuggestedReviewerGroupInfo | null>
   ) {
-    this._ccPendingConfirmation = e.detail.value;
-  }
-
-  _isState(knownLatestState?: LatestPatchState, value?: LatestPatchState) {
-    return knownLatestState === value;
+    this.ccPendingConfirmation = e.detail.value;
   }
 
   _reload() {
@@ -1303,82 +2023,77 @@
     this.cancel();
   }
 
-  _computeSendButtonLabel(canBeStarted: boolean) {
-    return canBeStarted
+  computeSendButtonLabel() {
+    this.sendButtonLabel = this.canBeStarted
       ? ButtonLabels.SEND + ' and ' + ButtonLabels.START_REVIEW
       : ButtonLabels.SEND;
   }
 
-  _computeSendButtonTooltip(canBeStarted?: boolean, commentEditing?: boolean) {
+  computeSendButtonTooltip(canBeStarted?: boolean, commentEditing?: boolean) {
     if (commentEditing) {
       return ButtonTooltips.DISABLED_COMMENT_EDITING;
     }
     return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND;
   }
 
-  _computeSavingLabelClass(savingComments: boolean) {
+  computeSavingLabelClass(savingComments: boolean) {
     return savingComments ? 'saving' : '';
   }
 
-  _computeSendButtonDisabled(
-    canBeStarted?: boolean,
-    draftCommentThreads?: CommentThread[],
-    text?: string,
-    reviewersMutated?: boolean,
-    labelsChanged?: boolean,
-    includeComments?: boolean,
-    disabled?: boolean,
-    commentEditing?: boolean,
-    change?: ChangeInfo,
-    account?: AccountInfo
-  ) {
+  computeSendButtonDisabled() {
     if (
-      canBeStarted === undefined ||
-      draftCommentThreads === undefined ||
-      text === undefined ||
-      reviewersMutated === undefined ||
-      labelsChanged === undefined ||
-      includeComments === undefined ||
-      disabled === undefined ||
-      commentEditing === undefined ||
-      change?.labels === undefined ||
-      account === undefined
+      this.canBeStarted === undefined ||
+      this.draftCommentThreads === undefined ||
+      this.draft === undefined ||
+      this.reviewersMutated === undefined ||
+      this.labelsChanged === undefined ||
+      this.includeComments === undefined ||
+      this.disabled === undefined ||
+      this.commentEditing === undefined ||
+      this.change?.labels === undefined ||
+      this.account === undefined
     ) {
       return undefined;
     }
-    if (commentEditing || disabled) {
+    if (this.commentEditing || this.disabled) {
       return true;
     }
-    if (canBeStarted === true) {
+    if (this.canBeStarted === true) {
       return false;
     }
-    const existingVote = Object.values(change.labels).some(
-      label => isDetailedLabelInfo(label) && getApprovalInfo(label, account)
+    const existingVote = Object.values(this.change.labels).some(
+      label =>
+        isDetailedLabelInfo(label) && getApprovalInfo(label, this.account!)
     );
-    const revotingOrNewVote = labelsChanged || existingVote;
-    const hasDrafts = includeComments && draftCommentThreads.length;
+    const revotingOrNewVote = this.labelsChanged || existingVote;
+    const hasDrafts =
+      this.includeComments && this.draftCommentThreads.length > 0;
     return (
-      !hasDrafts && !text.length && !reviewersMutated && !revotingOrNewVote
+      !hasDrafts &&
+      !this.draft.length &&
+      !this.reviewersMutated &&
+      !revotingOrNewVote
     );
   }
 
-  _computePatchSetWarning(patchNum?: PatchSetNum, labelsChanged?: boolean) {
-    let str = `Patch ${patchNum} is not latest.`;
-    if (labelsChanged) {
+  computePatchSetWarning() {
+    let str = `Patch ${this.patchNum} is not latest.`;
+    if (this.labelsChanged) {
       str += ' Voting may have no effect.';
     }
     return str;
   }
 
   setPluginMessage(message: string) {
-    this._pluginMessage = message;
+    this.pluginMessage = message;
   }
 
-  _sendDisabledChanged() {
+  sendDisabledChanged() {
     this.dispatchEvent(new CustomEvent('send-disabled-changed'));
   }
 
-  _getReviewerSuggestionsProvider(change: ChangeInfo) {
+  getReviewerSuggestionsProvider(change?: ChangeInfo) {
+    if (!change) return;
     const provider = GrReviewerSuggestionsProvider.create(
       this.restApiService,
       change._number,
@@ -1388,7 +2103,8 @@
     return provider;
   }
 
-  _getCcSuggestionsProvider(change: ChangeInfo) {
+  getCcSuggestionsProvider(change?: ChangeInfo) {
+    if (!change) return;
     const provider = GrReviewerSuggestionsProvider.create(
       this.restApiService,
       change._number,
@@ -1406,24 +2122,24 @@
     const actions = modified ? ['MODIFIED'] : ['NOT_MODIFIED'];
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
-    const selfId = (this._account && this._account._account_id) || -1;
+    const selfId = (this.account && this.account._account_id) || -1;
     for (const added of addedSet || []) {
       const addedId = added.user;
       const self = addedId === selfId ? '_SELF' : '';
-      const role = addedId === ownerId ? '_OWNER' : '_REVIEWER';
+      const role = addedId === ownerId ? 'OWNER' : '_REVIEWER';
       actions.push('ADD' + self + role);
     }
     for (const removed of removedSet || []) {
       const removedId = removed.user;
       const self = removedId === selfId ? '_SELF' : '';
-      const role = removedId === ownerId ? '_OWNER' : '_REVIEWER';
+      const role = removedId === ownerId ? 'OWNER' : '_REVIEWER';
       actions.push('REMOVE' + self + role);
     }
     this.reporting.reportInteraction('attention-set-actions', {actions});
   }
 
-  _computeAllReviewers() {
-    return [...this._reviewers];
+  computeAllReviewers() {
+    this.allReviewers = [...this.reviewers];
   }
 }