Repository: hypothesis/client
Pull request #6952: Do not try to match usernames when mathing display-name mentions
Author: login: acelaya
Labels: name: project: @mentions
Created: 2025-04-04T14:02:05Z | Merged: 2025-04-07T08:16:26Z

This PR ensures we do not try to match partial mentions against usernames in the list of mention suggestions, when the mention mode is `display-name` (LMS).

This avoids potentially incorrect suggestions due to some auto-generated username accidentally matching what the user has typed.

It's something relatively unlikely to happen, but makes sense to have this for consistency and correctness.


Diff:
diff --git a/src/sidebar/components/MarkdownEditor.tsx b/src/sidebar/components/MarkdownEditor.tsx
index 7261f3ee056..7810bce3b24 100644
--- a/src/sidebar/components/MarkdownEditor.tsx
+++ b/src/sidebar/components/MarkdownEditor.tsx
@@ -238,9 +238,9 @@ function TextArea({
   const userSuggestions = useMemo(
     () =>
       mentionsEnabled
-        ? usersMatchingMention(activeMention, usersForMentions)
+        ? usersMatchingMention(activeMention, usersForMentions, { mentionMode })
         : [],
-    [activeMention, mentionsEnabled, usersForMentions],
+    [activeMention, mentionMode, mentionsEnabled, usersForMentions],
   );
 
   const checkForMentionAtCaret = useCallback(
diff --git a/src/sidebar/helpers/mention-suggestions.ts b/src/sidebar/helpers/mention-suggestions.ts
index 3bbf2a250bd..cd145b2eb87 100644
--- a/src/sidebar/helpers/mention-suggestions.ts
+++ b/src/sidebar/helpers/mention-suggestions.ts
@@ -83,6 +83,7 @@ export function combineUsersForMentions({
 }
 
 export type UsersMatchingMentionOptions = {
+  mentionMode: MentionMode;
   /** Maximum amount of users to return. Defaults to 10 */
   maxUsers?: number;
 };
@@ -93,21 +94,27 @@ export type UsersMatchingMentionOptions = {
 export function usersMatchingMention(
   mentionToMatch: string | undefined,
   usersForMentions: UsersForMentions,
-  options?: UsersMatchingMentionOptions,
+  { mentionMode, maxUsers = 10 }: UsersMatchingMentionOptions,
 ): UserItem[] {
   if (mentionToMatch === undefined || usersForMentions.status === 'loading') {
     return [];
   }
 
   return usersForMentions.users
-    .filter(
-      u =>
+    .filter(u => {
+      if (!mentionToMatch) {
         // Match all users if the active mention is empty, which happens right
         // after typing `@`
-        !mentionToMatch ||
-        `${u.username} ${u.displayName ?? ''}`
-          .toLowerCase()
-          .match(mentionToMatch.toLowerCase()),
-    )
-    .slice(0, options?.maxUsers ?? 10);
+        return true;
+      }
+
+      const displayName = u.displayName ?? '';
+      const contentToMatch =
+        mentionMode === 'username'
+          ? `${u.username} ${displayName}`
+          : displayName;
+
+      return contentToMatch.match(new RegExp(mentionToMatch, 'i'));
+    })
+    .slice(0, maxUsers);
 }
diff --git a/src/sidebar/helpers/test/mention-suggestions-test.js b/src/sidebar/helpers/test/mention-suggestions-test.js
index 133d46de206..74b589cc5df 100644
--- a/src/sidebar/helpers/test/mention-suggestions-test.js
+++ b/src/sidebar/helpers/test/mention-suggestions-test.js
@@ -194,11 +194,13 @@ describe('usersMatchingMention', () => {
     {
       usersForMentions: { status: 'loaded', users: [] },
       mention: '',
+      options: { mentionMode: 'username' },
       expectedSuggestions: [],
     },
     {
       usersForMentions: { status: 'loading' },
       mention: '',
+      options: { mentionMode: 'username' },
       expectedSuggestions: [],
     },
 
@@ -226,6 +228,7 @@ describe('usersMatchingMention', () => {
         ],
       },
       mention: 'nothing_will_match',
+      options: { mentionMode: 'username' },
       expectedSuggestions: [],
     },
 
@@ -252,6 +255,7 @@ describe('usersMatchingMention', () => {
         ],
       },
       mention: undefined,
+      options: { mentionMode: 'username' },
       expectedSuggestions: [],
     },
 
@@ -279,6 +283,7 @@ describe('usersMatchingMention', () => {
         ],
       },
       mention: 'two',
+      options: { mentionMode: 'username' },
       expectedSuggestions: [
         {
           userid: 'acct:two@example.com',
@@ -287,6 +292,8 @@ describe('usersMatchingMention', () => {
         },
       ],
     },
+
+    // Matching is case-insensitive
     {
       usersForMentions: {
         status: 'loaded',
@@ -308,7 +315,8 @@ describe('usersMatchingMention', () => {
           },
         ],
       },
-      mention: 'johndoe',
+      mention: 'JohnDoe',
+      options: { mentionMode: 'username' },
       expectedSuggestions: [
         {
           userid: 'acct:one@example.com',
@@ -351,6 +359,7 @@ describe('usersMatchingMention', () => {
         ],
       },
       mention: '',
+      options: { mentionMode: 'username' },
       expectedSuggestions: [
         {
           userid: 'acct:one@example.com',
@@ -393,7 +402,7 @@ describe('usersMatchingMention', () => {
         ],
       },
       mention: 'johndoe',
-      options: { maxUsers: 2 },
+      options: { maxUsers: 2, mentionMode: 'username' },
       expectedSuggestions: [
         {
           userid: 'acct:one@example.com',
@@ -407,7 +416,45 @@ describe('usersMatchingMention', () => {
         },
       ],
     },
-  ].forEach(({ usersForMentions, mention, expectedSuggestions, options }) => {
+
+    // Matching in display-name mode ignores usernames
+    {
+      usersForMentions: {
+        status: 'loaded',
+        users: [
+          {
+            userid: 'acct:one@example.com',
+            username: 'foo', // This won't match
+            displayName: 'johndoe',
+          },
+          {
+            userid: 'acct:two@example.com',
+            username: 'two',
+            displayName: 'Foo One',
+          },
+          {
+            userid: 'acct:three@example.com',
+            username: 'jane',
+            displayName: 'Jane "foo" Doe',
+          },
+        ],
+      },
+      mention: 'foo',
+      options: { mentionMode: 'display-name' },
+      expectedSuggestions: [
+        {
+          userid: 'acct:two@example.com',
+          username: 'two',
+          displayName: 'Foo One',
+        },
+        {
+          userid: 'acct:three@example.com',
+          username: 'jane',
+          displayName: 'Jane "foo" Doe',
+        },
+      ],
+    },
+  ].forEach(({ usersForMentions, mention, options, expectedSuggestions }) => {
     it('suggests expected users for mention', () => {
       assert.deepEqual(
         usersMatchingMention(mention, usersForMentions, options),


Reviews:
user: login: acelaya
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T14:03:28Z
user: login: acelaya
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T14:05:26Z
user: login: robertknight
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T14:50:58Z

Review comments:
diff_hunk: @@ -93,21 +94,27 @@ export type UsersMatchingMentionOptions = {
 export function usersMatchingMention(
   mentionToMatch: string | undefined,
   usersForMentions: UsersForMentions,
-  options?: UsersMatchingMentionOptions,
+  { mentionMode, maxUsers = 10 }: UsersMatchingMentionOptions,
 ): UserItem[] {
   if (mentionToMatch === undefined || usersForMentions.status === 'loading') {
     return [];
   }
 
   return usersForMentions.users
-    .filter(
-      u =>
+    .filter(u => {
+      if (!mentionToMatch) {
         // Match all users if the active mention is empty, which happens right
         // after typing `@`
-        !mentionToMatch ||
-        `${u.username} ${u.displayName ?? ''}`
-          .toLowerCase()
-          .match(mentionToMatch.toLowerCase()),
-    )
-    .slice(0, options?.maxUsers ?? 10);
+        return true;
+      }
+
+      const displayName = u.displayName ?? '';
+      const contentToMatch =
+        mentionMode === 'username'
+          ? `${u.username} ${displayName}`
+          : displayName;
+
+      return contentToMatch.toLowerCase().match(mentionToMatch.toLowerCase());
path: src/sidebar/helpers/mention-suggestions.ts
user: login: acelaya
body: Mhh, I need to check if `contentToMatch.match(new RegExp(mentionToMatch, 'i'))` is equivalent.
created_at: 2025-04-04T14:03:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -93,21 +94,27 @@ export type UsersMatchingMentionOptions = {
 export function usersMatchingMention(
   mentionToMatch: string | undefined,
   usersForMentions: UsersForMentions,
-  options?: UsersMatchingMentionOptions,
+  { mentionMode, maxUsers = 10 }: UsersMatchingMentionOptions,
 ): UserItem[] {
   if (mentionToMatch === undefined || usersForMentions.status === 'loading') {
     return [];
   }
 
   return usersForMentions.users
-    .filter(
-      u =>
+    .filter(u => {
+      if (!mentionToMatch) {
         // Match all users if the active mention is empty, which happens right
         // after typing `@`
-        !mentionToMatch ||
-        `${u.username} ${u.displayName ?? ''}`
-          .toLowerCase()
-          .match(mentionToMatch.toLowerCase()),
-    )
-    .slice(0, options?.maxUsers ?? 10);
+        return true;
+      }
+
+      const displayName = u.displayName ?? '';
+      const contentToMatch =
+        mentionMode === 'username'
+          ? `${u.username} ${displayName}`
+          : displayName;
+
+      return contentToMatch.toLowerCase().match(mentionToMatch.toLowerCase());
path: src/sidebar/helpers/mention-suggestions.ts
user: login: acelaya
body: Yep, it does. I'll push a couple more tests and change it.
created_at: 2025-04-04T14:05:26Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov[bot]
created_at: 2025-04-04T14:04:33Z
body: ## [Codecov](https://app.codecov.io/gh/hypothesis/client/pull/6952?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hypothesis) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 99.44%. Comparing base [(`1b4b747`)](https://app.codecov.io/gh/hypothesis/client/commit/1b4b7471b60f984b5bdc4e02dae35773d14d56ea?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hypothesis) to head [(`9143749`)](https://app.codecov.io/gh/hypothesis/client/commit/9143749f0348fc72f17607547a246bb0006e76c1?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hypothesis).
> Report is 1 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main    #6952   +/-   ##
=======================================
  Coverage   99.44%   99.44%           
=======================================
  Files         275      275           
  Lines       10782    10788    +6     
  Branches     2598     2600    +2     
=======================================
+ Hits        10722    10728    +6     
  Misses         60       60           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/hypothesis/client/pull/6952?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hypothesis).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=hypothesis).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: sentry[bot]
created_at: 2025-04-08T15:51:23Z
body: ## Suspect Issues
This pull request was deployed and Sentry observed the following issues:

- ‼️ **SyntaxError: Invalid regular expression: /\/i: \ at end of pattern** `new RegExp(<anonymous>)` [View Issue](https://hypothesis.sentry.io/issues/6529235849/?referrer=github-pr-bot)
- ‼️ **SyntaxError: Invalid regular expression: \ at end of pattern** `filter(src/sidebar/helpers/mention-suggestions)` [View Issue](https://hypothesis.sentry.io/issues/6532142919/?referrer=github-pr-bot)

<sub>Did you find this useful? React with a 👍 or 👎</sub>
author_association: NONE