Merge "Load change notes eagerly to omit unparsable changes from results" into stable-3.6
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
index ad1703d..8ee8fc2 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
@@ -14,8 +14,9 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
 import com.google.common.io.CharStreams;
-import com.google.common.io.Resources;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
@@ -26,6 +27,7 @@
 import com.google.template.soy.shared.SoyAstCache;
 import java.io.IOException;
 import java.io.Reader;
+import java.net.URL;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -137,6 +139,8 @@
     }
 
     // Otherwise load the template as a resource.
-    builder.add(Resources.getResource(logicalPath), logicalPath);
+    URL resource = this.getClass().getClassLoader().getResource(logicalPath);
+    checkArgument(resource != null, "resource %s not found.", logicalPath);
+    builder.add(resource, logicalPath);
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 86ace10..d3f29fc 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -1284,6 +1284,10 @@
     if (value.basePatchNum === undefined)
       value.basePatchNum = ParentPatchSetNum;
 
+    if (value.patchNum === undefined) {
+      value.patchNum = computeLatestPatchNum(this._allPatchSets);
+    }
+
     const patchChanged = this.hasPatchRangeChanged(value);
     let patchNumChanged = this.hasPatchNumChanged(value);
 
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 8664de3..0902fe6 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -21,12 +21,10 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-formatted-text/gr-formatted-text';
-import '../../../styles/shared-styles';
 import '../gr-message-scores/gr-message-scores';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-message_html';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
 import {MessageTag, SpecialFilePath} from '../../../constants/constants';
-import {customElement, property, computed, observe} from '@polymer/decorators';
+import {customElement, property, state} from 'lit/decorators';
 import {
   ChangeInfo,
   ServerInfo,
@@ -42,6 +40,7 @@
 import {
   ChangeMessage,
   CommentThread,
+  isFormattedReviewerUpdate,
   LabelExtreme,
   PATCH_SET_PREFIX_PATTERN,
 } from '../../../utils/comment-util';
@@ -54,6 +53,9 @@
   computePredecessor,
 } from '../../../utils/patch-set-util';
 import {isServiceUser, replaceTemplates} from '../../../utils/account-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {when} from 'lit/directives/when';
+import {FormattedReviewerUpdateInfo} from '../../../types/types';
 
 const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
 const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
@@ -68,11 +70,7 @@
 }
 
 @customElement('gr-message')
