Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/framework/mr-comment-content/mr-attachment.js b/static_src/elements/framework/mr-comment-content/mr-attachment.js
new file mode 100644
index 0000000..c435dfd
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-attachment.js
@@ -0,0 +1,206 @@
+// 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 {SHARED_STYLES} from 'shared/shared-styles.js';
+import {FILE_DOWNLOAD_WARNING, ALLOWED_ATTACHMENT_EXTENSIONS,
+  ALLOWED_CONTENT_TYPE_PREFIXES} from 'shared/settings.js';
+import 'elements/chops/chops-button/chops-button.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+/**
+ * `<mr-attachment>`
+ *
+ * Display attachments for Monorail comments.
+ *
+ */
+export class MrAttachment extends connectStore(LitElement) {
+  /** @override */
+  static get properties() {
+    return {
+      attachment: {type: Object},
+      projectName: {type: String},
+      localId: {type: Number},
+      sequenceNum: {type: Number},
+      canDelete: {type: Boolean},
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        .attachment-view,
+        .attachment-download {
+          margin-left: 8px;
+          display: block;
+        }
+        .attachment-delete {
+          margin-left: 16px;
+          color: var(--chops-button-color);
+          background: var(--chops-button-bg);
+          border-color: transparent;
+        }
+        .comment-attachment {
+          min-width: 20%;
+          width: fit-content;
+          background: var(--chops-card-details-bg);
+          padding: 4px;
+          margin: 8px;
+          overflow: auto;
+        }
+        .comment-attachment-header {
+          display: flex;
+          flex-wrap: nowrap;
+        }
+        .filename {
+          margin-left: 8px;
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+        }
+        .filename-deleted {
+          margin-right: 4px;
+        }
+        .filesize {
+          margin-left: 8px;
+          white-space: nowrap;
+        }
+        .preview {
+          border: 2px solid #c3d9ff;
+          padding: 1px;
+          max-width: 98%;
+        }
+        .preview:hover {
+          border: 2px solid blue;
+        }
+      `];
+  }
+
+
+  /** @override */
+  render() {
+    return html`
+      <div class="comment-attachment">
+        <div class="filename">
+          ${this.attachment.isDeleted ? html`
+            <div class="filename-deleted">[Deleted]</div>
+          ` : ''}
+          <b>${this.attachment.filename}</b>
+          ${this.canDelete ? html`
+            <chops-button
+              class="attachment-delete"
+              @click=${this._deleteAttachment}>
+              ${this.attachment.isDeleted ? 'Undelete' : 'Delete'}
+            </chops-button>
+          ` : ''}
+        </div>
+        ${!this.attachment.isDeleted ? html`
+          <div class="comment-attachment-header">
+            <div class="filesize">${_bytesOrKbOrMb(this.attachment.size)}</div>
+            ${this.attachment.viewUrl ? html`
+              <a
+                class="attachment-view"
+                href=${this.attachment.viewUrl}
+                target="_blank"
+              >View</a>
+            `: ''}
+            <a
+              class="attachment-download"
+              href=${this.attachment.downloadUrl}
+              target="_blank"
+              ?hidden=${!this.attachment.downloadUrl}
+              @click=${this._warnOnDownload}
+            >Download</a>
+          </div>
+          ${this.attachment.thumbnailUrl ? html`
+            <a href=${this.attachment.viewUrl} target="_blank">
+              <img
+                class="preview" alt="attachment preview"
+                src=${this.attachment.thumbnailUrl}>
+            </a>
+          ` : ''}
+          ${_isVideo(this.attachment.contentType) ? html`
+            <video
+              src=${this.attachment.viewUrl}
+              class="preview"
+              controls
+              width="640"
+              preload="metadata"
+            ></video>
+          ` : ''}
+        ` : ''}
+      </div>
+    `;
+  }
+
+  /**
+   * Deletes a given attachment in a comment.
+   */
+  _deleteAttachment() {
+    const issueRef = {
+      projectName: this.projectName,
+      localId: this.localId,
+    };
+
+    const promise = prpcClient.call(
+        'monorail.Issues', 'DeleteAttachment',
+        {
+          issueRef,
+          sequenceNum: this.sequenceNum,
+          attachmentId: this.attachment.attachmentId,
+          delete: !this.attachment.isDeleted,
+        });
+
+    promise.then(() => {
+      store.dispatch(issueV0.fetchComments(issueRef));
+    }, (error) => {
+      console.log('Failed to (un)delete attachment', error);
+    });
+  }
+
+  /**
+   * Give the user a warning before they download files that Monorail thinks
+   * might have the potential to be unsafe.
+   * @param {MouseEvent} e
+   */
+  _warnOnDownload(e) {
+    const isAllowedType = ALLOWED_CONTENT_TYPE_PREFIXES.some((prefix) => {
+      return this.attachment.contentType.startsWith(prefix);
+    });
+    const isAllowedExtension = ALLOWED_ATTACHMENT_EXTENSIONS.some((ext) => {
+      return this.attachment.filename.toLowerCase().endsWith(ext);
+    });
+
+    if (isAllowedType || isAllowedExtension) return;
+    if (!window.confirm(FILE_DOWNLOAD_WARNING)) {
+      e.preventDefault();
+    }
+  }
+}
+
+function _isVideo(contentType) {
+  if (!contentType) return;
+  return contentType.startsWith('video/');
+}
+
+function _bytesOrKbOrMb(numBytes) {
+  if (numBytes < 1024) {
+    return `${numBytes} bytes`; // e.g., 128 bytes
+  } else if (numBytes < 99 * 1024) {
+    return `${(numBytes / 1024).toFixed(1)} KB`; // e.g. 23.4 KB
+  } else if (numBytes < 1024 * 1024) {
+    return `${(numBytes / 1024).toFixed(0)} KB`; // e.g., 219 KB
+  } else if (numBytes < 99 * 1024 * 1024) {
+    return `${(numBytes / 1024 / 1024).toFixed(1)} MB`; // e.g., 21.9 MB
+  } else {
+    return `${(numBytes / 1024 / 1024).toFixed(0)} MB`; // e.g., 100 MB
+  }
+}
+
+customElements.define('mr-attachment', MrAttachment);
diff --git a/static_src/elements/framework/mr-comment-content/mr-attachment.test.js b/static_src/elements/framework/mr-comment-content/mr-attachment.test.js
new file mode 100644
index 0000000..ec79c66
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-attachment.test.js
@@ -0,0 +1,228 @@
+// 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, expect} from 'chai';
+import {MrAttachment} from './mr-attachment.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {FILE_DOWNLOAD_WARNING} from 'shared/settings.js';
+
+let element;
+
+describe('mr-attachment', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-attachment');
+    document.body.appendChild(element);
+    sinon.stub(prpcClient, 'call').returns(Promise.resolve({}));
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    prpcClient.call.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrAttachment);
+  });
+
+  it('shows image thumbnail', async () => {
+    element.attachment = {
+      thumbnailUrl: 'thumbnail.jpeg',
+      contentType: 'image/jpeg',
+    };
+    await element.updateComplete;
+    const img = element.shadowRoot.querySelector('img');
+    assert.isNotNull(img);
+    assert.isTrue(img.src.endsWith('thumbnail.jpeg'));
+  });
+
+  it('shows video thumbnail', async () => {
+    element.attachment = {
+      viewUrl: 'video.mp4',
+      contentType: 'video/mpeg',
+    };
+    await element.updateComplete;
+    const video = element.shadowRoot.querySelector('video');
+    assert.isNotNull(video);
+    assert.isTrue(video.src.endsWith('video.mp4'));
+  });
+
+  it('does not show image thumbnail if deleted', async () => {
+    element.attachment = {
+      thumbnailUrl: 'thumbnail.jpeg',
+      contentType: 'image/jpeg',
+      isDeleted: true,
+    };
+    await element.updateComplete;
+    const img = element.shadowRoot.querySelector('img');
+    assert.isNull(img);
+  });
+
+  it('does not show video thumbnail if deleted', async () => {
+    element.attachment = {
+      viewUrl: 'video.mp4',
+      contentType: 'video/mpeg',
+      isDeleted: true,
+    };
+    await element.updateComplete;
+    const video = element.shadowRoot.querySelector('video');
+    assert.isNull(video);
+  });
+
+  it('deletes attachment', async () => {
+    prpcClient.call.callsFake(() => Promise.resolve({}));
+
+    element.attachment = {
+      attachmentId: 67890,
+      isDeleted: false,
+    };
+    element.canDelete = true;
+    element.projectName = 'proj';
+    element.localId = 1234;
+    element.sequenceNum = 3;
+    await element.updateComplete;
+
+    const deleteButton = element.shadowRoot.querySelector('chops-button');
+    deleteButton.click();
+
+    assert.deepEqual(prpcClient.call.getCall(0).args, [
+      'monorail.Issues', 'DeleteAttachment',
+      {
+        issueRef: {
+          projectName: 'proj',
+          localId: 1234,
+        },
+        sequenceNum: 3,
+        attachmentId: 67890,
+        delete: true,
+      },
+    ]);
+    assert.isTrue(prpcClient.call.calledOnce);
+  });
+
+  it('undeletes attachment', async () => {
+    prpcClient.call.callsFake(() => Promise.resolve({}));
+    element.attachment = {
+      attachmentId: 67890,
+      isDeleted: true,
+    };
+    element.canDelete = true;
+    element.projectName = 'proj';
+    element.localId = 1234;
+    element.sequenceNum = 3;
+    await element.updateComplete;
+
+    const deleteButton = element.shadowRoot.querySelector('chops-button');
+    deleteButton.click();
+
+    assert.deepEqual(prpcClient.call.getCall(0).args, [
+      'monorail.Issues', 'DeleteAttachment',
+      {
+        issueRef: {
+          projectName: 'proj',
+          localId: 1234,
+        },
+        sequenceNum: 3,
+        attachmentId: 67890,
+        delete: false,
+      },
+    ]);
+    assert.isTrue(prpcClient.call.calledOnce);
+  });
+
+  it('view link is not displayed if not given', async () => {
+    element.attachment = {};
+    await element.updateComplete;
+    const viewLink = element.shadowRoot.querySelector('.attachment-view');
+    assert.isNull(viewLink);
+  });
+
+  it('view link is displayed if given', async () => {
+    element.attachment = {
+      viewUrl: 'http://example.com/attachment.foo',
+    };
+    await element.updateComplete;
+    const viewLink = element.shadowRoot.querySelector('.attachment-view');
+    assert.isNotNull(viewLink);
+    expect(viewLink).to.be.displayed;
+    assert.equal(viewLink.href, 'http://example.com/attachment.foo');
+  });
+
+  describe('download', () => {
+    let downloadLink;
+
+    beforeEach(async () => {
+      sinon.stub(window, 'confirm').returns(false);
+
+
+      element.attachment = {};
+      await element.updateComplete;
+      downloadLink = element.shadowRoot.querySelector('.attachment-download');
+      // Prevent Karma from opening up new tabs because of simulated link
+      // clicks.
+      downloadLink.removeAttribute('target');
+    });
+
+    afterEach(() => {
+      window.confirm.restore();
+    });
+
+    it('download link is not displayed if not given', async () => {
+      element.attachment = {};
+      await element.updateComplete;
+      assert.isTrue(downloadLink.hidden);
+    });
+
+    it('download link is displayed if given', async () => {
+      element.attachment = {
+        downloadUrl: 'http://example.com/attachment.foo',
+      };
+      await element.updateComplete;
+      const downloadLink = element.shadowRoot.querySelector(
+          '.attachment-download');
+      assert.isFalse(downloadLink.hidden);
+      expect(downloadLink).to.be.displayed;
+      assert.equal(downloadLink.href, 'http://example.com/attachment.foo');
+    });
+
+    it('download allows recognized file extension and type', async () => {
+      element.attachment = {
+        contentType: 'image/png',
+        filename: 'not-a-virus.png',
+        downloadUrl: '#',
+      };
+      await element.updateComplete;
+
+      downloadLink.click();
+
+      sinon.assert.notCalled(window.confirm);
+    });
+
+    it('file extension matching is case insensitive', async () => {
+      element.attachment = {
+        contentType: 'image/png',
+        filename: 'not-a-virus.PNG',
+        downloadUrl: '#',
+      };
+      await element.updateComplete;
+
+      downloadLink.click();
+
+      sinon.assert.notCalled(window.confirm);
+    });
+
+    it('download warns on unrecognized file extension and type', async () => {
+      element.attachment = {
+        contentType: 'application/virus',
+        filename: 'fake-virus.exe',
+        downloadUrl: '#',
+      };
+      await element.updateComplete;
+
+      downloadLink.click();
+
+      sinon.assert.calledOnce(window.confirm);
+      sinon.assert.calledWith(window.confirm, FILE_DOWNLOAD_WARNING);
+    });
+  });
+});
diff --git a/static_src/elements/framework/mr-comment-content/mr-comment-content.js b/static_src/elements/framework/mr-comment-content/mr-comment-content.js
new file mode 100644
index 0000000..c2bf3e8
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-comment-content.js
@@ -0,0 +1,131 @@
+// 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 {ifDefined} from 'lit-html/directives/if-defined';
+import {autolink} from 'autolink.js';
+import {connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import {SHARED_STYLES, MD_STYLES} from 'shared/shared-styles.js';
+import {shouldRenderMarkdown, renderMarkdown} from 'shared/md-helper.js';
+import {unsafeHTML} from 'lit-html/directives/unsafe-html.js';
+
+/**
+ * `<mr-comment-content>`
+ *
+ * Displays text for a comment.
+ *
+ */
+export class MrCommentContent extends connectStore(LitElement) {
+  /** @override */
+  constructor() {
+    super();
+
+    this.content = '';
+    this.commentReferences = new Map();
+    this.isDeleted = false;
+    this.projectName = '';
+    this.author = '';
+    this.prefs = {};
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      content: {type: String},
+      commentReferences: {type: Object},
+      revisionUrlFormat: {type: String},
+      isDeleted: {
+        type: Boolean,
+        reflect: true,
+      },
+      projectName: {type: String},
+      author: {type: String},
+      prefs: {type: Object},
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      MD_STYLES,
+      css`
+        :host {
+          word-break: break-word;
+          font-size: var(--chops-main-font-size);
+          line-height: 130%;
+          font-family: var(--mr-toggled-font-family);
+        }
+        :host([isDeleted]) {
+          color: #888;
+          font-style: italic;
+        }
+        .line {
+          white-space: pre-wrap;
+        }
+        .strike-through {
+          text-decoration: line-through;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    if (shouldRenderMarkdown({project: this.projectName, author: this.author,
+          enabled: this._renderMarkdown})) {
+      return html`
+        <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+        <div class="markdown">
+          ${unsafeHTML(renderMarkdown(this.content))}
+        </div>
+        `;
+    }
+    const runs = autolink.markupAutolinks(
+        this.content, this.commentReferences, this.projectName,
+        this.revisionUrlFormat);
+    const templates = runs.map((run) => {
+      switch (run.tag) {
+        case 'b':
+          return html`<b class="line">${run.content}</b>`;
+        case 'br':
+          return html`<br>`;
+        case 'a':
+          return html`<a
+            class="line"
+            target="_blank"
+            href=${run.href}
+            class=${run.css}
+            title=${ifDefined(run.title)}
+          >${run.content}</a>`;
+        default:
+          return html`<span class="line">${run.content}</span>`;
+      }
+    });
+    return html`${templates}`;
+  }
+
+  /**
+   * Helper to get state of Markdown rendering.
+   * @return {boolean} Whether to render Markdown.
+   */
+  get _renderMarkdown() {
+    const {prefs} = this;
+    if (!prefs) return true;
+    return prefs.get('render_markdown');
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.commentReferences = issueV0.commentReferences(state);
+    this.projectName = issueV0.viewedIssueRef(state).projectName;
+    this.revisionUrlFormat =
+      projectV0.viewedPresentationConfig(state).revisionUrlFormat;
+    this.prefs = userV0.prefs(state);
+  }
+}
+customElements.define('mr-comment-content', MrCommentContent);
diff --git a/static_src/elements/framework/mr-comment-content/mr-comment-content.test.js b/static_src/elements/framework/mr-comment-content/mr-comment-content.test.js
new file mode 100644
index 0000000..4eeaab5
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-comment-content.test.js
@@ -0,0 +1,84 @@
+// 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 {MrCommentContent} from './mr-comment-content.js';
+
+
+let element;
+
+describe('mr-comment-content', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-comment-content');
+    document.body.appendChild(element);
+
+    document.body.style.setProperty('--mr-toggled-font-family', 'Some-font');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+
+    document.body.style.removeProperty('--mr-toggled-font-family');
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrCommentContent);
+  });
+
+  it('changes rendered font based on --mr-toggled-font-family', async () => {
+    element.content = 'A comment';
+
+    await element.updateComplete;
+
+    const fontFamily = window.getComputedStyle(element).getPropertyValue(
+        'font-family');
+
+    assert.equal(fontFamily, 'Some-font');
+  });
+
+  it('does not render spurious spaces', async () => {
+    element.content =
+      'Some text before a go/link and more text before <b>some bold text</b>.';
+
+    await element.updateComplete;
+
+    const textContents = Array.from(element.shadowRoot.children).map(
+        (child) => child.textContent);
+
+    assert.deepEqual(textContents, [
+      'Some text before a',
+      ' ',
+      'go/link',
+      ' and more text before ',
+      'some bold text',
+      '.',
+    ]);
+
+    assert.deepEqual(
+        element.shadowRoot.textContent,
+        'Some text before a go/link and more text before some bold text.');
+  });
+
+  it('does render markdown', async () => {
+    element.prefs = new Map([['render_markdown', true]]);
+    element.content = '### this is a header';
+    element.projectName = 'monkeyrail';
+
+    await element.updateComplete;
+
+    const headerText = element.shadowRoot.querySelector('h3').textContent;
+    assert.equal(headerText, 'this is a header');
+  });
+
+  it('does not render markdown when prefs are set to false', async () => {
+    element.prefs = new Map([['render_markdown', false]]);
+    element.projectName = 'monkeyrail';
+    element.content = '### this is a header';
+
+    await element.updateComplete;
+
+    const commentText = element.shadowRoot.textContent;
+    assert.equal(commentText, '### this is a header');
+  });
+});
diff --git a/static_src/elements/framework/mr-comment-content/mr-description.js b/static_src/elements/framework/mr-comment-content/mr-description.js
new file mode 100644
index 0000000..89ae105
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-description.js
@@ -0,0 +1,137 @@
+// 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 './mr-comment-content.js';
+import './mr-attachment.js';
+
+import {relativeTime} from
+  'elements/chops/chops-timestamp/chops-timestamp-helpers';
+
+
+/**
+ * `<mr-description>`
+ *
+ * Element for displaying a description or survey.
+ *
+ */
+export class MrDescription extends LitElement {
+  /** @override */
+  constructor() {
+    super();
+
+    this.descriptionList = [];
+    this.selectedIndex = 0;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      descriptionList: {type: Array},
+      selectedIndex: {type: Number},
+    };
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    super.updated(changedProperties);
+
+    if (changedProperties.has('descriptionList')) {
+      if (!this.descriptionList || !this.descriptionList.length) return;
+      this.selectedIndex = this.descriptionList.length - 1;
+    }
+  }
+
+  /** @override */
+  static get styles() {
+    return css`
+      .select-container {
+        text-align: right;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    const selectedDescription = this.selectedDescription;
+
+    return html`
+      <div class="select-container">
+        <select
+          @change=${this._selectChanged}
+          ?hidden=${!this.descriptionList || this.descriptionList.length <= 1}
+          aria-label="Description history menu">
+          ${this.descriptionList.map((desc, i) => this._renderDescriptionOption(desc, i))}
+        </select>
+      </div>
+      <mr-comment-content
+        .content=${selectedDescription.content}
+        .author=${selectedDescription.commenter.displayName}
+      ></mr-comment-content>
+      <div>
+        ${(selectedDescription.attachments || []).map((attachment) => html`
+          <mr-attachment
+            .attachment=${attachment}
+            .projectName=${selectedDescription.projectName}
+            .localId=${selectedDescription.localId}
+            .sequenceNum=${selectedDescription.sequenceNum}
+            .canDelete=${selectedDescription.canDelete}
+          ></mr-attachment>
+        `)}
+      </div>
+    `;
+  }
+
+  /**
+   * Getter for the currently viewed description.
+   * @return {Comment} The description object.
+   */
+  get selectedDescription() {
+    const descriptions = this.descriptionList || [];
+    const index = Math.max(
+      Math.min(this.selectedIndex, descriptions.length - 1),
+      0);
+    return descriptions[index] || {};
+  }
+
+  /**
+   * Helper to render a <select> <option> for a single description, for our
+   * description selector.
+   * @param {Comment} description
+   * @param {Number} index
+   * @return {TemplateResult}
+   * @private
+   */
+  _renderDescriptionOption(description, index) {
+    const {commenter, timestamp} = description || {};
+    const byLine = commenter ? `by ${commenter.displayName}` : '';
+    return html`
+      <option value=${index} ?selected=${index === this.selectedIndex}>
+        Description #${index + 1} ${byLine} (${_relativeTime(timestamp)})
+      </option>
+    `;
+  }
+
+  /**
+   * Updates the element's selectedIndex when the user changes the select menu.
+   * @param {Event} evt
+   */
+  _selectChanged(evt) {
+    if (!evt || !evt.target) return;
+    this.selectedIndex = Number.parseInt(evt.target.value);
+  }
+}
+
+/**
+ * Template helper for rendering relative time.
+ * @param {number} unixTime Unix timestamp in seconds.
+ * @return {string} human readable timestamp.
+ */
+function _relativeTime(unixTime) {
+  unixTime = Number.parseInt(unixTime);
+  if (Number.isNaN(unixTime)) return;
+  return relativeTime(new Date(unixTime * 1000));
+}
+
+customElements.define('mr-description', MrDescription);
diff --git a/static_src/elements/framework/mr-comment-content/mr-description.test.js b/static_src/elements/framework/mr-comment-content/mr-description.test.js
new file mode 100644
index 0000000..9d39149
--- /dev/null
+++ b/static_src/elements/framework/mr-comment-content/mr-description.test.js
@@ -0,0 +1,81 @@
+// 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 {MrDescription} from './mr-description.js';
+
+
+let element;
+
+describe('mr-description', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-description');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrDescription);
+  });
+
+  it('changes rendered description on select change', async () => {
+    element.descriptionList = [
+      {content: 'description one', commenter: {displayName: 'name'}},
+      {content: 'description two', commenter: {displayName: 'name'}},
+    ];
+
+    await element.updateComplete;
+    await element.updateComplete;
+
+    const commentContent =
+      element.shadowRoot.querySelector('mr-comment-content');
+    assert.equal('description two', commentContent.content);
+
+    element.selectedIndex = 0;
+
+    await element.updateComplete;
+
+    assert.equal('description one', commentContent.content);
+  });
+
+  it('hides selector when only one description', async () => {
+    element.descriptionList = [
+      {content: 'Hello world', commenter: {displayName: 'name@email.com'}},
+      {content: 'rutabaga', commenter: {displayName: 'name@email.com'}},
+    ];
+
+    await element.updateComplete;
+
+    const selectMenu = element.shadowRoot.querySelector('select');
+    assert.isFalse(selectMenu.hidden);
+
+    element.descriptionList = [
+      {content: 'blehh', commenter: {displayName: 'name@email.com'}},
+    ];
+
+    await element.updateComplete;
+
+    assert.isTrue(selectMenu.hidden);
+  });
+
+  it('selector still renders when one description is deleted', async () => {
+    element.descriptionList = [
+      {content: 'Hello world', commenter: {displayName: 'name@email.com'}},
+      {isDeleted: true, commenter: {displayName: 'name@email.com'}},
+    ];
+
+    await element.updateComplete;
+
+    const selectMenu = element.shadowRoot.querySelector('select');
+    assert.isFalse(selectMenu.hidden);
+
+    const options = selectMenu.querySelectorAll('option');
+
+    assert.include(options[0].textContent, 'Description #1 by name@email.com');
+    assert.include(options[1].textContent, 'Description #2');
+  });
+});