Merge "Replace mentions with gr-account-labels"
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index bd01af9..da90b17 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -15,12 +15,15 @@
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
 import {configModelToken} from '../../../models/config/config-model';
-import {CommentLinks} from '../../../api/rest-api';
+import {CommentLinks, EmailAddress} from '../../../api/rest-api';
 import {
   applyHtmlRewritesFromConfig,
   applyLinkRewritesFromConfig,
   linkifyNormalUrls,
 } from '../../../utils/link-util';
+import '../gr-account-chip/gr-account-chip';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {getAppContext} from '../../../services/app-context';
 
 /**
  * This element optionally renders markdown and also applies some regex
@@ -37,6 +40,8 @@
   @state()
   private repoCommentLinks: CommentLinks = {};
 
+  private readonly flagsService = getAppContext().flagsService;
+
   private readonly getConfigModel = resolve(this, configModelToken);
 
   /**
@@ -91,6 +96,9 @@
       li {
         margin-left: var(--spacing-xl);
       }
+      gr-account-chip {
+        display: inline;
+      }
       .plaintext {
         font: inherit;
         white-space: var(--linked-text-white-space, pre-wrap);
@@ -200,6 +208,36 @@
 
     return text;
   }
+
+  override updated() {
+    // Look for @mentions and replace them with an account-label chip.
+    if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
+      this.convertEmailsToAccountChips();
+    }
+  }
+
+  private convertEmailsToAccountChips() {
+    for (const emailLink of this.renderRoot.querySelectorAll(
+      'a[href^="mailto"]'
+    )) {
+      const previous = emailLink.previousSibling;
+      // This Regexp matches the beginning of the MENTIONS_REGEX at the end of
+      // an element.
+      if (
+        previous?.nodeName === '#text' &&
+        previous?.textContent?.match(/(^|\s)@$/)
+      ) {
+        const accountChip = document.createElement('gr-account-chip');
+        accountChip.account = {
+          email: emailLink.textContent as EmailAddress,
+        };
+        accountChip.removable = false;
+        // Remove the trailing @ from the previous element.
+        previous.textContent = previous.textContent.slice(0, -1);
+        emailLink.parentNode?.replaceChild(accountChip, emailLink);
+      }
+    }
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index dcd13bd..f3c9d9c 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -15,9 +15,15 @@
 import './gr-formatted-text';
 import {GrFormattedText} from './gr-formatted-text';
 import {createConfig} from '../../../test/test-data-generators';
-import {waitUntilObserved} from '../../../test/test-utils';
-import {CommentLinks} from '../../../api/rest-api';
+import {
+  queryAndAssert,
+  stubFlags,
+  waitUntilObserved,
+} from '../../../test/test-utils';
+import {CommentLinks, EmailAddress} from '../../../api/rest-api';
 import {testResolver} from '../../../test/common-test-setup';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
 
 suite('gr-formatted-text tests', () => {
   let element: GrFormattedText;
@@ -235,6 +241,7 @@
         `
       );
     });
+
     test('renders multiline-code without linking or rewriting', async () => {
       element.content = `\`\`\`\nmultiline code\n\`\`\`
         \n\`\`\`\nmultiline code with plain link: google.com\n\`\`\`
@@ -281,6 +288,79 @@
       );
     });
 
+    test('does not handle @mentions if not enabled', async () => {
+      stubFlags('isEnabled')
+        .withArgs(KnownExperimentId.MENTION_USERS)
+        .returns(false);
+      element.content = '@someone@google.com';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>
+                @
+                <a href="mailto:someone@google.com"> someone@google.com </a>
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
+
+    test('handles @mentions if enabled', async () => {
+      stubFlags('isEnabled')
+        .withArgs(KnownExperimentId.MENTION_USERS)
+        .returns(true);
+      element.content = '@someone@google.com';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>
+                <gr-account-chip></gr-account-chip>
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+      const accountChip = queryAndAssert<GrAccountChip>(
+        element,
+        'gr-account-chip'
+      );
+      assert.equal(
+        accountChip.account?.email,
+        'someone@google.com' as EmailAddress
+      );
+    });
+
+    test('does not handle @mentions that is part of a code block', async () => {
+      stubFlags('isEnabled')
+        .withArgs(KnownExperimentId.MENTION_USERS)
+        .returns(true);
+      element.content = '`@`someone@google.com';
+      await element.updateComplete;
+
+      assert.shadowDom.equal(
+        element,
+        /* HTML */ `
+          <marked-element>
+            <div slot="markdown-html">
+              <p>
+                <code>@</code>
+                <a href="mailto:someone@google.com"> someone@google.com </a>
+              </p>
+            </div>
+          </marked-element>
+        `
+      );
+    });
+
     test('renders inline links into <a> tags', async () => {
       element.content = '[myLink](https://www.google.com)';
       await element.updateComplete;
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 207152c..a7d0587 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -29,7 +29,7 @@
 const SUGGESTIONS_LIMIT = 15;
 // https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
 export const MENTIONS_REGEX =
-  /(?:^|\s)@([a-zA-Z0-9.!#$%&'*+=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)(?=\s+|$)/g;
+  /(?<=^|\s)@([a-zA-Z0-9.!#$%&'*+=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)(?=\s+|$)/g;
 
 export function accountKey(account: AccountInfo): AccountId | EmailAddress {
   if (account._account_id !== undefined) return account._account_id;