Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.js b/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.js
new file mode 100644
index 0000000..aad9a8a
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.js
@@ -0,0 +1,163 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {cache} from 'lit-html/directives/cache.js';
+import {LitElement, html, css} from 'lit-element';
+
+import '../../chops/chops-button/chops-button.js';
+import './mr-comment.js';
+import {connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as ui from 'reducers/ui.js';
+import {userIsMember} from 'shared/helpers.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * `<mr-comment-list>`
+ *
+ * Display a list of Monorail comments.
+ *
+ */
+export class MrCommentList extends connectStore(LitElement) {
+  /** @override */
+  constructor() {
+    super();
+
+    this.commentsShownCount = 2;
+    this.comments = [];
+    this.headingLevel = 4;
+
+    this.focusId = null;
+
+    this.usersProjects = new Map();
+
+    this._hideComments = true;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      commentsShownCount: {type: Number},
+      comments: {type: Array},
+      headingLevel: {type: Number},
+
+      focusId: {type: String},
+
+      usersProjects: {type: Object},
+
+      _hideComments: {type: Boolean},
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.focusId = ui.focusId(state);
+    this.usersProjects = userV0.projectsPerUser(state);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    super.updated(changedProperties);
+
+    if (!this._hideComments) return;
+
+    // If any hidden comment is focused, show all hidden comments.
+    const hiddenCount =
+      _hiddenCount(this.comments.length, this.commentsShownCount);
+    const hiddenComments = this.comments.slice(0, hiddenCount);
+    for (const comment of hiddenComments) {
+      if ('c' + comment.sequenceNum === this.focusId) {
+        this._hideComments = false;
+        break;
+      }
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return [SHARED_STYLES, css`
+      button.toggle {
+        background: none;
+        color: var(--chops-link-color);
+        border: 0;
+        border-bottom: var(--chops-normal-border);
+        border-top: var(--chops-normal-border);
+        width: 100%;
+        padding: 0.5em 8px;
+        text-align: left;
+        font-size: var(--chops-main-font-size);
+      }
+      button.toggle:hover {
+        cursor: pointer;
+        text-decoration: underline;
+      }
+      button.toggle[hidden] {
+        display: none;
+      }
+      .edit-slot {
+        margin-top: 3em;
+      }
+    `];
+  }
+
+  /** @override */
+  render() {
+    const hiddenCount =
+      _hiddenCount(this.comments.length, this.commentsShownCount);
+    return html`
+      <button @click=${this._toggleHide}
+          class="toggle"
+          ?hidden=${hiddenCount <= 0}>
+        ${this._hideComments ? 'Show' : 'Hide'}
+        ${hiddenCount}
+        older
+        ${hiddenCount == 1 ? 'comment' : 'comments'}
+      </button>
+      ${cache(this._hideComments ? '' :
+    html`${this.comments.slice(0, hiddenCount).map(
+        this.renderComment.bind(this))}`)}
+      ${this.comments.slice(hiddenCount).map(this.renderComment.bind(this))}
+    `;
+  }
+
+  /**
+   * Helper to render a single comment.
+   * @param {Comment} comment
+   * @return {TemplateResult}
+   */
+  renderComment(comment) {
+    const commenterIsMember = userIsMember(
+        comment.commenter, comment.projectName, this.usersProjects);
+    return html`
+      <mr-comment
+          .comment=${comment}
+          headingLevel=${this.headingLevel}
+          ?highlighted=${'c' + comment.sequenceNum === this.focusId}
+          ?commenterIsMember=${commenterIsMember}
+      ></mr-comment>`;
+  }
+
+  /**
+   * Hides or unhides comments that are hidden by default. For example,
+   * if an issue has 200 comments, the first 100 comments are shown initially,
+   * then the last 100 can be toggled to be shown.
+   * @private
+   */
+  _toggleHide() {
+    this._hideComments = !this._hideComments;
+  }
+}
+
+/**
+ * Computes how many comments the user is able to expand.
+ * @param {number} commentCount Total comments.
+ * @param {number} commentsShownCount The number of comments shown.
+ * @return {number} The number of hidden comments.
+ * @private
+ */
+function _hiddenCount(commentCount, commentsShownCount) {
+  return Math.max(commentCount - commentsShownCount, 0);
+}
+
+customElements.define('mr-comment-list', MrCommentList);
diff --git a/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.test.js b/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.test.js
new file mode 100644
index 0000000..548b7a7
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.test.js
@@ -0,0 +1,108 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import sinon from 'sinon';
+import {MrCommentList} from './mr-comment-list.js';
+
+
+let element;
+
+describe('mr-comment-list', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-comment-list');
+    document.body.appendChild(element);
+    element.comments = [
+      {
+        canFlag: true,
+        localId: 898395,
+        canDelete: true,
+        projectName: 'chromium',
+        commenter: {
+          displayName: 'user@example.com',
+          userId: '12345',
+        },
+        content: 'foo',
+        sequenceNum: 1,
+        timestamp: 1549319989,
+      },
+      {
+        canFlag: true,
+        localId: 898395,
+        canDelete: true,
+        projectName: 'chromium',
+        commenter: {
+          displayName: 'user@example.com',
+          userId: '12345',
+        },
+        content: 'foo',
+        sequenceNum: 2,
+        timestamp: 1549320089,
+      },
+      {
+        canFlag: true,
+        localId: 898395,
+        canDelete: true,
+        projectName: 'chromium',
+        commenter: {
+          displayName: 'user@example.com',
+          userId: '12345',
+        },
+        content: 'foo',
+        sequenceNum: 3,
+        timestamp: 1549320189,
+      },
+    ];
+
+    // Stub RAF to execute immediately.
+    sinon.stub(window, 'requestAnimationFrame').callsFake((func) => func());
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    window.requestAnimationFrame.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrCommentList);
+  });
+
+  it('scrolls to comment', async () => {
+    await element.updateComplete;
+
+    const commentElements = element.shadowRoot.querySelectorAll('mr-comment');
+    const commentElement = commentElements[commentElements.length - 1];
+    sinon.stub(commentElement, 'scrollIntoView');
+
+    element.focusId = 'c3';
+
+    await element.updateComplete;
+
+    assert.isTrue(element._hideComments);
+    assert.isTrue(commentElement.scrollIntoView.calledOnce);
+
+    commentElement.scrollIntoView.restore();
+  });
+
+  it('scrolls to hidden comment', async () => {
+    await element.updateComplete;
+
+    element.focusId = 'c1';
+
+    await element.updateComplete;
+
+    assert.isFalse(element._hideComments);
+    // TODO: Check that the comment has been scrolled into view.
+  });
+
+  it('doesnt scroll to unknown comment', async () => {
+    await element.updateComplete;
+
+    element.focusId = 'c100';
+
+    await element.updateComplete;
+
+    assert.isTrue(element._hideComments);
+  });
+});
diff --git a/static_src/elements/issue-detail/mr-comment-list/mr-comment.js b/static_src/elements/issue-detail/mr-comment-list/mr-comment.js
new file mode 100644
index 0000000..e56bef3
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-comment-list/mr-comment.js
@@ -0,0 +1,416 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+import {store} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-timestamp/chops-timestamp.js';
+import 'elements/framework/mr-comment-content/mr-comment-content.js';
+import 'elements/framework/mr-comment-content/mr-attachment.js';
+import 'elements/framework/mr-dropdown/mr-dropdown.js';
+import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {issueStringToRef} from 'shared/convertersV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import 'shared/typedef.js';
+
+const ISSUE_REF_FIELD_NAMES = [
+  'Blocking',
+  'Blockedon',
+  'Mergedinto',
+];
+
+/**
+ * `<mr-comment>`
+ *
+ * A component for an individual comment.
+ *
+ */
+export class MrComment extends LitElement {
+  /** @override */
+  constructor() {
+    super();
+
+    this._isExpandedIfDeleted = false;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      comment: {type: Object},
+      headingLevel: {type: String},
+      highlighted: {
+        type: Boolean,
+        reflect: true,
+      },
+      commenterIsMember: {type: Boolean},
+      _isExpandedIfDeleted: {type: Boolean},
+      _showOriginalContent: {type: Boolean},
+    };
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    super.updated(changedProperties);
+
+    if (changedProperties.has('highlighted') && this.highlighted) {
+      window.requestAnimationFrame(() => {
+        this.scrollIntoView();
+        // TODO(ehmaldonado): Figure out a way to get the height from the issue
+        // header, and scroll by that amount.
+        window.scrollBy(0, -150);
+      });
+    }
+  }
+
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          display: block;
+          margin: 1.5em 0 0 0;
+        }
+        :host([highlighted]) {
+          border: 1px solid var(--chops-primary-accent-color);
+          box-shadow: 0 0 4px 4px var(--chops-active-choice-bg);
+        }
+        :host([hidden]) {
+          display: none;
+        }
+        .comment-header {
+          background: var(--chops-card-heading-bg);
+          padding: 3px 1px 1px 8px;
+          width: 100%;
+          display: flex;
+          flex-direction: row;
+          justify-content: space-between;
+          align-items: center;
+          box-sizing: border-box;
+        }
+        .comment-header a {
+          display: inline-flex;
+        }
+        .role-label {
+          background-color: var(--chops-gray-600);
+          border-radius: 3px;
+          color: var(--chops-white);
+          display: inline-block;
+          padding: 2px 4px;
+          font-size: 75%;
+          font-weight: bold;
+          line-height: 14px;
+          vertical-align: text-bottom;
+          margin-left: 16px;
+        }
+        .comment-options {
+          float: right;
+          text-align: right;
+          text-decoration: none;
+        }
+        .comment-body {
+          margin: 4px;
+          box-sizing: border-box;
+        }
+        .deleted-comment-notice {
+          margin-left: 4px;
+        }
+        .issue-diff {
+          background: var(--chops-card-details-bg);
+          display: inline-block;
+          padding: 4px 8px;
+          width: 100%;
+          box-sizing: border-box;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      ${this._renderHeading()}
+      ${_shouldShowComment(this._isExpandedIfDeleted, this.comment) ? html`
+        ${this._renderDiff()}
+        ${this._renderBody()}
+      ` : ''}
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderHeading() {
+    return html`
+      <div
+        role="heading"
+        aria-level=${this.headingLevel}
+        class="comment-header">
+        <div>
+          <a
+            href="?id=${this.comment.localId}#c${this.comment.sequenceNum}"
+            class="comment-link"
+          >Comment ${this.comment.sequenceNum}</a>
+
+          ${this._renderByline()}
+        </div>
+        ${_shouldOfferCommentOptions(this.comment) ? html`
+          <div class="comment-options">
+            <mr-dropdown
+              .items=${this._commentOptions}
+              label="Comment options"
+              icon="more_vert"
+            ></mr-dropdown>
+          </div>
+        ` : ''}
+      </div>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderByline() {
+    if (_shouldShowComment(this._isExpandedIfDeleted, this.comment)) {
+      return html`
+        by
+        <mr-user-link .userRef=${this.comment.commenter}></mr-user-link>
+        on
+        <chops-timestamp
+          .timestamp=${this.comment.timestamp}
+        ></chops-timestamp>
+        ${this.commenterIsMember && !this.comment.isDeleted ? html`
+          <span class="role-label">Project Member</span>` : ''}
+      `;
+    } else {
+      return html`<span class="deleted-comment-notice">Deleted</span>`;
+    }
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderDiff() {
+    if (!(this.comment.descriptionNum || this.comment.amendments)) return '';
+
+    return html`
+      <div class="issue-diff">
+        ${(this.comment.amendments || []).map((delta) => html`
+          <strong>${delta.fieldName}:</strong>
+          ${_issuesForAmendment(delta, this.comment.projectName).map((issueForAmendment) => html`
+            <mr-issue-link
+              projectName=${this.comment.projectName}
+              .issue=${issueForAmendment.issue}
+              text=${issueForAmendment.text}
+            ></mr-issue-link>
+          `)}
+          ${!_amendmentHasIssueRefs(delta.fieldName) ? delta.newOrDeltaValue : ''}
+          ${delta.oldValue ? `(was: ${delta.oldValue})` : ''}
+          <br>
+        `)}
+        ${this.comment.descriptionNum ? 'Description was changed.' : ''}
+      </div><br>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderBody() {
+    const commentContent = this._showOriginalContent ?
+      this.comment.inboundMessage :
+      this.comment.content;
+    return html`
+      <div class="comment-body">
+        <mr-comment-content
+          ?hidden=${this.comment.descriptionNum}
+          .content=${commentContent}
+          .author=${this.comment.commenter.displayName}
+          ?isDeleted=${this.comment.isDeleted}
+        ></mr-comment-content>
+        <div ?hidden=${this.comment.descriptionNum}>
+          ${(this.comment.attachments || []).map((attachment) => html`
+            <mr-attachment
+              .attachment=${attachment}
+              projectName=${this.comment.projectName}
+              localId=${this.comment.localId}
+              sequenceNum=${this.comment.sequenceNum}
+              ?canDelete=${this.comment.canDelete}
+            ></mr-attachment>
+          `)}
+        </div>
+      </div>
+    `;
+  }
+
+  /**
+   * Displays three dot menu options available to the current user for a given
+   * comment.
+   * @return {Array<MenuItem>}
+   */
+  get _commentOptions() {
+    const options = [];
+    if (_canExpandDeletedComment(this.comment)) {
+      const text =
+        (this._isExpandedIfDeleted ? 'Hide' : 'Show') + ' comment content';
+      options.push({
+        text: text,
+        handler: this._toggleHideDeletedComment.bind(this),
+      });
+      options.push({separator: true});
+    }
+    if (this.comment.canDelete) {
+      const text =
+        (this.comment.isDeleted ? 'Undelete' : 'Delete') + ' comment';
+      options.push({
+        text: text,
+        handler: _deleteComment.bind(null, this.comment),
+      });
+    }
+    if (this.comment.canFlag) {
+      const text = (this.comment.isSpam ? 'Unflag' : 'Flag') + ' comment';
+      options.push({
+        text: text,
+        handler: _flagComment.bind(null, this.comment),
+      });
+    }
+    if (this.comment.inboundMessage) {
+      const text =
+        (this._showOriginalContent ? 'Hide' : 'Show') + ' original email';
+      options.push({
+        text: text,
+        handler: this._toggleShowOriginalContent.bind(this),
+      });
+    }
+    return options;
+  }
+
+  /**
+   * Toggles whether the email of the user who deleted the comment should be
+   * shown.
+   */
+  _toggleShowOriginalContent() {
+    this._showOriginalContent = !this._showOriginalContent;
+  }
+
+  /**
+   * Change if deleted content for a comment is shown or not.
+   */
+  _toggleHideDeletedComment() {
+    this._isExpandedIfDeleted = !this._isExpandedIfDeleted;
+  }
+}
+
+/**
+ * Says whether a comment should be shown or not.
+ * @param {boolean} isExpandedIfDeleted If the user has chosen to see the
+ *   deleted comment.
+ * @param {IssueComment} comment
+ * @return {boolean} If the comment should be shown.
+ */
+function _shouldShowComment(isExpandedIfDeleted, comment) {
+  return !comment.isDeleted || isExpandedIfDeleted;
+}
+
+/**
+ * Whether the user can view additional comment options like flagging or
+ * deleting.
+ * @param {IssueComment} comment
+ * @return {boolean}
+ */
+function _shouldOfferCommentOptions(comment) {
+  return comment.canDelete || comment.canFlag;
+}
+
+/**
+ * Whether a user has permission to view a given deleted comment.
+ * @param {IssueComment} comment
+ * @return {boolean}
+ */
+function _canExpandDeletedComment(comment) {
+  return ((comment.isSpam && comment.canFlag) ||
+          (comment.isDeleted && comment.canDelete));
+}
+
+/**
+ * Deletes a given comment or undeletes it if it's already deleted.
+ * @param {IssueComment} comment The comment to delete.
+ */
+async function _deleteComment(comment) {
+  const issueRef = {
+    projectName: comment.projectName,
+    localId: comment.localId,
+  };
+  await prpcClient.call('monorail.Issues', 'DeleteIssueComment', {
+    issueRef,
+    sequenceNum: comment.sequenceNum,
+    delete: comment.isDeleted === undefined,
+  });
+  store.dispatch(issueV0.fetchComments(issueRef));
+}
+
+/**
+ * Sends a request to flag a comment as spam. Flags or unflags based on
+ * the comments existing isSpam state.
+ * @param {IssueComment} comment The comment to flag.
+ */
+async function _flagComment(comment) {
+  const issueRef = {
+    projectName: comment.projectName,
+    localId: comment.localId,
+  };
+  await prpcClient.call('monorail.Issues', 'FlagComment', {
+    issueRef,
+    sequenceNum: comment.sequenceNum,
+    flag: comment.isSpam === undefined,
+  });
+  store.dispatch(issueV0.fetchComments(issueRef));
+}
+
+/**
+ * Finds if a given change in a comment contains issues (ie: for Blocking or
+ * BlockedOn edits), then formats those issues into a list to be rendered by the
+ * frontend.
+ * @param {Amendment} delta
+ * @param {string} projectName The project name the user is currently viewing.
+ * @return {Array<{issue: Issue, text: string}>}
+ */
+function _issuesForAmendment(delta, projectName) {
+  if (!_amendmentHasIssueRefs(delta.fieldName) ||
+      !delta.newOrDeltaValue) {
+    return [];
+  }
+  // TODO(ehmaldonado): Request the issue to check for permissions and display
+  // the issue summary.
+  return delta.newOrDeltaValue.split(' ').map((deltaValue) => {
+    let refString = deltaValue;
+
+    // When an issue is removed, its ID is prepended with a minus sign.
+    if (refString.startsWith('-')) {
+      refString = refString.substr(1);
+    }
+    const issueRef = issueStringToRef(refString, projectName);
+    return {
+      issue: {
+        ...issueRef,
+      },
+      text: deltaValue,
+    };
+  });
+}
+
+/**
+ * Check if a field is one of the field types that accepts issues as input.
+ * @param {string} fieldName
+ * @return {boolean} If the field contains issues.
+ */
+function _amendmentHasIssueRefs(fieldName) {
+  return ISSUE_REF_FIELD_NAMES.includes(fieldName);
+}
+
+customElements.define('mr-comment', MrComment);
diff --git a/static_src/elements/issue-detail/mr-comment-list/mr-comment.test.js b/static_src/elements/issue-detail/mr-comment-list/mr-comment.test.js
new file mode 100644
index 0000000..6933825
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-comment-list/mr-comment.test.js
@@ -0,0 +1,257 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import sinon from 'sinon';
+import {MrComment} from './mr-comment.js';
+
+
+let element;
+
+/**
+ * Testing helper to find if an Array of options has an option with some
+ * text.
+ * @param {Array<MenuItem>} options Dropdown options to look through.
+ * @param {string} needle The text to search for.
+ * @return {boolean} Whether the option exists or not.
+ */
+const hasOptionWithText = (options, needle) => {
+  return options.some(({text}) => text === needle);
+};
+
+describe('mr-comment', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-comment');
+    element.comment = {
+      canFlag: true,
+      localId: 898395,
+      canDelete: true,
+      projectName: 'chromium',
+      commenter: {
+        displayName: 'user@example.com',
+        userId: '12345',
+      },
+      content: 'foo',
+      sequenceNum: 3,
+      timestamp: 1549319989,
+    };
+    document.body.appendChild(element);
+
+    // Stub RAF to execute immediately.
+    sinon.stub(window, 'requestAnimationFrame').callsFake((func) => func());
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    window.requestAnimationFrame.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrComment);
+  });
+
+  it('scrolls to comment', async () => {
+    sinon.stub(element, 'scrollIntoView');
+
+    element.highlighted = true;
+    await element.updateComplete;
+
+    assert.isTrue(element.scrollIntoView.calledOnce);
+
+    element.scrollIntoView.restore();
+  });
+
+  it('comment header renders self link to comment', async () => {
+    element.comment = {
+      localId: 1,
+      projectName: 'test',
+      sequenceNum: 2,
+      commenter: {
+        displayName: 'user@example.com',
+        userId: '12345',
+      },
+    };
+
+    await element.updateComplete;
+
+    const link = element.shadowRoot.querySelector('.comment-link');
+
+    assert.equal(link.textContent, 'Comment 2');
+    assert.include(link.href, '?id=1#c2');
+  });
+
+  it('renders issue links for Blockedon issue amendments', async () => {
+    element.comment = {
+      projectName: 'test',
+      amendments: [
+        {
+          fieldName: 'Blockedon',
+          newOrDeltaValue: '-2 3',
+        },
+      ],
+      commenter: {
+        displayName: 'user@example.com',
+        userId: '12345',
+      },
+    };
+
+    await element.updateComplete;
+
+    const links = element.shadowRoot.querySelectorAll('mr-issue-link');
+
+    assert.equal(links.length, 2);
+
+    assert.equal(links[0].text, '-2');
+    assert.deepEqual(links[0].href, '/p/test/issues/detail?id=2');
+
+    assert.equal(links[1].text, '3');
+    assert.deepEqual(links[1].href, '/p/test/issues/detail?id=3');
+  });
+
+  it('renders issue links for Blocking issue amendments', async () => {
+    element.comment = {
+      projectName: 'test',
+      amendments: [
+        {
+          fieldName: 'Blocking',
+          newOrDeltaValue: '-2 3',
+        },
+      ],
+      commenter: {
+        displayName: 'user@example.com',
+        userId: '12345',
+      },
+    };
+
+    await element.updateComplete;
+
+    const links = element.shadowRoot.querySelectorAll('mr-issue-link');
+
+    assert.equal(links.length, 2);
+
+    assert.equal(links[0].text, '-2');
+    assert.deepEqual(links[0].href, '/p/test/issues/detail?id=2');
+
+    assert.equal(links[1].text, '3');
+    assert.deepEqual(links[1].href, '/p/test/issues/detail?id=3');
+  });
+
+  it('renders issue links for Mergedinto issue amendments', async () => {
+    element.comment = {
+      projectName: 'test',
+      amendments: [
+        {
+          fieldName: 'Mergedinto',
+          newOrDeltaValue: '-2 3',
+        },
+      ],
+      commenter: {
+        displayName: 'user@example.com',
+        userId: '12345',
+      },
+    };
+
+    await element.updateComplete;
+
+    const links = element.shadowRoot.querySelectorAll('mr-issue-link');
+
+    assert.equal(links.length, 2);
+
+    assert.equal(links[0].text, '-2');
+    assert.deepEqual(links[0].href, '/p/test/issues/detail?id=2');
+
+    assert.equal(links[1].text, '3');
+    assert.deepEqual(links[1].href, '/p/test/issues/detail?id=3');
+  });
+
+  describe('3-dot menu options', () => {
+    it('allows showing deleted comment content', () => {
+      element._isExpandedIfDeleted = false;
+
+      // The comment is deleted.
+      element.comment = {content: 'test', isDeleted: true, canDelete: true};
+      assert.isTrue(hasOptionWithText(element._commentOptions,
+          'Show comment content'));
+
+      // The comment is spam.
+      element.comment = {content: 'test', isSpam: true, canFlag: true};
+      assert.isTrue(hasOptionWithText(element._commentOptions,
+          'Show comment content'));
+    });
+
+    it('allows hiding deleted comment content', () => {
+      element._isExpandedIfDeleted = true;
+
+      // The comment is deleted.
+      element.comment = {content: 'test', isDeleted: true, canDelete: true};
+      assert.isTrue(hasOptionWithText(element._commentOptions,
+          'Hide comment content'));
+
+      // The comment is spam.
+      element.comment = {content: 'test', isSpam: true, canFlag: true};
+      assert.isTrue(hasOptionWithText(element._commentOptions,
+          'Hide comment content'));
+    });
+
+    it('disallows showing deleted comment content', () => {
+      // The comment is deleted.
+      element.comment = {content: 'test', isDeleted: true, canDelete: false};
+      assert.isFalse(hasOptionWithText(element._commentOptions,
+          'Hide comment content'));
+
+      // The comment is spam.
+      element.comment = {content: 'test', isSpam: true, canFlag: false};
+      assert.isFalse(hasOptionWithText(element._commentOptions,
+          'Hide comment content'));
+    });
+
+    it('allows deleting comment', () => {
+      element.comment = {content: 'test', isDeleted: false, canDelete: true};
+      assert.isTrue(hasOptionWithText(element._commentOptions,
+          'Delete comment'));
+    });
+
+    it('disallows deleting comment', () => {
+      element.comment = {content: 'test', isDeleted: false, canDelete: false};
+      assert.isFalse(hasOptionWithText(element._commentOptions,
+          'Delete comment'));
+    });
+
+    it('allows undeleting comment', () => {
+      element.comment = {content: 'test', isDeleted: true, canDelete: true};
+      assert.isTrue(hasOptionWithText(element._commentOptions,
+          'Undelete comment'));
+    });
+
+    it('disallows undeleting comment', () => {
+      element.comment = {content: 'test', isDeleted: true, canDelete: false};
+      assert.isFalse(hasOptionWithText(element._commentOptions,
+          'Undelete comment'));
+    });
+
+    it('allows flagging comment as spam', () => {
+      element.comment = {content: 'test', isSpam: false, canFlag: true};
+      assert.isTrue(hasOptionWithText(element._commentOptions,
+          'Flag comment'));
+    });
+
+    it('disallows flagging comment as spam', () => {
+      element.comment = {content: 'test', isSpam: false, canFlag: false};
+      assert.isFalse(hasOptionWithText(element._commentOptions,
+          'Flag comment'));
+    });
+
+    it('allows unflagging comment as spam', () => {
+      element.comment = {content: 'test', isSpam: true, canFlag: true};
+      assert.isTrue(hasOptionWithText(element._commentOptions,
+          'Unflag comment'));
+    });
+
+    it('disallows unflagging comment as spam', () => {
+      element.comment = {content: 'test', isSpam: true, canFlag: false};
+      assert.isFalse(hasOptionWithText(element._commentOptions,
+          'Unflag comment'));
+    });
+  });
+});