Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/help/mr-click-throughs/mr-click-throughs.js b/static_src/elements/help/mr-click-throughs/mr-click-throughs.js
new file mode 100644
index 0000000..8b142f0
--- /dev/null
+++ b/static_src/elements/help/mr-click-throughs/mr-click-throughs.js
@@ -0,0 +1,185 @@
+// 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, connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * `<mr-click-throughs>`
+ *
+ * An element that displays help dialogs that the user is required
+ * to click through before they can participate in the community.
+ *
+ */
+export class MrClickThroughs extends connectStore(LitElement) {
+  /** @override */
+  constructor() {
+    super();
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      userDisplayName: {type: String},
+      prefs: {type: Object},
+      prefsLoaded: {type: Boolean},
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return [SHARED_STYLES, css`
+      :host {
+        --chops-dialog-max-width: 800px;
+      }
+      h2 {
+        margin-top: 0;
+        display: flex;
+        justify-content: space-between;
+        font-weight: normal;
+        border-bottom: 2px solid white;
+        font-size: var(--chops-large-font-size);
+        padding-bottom: 0.5em;
+      }
+      .edit-actions {
+        width: 100%;
+        margin: 0.5em 0;
+        text-align: right;
+      }
+    `];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <chops-dialog
+        id="privacyDialog"
+        ?opened=${this._showPrivacyDialog}
+        forced
+      >
+        <h2>Email display settings</h2>
+
+        <p>There is a <a href="/hosting/settings">setting</a> to control how
+        your email address appears on comments and issues that you post.</p>
+
+        <p>Project members will always see your full email address.  By
+        default, other users who visit the site will see an
+        abbreviated version of your email address.</p>
+
+        <p>If you do not wish your email address to be shared, there
+        are other ways to <a
+        href="http://www.chromium.org/getting-involved">get
+        involved</a> in the community.  To report a problem when using
+        the Chrome browser, you may use the "Report an issue..."  item
+        on the "Help" menu.</p>
+
+
+        <div class="edit-actions">
+          <chops-button @click=${this.dismissPrivacyDialog}>
+            Got it
+          </chops-button>
+        </div>
+      </chops-dialog>
+
+      <chops-dialog
+        id="corpModeDialog"
+        ?opened=${this._showCorpModeDialog}
+        forced
+      >
+        <h2>This site hosts public issues in public projects</h2>
+
+        <p>Unlike our internal issue tracker, this site makes most
+        issues public, unless the issue is labeled with a Restrict-View-*
+        label, such as Restrict-View-Google.</p>
+
+        <p>Components are not used for permissions.  And, regardless of
+        restriction labels, the issue reporter, owner,
+        and Cc&apos;d users may always view the issue.</p>
+
+        ${this.prefs.get('restrict_new_issues') ? html`
+          <p>Your account is a member of a user group that indicates that
+          you may have access to confidential information.  To help prevent
+          leaks when working in public projects, the issue tracker UX has
+          been altered for you:</p>
+
+          <ul>
+            <li>When you open a new issue, the form will initially have a
+            Restrict-View-Google label.  If you know that your issue does
+            not contain confidential information, please remove the label.</li>
+            <li>When you view public issues, a red banner is shown to remind
+            you that any comments or attachments you post will be public.</li>
+          </ul>
+        ` : ''}
+
+        <div class="edit-actions">
+          <chops-button @click=${this.dismissCorpModeDialog}>
+            Got it
+          </chops-button>
+        </div>
+      </chops-dialog>
+    `;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.prefs = userV0.prefs(state);
+    this.prefsLoaded = userV0.currentUser(state).prefsLoaded;
+  }
+
+  /**
+   * Checks whether the user should see a dialogue telling them about
+   * Monorail's privacy settings.
+   */
+  get _showPrivacyDialog() {
+    if (!this.userDisplayName) return false;
+    if (!this.prefsLoaded) return false;
+    if (!this.prefs) return false;
+    if (this.prefs.get('privacy_click_through')) return false;
+    return true;
+  }
+
+  /**
+   * Computes whether the user should see the dialog telling them about corp mode.
+   */
+  get _showCorpModeDialog() {
+    // TODO(jrobbins): Replace this with a API call that gets the project.
+    if (window.CS_env.projectIsRestricted) return false;
+    if (!this.userDisplayName) return false;
+    if (!this.prefsLoaded) return false;
+    if (!this.prefs) return false;
+    if (!this.prefs.get('public_issue_notice')) return false;
+    if (this.prefs.get('corp_mode_click_through')) return false;
+    return true;
+  }
+
+  /**
+   * Event handler for dismissing Monorail's privacy notice.
+   */
+  dismissPrivacyDialog() {
+    this.dismissCue('privacy_click_through');
+  }
+
+  /**
+   * Event handler for dismissing corp mode.
+   */
+  dismissCorpModeDialog() {
+    this.dismissCue('corp_mode_click_through');
+  }
+
+  /**
+   * Dispatches a Redux action to tell Monorail's backend that the user
+   * clicked through a particular cue.
+   * @param {string} pref The pref to set to true.
+   */
+  dismissCue(pref) {
+    const newPrefs = [{name: pref, value: 'true'}];
+    store.dispatch(userV0.setPrefs(newPrefs, !!this.userDisplayName));
+  }
+}
+
+customElements.define('mr-click-throughs', MrClickThroughs);
diff --git a/static_src/elements/help/mr-click-throughs/mr-click-throughs.test.js b/static_src/elements/help/mr-click-throughs/mr-click-throughs.test.js
new file mode 100644
index 0000000..e735380
--- /dev/null
+++ b/static_src/elements/help/mr-click-throughs/mr-click-throughs.test.js
@@ -0,0 +1,120 @@
+// 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 {MrClickThroughs} from './mr-click-throughs.js';
+import page from 'page';
+
+let element;
+
+describe('mr-click-throughs', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-click-throughs');
+    document.body.appendChild(element);
+
+    sinon.stub(page, 'call');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+
+    page.call.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrClickThroughs);
+  });
+
+  it('stateChanged', () => {
+    const state = {userV0: {currentUser:
+      {prefs: new Map(), prefsLoaded: false}}};
+    element.stateChanged(state);
+    assert.deepEqual(element.prefs, new Map([['render_markdown', false]]));
+    assert.isFalse(element.prefsLoaded);
+  });
+
+  it('anon does not see privacy dialog', () => {
+    assert.isFalse(element._showPrivacyDialog);
+  });
+
+  it('signed in user sees no privacy dialog before prefs load', () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = false;
+    assert.isFalse(element._showPrivacyDialog);
+  });
+
+  it('signed in user sees no privacy dialog if dismissal pref set', () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = true;
+    element.prefs = new Map([['privacy_click_through', true]]);
+    assert.isFalse(element._showPrivacyDialog);
+  });
+
+  it('signed in user sees privacy dialog if dismissal pref missing', () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = true;
+    element.prefs = new Map();
+    assert.isTrue(element._showPrivacyDialog);
+  });
+
+  it('anon does not see corp mode dialog', () => {
+    assert.isFalse(element._showCorpModeDialog);
+  });
+
+  it('signed in user sees no corp mode dialog before prefs load', () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = false;
+    assert.isFalse(element._showCorpModeDialog);
+  });
+
+  it('signed in user sees no corp mode dialog if dismissal pref set', () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = true;
+    element.prefs = new Map([['corp_mode_click_through', true]]);
+    assert.isFalse(element._showCorpModeDialog);
+  });
+
+  it('non-corp user sees no corp mode dialog', () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = true;
+    element.prefs = new Map();
+    assert.isFalse(element._showCorpModeDialog);
+  });
+
+  it('corp user sees corp mode dialog if dismissal pref missing', () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = true;
+    element.prefs = new Map([['public_issue_notice', true]]);
+    assert.isTrue(element._showCorpModeDialog);
+  });
+
+  it('corp user sees no corp mode dialog in members-only project', () => {
+    window.CS_env = {projectIsRestricted: true};
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = true;
+    element.prefs = new Map([['public_issue_notice', true]]);
+    assert.isFalse(element._showCorpModeDialog);
+  });
+
+  it('corp user sees corp mode dialog with no RVG warning', async () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = true;
+    element.prefs = new Map([['public_issue_notice', true]]);
+
+    await element.updateComplete;
+    assert.notInclude(element.shadowRoot.innerHTML, 'altered');
+  });
+
+  it('corp user sees corp mode dialog with RVG warning', async () => {
+    element.userDisplayName = 'user@example.com';
+    element.prefsLoaded = true;
+    element.prefs = new Map([
+      ['public_issue_notice', true],
+      ['restrict_new_issues', true],
+    ]);
+
+    await element.updateComplete;
+    assert.include(element.shadowRoot.innerHTML, 'altered');
+  });
+});
diff --git a/static_src/elements/help/mr-cue/cue-helpers.js b/static_src/elements/help/mr-cue/cue-helpers.js
new file mode 100644
index 0000000..4aa30d7
--- /dev/null
+++ b/static_src/elements/help/mr-cue/cue-helpers.js
@@ -0,0 +1,49 @@
+// 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.
+
+/**
+ * @fileoverview Shared helpers for dealing with how <mr-cue> element instances
+ * are used.
+ */
+
+export const cueNames = Object.freeze({
+  CODE_OF_CONDUCT: 'code_of_conduct',
+  AVAILABILITY_MSGS: 'availability_msgs',
+  SWITCH_TO_PARENT_ACCOUNT: 'switch_to_parent_account',
+  SEARCH_FOR_NUMBERS: 'search_for_numbers',
+});
+
+export const AVAILABLE_CUES = Object.freeze(new Set(Object.values(cueNames)));
+
+export const CUE_DISPLAY_PREFIX = 'cue.';
+
+/**
+ * Converts a cue name to the format expected by components like <mr-metadata>
+ * for the purpose of ordering fields.
+ *
+ * @param {string} cueName The name of the cue.
+ * @return {string} A "cue.cue_name" formatted String used in ordering cues
+ *   alongside field types (ie: Owner) in various field specs.
+ */
+export const cueNameToSpec = (cueName) => {
+  return CUE_DISPLAY_PREFIX + cueName;
+};
+
+/**
+ * Converts an issue field specifier to the name of the cue it references if
+ * it references a cue. ie: "cue.cue_name" would reference "cue_name".
+ *
+ * @param {string} spec A "cue.cue_name" format String specifying that a
+ *   specific cue should be mixed alongside issue fields in a component like
+ *   <mr-metadata>.
+ * @return {string} Name of the cue customized in the spec or an empty
+ *   String if the spec does not reference a cue.
+ */
+export const specToCueName = (spec) => {
+  spec = spec.toLowerCase();
+  if (spec.startsWith(CUE_DISPLAY_PREFIX)) {
+    return spec.substring(CUE_DISPLAY_PREFIX.length);
+  }
+  return '';
+};
diff --git a/static_src/elements/help/mr-cue/cue-helpers.test.js b/static_src/elements/help/mr-cue/cue-helpers.test.js
new file mode 100644
index 0000000..3bc084a
--- /dev/null
+++ b/static_src/elements/help/mr-cue/cue-helpers.test.js
@@ -0,0 +1,30 @@
+// 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 {cueNameToSpec, specToCueName} from './cue-helpers.js';
+
+
+describe('cue-helpers', () => {
+  describe('cueNameToSpec', () => {
+    it('appends cue prefix', () => {
+      assert.equal(cueNameToSpec('test'), 'cue.test');
+    });
+  });
+
+  describe('specToCueName', () => {
+    it('extracts cue name from matching spec', () => {
+      assert.equal(specToCueName('cue.test'), 'test');
+      assert.equal(specToCueName('cue.hello-world'), 'hello-world');
+      assert.equal(specToCueName('cue.under_score'), 'under_score');
+    });
+
+    it('does not extract cue name from non-matching spec', () => {
+      assert.equal(specToCueName('.cue.test'), '');
+      assert.equal(specToCueName('hello-world-cue.'), '');
+      assert.equal(specToCueName('cu.under_score'), '');
+      assert.equal(specToCueName('field'), '');
+    });
+  });
+});
diff --git a/static_src/elements/help/mr-cue/mr-cue.js b/static_src/elements/help/mr-cue/mr-cue.js
new file mode 100644
index 0000000..22b1290
--- /dev/null
+++ b/static_src/elements/help/mr-cue/mr-cue.js
@@ -0,0 +1,282 @@
+// 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 qs from 'qs';
+import {store, connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {cueNames} from './cue-helpers.js';
+
+
+/**
+ * `<mr-cue>`
+ *
+ * An element that displays one of a set of predefined help messages
+ * iff that message is appropriate to the current user and page.
+ *
+ * TODO: Factor this class out into a base view component and separate
+ * usage-specific components, such as those for user prefs.
+ *
+ */
+export class MrCue extends connectStore(LitElement) {
+  /** @override */
+  constructor() {
+    super();
+    this.prefs = new Map();
+    this.issue = null;
+    this.referencedUsers = new Map();
+    this.nondismissible = false;
+    this.cuePrefName = '';
+    this.loginUrl = '';
+    this.hidden = this._shouldBeHidden(this.signedIn, this.prefsLoaded,
+        this.cuePrefName, this.message);
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      issue: {type: Object},
+      referencedUsers: {type: Object},
+      user: {type: Object},
+      cuePrefName: {type: String},
+      nondismissible: {type: Boolean},
+      prefs: {type: Object},
+      prefsLoaded: {type: Boolean},
+      jumpLocalId: {type: Number},
+      loginUrl: {type: String},
+      hidden: {
+        type: Boolean,
+        reflect: true,
+      },
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return [SHARED_STYLES, css`
+      :host {
+        display: block;
+        margin: 2px 0;
+        padding: 2px 4px 2px 8px;
+        background: var(--chops-notice-bubble-bg);
+        border: var(--chops-notice-border);
+        text-align: center;
+      }
+      :host([centered]) {
+        display: flex;
+        justify-content: center;
+      }
+      :host([hidden]) {
+        display: none;
+      }
+      button[hidden] {
+        visibility: hidden;
+      }
+      i.material-icons {
+        font-size: 14px;
+      }
+      button {
+        background: none;
+        border: none;
+        float: right;
+        padding: 2px;
+        cursor: pointer;
+        border-radius: 50%;
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+      }
+      button:hover {
+        background: rgba(0, 0, 0, .2);
+      }
+    `];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <button
+        @click=${this.dismiss}
+        title="Don't show this message again."
+        ?hidden=${this.nondismissible}>
+        <i class="material-icons">close</i>
+      </button>
+      <div id="message">${this.message}</div>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult} lit-html template for the cue message a user
+   * should see.
+   */
+  get message() {
+    if (this.cuePrefName === cueNames.CODE_OF_CONDUCT) {
+      return html`
+        Please keep discussions respectful and constructive.
+        See our
+        <a href="${this.codeOfConductUrl}"
+           target="_blank">code of conduct</a>.
+        `;
+    } else if (this.cuePrefName === cueNames.AVAILABILITY_MSGS) {
+      if (this._availablityMsgsRelevant(this.issue)) {
+        return html`
+          <b>Note:</b>
+          Clock icons indicate that users may not be available.
+          Tooltips show the reason.
+          `;
+      }
+    } else if (this.cuePrefName === cueNames.SWITCH_TO_PARENT_ACCOUNT) {
+      if (this._switchToParentAccountRelevant()) {
+        return html`
+          You are signed in to a linked account.
+          <a href="${this.loginUrl}">
+             Switch to ${this.user.linkedParentRef.displayName}</a>.
+          `;
+      }
+    } else if (this.cuePrefName === cueNames.SEARCH_FOR_NUMBERS) {
+      if (this._searchForNumbersRelevant(this.jumpLocalId)) {
+        return html`
+          <b>Tip:</b>
+          To find issues containing "${this.jumpLocalId}", use quotes.
+          `;
+      }
+    }
+    return;
+  }
+
+  /**
+  * Conditionally returns a hardcoded code of conduct URL for
+  * different projects.
+  * @return {string} the URL for the code of conduct.
+   */
+  get codeOfConductUrl() {
+    // TODO(jrobbins): Store this in the DB and pass it via the API.
+    if (this.projectName === 'fuchsia') {
+      return 'https://fuchsia.dev/fuchsia-src/CODE_OF_CONDUCT';
+    }
+    return ('https://chromium.googlesource.com/' +
+            'chromium/src/+/main/CODE_OF_CONDUCT.md');
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    const hiddenWatchProps = ['prefsLoaded', 'cuePrefName', 'signedIn',
+      'prefs'];
+    const shouldUpdateHidden = Array.from(changedProperties.keys())
+        .some((propName) => hiddenWatchProps.includes(propName));
+    if (shouldUpdateHidden) {
+      this.hidden = this._shouldBeHidden(this.signedIn, this.prefsLoaded,
+          this.cuePrefName, this.message);
+    }
+  }
+
+  /**
+   * Checks if there are any unavailable users and only displays this cue if so.
+   * @param {Issue} issue
+   * @return {boolean} Whether the User Availability cue should be
+   *   displayed or not.
+   */
+  _availablityMsgsRelevant(issue) {
+    if (!issue) return false;
+    return (this._anyUnvailable([issue.ownerRef]) ||
+            this._anyUnvailable(issue.ccRefs));
+  }
+
+  /**
+   * Checks if a given list of users contains any unavailable users.
+   * @param {Array<UserRef>} userRefList
+   * @return {boolean} Whether there are unavailable users.
+   */
+  _anyUnvailable(userRefList) {
+    if (!userRefList) return false;
+    for (const userRef of userRefList) {
+      if (userRef) {
+        const participant = this.referencedUsers.get(userRef.displayName);
+        if (participant && participant.availability) return true;
+      }
+    }
+  }
+
+  /**
+   * Finds if the user has a linked parent account that's separate from the
+   * one they are logged into and conditionally hides the cue if so.
+   * @return {boolean} Whether to show the cue to switch to a parent account.
+   */
+  _switchToParentAccountRelevant() {
+    return this.user && this.user.linkedParentRef;
+  }
+
+  /**
+   * Determines whether the user should see a cue telling them how to avoid the
+   * "jump to issue" feature.
+   * @param {number} jumpLocalId the ID of the issue the user jumped to.
+   * @return {boolean} Whether the user jumped to a number or not.
+   */
+  _searchForNumbersRelevant(jumpLocalId) {
+    return !!jumpLocalId;
+  }
+
+  /**
+   * Checks the user's preferences to hide a particular cue if they have
+   * dismissed it.
+   * @param {boolean} signedIn Whether the user is signed in.
+   * @param {boolean} prefsLoaded Whether the user's prefs have been fetched
+   *   from the API.
+   * @param {string} cuePrefName The name of the cue being checked.
+   * @param {string} message
+   * @return {boolean} Whether the cue should be hidden.
+   */
+  _shouldBeHidden(signedIn, prefsLoaded, cuePrefName, message) {
+    if (signedIn && !prefsLoaded) return true;
+    if (this.alreadyDismissed(cuePrefName)) return true;
+    return !message;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.projectName = projectV0.viewedProjectName(state);
+    this.issue = issueV0.viewedIssue(state);
+    this.referencedUsers = issueV0.referencedUsers(state);
+    this.user = userV0.currentUser(state);
+    this.prefs = userV0.prefs(state);
+    this.signedIn = this.user && this.user.userId;
+    this.prefsLoaded = userV0.currentUser(state).prefsLoaded;
+
+    const queryString = window.location.search.substring(1);
+    const queryParams = qs.parse(queryString);
+    const q = queryParams.q;
+    if (q && q.match(new RegExp('^\\d+$'))) {
+      this.jumpLocalId = Number(q);
+    }
+  }
+
+  /**
+   * Check whether a cue has already been dismissed in a user's
+   * preferences.
+   * @param {string} pref The name of the user preference to check.
+   * @return {boolean} Whether the cue was dismissed or not.
+   */
+  alreadyDismissed(pref) {
+    return this.prefs && this.prefs.get(pref);
+  }
+
+  /**
+   * Sends a request to the API to save that a user has dismissed a cue.
+   * The results of this request update Redux's state, which leads to
+   * the cue disappearing for the user after the request finishes.
+   * @return {void}
+   */
+  dismiss() {
+    const newPrefs = [{name: this.cuePrefName, value: 'true'}];
+    store.dispatch(userV0.setPrefs(newPrefs, this.signedIn));
+  }
+}
+
+customElements.define('mr-cue', MrCue);
diff --git a/static_src/elements/help/mr-cue/mr-cue.test.js b/static_src/elements/help/mr-cue/mr-cue.test.js
new file mode 100644
index 0000000..2722076
--- /dev/null
+++ b/static_src/elements/help/mr-cue/mr-cue.test.js
@@ -0,0 +1,177 @@
+// 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 {MrCue} from './mr-cue.js';
+import page from 'page';
+import {rootReducer} from 'reducers/base.js';
+
+let element;
+
+describe('mr-cue', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-cue');
+    document.body.appendChild(element);
+
+    sinon.stub(page, 'call');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+
+    page.call.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrCue);
+  });
+
+  it('stateChanged', () => {
+    const state = rootReducer({
+      userV0: {currentUser: {prefs: new Map(), prefsLoaded: false}},
+    }, {});
+    element.stateChanged(state);
+    assert.deepEqual(element.prefs, new Map([['render_markdown', false]]));
+    assert.isFalse(element.prefsLoaded);
+  });
+
+  it('cues are hidden before prefs load', () => {
+    element.prefsLoaded = false;
+    assert.isTrue(element.hidden);
+  });
+
+  it('cue is hidden if user already dismissed it', () => {
+    element.prefsLoaded = true;
+    element.cuePrefName = 'code_of_conduct';
+    element.prefs = new Map([['code_of_conduct', true]]);
+    assert.isTrue(element.hidden);
+  });
+
+  it('cue is hidden if no relevent message', () => {
+    element.prefsLoaded = true;
+    element.cuePrefName = 'this_has_no_message';
+    assert.isTrue(element.hidden);
+  });
+
+  it('cue is shown if relevant message has not been dismissed', async () => {
+    element.prefsLoaded = true;
+    element.cuePrefName = 'code_of_conduct';
+
+    await element.updateComplete;
+
+    assert.isFalse(element.hidden);
+    const messageEl = element.shadowRoot.querySelector('#message');
+    assert.include(messageEl.innerHTML, 'chromium.googlesource.com');
+  });
+
+  it('code of conduct is specific to the project', async () => {
+    element.prefsLoaded = true;
+    element.cuePrefName = 'code_of_conduct';
+    element.projectName = 'fuchsia';
+
+    await element.updateComplete;
+
+    assert.isFalse(element.hidden);
+    const messageEl = element.shadowRoot.querySelector('#message');
+    assert.include(messageEl.innerHTML, 'fuchsia.dev');
+  });
+
+  it('availability cue is hidden if no relevent issue particpants', () => {
+    element.prefsLoaded = true;
+    element.cuePrefName = 'availability_msgs';
+    element.issue = {summary: 'no owners or cc'};
+    assert.isTrue(element.hidden);
+
+    element.issue = {
+      summary: 'owner and ccs have no availability msg',
+      ownerRef: {},
+      ccRefs: [{}, {}],
+    };
+    assert.isTrue(element.hidden);
+  });
+
+  it('availability cue is shown if issue particpants are unavailable',
+      async () => {
+        element.prefsLoaded = true;
+        element.cuePrefName = 'availability_msgs';
+        element.referencedUsers = new Map([
+          ['user@example.com', {availability: 'Never visited'}],
+        ]);
+
+        element.issue = {
+          summary: 'owner is unavailable',
+          ownerRef: {displayName: 'user@example.com'},
+          ccRefs: [{}, {}],
+        };
+        await element.updateComplete;
+
+        assert.isFalse(element.hidden);
+        const messageEl = element.shadowRoot.querySelector('#message');
+        assert.include(messageEl.innerText, 'Clock icons');
+
+        element.issue = {
+          summary: 'owner is unavailable',
+          ownerRef: {},
+          ccRefs: [
+            {displayName: 'ok@example.com'},
+            {displayName: 'user@example.com'}],
+        };
+        await element.updateComplete;
+        assert.isFalse(element.hidden);
+        assert.include(messageEl.innerText, 'Clock icons');
+      });
+
+  it('switch_to_parent_account cue is hidden if no linked account', () => {
+    element.prefsLoaded = true;
+    element.cuePrefName = 'switch_to_parent_account';
+
+    element.user = undefined;
+    assert.isTrue(element.hidden);
+
+    element.user = {groups: []};
+    assert.isTrue(element.hidden);
+  });
+
+  it('switch_to_parent_account is shown if user has parent account',
+      async () => {
+        element.prefsLoaded = true;
+        element.cuePrefName = 'switch_to_parent_account';
+        element.user = {linkedParentRef: {displayName: 'parent@example.com'}};
+
+        await element.updateComplete;
+        assert.isFalse(element.hidden);
+        const messageEl = element.shadowRoot.querySelector('#message');
+        assert.include(messageEl.innerText, 'a linked account');
+      });
+
+  it('search_for_numbers cue is hidden if no number was used', () => {
+    element.prefsLoaded = true;
+    element.cuePrefName = 'search_for_numbers';
+    element.issue = {};
+    element.jumpLocalId = null;
+    assert.isTrue(element.hidden);
+  });
+
+  it('search_for_numbers cue is shown if jumped to issue ID',
+      async () => {
+        element.prefsLoaded = true;
+        element.cuePrefName = 'search_for_numbers';
+        element.issue = {};
+        element.jumpLocalId = '123'.match(new RegExp('^\\d+$'));
+
+        await element.updateComplete;
+        assert.isFalse(element.hidden);
+        const messageEl = element.shadowRoot.querySelector('#message');
+        assert.include(messageEl.innerText, 'use quotes');
+      });
+
+  it('cue is dismissible unless there is attribute nondismissible',
+      async () => {
+        assert.isFalse(element.nondismissible);
+
+        element.setAttribute('nondismissible', '');
+        await element.updateComplete;
+        assert.isTrue(element.nondismissible);
+      });
+});
diff --git a/static_src/elements/help/mr-cue/mr-fed-ref-cue.js b/static_src/elements/help/mr-cue/mr-fed-ref-cue.js
new file mode 100644
index 0000000..8e8626f
--- /dev/null
+++ b/static_src/elements/help/mr-cue/mr-fed-ref-cue.js
@@ -0,0 +1,83 @@
+// 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 {html, css} from 'lit-element';
+import * as userV0 from 'reducers/userV0.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {store} from 'reducers/base.js';
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {fromShortlink, GoogleIssueTrackerIssue} from 'shared/federated.js';
+import {MrCue} from './mr-cue.js';
+
+/**
+ * `<mr-fed-ref-cue>`
+ *
+ * Displays information and login/logout links for the federated references
+ * info popup.
+ *
+ */
+export class MrFedRefCue extends MrCue {
+  /** @override */
+  static get properties() {
+    return {
+      ...MrCue.properties,
+      fedRefShortlink: {type: String},
+    };
+  }
+
+  /** @override */
+  static get styles() {
+    return [
+      ...MrCue.styles,
+      css`
+        :host {
+          margin: 0;
+          width: 120px;
+          font-size: 11px;
+        }
+      `,
+    ];
+  }
+
+  get message() {
+    const fedRef = fromShortlink(this.fedRefShortlink);
+    if (fedRef && fedRef instanceof GoogleIssueTrackerIssue) {
+      let authLink;
+      if (this.user && this.user.gapiEmail) {
+        authLink = html`
+          <br /><br />
+          <a href="#"
+            @click=${() => store.dispatch(userV0.initGapiLogout())}
+          >Sign out</a>
+          <br />
+          (for references only)
+        `;
+      } else {
+        const clickLoginHandler = async () => {
+          await store.dispatch(userV0.initGapiLogin(this.issue));
+          // Re-fetch related issues.
+          store.dispatch(issueV0.fetchRelatedIssues(this.issue));
+        };
+        authLink = html`
+          <br /><br />
+          Googlers, to enable viewing status & title,
+          <a href="#"
+            @click=${clickLoginHandler}
+            >sign in here</a> with your Google email.
+        `;
+      }
+      return html`
+        This references an issue in the ${fedRef.trackerName} issue tracker.
+        ${authLink}
+      `;
+    } else {
+      return html`
+        This references an issue in another tracker. Status not displayed.
+      `;
+    }
+  }
+}
+
+customElements.define('mr-fed-ref-cue', MrFedRefCue);