-export class GrMessage extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrMessage extends LitElement {
   /**
    * Fired when this message's reply link is tapped.
    *
@@ -98,12 +96,11 @@
   changeNum?: NumericChangeId;
 
   @property({type: Object})
-  message: ChangeMessage | undefined;
+  message?: ChangeMessage | (ChangeMessage & FormattedReviewerUpdateInfo);
 
   @property({type: Array})
   commentThreads: CommentThread[] = [];
 
-  @computed('message')
   get author() {
     return this.message?.author || this.message?.updated_by;
   }
@@ -114,31 +111,8 @@
   @property({type: Boolean})
   hideAutomated = false;
 
-  @property({
-    type: Boolean,
-    reflectToAttribute: true,
-    computed: '_computeIsHidden(hideAutomated, isAutomated)',
-  })
-  override hidden = false;
-
-  @computed('message')
-  get isAutomated() {
-    return !!this.message && this._computeIsAutomated(this.message);
-  }
-
-  @computed('message')
-  get showOnBehalfOf() {
-    return !!this.message && this._computeShowOnBehalfOf(this.message);
-  }
-
-  @property({
-    type: Boolean,
-    computed: '_computeShowReplyButton(message, _loggedIn)',
-  })
-  showReplyButton = false;
-
   @property({type: String})
-  projectName?: string;
+  projectName?: RepoName;
 
   /**
    * A mapping from label names to objects representing the minimum and
@@ -147,51 +121,23 @@
   @property({type: Object})
   labelExtremes?: LabelExtreme;
 
-  @property({type: Object})
-  _projectConfig?: ConfigInfo;
+  @state()
+  private projectConfig?: ConfigInfo;
 
   @property({type: Boolean})
-  _loggedIn = false;
+  loggedIn = false;
 
-  @property({type: Boolean})
-  _isAdmin = false;
+  @state()
+  private isAdmin = false;
 
-  @property({type: Boolean})
-  _isDeletingChangeMsg = false;
-
-  @property({type: Boolean, computed: '_computeExpanded(message.expanded)'})
-  _expanded = false;
-
-  @property({
-    type: String,
-    computed:
-      '_computeMessageContentExpanded(_expanded, message.message,' +
-      ' message.accounts_in_message,' +
-      ' message.tag)',
-  })
-  _messageContentExpanded = '';
-
-  @property({
-    type: String,
-    computed:
-      '_computeMessageContentCollapsed(message.message,' +
-      ' message.accounts_in_message,' +
-      ' message.tag,' +
-      ' commentThreads)',
-  })
-  _messageContentCollapsed = '';
-
-  @property({
-    type: String,
-    computed: '_computeCommentCountText(commentThreads)',
-  })
-  _commentCountText = '';
+  @state()
+  private isDeletingChangeMsg = false;
 
   private readonly restApiService = getAppContext().restApiService;
 
   constructor() {
     super();
-    this.addEventListener('click', e => this._handleClick(e));
+    this.addEventListener('click', e => this.handleClick(e));
   }
 
   override connectedCallback() {
@@ -200,44 +146,380 @@
       this.config = config;
     });
     this.restApiService.getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
+      this.loggedIn = loggedIn;
     });
     this.restApiService.getIsAdmin().then(isAdmin => {
-      this._isAdmin = !!isAdmin;
+      this.isAdmin = !!isAdmin;
     });
   }
 
-  @observe('message.expanded')
-  _updateExpandedClass(expanded: boolean) {
-    if (expanded) {
+  static override get styles() {
+    return css`
+      :host {
+        display: block;
+        position: relative;
+        cursor: pointer;
+        overflow-y: hidden;
+      }
+      :host(.expanded) {
+        cursor: auto;
+      }
+      .collapsed .contentContainer {
+        align-items: center;
+        color: var(--deemphasized-text-color);
+        display: flex;
+        white-space: nowrap;
+      }
+      .contentContainer {
+        padding: var(--spacing-m) var(--spacing-l);
+      }
+      .expanded .contentContainer {
+        background-color: var(--background-color-secondary);
+      }
+      .collapsed .contentContainer {
+        background-color: var(--background-color-primary);
+      }
+      div.serviceUser.expanded div.contentContainer {
+        background-color: var(
+          --background-color-service-user,
+          var(--background-color-secondary)
+        );
+      }
+      div.serviceUser.collapsed div.contentContainer {
+        background-color: var(
+          --background-color-service-user,
+          var(--background-color-primary)
+        );
+      }
+      .name {
+        font-weight: var(--font-weight-bold);
+      }
+      .message {
+        --gr-formatted-text-prose-max-width: 120ch;
+      }
+      .collapsed .message {
+        max-width: none;
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
+      .collapsed .author,
+      .collapsed .content,
+      .collapsed .message,
+      .collapsed .updateCategory,
+      gr-account-chip {
+        display: inline;
+      }
+      gr-button {
+        margin: 0 -4px;
+      }
+      .collapsed gr-thread-list,
+      .collapsed .replyBtn,
+      .collapsed .deleteBtn,
+      .collapsed .hideOnCollapsed,
+      .hideOnOpen {
+        display: none;
+      }
+      .replyBtn {
+        margin-right: var(--spacing-m);
+      }
+      .collapsed .hideOnOpen {
+        display: block;
+      }
+      .collapsed .content {
+        flex: 1;
+        margin-right: var(--spacing-m);
+        min-width: 0;
+        overflow: hidden;
+      }
+      .collapsed .content.messageContent {
+        text-overflow: ellipsis;
+      }
+      .collapsed .dateContainer {
+        position: static;
+      }
+      .collapsed .author {
+        overflow: hidden;
+        color: var(--primary-text-color);
+        margin-right: var(--spacing-s);
+      }
+      .authorLabel {
+        min-width: 130px;
+        --account-max-length: 120px;
+        margin-right: var(--spacing-s);
+      }
+      .expanded .author {
+        cursor: pointer;
+        margin-bottom: var(--spacing-m);
+      }
+      .expanded .content {
+        padding-left: 40px;
+      }
+      .dateContainer {
+        position: absolute;
+        /* right and top values should match .contentContainer padding */
+        right: var(--spacing-l);
+        top: var(--spacing-m);
+      }
+      .dateContainer gr-button {
+        margin-right: var(--spacing-m);
+        color: var(--deemphasized-text-color);
+      }
+      .dateContainer .patchset:before {
+        content: 'Patchset ';
+      }
+      .dateContainer .patchsetDiffButton {
+        margin-right: var(--spacing-m);
+        --gr-button-padding: 0 var(--spacing-m);
+      }
+      span.date {
+        color: var(--deemphasized-text-color);
+      }
+      span.date:hover {
+        text-decoration: underline;
+      }
+      .dateContainer iron-icon {
+        cursor: pointer;
+        vertical-align: top;
+      }
+      .commentsSummary {
+        margin-right: var(--spacing-s);
+        min-width: 115px;
+      }
+      .expanded .commentsSummary {
+        display: none;
+      }
+      .commentsIcon {
+        vertical-align: top;
+      }
+      gr-account-label::part(gr-account-label-text) {
+        font-weight: var(--font-weight-bold);
+      }
+      iron-icon {
+        --iron-icon-height: 20px;
+        --iron-icon-width: 20px;
+      }
+      @media screen and (max-width: 50em) {
+        .expanded .content {
+          padding-left: 0;
+        }
+        .commentsSummary {
+          min-width: 0px;
+        }
+        .authorLabel {
+          width: 100px;
+        }
+        .dateContainer .patchset:before {
+          content: 'PS ';
+        }
+      }
+    `;
+  }
+
+  override willUpdate(changedProperties: PropertyValues) {
+    if (changedProperties.has('projectName')) {
+      this.projectNameChanged();
+    }
+  }
+
+  override render() {
+    if (!this.message) return nothing;
+    if (this.hideAutomated && this.computeIsAutomated()) return nothing;
+    this.updateExpandedClass();
+    return html` <div class=${this.computeClass()}>
+      <div class="contentContainer">
+        ${this.renderAuthor()} ${this.renderCommentsSummary()}
+        ${this.renderMessageContent()} ${this.renderReviewerUpdate()}
+        ${this.renderDateContainer()}
+      </div>
+    </div>`;
+  }
+
+  private renderAuthor() {
+    assertIsDefined(this.message, 'message');
+    return html` <div class="author" @click=${this.handleAuthorClick}>
+      ${when(
+        this.computeShowOnBehalfOf(),
+        () => html`
+          <span>
+            <span class="name">${this.message?.real_author?.name}</span>
+            on behalf of
+          </span>
+        `
+      )}
+      <gr-account-label
+        .account=${this.author}
+        class="authorLabel"
+      ></gr-account-label>
+      <gr-message-scores
+        .labelExtremes=${this.labelExtremes}
+        .message=${this.message}
+        .change=${this.change}
+      ></gr-message-scores>
+    </div>`;
+  }
+
+  private renderCommentsSummary() {
+    if (!this.commentThreads?.length) return nothing;
+
+    const commentCountText = pluralize(this.commentThreads.length, 'comment');
+    return html`
+      <div class="commentsSummary">
+        <iron-icon icon="gr-icons:comment" class="commentsIcon"></iron-icon>
+        <span class="numberOfComments">${commentCountText}</span>
+      </div>
+    `;
+  }
+
+  private renderMessageContent() {
+    if (!this.message?.message) return nothing;
+    const messageContentCollapsed =
+      this.computeMessageContent(
+        false,
+        this.message.message.substring(0, 1000),
+        this.message.accounts_in_message,
+        this.message.tag
+      ) || this.patchsetCommentSummary();
+    return html` <div class="content messageContent">
+      <div class="message hideOnOpen">${messageContentCollapsed}</div>
+      ${this.renderExpandedMessageContent()}
+    </div>`;
+  }
+
+  private renderExpandedMessageContent() {
+    if (!this.message?.expanded) return nothing;
+    const messageContentExpanded = this.computeMessageContent(
+      true,
+      this.message.message,
+      this.message.accounts_in_message,
+      this.message.tag
+    );
+    return html`
+      <gr-formatted-text
+        noTrailingMargin
+        class="message hideOnCollapsed"
+        .content=${messageContentExpanded}
+        .config=${this.projectConfig?.commentlinks}
+      ></gr-formatted-text>
+      ${when(messageContentExpanded, () => this.renderActionContainer())}
+      <gr-thread-list
+        ?hidden=${!this.commentThreads.length}
+        .threads=${this.commentThreads}
+        hide-dropdown
+        show-comment-context
+        .messageId=${this.message.id}
+      >
+      </gr-thread-list>
+    `;
+  }
+
+  private renderActionContainer() {
+    if (!this.computeShowReplyButton()) return nothing;
+    return html` <div class="replyActionContainer">
+      <gr-button class="replyBtn" link="" @click=${this.handleReplyTap}>
+        Reply
+      </gr-button>
+      ${when(
+        this.isAdmin,
+        () => html`
+          <gr-button
+            ?disabled=${this.isDeletingChangeMsg}
+            class="deleteBtn"
+            link=""
+            @click=${this.handleDeleteMessage}
+          >
+            Delete
+          </gr-button>
+        `
+      )}
+    </div>`;
+  }
+
+  private renderReviewerUpdate() {
+    assertIsDefined(this.message, 'message');
+    if (!isFormattedReviewerUpdate(this.message)) return;
+    return html` <div class="content">
+      ${this.message.updates.map(update => this.renderMessageUpdate(update))}
+    </div>`;
+  }
+
+  private renderMessageUpdate(update: {
+    message: string;
+    reviewers: AccountInfo[];
+  }) {
+    return html`<div class="updateCategory">
+      ${update.message}
+      ${update.reviewers.map(
+        reviewer => html`
+          <gr-account-chip .account=${reviewer} .change=${this.change}>
+          </gr-account-chip>
+        `
+      )}
+    </div>`;
+  }
+
+  private renderDateContainer() {
+    return html`<span class="dateContainer">
+      ${this.renderDiffButton()}
+      ${when(
+        this.message?._revision_number,
+        () => html`
+          <span class="patchset">${this.message?._revision_number} |</span>
+        `
+      )}
+      ${when(
+        this.message?.id,
+        () => html`
+          <span class="date" @click=${this.handleAnchorClick}>
+            <gr-date-formatter
+              withTooltip
+              showDateAndTime
+              .dateStr=${this.message?.date}
+            ></gr-date-formatter>
+          </span>
+        `,
+        () => html`
+          <span class="date">
+            <gr-date-formatter
+              withTooltip
+              showDateAndTime
+              .dateStr=${this.message?.date}
+            ></gr-date-formatter>
+          </span>
+        `
+      )}
+      <iron-icon
+        id="expandToggle"
+        @click=${this.toggleExpanded}
+        title="Toggle expanded state"
+        icon=${this.computeExpandToggleIcon()}
+      ></iron-icon>
+    </span>`;
+  }
+
+  private renderDiffButton() {
+    if (!this.showViewDiffButton()) return nothing;
+    return html` <gr-button
+      class="patchsetDiffButton"
+      @click=${this.handleViewPatchsetDiff}
+      link
+    >
+      View Diff
+    </gr-button>`;
+  }
+
+  private updateExpandedClass() {
+    if (this.message?.expanded) {
       this.classList.add('expanded');
     } else {
       this.classList.remove('expanded');
     }
   }
 
-  _computeCommentCountText(commentThreads?: CommentThread[]) {
-    if (!commentThreads?.length) {
-      return undefined;
-    }
-
-    return pluralize(commentThreads.length, 'comment');
-  }
-
-  _computeMessageContentExpanded(
-    expanded: boolean,
-    content?: string,
-    accountsInMessage?: AccountInfo[],
-    tag?: ReviewInputTag
-  ) {
-    if (!expanded) return '';
-    return this._computeMessageContent(true, content, accountsInMessage, tag);
-  }
-
-  _patchsetCommentSummary(commentThreads: CommentThread[] = []) {
+  // Private but used in tests.
+  patchsetCommentSummary() {
     const id = this.message?.id;
     if (!id) return '';
-    const patchsetThreads = commentThreads.filter(
+    const patchsetThreads = (this.commentThreads ?? []).filter(
       thread => thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
     );
     for (const thread of patchsetThreads) {
@@ -258,45 +540,29 @@
     return '';
   }
 
-  _computeMessageContentCollapsed(
-    content?: string,
-    accountsInMessage?: AccountInfo[],
-    tag?: ReviewInputTag,
-    commentThreads?: CommentThread[]
-  ) {
-    // Content is under text-overflow, so it's always shorten
-    const shortenedContent = content?.substring(0, 1000);
-    const summary = this._computeMessageContent(
-      false,
-      shortenedContent,
-      accountsInMessage,
-      tag
-    );
-    if (summary || !commentThreads) return summary;
-    return this._patchsetCommentSummary(commentThreads);
-  }
-
-  _showViewDiffButton(message?: ChangeMessage) {
+  private showViewDiffButton() {
     return (
-      this._isNewPatchsetTag(message?.tag) || this._isMergePatchset(message)
+      this.isNewPatchsetTag(this.message?.tag) ||
+      this.isMergePatchset(this.message)
     );
   }
 
-  _isMergePatchset(message?: ChangeMessage) {
+  private isMergePatchset(message?: ChangeMessage) {
     return (
       message?.tag === MessageTag.TAG_MERGED &&
       message?.message.match(MERGED_PATCHSET_PATTERN)
     );
   }
 
-  _isNewPatchsetTag(tag?: ReviewInputTag) {
+  private isNewPatchsetTag(tag?: ReviewInputTag) {
     return (
       tag === MessageTag.TAG_NEW_PATCHSET ||
       tag === MessageTag.TAG_NEW_WIP_PATCHSET
     );
   }
 
-  _handleViewPatchsetDiff(e: Event) {
+  // Private but used in tests
+  handleViewPatchsetDiff(e: Event) {
     if (!this.message || !this.change) return;
     let patchNum: PatchSetNum;
     let basePatchNum: PatchSetNum;
@@ -323,14 +589,15 @@
     e.stopPropagation();
   }
 
-  _computeMessageContent(
+  // private but used in tests
+  computeMessageContent(
     isExpanded: boolean,
     content?: string,
     accountsInMessage?: AccountInfo[],
     tag?: ReviewInputTag
   ) {
     if (!content) return '';
-    const isNewPatchSet = this._isNewPatchsetTag(tag);
+    const isNewPatchSet = this.isNewPatchsetTag(tag);
 
     if (accountsInMessage) {
       content = replaceTemplates(content, accountsInMessage, this.config);
@@ -371,74 +638,68 @@
     return mappedLines.join('\n').trim();
   }
 
-  _computeAuthor(message: ChangeMessage) {
-    return message.author || message.updated_by;
-  }
-
-  _computeShowOnBehalfOf(message: ChangeMessage) {
-    const author = this._computeAuthor(message);
+  // private but used in tests
+  computeShowOnBehalfOf() {
+    if (!this.message) return false;
     return !!(
-      author &&
-      message.real_author &&
-      author._account_id !== message.real_author._account_id
+      this.author &&
+      this.message.real_author &&
+      this.author._account_id !== this.message.real_author._account_id
     );
   }
 
-  _computeShowReplyButton(message?: ChangeMessage, loggedIn?: boolean) {
+  // private but used in tests.
+  computeShowReplyButton() {
     return (
-      message &&
-      !!message.message &&
-      loggedIn &&
-      !this._computeIsAutomated(message)
+      !!this.message &&
+      !!this.message.message &&
+      this.loggedIn &&
+      !this.computeIsAutomated()
     );
   }
 
-  _computeExpanded(expanded: boolean) {
-    return expanded;
-  }
-
-  _handleClick(e: Event) {
-    if (this.message?.expanded) {
+  private handleClick(e: Event) {
+    if (!this.message || this.message?.expanded) {
       return;
     }
     e.stopPropagation();
-    this.set('message.expanded', true);
+    this.message.expanded = true;
+    this.requestUpdate();
   }
 
-  _handleAuthorClick(e: Event) {
-    if (!this.message?.expanded) {
+  private handleAuthorClick(e: Event) {
+    if (!this.message || !this.message?.expanded) {
       return;
     }
     e.stopPropagation();
-    this.set('message.expanded', false);
+    this.message.expanded = false;
+    this.requestUpdate();
   }
 
-  _computeIsAutomated(message: ChangeMessage) {
+  // private but used in tests.
+  computeIsAutomated() {
     return !!(
-      message.reviewer ||
-      this._computeIsReviewerUpdate(message) ||
-      (message.tag && message.tag.startsWith('autogenerated'))
+      this.message?.reviewer ||
+      this.computeIsReviewerUpdate() ||
+      (this.message?.tag && this.message.tag.startsWith('autogenerated'))
     );
   }
 
-  _computeIsHidden(hideAutomated: boolean, isAutomated: boolean) {
-    return hideAutomated && isAutomated;
+  private computeIsReviewerUpdate() {
+    return this.message?.type === 'REVIEWER_UPDATE';
   }
 
-  _computeIsReviewerUpdate(message: ChangeMessage) {
-    return message.type === 'REVIEWER_UPDATE';
-  }
-
-  _computeClass(expanded?: boolean, author?: AccountInfo) {
+  private computeClass() {
+    const expanded = this.message?.expanded;
     const classes = [];
     classes.push(expanded ? 'expanded' : 'collapsed');
-    if (isServiceUser(author)) classes.push('serviceUser');
+    if (isServiceUser(this.author)) classes.push('serviceUser');
     return classes.join(' ');
   }
 
-  _handleAnchorClick(e: Event) {
+  private handleAnchorClick(e: Event) {
     e.preventDefault();
-    // The element which triggers _handleAnchorClick is rendered only if
+    // The element which triggers handleAnchorClick is rendered only if
     // message.id defined: the element is wrapped in dom-if if="[[message.id]]"
     const detail: MessageAnchorTapDetail = {
       id: this.message!.id,
@@ -452,7 +713,7 @@
     );
   }
 
-  _handleReplyTap(e: Event) {
+  private handleReplyTap(e: Event) {
     e.preventDefault();
     this.dispatchEvent(
       new CustomEvent('reply', {
@@ -463,14 +724,14 @@
     );
   }
 
-  _handleDeleteMessage(e: Event) {
+  private handleDeleteMessage(e: Event) {
     e.preventDefault();
     if (!this.message || !this.message.id || !this.changeNum) return;
-    this._isDeletingChangeMsg = true;
+    this.isDeletingChangeMsg = true;
     this.restApiService
       .deleteChangeCommitMessage(this.changeNum, this.message.id)
       .then(() => {
-        this._isDeletingChangeMsg = false;
+        this.isDeletingChangeMsg = false;
         this.dispatchEvent(
           new CustomEvent('change-message-deleted', {
             detail: {message: this.message},
@@ -481,23 +742,25 @@
       });
   }
 
-  @observe('projectName')
-  _projectNameChanged(name?: string) {
-    if (!name) {
-      this._projectConfig = undefined;
+  private projectNameChanged() {
+    if (!this.projectName) {
+      this.projectConfig = undefined;
       return;
     }
-    this.restApiService.getProjectConfig(name as RepoName).then(config => {
-      this._projectConfig = config;
+    this.restApiService.getProjectConfig(this.projectName).then(config => {
+      this.projectConfig = config;
     });
   }
 
-  _computeExpandToggleIcon(expanded: boolean) {
-    return expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
+  private computeExpandToggleIcon() {
+    return this.message?.expanded
+      ? 'gr-icons:expand-less'
+      : 'gr-icons:expand-more';
   }
 
-  _toggleExpanded(e: Event) {
+  private toggleExpanded(e: Event) {
     e.stopPropagation();
-    this.set('message.expanded', !this.message?.expanded);
+    if (!this.message) return;
+    this.message = {...this.message, expanded: !this.message.expanded};
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
deleted file mode 100644
index 70e6381..0000000
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ /dev/null
@@ -1,307 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: block;
-      position: relative;
-      cursor: pointer;
-      overflow-y: hidden;
-    }
-    :host(.expanded) {
-      cursor: auto;
-    }
-    .collapsed .contentContainer {
-      align-items: center;
-      color: var(--deemphasized-text-color);
-      display: flex;
-      white-space: nowrap;
-    }
-    .contentContainer {
-      padding: var(--spacing-m) var(--spacing-l);
-    }
-    .expanded .contentContainer {
-      background-color: var(--background-color-secondary);
-    }
-    .collapsed .contentContainer {
-      background-color: var(--background-color-primary);
-    }
-    div.serviceUser.expanded div.contentContainer {
-      background-color: var(
-        --background-color-service-user,
-        var(--background-color-secondary)
-      );
-    }
-    div.serviceUser.collapsed div.contentContainer {
-      background-color: var(
-        --background-color-service-user,
-        var(--background-color-primary)
-      );
-    }
-    .name {
-      font-weight: var(--font-weight-bold);
-    }
-    .message {
-      --gr-formatted-text-prose-max-width: 120ch;
-    }
-    .collapsed .message {
-      max-width: none;
-      overflow: hidden;
-      text-overflow: ellipsis;
-    }
-    .collapsed .author,
-    .collapsed .content,
-    .collapsed .message,
-    .collapsed .updateCategory,
-    gr-account-chip {
-      display: inline;
-    }
-    gr-button {
-      margin: 0 -4px;
-    }
-    .collapsed gr-thread-list,
-    .collapsed .replyBtn,
-    .collapsed .deleteBtn,
-    .collapsed .hideOnCollapsed,
-    .hideOnOpen {
-      display: none;
-    }
-    .replyBtn {
-      margin-right: var(--spacing-m);
-    }
-    .collapsed .hideOnOpen {
-      display: block;
-    }
-    .collapsed .content {
-      flex: 1;
-      margin-right: var(--spacing-m);
-      min-width: 0;
-      overflow: hidden;
-    }
-    .collapsed .content.messageContent {
-      text-overflow: ellipsis;
-    }
-    .collapsed .dateContainer {
-      position: static;
-    }
-    .collapsed .author {
-      overflow: hidden;
-      color: var(--primary-text-color);
-      margin-right: var(--spacing-s);
-    }
-    .authorLabel {
-      min-width: 130px;
-      --account-max-length: 120px;
-      margin-right: var(--spacing-s);
-    }
-    .expanded .author {
-      cursor: pointer;
-      margin-bottom: var(--spacing-m);
-    }
-    .expanded .content {
-      padding-left: 40px;
-    }
-    .dateContainer {
-      position: absolute;
-      /* right and top values should match .contentContainer padding */
-      right: var(--spacing-l);
-      top: var(--spacing-m);
-    }
-    .dateContainer gr-button {
-      margin-right: var(--spacing-m);
-      color: var(--deemphasized-text-color);
-    }
-    .dateContainer .patchset:before {
-      content: 'Patchset ';
-    }
-    .dateContainer .patchsetDiffButton {
-      margin-right: var(--spacing-m);
-      --gr-button-padding: 0 var(--spacing-m);
-    }
-    span.date {
-      color: var(--deemphasized-text-color);
-    }
-    span.date:hover {
-      text-decoration: underline;
-    }
-    .dateContainer iron-icon {
-      cursor: pointer;
-      vertical-align: top;
-    }
-    .commentsSummary {
-      margin-right: var(--spacing-s);
-      min-width: 115px;
-    }
-    .expanded .commentsSummary {
-      display: none;
-    }
-    .commentsIcon {
-      vertical-align: top;
-    }
-    gr-account-label::part(gr-account-label-text) {
-      font-weight: var(--font-weight-bold);
-    }
-    iron-icon {
-      --iron-icon-height: 20px;
-      --iron-icon-width: 20px;
-    }
-    @media screen and (max-width: 50em) {
-      .expanded .content {
-        padding-left: 0;
-      }
-      .commentsSummary {
-        min-width: 0px;
-      }
-      .authorLabel {
-        width: 100px;
-      }
-      .dateContainer .patchset:before {
-        content: 'PS ';
-      }
-    }
-  </style>
-  <div class$="[[_computeClass(_expanded, author)]]">
-    <div class="contentContainer">
-      <div class="author" on-click="_handleAuthorClick">
-        <span hidden$="[[!showOnBehalfOf]]">
-          <span class="name">[[message.real_author.name]]</span>
-          on behalf of
-        </span>
-        <gr-account-label
-          account="[[author]]"
-          class="authorLabel"
-        ></gr-account-label>
-        <gr-message-scores
-          label-extremes="[[labelExtremes]]"
-          message="[[message]]"
-          change="[[change]]"
-        ></gr-message-scores>
-      </div>
-      <template is="dom-if" if="[[_commentCountText]]">
-        <div class="commentsSummary">
-          <iron-icon icon="gr-icons:comment" class="commentsIcon"></iron-icon>
-          <span class="numberOfComments">[[_commentCountText]]</span>
-        </div>
-      </template>
-      <template is="dom-if" if="[[message.message]]">
-        <div class="content messageContent">
-          <div class="message hideOnOpen">[[_messageContentCollapsed]]</div>
-          <template is="dom-if" if="[[_expanded]]">
-            <gr-formatted-text
-              noTrailingMargin
-              class="message hideOnCollapsed"
-              content="[[_messageContentExpanded]]"
-              config="[[_projectConfig.commentlinks]]"
-            ></gr-formatted-text>
-            <template is="dom-if" if="[[_messageContentExpanded]]">
-              <div
-                class="replyActionContainer"
-                hidden$="[[!showReplyButton]]"
-                hidden=""
-              >
-                <gr-button
-                  class="replyBtn"
-                  link=""
-                  small=""
-                  on-click="_handleReplyTap"
-                >
-                  Reply
-                </gr-button>
-                <gr-button
-                  disabled$="[[_isDeletingChangeMsg]]"
-                  class="deleteBtn"
-                  hidden$="[[!_isAdmin]]"
-                  hidden=""
-                  link=""
-                  small=""
-                  on-click="_handleDeleteMessage"
-                >
-                  Delete
-                </gr-button>
-              </div>
-            </template>
-            <gr-thread-list
-              hidden$="[[!commentThreads.length]]"
-              threads="[[commentThreads]]"
-              hide-dropdown
-              show-comment-context
-              message-id="[[message.id]]"
-            >
-            </gr-thread-list>
-          </template>
-        </div>
-      </template>
-      <template is="dom-if" if="[[_computeIsReviewerUpdate(message)]]">
-        <div class="content">
-          <template is="dom-repeat" items="[[message.updates]]" as="update">
-            <div class="updateCategory">
-              [[update.message]]
-              <template
-                is="dom-repeat"
-                items="[[update.reviewers]]"
-                as="reviewer"
-              >
-                <gr-account-chip account="[[reviewer]]" change="[[change]]">
-                </gr-account-chip>
-              </template>
-            </div>
-          </template>
-        </div>
-      </template>
-      <span class="dateContainer">
-        <template is="dom-if" if="[[_showViewDiffButton(message)]]">
-          <gr-button
-            class="patchsetDiffButton"
-            on-click="_handleViewPatchsetDiff"
-            link
-          >
-            View Diff
-          </gr-button>
-        </template>
-        <template is="dom-if" if="[[message._revision_number]]">
-          <span class="patchset">[[message._revision_number]] |</span>
-        </template>
-        <template is="dom-if" if="[[!message.id]]">
-          <span class="date">
-            <gr-date-formatter
-              withTooltip
-              showDateAndTime
-              date-str="[[message.date]]"
-            ></gr-date-formatter>
-          </span>
-        </template>
-        <template is="dom-if" if="[[message.id]]">
-          <span class="date" on-click="_handleAnchorClick">
-            <gr-date-formatter
-              withTooltip
-              showDateAndTime
-              date-str="[[message.date]]"
-            ></gr-date-formatter>
-          </span>
-        </template>
-        <iron-icon
-          id="expandToggle"
-          on-click="_toggleExpanded"
-          title="Toggle expanded state"
-          icon="[[_computeExpandToggleIcon(_expanded)]]"
-        ></iron-icon>
-      </span>
-    </div>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
index ffe59f0..bbe39ff 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -51,8 +51,8 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {CommentSide} from '../../../constants/constants';
 import {SinonStubbedMember} from 'sinon';
-
-const basicFixture = fixtureFromElement('gr-message');
+import {html} from 'lit';
+import {fixture} from '@open-wc/testing-helpers';
 
 suite('gr-message tests', () => {
   let element: GrMessage;
@@ -60,8 +60,7 @@
   suite('when admin and logged in', () => {
     setup(async () => {
       stubRestApi('getIsAdmin').returns(Promise.resolve(true));
-      element = basicFixture.instantiate();
-      await flush();
+      element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
     });
 
     test('reply event', async () => {
@@ -85,9 +84,7 @@
         promise.resolve();
       });
       await flush();
-      assert.isFalse(
-        queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
-      );
+      assert.isOk(query<HTMLElement>(element, '.replyActionContainer'));
       tap(queryAndAssert(element, '.replyBtn'));
       await promise;
     });
@@ -106,9 +103,9 @@
         _revision_number: 1 as PatchSetNum,
         expanded: true,
       };
+      await element.updateComplete;
 
-      await flush();
-      assert.isFalse(queryAndAssert<HTMLElement>(element, '.deleteBtn').hidden);
+      assert.isOk(query<HTMLElement>(element, '.deleteBtn'));
     });
 
     test('delete change message', async () => {
@@ -126,11 +123,13 @@
         _revision_number: 1 as PatchSetNum,
         expanded: true,
       };
+      await element.updateComplete;
 
       const promise = mockPromise();
       element.addEventListener(
         'change-message-deleted',
-        (e: CustomEvent<ChangeMessageDeletedEventDetail>) => {
+        async (e: CustomEvent<ChangeMessageDeletedEventDetail>) => {
+          await element.updateComplete;
           assert.deepEqual(e.detail.message, element.message);
           assert.isFalse(
             queryAndAssert<GrButton>(element, '.deleteBtn').disabled
@@ -138,82 +137,192 @@
           promise.resolve();
         }
       );
-      await flush();
       tap(queryAndAssert(element, '.deleteBtn'));
+      await element.updateComplete;
       assert.isTrue(queryAndAssert<GrButton>(element, '.deleteBtn').disabled);
       await promise;
     });
 
-    test('autogenerated prefix hiding', () => {
+    test('autogenerated prefix hiding', async () => {
       element.message = {
         ...createChangeMessage(),
         tag: 'autogenerated:gerrit:test' as ReviewInputTag,
         expanded: false,
       };
+      await element.updateComplete;
 
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
+      assert.isTrue(element.computeIsAutomated());
+      expect(element).shadowDom.to.equal(/* HTML */ `<div class="collapsed">
+        <div class="contentContainer">
+          <div class="author">
+            <gr-account-label class="authorLabel"> </gr-account-label>
+            <gr-message-scores> </gr-message-scores>
+          </div>
+          <div class="content messageContent">
+            <div class="hideOnOpen message">
+              This is a message with id cm_id_1
+            </div>
+          </div>
+          <span class="dateContainer">
+            <span class="date">
+              <gr-date-formatter showdateandtime="" withtooltip="">
+              </gr-date-formatter>
+            </span>
+            <iron-icon
+              icon="gr-icons:expand-more"
+              id="expandToggle"
+              title="Toggle expanded state"
+            >
+            </iron-icon>
+          </span>
+        </div>
+      </div>`);
 
       element.hideAutomated = true;
+      await element.updateComplete;
 
-      assert.isTrue(element.hidden);
+      expect(element).shadowDom.to.equal(/* HTML */ '');
     });
 
-    test('reviewer message treated as autogenerated', () => {
+    test('reviewer message treated as autogenerated', async () => {
       element.message = {
         ...createChangeMessage(),
         tag: 'autogenerated:gerrit:test' as ReviewInputTag,
         reviewer: {},
         expanded: false,
       };
+      await element.updateComplete;
 
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
+      assert.isTrue(element.computeIsAutomated());
+      expect(element).shadowDom.to.equal(/* HTML */ `<div class="collapsed">
+        <div class="contentContainer">
+          <div class="author">
+            <gr-account-label class="authorLabel"> </gr-account-label>
+            <gr-message-scores> </gr-message-scores>
+          </div>
+          <div class="content messageContent">
+            <div class="hideOnOpen message">
+              This is a message with id cm_id_1
+            </div>
+          </div>
+          <span class="dateContainer">
+            <span class="date">
+              <gr-date-formatter showdateandtime="" withtooltip="">
+              </gr-date-formatter>
+            </span>
+            <iron-icon
+              icon="gr-icons:expand-more"
+              id="expandToggle"
+              title="Toggle expanded state"
+            >
+            </iron-icon>
+          </span>
+        </div>
+      </div>`);
 
       element.hideAutomated = true;
+      await element.updateComplete;
 
-      assert.isTrue(element.hidden);
+      expect(element).shadowDom.to.equal(/* HTML */ '');
     });
 
-    test('batch reviewer message treated as autogenerated', () => {
+    test('batch reviewer message treated as autogenerated', async () => {
       element.message = {
         ...createChangeMessage(),
         type: 'REVIEWER_UPDATE',
         reviewer: {},
         expanded: false,
+        updates: [],
       };
+      await element.updateComplete;
 
-      assert.isTrue(element.isAutomated);
-      assert.isFalse(element.hidden);
+      assert.isTrue(element.computeIsAutomated());
+      expect(element).shadowDom.to.equal(/* HTML */ `<div class="collapsed">
+        <div class="contentContainer">
+          <div class="author">
+            <gr-account-label class="authorLabel"> </gr-account-label>
+            <gr-message-scores> </gr-message-scores>
+          </div>
+          <div class="content messageContent">
+            <div class="hideOnOpen message">
+              This is a message with id cm_id_1
+            </div>
+          </div>
+          <div class="content"></div>
+          <span class="dateContainer">
+            <span class="date">
+              <gr-date-formatter showdateandtime="" withtooltip="">
+              </gr-date-formatter>
+            </span>
+            <iron-icon
+              icon="gr-icons:expand-more"
+              id="expandToggle"
+              title="Toggle expanded state"
+            >
+            </iron-icon>
+          </span>
+        </div>
+      </div>`);
 
       element.hideAutomated = true;
+      await element.updateComplete;
 
-      assert.isTrue(element.hidden);
+      expect(element).shadowDom.to.equal(/* HTML */ '');
     });
 
-    test('tag that is not autogenerated prefix does not hide', () => {
+    test('tag that is not autogenerated prefix does not hide', async () => {
       element.message = {
         ...createChangeMessage(),
         tag: 'something' as ReviewInputTag,
         expanded: false,
       };
+      await element.updateComplete;
 
-      assert.isFalse(element.isAutomated);
-      assert.isFalse(element.hidden);
+      assert.isFalse(element.computeIsAutomated());
+      const rendered = /* HTML */ `<div class="collapsed">
+        <div class="contentContainer">
+          <div class="author">
+            <gr-account-label class="authorLabel"> </gr-account-label>
+            <gr-message-scores> </gr-message-scores>
+          </div>
+          <div class="content messageContent">
+            <div class="hideOnOpen message">
+              This is a message with id cm_id_1
+            </div>
+          </div>
+          <span class="dateContainer">
+            <span class="date">
+              <gr-date-formatter showdateandtime="" withtooltip="">
+              </gr-date-formatter>
+            </span>
+            <iron-icon
+              icon="gr-icons:expand-more"
+              id="expandToggle"
+              title="Toggle expanded state"
+            >
+            </iron-icon>
+          </span>
+        </div>
+      </div>`;
+      expect(element).shadowDom.to.equal(rendered);
 
       element.hideAutomated = true;
+      await element.updateComplete;
+      console.error(element.computeIsAutomated());
 
-      assert.isFalse(element.hidden);
+      expect(element).shadowDom.to.equal(rendered);
     });
 
     test('reply button hidden unless logged in', () => {
-      const message = {
+      element.message = {
         ...createChangeMessage(),
         message: 'Uploaded patch set 1.',
         expanded: false,
       };
-      assert.isFalse(element._computeShowReplyButton(message, false));
-      assert.isTrue(element._computeShowReplyButton(message, true));
+      element.loggedIn = false;
+      assert.isFalse(element.computeShowReplyButton());
+      element.loggedIn = true;
+      assert.isTrue(element.computeShowReplyButton());
     });
 
     test('_computeShowOnBehalfOf', () => {
@@ -222,29 +331,32 @@
         message: '...',
         expanded: false,
       };
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      element.message = message;
+      assert.isNotOk(element.computeShowOnBehalfOf());
       message.author = {_account_id: 1115495 as AccountId};
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      assert.isNotOk(element.computeShowOnBehalfOf());
       message.real_author = {_account_id: 1115495 as AccountId};
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      assert.isNotOk(element.computeShowOnBehalfOf());
       message.real_author._account_id = 123456 as AccountId;
-      assert.isOk(element._computeShowOnBehalfOf(message));
+      assert.isOk(element.computeShowOnBehalfOf());
       message.updated_by = message.author;
       delete message.author;
-      assert.isOk(element._computeShowOnBehalfOf(message));
+      assert.isOk(element.computeShowOnBehalfOf());
       delete message.updated_by;
-      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      assert.isNotOk(element.computeShowOnBehalfOf());
     });
 
-    test('clicking on date link fires event', () => {
+    test('clicking on date link fires event', async () => {
       element.message = {
         ...createChangeMessage(),
         type: 'REVIEWER_UPDATE',
         reviewer: {},
         id: '47c43261_55aa2c41' as ChangeMessageId,
         expanded: false,
+        updates: [],
       };
-      flush();
+      await element.updateComplete;
+
       const stub = sinon.stub();
       element.addEventListener('message-anchor-tap', stub);
       const dateEl = queryAndAssert(element, '.date');
@@ -252,7 +364,7 @@
       tap(dateEl);
 
       assert.isTrue(stub.called);
-      assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message.id});
+      assert.deepEqual(stub.lastCall.args[0].detail, {id: element.message?.id});
     });
 
     suite('uploaded patchset X message navigates to X - 1 vs  X', () => {
@@ -267,7 +379,7 @@
           ...createChangeMessage(),
           message: 'Uploaded patch set 1.',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
         assert.isTrue(
           navStub.calledWithExactly(element.change!, {
             patchNum: 1 as PatchSetNum,
@@ -281,7 +393,7 @@
           ...createChangeMessage(),
           message: 'Uploaded patch set 2.',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
         assert.isTrue(
           navStub.calledWithExactly(element.change!, {
             patchNum: 2 as PatchSetNum,
@@ -293,7 +405,7 @@
           ...createChangeMessage(),
           message: 'Uploaded patch set 200.',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
         assert.isTrue(
           navStub.calledWithExactly(element.change!, {
             patchNum: 200 as PatchSetNum,
@@ -307,7 +419,7 @@
           ...createChangeMessage(),
           message: 'Commit message updated.',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
         assert.isTrue(
           navStub.calledWithExactly(element.change!, {
             patchNum: 4 as PatchSetNum,
@@ -321,7 +433,7 @@
           ...createChangeMessage(),
           message: 'abcd↵3 is the latest approved patch-set.↵abc',
         };
-        element._handleViewPatchsetDiff(new MouseEvent('click'));
+        element.handleViewPatchsetDiff(new MouseEvent('click'));
         assert.isTrue(
           navStub.calledWithExactly(element.change!, {
             patchNum: 4 as PatchSetNum,
@@ -334,7 +446,7 @@
     suite('compute messages', () => {
       test('empty', () => {
         assert.equal(
-          element._computeMessageContent(
+          element.computeMessageContent(
             true,
             '',
             undefined,
@@ -343,7 +455,7 @@
           ''
         );
         assert.equal(
-          element._computeMessageContent(
+          element.computeMessageContent(
             false,
             '',
             undefined,
@@ -356,13 +468,9 @@
       test('new patchset', () => {
         const original = 'Uploaded patch set 1.';
         const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
-        let actual = element._computeMessageContent(true, original, [], tag);
-        assert.equal(
-          actual,
-          element._computeMessageContentCollapsed(original, [], tag, [])
-        );
+        let actual = element.computeMessageContent(true, original, [], tag);
         assert.equal(actual, original);
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(false, original, [], tag);
         assert.equal(actual, original);
       });
 
@@ -370,13 +478,9 @@
         const original = 'Patch Set 27: Patch Set 26 was rebased';
         const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
         const expected = 'Patch Set 26 was rebased';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        assert.equal(
-          actual,
-          element._computeMessageContentCollapsed(original, [], tag, [])
-        );
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
 
@@ -384,31 +488,27 @@
         const original = 'Patch Set 1:\n\nThis change is ready for review.';
         const tag = undefined;
         const expected = 'This change is ready for review.';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        assert.equal(
-          actual,
-          element._computeMessageContentCollapsed(original, [], tag, [])
-        );
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
       test('new patchset with vote', () => {
         const original = 'Uploaded patch set 2: Code-Review+1';
         const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
         const expected = 'Uploaded patch set 2: Code-Review+1';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
       test('vote', () => {
         const original = 'Patch Set 1: Code-Style+1';
         const tag = undefined;
         const expected = '';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
 
@@ -416,9 +516,9 @@
         const original = 'Patch Set 1:\n\n(3 comments)';
         const tag = undefined;
         const expected = '';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
 
@@ -432,14 +532,14 @@
           createAccountWithIdNameAndEmail(1),
           createAccountWithIdNameAndEmail(2),
         ];
-        let actual = element._computeMessageContent(
+        let actual = element.computeMessageContent(
           true,
           original,
           accountsInMessage,
           tag
         );
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(
+        actual = element.computeMessageContent(
           false,
           original,
           accountsInMessage,
@@ -454,9 +554,9 @@
         const tag = undefined;
         const expected =
           'Removed vote: \n\n * Code-Style+1 by Gerrit Account 1\n * Code-Style-1 by Gerrit Account 2';
-        let actual = element._computeMessageContent(true, original, [], tag);
+        let actual = element.computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, [], tag);
+        actual = element.computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
     });
@@ -466,11 +566,10 @@
     setup(async () => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       stubRestApi('getIsAdmin').returns(Promise.resolve(false));
-      element = basicFixture.instantiate();
-      await flush();
+      element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
     });
 
-    test('reply and delete button should be hidden', () => {
+    test('reply and delete button should be hidden', async () => {
       element.message = {
         ...createChangeMessage(),
         id: '47c43261_55aa2c41' as ChangeMessageId,
@@ -485,25 +584,24 @@
         expanded: true,
       };
 
-      flush();
-      assert.isTrue(
-        queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
-      );
-      assert.isTrue(queryAndAssert<HTMLElement>(element, '.deleteBtn').hidden);
+      await element.updateComplete;
+      assert.isNotOk(query<HTMLElement>(element, '.replyActionContainer'));
+      assert.isNotOk(query<HTMLElement>(element, '.deleteBtn'));
     });
   });
 
-  suite('patchset comment summary', () => {
-    setup(() => {
-      element = basicFixture.instantiate();
+  suite('patchset comment summary', async () => {
+    setup(async () => {
+      element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
       element.message = {
         ...createChangeMessage(),
         id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3' as ChangeMessageId,
       };
+      await element.updateComplete;
     });
 
     test('single patchset comment posted', () => {
-      const threads = [
+      element.commentThreads = [
         {
           comments: [
             {
@@ -516,7 +614,6 @@
               message: 'testing the load',
               unresolved: false,
               path: '/PATCHSET_LEVEL',
-              collapsed: false,
             },
           ],
           patchNum: 1 as PatchSetNum,
@@ -525,23 +622,15 @@
           commentSide: CommentSide.REVISION,
         },
       ];
+      assert.equal(element.patchsetCommentSummary(), 'testing the load');
       assert.equal(
-        element._computeMessageContentCollapsed(
-          '',
-          undefined,
-          undefined,
-          threads
-        ),
-        'testing the load'
-      );
-      assert.equal(
-        element._computeMessageContent(false, '', undefined, undefined),
+        element.computeMessageContent(false, '', undefined, undefined),
         ''
       );
     });
 
     test('single patchset comment with reply', () => {
-      const threads = [
+      element.commentThreads = [
         {
           comments: [
             {
@@ -552,7 +641,6 @@
               message: 'testing the load',
               unresolved: false,
               path: '/PATCHSET_LEVEL',
-              collapsed: false,
             },
             {
               change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3',
@@ -564,7 +652,6 @@
               unresolved: false,
               path: '/PATCHSET_LEVEL',
               __draft: true,
-              collapsed: true,
             },
           ],
           patchNum: 1 as PatchSetNum,
@@ -573,17 +660,9 @@
           commentSide: CommentSide.REVISION,
         },
       ];
+      assert.equal(element.patchsetCommentSummary(), 'n');
       assert.equal(
-        element._computeMessageContentCollapsed(
-          '',
-          undefined,
-          undefined,
-          threads
-        ),
-        'n'
-      );
-      assert.equal(
-        element._computeMessageContent(false, '', undefined, undefined),
+        element.computeMessageContent(false, '', undefined, undefined),
         ''
       );
     });
@@ -592,11 +671,10 @@
   suite('when logged in but not admin', () => {
     setup(async () => {
       stubRestApi('getIsAdmin').returns(Promise.resolve(false));
-      element = basicFixture.instantiate();
-      await flush();
+      element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
     });
 
-    test('can see reply but not delete button', () => {
+    test('can see reply but not delete button', async () => {
       element.message = {
         ...createChangeMessage(),
         id: '47c43261_55aa2c41' as ChangeMessageId,
@@ -610,17 +688,16 @@
         _revision_number: 1 as PatchSetNum,
         expanded: true,
       };
+      await element.updateComplete;
 
-      flush();
-      assert.isFalse(
-        queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
-      );
-      assert.isTrue(queryAndAssert<HTMLElement>(element, '.deleteBtn').hidden);
+      assert.isOk(query<HTMLElement>(element, '.replyActionContainer'));
+      assert.isNotOk(query<HTMLElement>(element, '.deleteBtn'));
     });
 
-    test('reply button shown when message is updated', () => {
+    test('reply button shown when message is updated', async () => {
       element.message = undefined;
-      flush();
+      await element.updateComplete;
+
       let replyEl = query(element, '.replyActionContainer');
       // We don't even expect the button to show up in the DOM when the message
       // is undefined.
@@ -639,10 +716,10 @@
         _revision_number: 1 as PatchSetNum,
         expanded: true,
       };
-      flush();
+      await element.updateComplete;
+
       replyEl = queryAndAssert(element, '.replyActionContainer');
       assert.isOk(replyEl);
-      assert.isFalse((replyEl as HTMLElement).hidden);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 28acbea..f6b0ad4 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -57,6 +57,7 @@
 import {commentsModelToken} from '../../../models/comments/comments-model';
 import {changeModelToken} from '../../../models/change/change-model';
 import {resolve, DIPolymerElement} from '../../../models/dependency';
+import {queryAll} from '../../../utils/common-util';
 
 /**
  * The content of the enum is also used in the UI for the button text.
@@ -305,7 +306,7 @@
     super.disconnectedCallback();
   }
 
-  scrollToMessage(messageID: string) {
+  async scrollToMessage(messageID: string) {
     const selector = `[data-message-id="${messageID}"]`;
     const el = this.shadowRoot!.querySelector(selector) as
       | GrMessage
@@ -317,13 +318,15 @@
       );
       return;
     }
-    if (!el) {
+    if (!el || !el.message) {
       this._showAllActivity = true;
       setTimeout(() => this.scrollToMessage(messageID));
       return;
     }
 
-    el.set('message.expanded', true);
+    el.message.expanded = true;
+    el.requestUpdate();
+    await el.updateComplete;
     let top = el.offsetTop;
     for (
       let offsetParent = el.offsetParent as HTMLElement | null;
@@ -409,11 +412,14 @@
   }
 
   _updateExpandedStateOfAllMessages(exp: boolean) {
-    if (this._combinedMessages) {
-      for (let i = 0; i < this._combinedMessages.length; i++) {
-        this._combinedMessages[i].expanded = exp;
-        this.notifyPath(`_combinedMessages.${i}.expanded`);
-      }
+    if (!this._combinedMessages) return;
+
+    for (let i = 0; i < this._combinedMessages.length; i++) {
+      this._combinedMessages[i].expanded = exp;
+      this.notifyPath(`_combinedMessages.${i}.expanded`);
+    }
+    for (const message of queryAll<GrMessage>(this, 'gr-message')) {
+      message.requestUpdate('message');
     }
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
index 283ea357..b9cb616 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
@@ -41,6 +41,7 @@
   UrlEncodedCommentId,
 } from '../../../types/common';
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {assertIsDefined} from '../../../utils/common-util';
 
 createCommentApiMockWithTemplateElement(
   'gr-messages-list-comment-mock-api',
@@ -167,43 +168,53 @@
       await flush();
     });
 
-    test('expand/collapse all', () => {
+    test('expand/collapse all', async () => {
       let allMessageEls = getMessages();
       for (const message of allMessageEls) {
-        message._expanded = false;
+        assertIsDefined(message.message);
+        message.message = {...message.message, expanded: false};
+        await message.updateComplete;
       }
       MockInteractions.tap(allMessageEls[1]);
-      assert.isTrue(allMessageEls[1]._expanded);
+      assert.isTrue(allMessageEls[1].message?.expanded);
 
       MockInteractions.tap(queryAndAssert(element, '#collapse-messages'));
       allMessageEls = getMessages();
       for (const message of allMessageEls) {
-        assert.isTrue(message._expanded);
+        assert.isTrue(message.message?.expanded);
       }
 
       MockInteractions.tap(queryAndAssert(element, '#collapse-messages'));
       allMessageEls = getMessages();
       for (const message of allMessageEls) {
-        assert.isFalse(message._expanded);
+        assert.isFalse(message.message?.expanded);
       }
     });
 
     test('expand/collapse from external keypress', () => {
       // Start with one expanded message. -> not all collapsed
       element.scrollToMessage(messages[1].id);
-      assert.isFalse([...getMessages()].filter(m => m._expanded).length === 0);
+      assert.isFalse(
+        [...getMessages()].filter(m => m.message?.expanded).length === 0
+      );
 
       // Press 'z' -> all collapsed
       element.handleExpandCollapse(false);
-      assert.isTrue([...getMessages()].filter(m => m._expanded).length === 0);
+      assert.isTrue(
+        [...getMessages()].filter(m => m.message?.expanded).length === 0
+      );
 
       // Press 'x' -> all expanded
       element.handleExpandCollapse(true);
-      assert.isTrue([...getMessages()].filter(m => !m._expanded).length === 0);
+      assert.isTrue(
+        [...getMessages()].filter(m => !m.message?.expanded).length === 0
+      );
 
       // Press 'z' -> all collapsed
       element.handleExpandCollapse(false);
-      assert.isTrue([...getMessages()].filter(m => m._expanded).length === 0);
+      assert.isTrue(
+        [...getMessages()].filter(m => m.message?.expanded).length === 0
+      );
     });
 
     test('showAllActivity does not appear when all msgs are important', () => {
@@ -211,50 +222,52 @@
       assert.isNotOk(query(element, '.showAllActivityToggle'));
     });
 
-    test('scroll to message', () => {
+    test('scroll to message', async () => {
       const allMessageEls = getMessages();
       for (const message of allMessageEls) {
-        message.set('message.expanded', false);
+        assertIsDefined(message.message);
+        message.message = {...message.message, expanded: false};
       }
 
       const scrollToStub = sinon.stub(window, 'scrollTo');
       const highlightStub = sinon.stub(element, '_highlightEl');
 
-      element.scrollToMessage('invalid');
+      await element.scrollToMessage('invalid');
 
       for (const message of allMessageEls) {
+        assertIsDefined(message.message);
         assert.isFalse(
-          message._expanded,
+          message.message.expanded,
           'expected gr-message to not be expanded'
         );
       }
 
       const messageID = messages[1].id;
-      element.scrollToMessage(messageID);
+      await element.scrollToMessage(messageID);
       assert.isTrue(
         queryAndAssert<GrMessage>(element, `[data-message-id="${messageID}"]`)
-          ._expanded
+          .message?.expanded
       );
 
       assert.isTrue(scrollToStub.calledOnce);
       assert.isTrue(highlightStub.calledOnce);
     });
 
-    test('scroll to message offscreen', () => {
+    test('scroll to message offscreen', async () => {
       const scrollToStub = sinon.stub(window, 'scrollTo');
       const highlightStub = sinon.stub(element, '_highlightEl');
       element.messages = generateRandomMessages(25);
-      flush();
+      await element.updateComplete;
       assert.isFalse(scrollToStub.called);
       assert.isFalse(highlightStub.called);
 
       const messageID = element.messages[1].id;
-      element.scrollToMessage(messageID);
+      await element.scrollToMessage(messageID);
       assert.isTrue(scrollToStub.calledOnce);
       assert.isTrue(highlightStub.calledOnce);
       assert.isTrue(
         queryAndAssert<GrMessage>(element, `[data-message-id="${messageID}"]`)
-          ._expanded
+          .message?.expanded
       );
     });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index c651d08..5d0f52a 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -423,8 +423,9 @@
     }
   }
 
-  handleEditCommitMessage() {
+  async handleEditCommitMessage() {
     this.editing = true;
+    await this.updateComplete;
     this.focusTextarea();
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 4456381..dc2cbc7 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -25,7 +25,7 @@
       <g id="expand-less"><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="expand-more"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#unfold_more -->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=unfold_more -->
       <g id="unfold-more"><path d="M0 0h24v24H0z" fill="none"></path><path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="search"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"></path></g>
@@ -61,11 +61,11 @@
       <g id="info"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="info-outline"><path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=ic_hourglass_full-->
       <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"></path><path d="M0 0h24v24H0V0z" fill="none"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#mode_comment-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=mode_comment-->
       <g id="comment"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#calendar_today-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=calendar_today-->
       <g id="calendar"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="error"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></g>
@@ -77,11 +77,13 @@
       <g id="unified"><path d="M4,2 L17,2 C18.1045695,2 19,2.8954305 19,4 L19,16 C19,17.1045695 18.1045695,18 17,18 L4,18 C2.8954305,18 2,17.1045695 2,16 L2,4 L2,4 C2,2.8954305 2.8954305,2 4,2 L4,2 Z M4,7 L4,9 L17,9 L17,7 L4,7 Z M4,11 L4,13 L17,13 L17,11 L4,11 Z" id="Combined-Shape" transform="scale(1.12, 1.2)"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="content-copy"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="build"><path d="M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="check"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#check_circle-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=check_circle-->
       <g id="check-circle"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#check_circle_outline-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=check_circle_outline-->
       <g id="check-circle-outline"><path d="M0 0h24v24H0V0zm0 0h24v24H0V0z" fill="none"/><path d="M16.59 7.58L10 14.17l-3.59-3.58L5 12l5 5 8-8zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></g>
       <!-- This SVG is a copy from https://fonts.google.com/icons?selected=Material+Icons:event_busy&icon.query=check+circle-->
       <g id="check-circle-filled"><path d="M12,2C6.48,2,2,6.48,2,12c0,5.52,4.48,10,10,10s10-4.48,10-10C22,6.48,17.52,2,12,2z M10,17l-4-4l1.4-1.4l2.6,2.6l6.6-6.6 L18,9L10,17z"/><path d="M0,0h24v24H0V0z" fill="none"/></g>
@@ -110,45 +112,45 @@
       <g id="review"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
       <!-- This is a custom PolyGerrit SVG -->
       <g id="zeroState"><path d="M22 9V7h-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2v-2h-2V9h2zm-4 10H4V5h14v14zM6 13h5v4H6zm6-6h4v3h-4zM6 7h5v5H6zm6 4h4v6h-4z"></path></g>
-      <!-- This SVG is an adaptation of material.io https://material.io/icons/#label_important-->
+      <!-- This SVG is an adaptation of material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=label_important-->
       <g id="attention"><path d="M1 23 l13 0 c.67 0 1.27 -.33 1.63 -.84 l7.37 -10.16 l-7.37 -10.16 c-.36 -.51 -.96 -.84 -1.63 -.84 L1 1 L7 12 z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#pets-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=pets-->
       <g id="pets"><circle cx="4.5" cy="9.5" r="2.5"/><circle cx="9" cy="5.5" r="2.5"/><circle cx="15" cy="5.5" r="2.5"/><circle cx="19.5" cy="9.5" r="2.5"/><path d="M17.34 14.86c-.87-1.02-1.6-1.89-2.48-2.91-.46-.54-1.05-1.08-1.75-1.32-.11-.04-.22-.07-.33-.09-.25-.04-.52-.04-.78-.04s-.53 0-.79.05c-.11.02-.22.05-.33.09-.7.24-1.28.78-1.75 1.32-.87 1.02-1.6 1.89-2.48 2.91-1.31 1.31-2.92 2.76-2.62 4.79.29 1.02 1.02 2.03 2.33 2.32.73.15 3.06-.44 5.54-.44h.18c2.48 0 4.81.58 5.54.44 1.31-.29 2.04-1.31 2.33-2.32.31-2.04-1.3-3.49-2.61-4.8z"/><path d="M0 0h24v24H0z" fill="none"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#visibility-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=visibility-->
       <g id="ready"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons -->
       <g id="schedule"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"></path></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#bug_report-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=bug_report-->
       <g id="bug"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.gstatic.com/s/i/googlematerialicons/move_item/v1/24px.svg -->
       <g id="move-item"><path d="M15,19H5V5h10v4h2V5c0-1.1-0.89-2-2-2H5C3.9,3,3,3.9,3,5v14c0,1.1,0.9,2,2,2h10c1.11,0,2-0.9,2-2v-4h-2V19z"/><polygon points="20.01,8.01 18.59,9.41 20.17,11 8,11 8,13 20.17,13 18.59,14.59 20.01,15.99 24,12"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#warning-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=warning-->
       <g id="warning"><path d="M0 0h24v24H0z" fill="none"/><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#timelapse-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=timelapse-->
       <g id="timelapse"><path d="M0 0h24v24H0z" fill="none"/><path d="M16.24 7.76C15.07 6.59 13.54 6 12 6v6l-4.24 4.24c2.34 2.34 6.14 2.34 8.49 0 2.34-2.34 2.34-6.14-.01-8.48zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#mark_chat_read-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=mark_chat_read-->
       <g id="markChatRead"><path d="M12,18l-6,0l-4,4V4c0-1.1,0.9-2,2-2h16c1.1,0,2,0.9,2,2v7l-2,0V4H4v12l8,0V18z M23,14.34l-1.41-1.41l-4.24,4.24l-2.12-2.12 l-1.41,1.41L17.34,20L23,14.34z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#message-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=message-->
       <g id="message"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#launch-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=launch-->
       <g id="launch"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#filter-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=filter-->
       <g id="filter"><path d="M0,0h24 M24,24H0" fill="none"/><path d="M4.25,5.61C6.27,8.2,10,13,10,13v6c0,0.55,0.45,1,1,1h2c0.55,0,1-0.45,1-1v-6c0,0,3.72-4.8,5.74-7.39 C20.25,4.95,19.78,4,18.95,4H5.04C4.21,4,3.74,4.95,4.25,5.61z"/><path d="M0,0h24v24H0V0z" fill="none"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#arrow_drop_down-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=arrow_drop_down-->
       <g id="arrowDropDown"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 10l5 5 5-5z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#arrow_drop_up-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=arrow_drop_up-->
       <g id="arrowDropUp"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 14l5-5 5 5z"/></g>
       <!-- This is just a placeholder, i.e. an empty icon that has the same size as a normal icon. -->
       <g id="placeholder"><path d="M0 0h24v24H0z" fill="none"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#insert_photo-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=insert_photo-->
       <g id="insert-photo"><path d="M0 0h24v24H0z" fill="none"/><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#download-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=download-->
       <g id="download"><path d="M0 0h24v24H0z" fill="none"/><path d="M5,20h14v-2H5V20z M19,9h-4V3H9v6H5l7,7L19,9z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#system_update-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=system_update-->
       <g id="system-update"><path d="M0 0h24v24H0z" fill="none"/><path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14zm-1-6h-3V8h-2v5H8l4 4 4-4z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#swap_horiz-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=swap_horiz-->
       <g id="swapHoriz"><path d="M0 0h24v24H0z" fill="none"/><path d="M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></g>
-      <!-- This SVG is a copy from material.io https://material.io/icons/#link-->
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=link-->
       <g id="link"><path d="M0 0h24v24H0z" fill="none"/><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></g>
       <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Aplay_arrow-->
       <g id="playArrow"><path d="M0 0h24v24H0z" fill="none"/><path d="M8 5v14l11-7z"/></g>
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 2aefa99..669c491 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -38,6 +38,7 @@
 import {isMergeParent, getParentIndex} from './patch-set-util';
 import {DiffInfo} from '../types/diff';
 import {LineNumber} from '../api/diff';
+import {FormattedReviewerUpdateInfo} from '../types/types';
 
 export interface DraftCommentProps {
   // This must be true for all drafts. Drafts received from the backend will be
@@ -98,6 +99,12 @@
   commentThreads: CommentThread[];
 }
 
+export function isFormattedReviewerUpdate(
+  message: ChangeMessage
+): message is ChangeMessage & FormattedReviewerUpdateInfo {
+  return message.type === 'REVIEWER_UPDATE';
+}
+
 export type LabelExtreme = {[labelName: string]: VotingRangeInfo};
 
 export const PATCH_SET_PREFIX_PATTERN =