Merge "Hide suggest edit button in permanent editing mode"
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 b079dec..04a9fea 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
@@ -4,6 +4,7 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import {BehaviorSubject} from 'rxjs';
+import '../gr-copy-links/gr-copy-links';
 import '@polymer/paper-tabs/paper-tabs';
 import '../../../styles/gr-a11y-styles';
 import '../../../styles/gr-paper-styles';
@@ -178,6 +179,9 @@
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
 import {filesModelToken} from '../../../models/change/files-model';
+import {getBaseUrl, prependOrigin} from '../../../utils/url-util';
+import {CopyLink, GrCopyLinks} from '../gr-copy-links/gr-copy-links';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
@@ -267,6 +271,8 @@
 
   @query('gr-thread-list') threadList?: GrThreadList;
 
+  @query('gr-copy-links') private copyLinksDropdown?: GrCopyLinks;
+
   /**
    * URL params passed from the router.
    * Use params getter/setter.
@@ -529,6 +535,8 @@
 
   readonly restApiService = getAppContext().restApiService;
 
+  private readonly flagsService = getAppContext().flagsService;
+
   // Private but used in tests.
   readonly userModel = getAppContext().userModel;
 
@@ -619,6 +627,7 @@
     // TODO: Do we still need docOnly bindings?
     this.shortcutsController.addAbstract(Shortcut.SEND_REPLY, () => {}); // docOnly
     this.shortcutsController.addAbstract(Shortcut.EMOJI_DROPDOWN, () => {}); // docOnly
+    this.shortcutsController.addAbstract(Shortcut.MENTIONS_DROPDOWN, () => {}); // docOnly
     this.shortcutsController.addAbstract(Shortcut.REFRESH_CHANGE, () =>
       fireReload(this, true)
     );
@@ -675,6 +684,10 @@
     this.shortcutsController.addAbstract(Shortcut.TOGGLE_ATTENTION_SET, () =>
       this.handleToggleAttentionSet()
     );
+    this.shortcutsController.addAbstract(
+      Shortcut.OPEN_COPY_LINKS_DROPDOWN,
+      () => this.copyLinksDropdown?.openDropdown()
+    );
   }
 
   private setupSubscriptions() {
@@ -891,6 +904,9 @@
         .changeCopyClipboard {
           margin-left: var(--spacing-s);
         }
+        .showCopyLinkDialogButton {
+          --gr-button-padding: var(--spacing-m) var(--spacing-xs);
+        }
         #replyBtn {
           margin-bottom: var(--spacing-m);
         }
@@ -1256,23 +1272,73 @@
         ?hidden=${!this.loggedIn}
       ></gr-change-star>
 
-      <a
-        class="changeNumber"
-        aria-label=${`Change ${this.change?._number}`}
-        href=${ifDefined(this.computeChangeUrl(true))}
-        >${this.change?._number}</a
-      >
-      <span class="changeNumberColon">:&nbsp;</span>
-      <span class="headerSubject">${this.change?.subject}</span>
-      <gr-copy-clipboard
-        class="changeCopyClipboard"
-        hideInput=""
-        text=${this.computeCopyTextForTitle()}
-      >
-      </gr-copy-clipboard>
+      ${when(
+        this.flagsService.isEnabled(KnownExperimentId.COPY_LINK_DIALOG),
+        () => html`<a
+            class="changeNumber"
+            aria-label=${`Change ${this.change?._number}`}
+            href=${ifDefined(this.computeChangeUrl(true))}
+            >${this.change?._number}</a
+          ><gr-button
+            flatten
+            down-arrow
+            class="showCopyLinkDialogButton"
+            @click=${() => this.copyLinksDropdown?.toggleDropdown()}
+          ></gr-button>
+          ${this.renderCopyLinksDropdown()}
+          <span class="headerSubject">${this.change?.subject}</span>`,
+        () => html`<a
+            class="changeNumber"
+            aria-label=${`Change ${this.change?._number}`}
+            href=${ifDefined(this.computeChangeUrl(true))}
+            >${this.change?._number}</a
+          >
+          <span class="changeNumberColon">:&nbsp;</span>
+          <span class="headerSubject">${this.change?.subject}</span>
+          <gr-copy-clipboard
+            class="changeCopyClipboard"
+            hideInput=""
+            text=${this.computeCopyTextForTitle()}
+          >
+          </gr-copy-clipboard>`
+      )}
     </div>`;
   }
 
+  private renderCopyLinksDropdown() {
+    const url = this.computeChangeUrl();
+    if (!url) return;
+    const changeURL = prependOrigin(getBaseUrl() + url);
+    const links: CopyLink[] = [
+      {
+        label: 'Change Number',
+        shortcut: 'n',
+        value: `${this.change?._number}`,
+      },
+      {
+        label: 'Change URL',
+        shortcut: 'u',
+        value: changeURL,
+      },
+      {
+        label: 'Title and URL',
+        shortcut: 't',
+        value: `${this.change?._number}: ${this.change?.subject} | ${changeURL}`,
+      },
+      {
+        label: 'URL and title',
+        shortcut: 'r',
+        value: `${changeURL}: ${this.change?.subject}`,
+      },
+      {
+        label: 'Change-Id',
+        shortcut: 'd',
+        value: `${this.change?.id.split('~').pop()}`,
+      },
+    ];
+    return html`<gr-copy-links .copyLinks=${links}> </gr-copy-links>`;
+  }
+
   private renderCommitActions() {
     return html` <div class="commitActions">
       <!-- always show gr-change-actions regardless if logged in or not -->
diff --git a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts
new file mode 100644
index 0000000..4b76625
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts
@@ -0,0 +1,160 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import '@polymer/iron-dropdown/iron-dropdown';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import {LitElement, html, css, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {strToClassName} from '../../../utils/dom-util';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {copyToClipbard, queryAndAssert} from '../../../utils/common-util';
+import {ValueChangedEvent} from '../../../types/events';
+
+export interface CopyLink {
+  label: string;
+  shortcut: string;
+  value: string;
+}
+
+const AWAIT_MAX_ITERS = 10;
+const AWAIT_STEP = 5;
+
+@customElement('gr-copy-links')
+export class GrCopyLinks extends LitElement {
+  @property({type: Array})
+  copyLinks: CopyLink[] = [];
+
+  @state() isDropdownOpen = false;
+
+  @query('iron-dropdown') private dropdown?: IronDropdownElement;
+
+  static override get styles() {
+    return [
+      css`
+        iron-dropdown {
+          box-shadow: var(--elevation-level-2);
+          width: min(90vw, 540px);
+          background-color: var(--dialog-background-color);
+          border-radius: var(--border-radius);
+        }
+        [slot='dropdown-content'] {
+          padding: var(--spacing-m) var(--spacing-l) var(--spacing-m);
+        }
+        .copy-link-row {
+          margin-bottom: var(--spacing-m);
+          display: flex;
+          align-items: center;
+        }
+        .copy-link-row label {
+          flex: 0 0 120px;
+          color: var(--deemphasized-text-color);
+        }
+        .copy-link-row input {
+          width: 320px;
+        }
+        .copy-link-row .shortcut {
+          width: 25px;
+          margin: 0 var(--spacing-m);
+          color: var(--deemphasized-text-color);
+        }
+        .copy-link-row gr-copy-clipboard {
+          flex: 0 0 20px;
+        }
+        /* TODO(milutin): It's from shared styles, move it to input styles */
+        input {
+          background-color: var(--background-color-primary);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          box-sizing: border-box;
+          color: var(--primary-text-color);
+          margin: 0;
+          padding: var(--spacing-s);
+          font: inherit;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.copyLinks) return nothing;
+    return html`<iron-dropdown
+      .horizontalAlign=${'left'}
+      .verticalAlign=${'top'}
+      .verticalOffset=${24}
+      @keydown=${this.handleKeydown}
+      @opened-changed=${(e: ValueChangedEvent<boolean>) =>
+        (this.isDropdownOpen = e.detail.value)}
+    >
+      ${this.renderCopyLinks()}
+    </iron-dropdown>`;
+  }
+
+  private renderCopyLinks() {
+    return html`<div slot="dropdown-content">
+      ${this.copyLinks?.map(link => this.renderCopyLinkRow(link))}
+    </div>`;
+  }
+
+  private renderCopyLinkRow(copyLink: CopyLink) {
+    const {label, shortcut, value} = copyLink;
+    const id = `${strToClassName(label, '')}-field`;
+    // TODO(milutin): Use input in gr-copy-clipboard instead of creating new
+    // one. Move shorcut to gr-copy-clipboard.
+    return html`<div class="copy-link-row">
+      <label for=${id}>${label}</label
+      ><input type="text" readonly="" id=${id} class="input" .value=${value} />
+      <span class="shortcut">${`l - ${shortcut}`}</span>
+      <gr-copy-clipboard hideInput="" text=${value}></gr-copy-clipboard>
+    </div>`;
+  }
+
+  private async handleKeydown(e: KeyboardEvent) {
+    const copyLink = this.copyLinks?.find(link => link.shortcut === e.key);
+    if (!copyLink) return;
+    await copyToClipbard(copyLink.value, copyLink.label);
+    this.closeDropdown();
+  }
+
+  toggleDropdown() {
+    this.isDropdownOpen ? this.closeDropdown() : this.openDropdown();
+  }
+
+  private closeDropdown() {
+    this.dropdown?.close();
+  }
+
+  openDropdown() {
+    this.dropdown?.open();
+    this.awaitOpen(() => {
+      queryAndAssert<HTMLInputElement>(this.dropdown, 'input')?.select();
+    });
+  }
+
+  /**
+   * NOTE: (milutin) Slightly hacky way to listen to the overlay actually
+   * opening. It's from gr-editable-label. It will be removed when we
+   * migrate out of iron-* components.
+   */
+  private awaitOpen(fn: () => void) {
+    let iters = 0;
+    const step = () => {
+      setTimeout(() => {
+        if (this.dropdown?.style.display !== 'none') {
+          fn.call(this);
+        } else if (iters++ < AWAIT_MAX_ITERS) {
+          step.call(this);
+        }
+      }, AWAIT_STEP);
+    };
+    step.call(this);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-copy-links': GrCopyLinks;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts
new file mode 100644
index 0000000..f184acd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links_test.ts
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup-karma';
+import {fixture, html, assert} from '@open-wc/testing';
+import './gr-copy-links';
+import {GrCopyLinks} from './gr-copy-links';
+import {pressKey, queryAndAssert, waitUntil} from '../../../test/test-utils';
+import {IronDropdownElement} from '@polymer/iron-dropdown';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrCopyClipboard} from '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+
+suite('gr-copy-links tests', () => {
+  let element: GrCopyLinks;
+  setup(async () => {
+    const links = [
+      {
+        label: 'Change ID',
+        shortcut: 'd',
+        value: '123456',
+      },
+    ];
+    element = await fixture<GrCopyLinks>(
+      html`<gr-copy-links .copyLinks=${links}></gr-copy-links>`
+    );
+    await element.updateComplete;
+    element.openDropdown();
+    await waitUntil(() => element.isDropdownOpen);
+    await element.updateComplete;
+  });
+
+  test('renders', () => {
+    assert.shadowDom.equal(
+      element,
+      /* HTML */ `<iron-dropdown
+        aria-disabled="false"
+        horizontal-align="left"
+        vertical-align="top"
+      >
+      <div slot="dropdown-content">
+          <div class="copy-link-row">
+            <label for="Change_ID-field">Change ID</label>
+            <input
+              class="input"
+              id="Change_ID-field"
+              readonly=""
+              type="text"
+            >
+            <span class="shortcut">l - d</span>
+            <gr-copy-clipboard hideinput="" text="123456">
+            </gr-copy-clipboard>
+          </div>
+      </iron-dropdown>`,
+      {
+        // iron-dropdown sizing seems to vary between local & CI
+        ignoreAttributes: [{tags: ['iron-dropdown'], attributes: ['style']}],
+      }
+    );
+  });
+
+  test('click writes to clipboard', () => {
+    const clipboardStub = sinon.stub(navigator.clipboard, 'writeText');
+    const copyClipboard = queryAndAssert<GrCopyClipboard>(
+      element,
+      'gr-copy-clipboard'
+    );
+    const copyBtn = queryAndAssert<GrButton>(copyClipboard, '.copyToClipboard');
+    copyBtn.click();
+    assert.isTrue(clipboardStub.called);
+    assert.isTrue(clipboardStub.calledWith('123456'));
+  });
+
+  test('shorcuts writes to clipboard', () => {
+    const clipboardStub = sinon.stub(window.navigator.clipboard, 'writeText');
+    const ironDropdown = queryAndAssert<IronDropdownElement>(
+      element,
+      'iron-dropdown'
+    );
+    pressKey(ironDropdown, 'd');
+    assert.isTrue(clipboardStub.called);
+    assert.isTrue(clipboardStub.calledWith('123456'));
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index 5758ef2..6a1e451 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -8,9 +8,13 @@
 import {ChangeInfo, DownloadInfo, PatchSetNum} from '../../../types/common';
 import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
 import {GrButton} from '../../shared/gr-button/gr-button';
-import {hasOwnProperty, queryAndAssert} from '../../../utils/common-util';
+import {
+  copyToClipbard,
+  hasOwnProperty,
+  queryAndAssert,
+} from '../../../utils/common-util';
 import {GrOverlayStops} from '../../shared/gr-overlay/gr-overlay';
-import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {fireEvent} from '../../../utils/event-util';
 import {addShortcut} from '../../../utils/dom-util';
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -227,14 +231,15 @@
     return [];
   }
 
-  private handleNumberKey(e: KeyboardEvent) {
+  private async handleNumberKey(e: KeyboardEvent) {
     const index = Number(e.key) - 1;
     const commands = this.computeDownloadCommands();
     if (index > commands.length) return;
-    navigator.clipboard.writeText(commands[index].command).then(() => {
-      fireAlert(this, `${commands[index].title} command copied to clipboard`);
-      fireEvent(this, 'close');
-    });
+    await copyToClipbard(
+      commands[index].command,
+      `${commands[index].title} command`
+    );
+    fireEvent(this, 'close');
   }
 
   override focus() {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 5b0b83b..2773675 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -993,6 +993,7 @@
         @comment-text-changed=${(e: ValueChangedEvent<string>) => {
           this.patchsetLevelDraftMessage = e.detail.value;
         }}
+        .messagePlaceholder=${this.messagePlaceholder}
         hide-header
         permanent-editing-mode
       ></gr-comment>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 8f23070..6dc33ce 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -52,8 +52,8 @@
 import {GrButton} from '../gr-button/gr-button';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffLayer, RenderPreferences} from '../../../api/diff';
-import {assertIsDefined} from '../../../utils/common-util';
-import {fire, fireAlert} from '../../../utils/event-util';
+import {assertIsDefined, copyToClipbard} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
 import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
 import {anyLineTooLong} from '../../../embed/diff/gr-diff/gr-diff-utils';
@@ -777,9 +777,7 @@
       GerritNav.getUrlForCommentsTab(this.changeNum, this.repoName, comment.id)
     );
     assertIsDefined(url, 'url for comment');
-    navigator.clipboard.writeText(generateAbsoluteUrl(url)).then(() => {
-      fireAlert(this, 'Link copied to clipboard');
-    });
+    copyToClipbard(generateAbsoluteUrl(url), 'Link');
   }
 
   private getDisplayPath() {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index a552e3d..72721c3 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -164,6 +164,9 @@
   @property({type: Boolean, attribute: 'robot-button-disabled'})
   robotButtonDisabled = false;
 
+  @property({type: String})
+  messagePlaceholder?: string;
+
   /* private, but used in css rules */
   @property({type: Boolean, reflect: true})
   saving = false;
@@ -679,6 +682,7 @@
         code=""
         ?disabled=${this.saving}
         rows="4"
+        .placeholder=${this.messagePlaceholder}
         text=${this.messageText}
         @text-changed=${(e: ValueChangedEvent) => {
           // TODO: This is causing a re-render of <gr-comment> on every key
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index f870278..0e9b874 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -6,7 +6,11 @@
 import '@polymer/iron-input/iron-input';
 import '../gr-button/gr-button';
 import '../gr-icon/gr-icon';
-import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
+import {
+  assertIsDefined,
+  copyToClipbard,
+  queryAndAssert,
+} from '../../../utils/common-util';
 import {classMap} from 'lit/directives/class-map.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {LitElement, css, html} from 'lit';
@@ -58,6 +62,8 @@
           font-size: var(--font-size-mono);
           line-height: var(--line-height-mono);
           width: 100%;
+          background-color: var(--view-background-color);
+          color: var(--primary-text-color);
         }
         gr-icon {
           color: var(--deemphasized-text-color);
@@ -127,7 +133,7 @@
     this.text = queryAndAssert<HTMLInputElement>(this, '#input').value;
     assertIsDefined(this.text, 'text');
     this.iconEl.icon = 'check';
-    navigator.clipboard.writeText(this.text);
+    copyToClipbard(this.text, 'Link');
     setTimeout(() => (this.iconEl.icon = 'content_copy'), COPY_TIMEOUT_MS);
   }
 }
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 9ca2cef..25c057e 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -25,4 +25,5 @@
   PATCHSET_LEVEL_COMMENT_USES_GRCOMMENT = 'UiFeature__patchset_level_comment_uses_GrComment',
   RENDER_MARKDOWN = 'UiFeature__render_markdown',
   AUTO_APP_THEME = 'UiFeature__auto_app_theme',
+  COPY_LINK_DIALOG = 'UiFeature__copy_link_dialog',
 }
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
index 2996e99..a7f4d19 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
@@ -49,6 +49,7 @@
 
   OPEN_REPLY_DIALOG = 'OPEN_REPLY_DIALOG',
   OPEN_DOWNLOAD_DIALOG = 'OPEN_DOWNLOAD_DIALOG',
+  OPEN_COPY_LINKS_DROPDOWN = 'OPEN_COPY_LINKS_DROPDOWN',
   EXPAND_ALL_MESSAGES = 'EXPAND_ALL_MESSAGES',
   COLLAPSE_ALL_MESSAGES = 'COLLAPSE_ALL_MESSAGES',
   UP_TO_DASHBOARD = 'UP_TO_DASHBOARD',
@@ -100,6 +101,7 @@
   SEARCH = 'SEARCH',
   SEND_REPLY = 'SEND_REPLY',
   EMOJI_DROPDOWN = 'EMOJI_DROPDOWN',
+  MENTIONS_DROPDOWN = 'MENTIONS_DROPDOWN',
   TOGGLE_BLAME = 'TOGGLE_BLAME',
 
   TOGGLE_CHECKBOX = 'TOGGLE_CHECKBOX',
@@ -222,6 +224,12 @@
     {key: 'd'}
   );
   describe(
+    Shortcut.OPEN_COPY_LINKS_DROPDOWN,
+    ShortcutSection.ACTIONS,
+    'Open link dialog',
+    {key: 'l'}
+  );
+  describe(
     Shortcut.EXPAND_ALL_MESSAGES,
     ShortcutSection.ACTIONS,
     'Expand all messages',
@@ -523,5 +531,11 @@
     'Emoji dropdown',
     {key: ':', docOnly: true}
   );
+  describe(
+    Shortcut.MENTIONS_DROPDOWN,
+    ShortcutSection.REPLY_DIALOG,
+    'Mentions dropdown',
+    {key: '@', docOnly: true}
+  );
   return config;
 }
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 0c99269..05c054b 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -4,6 +4,8 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
+import {fireAlert} from './event-util';
+
 /**
  * @fileoverview Functions in this file contains some widely used
  * code patterns. If you noticed a repeated code and none of the existing util
@@ -160,3 +162,8 @@
 ): T[] {
   return a.filter(aVal => !b.some(bVal => compareBy(aVal, bVal)));
 }
+
+export async function copyToClipbard(text: string, copyTargetName?: string) {
+  await navigator.clipboard.writeText(text);
+  fireAlert(document, `${copyTargetName ?? text} was copied to clipboard`);
+}