FIX: Show user filter hints when typing `@` in search (#13799)

FIX: Show user filter hints when typing @ in search (#13799)

Will show the last 6 seen users as filtering suggestions when typing @ in quick search. (Previously the user suggestion required a character after the @.)

This also adds a default limit of 6 to the user search query, previously the backend was returning 20 results but a maximum of 6 results was being shown anyway.

diff --git a/app/assets/javascripts/discourse/app/lib/user-search.js b/app/assets/javascripts/discourse/app/lib/user-search.js
index 0f14ca4..79b3c58 100644
--- a/app/assets/javascripts/discourse/app/lib/user-search.js
+++ b/app/assets/javascripts/discourse/app/lib/user-search.js
@@ -22,6 +22,8 @@ function performSearch(
   allowedUsers,
   groupMembersOf,
   includeStagedUsers,
+  lastSeenUsers,
+  limit,
   resultsFn
 ) {
   let cached = cache[term];
@@ -32,7 +34,7 @@ function performSearch(
 
   const eagerComplete = eagerCompleteSearch(term, topicId || categoryId);
 
-  if (term === "" && !eagerComplete) {
+  if (term === "" && !eagerComplete && !lastSeenUsers) {
     // The server returns no results in this case, so no point checking
     // do not return empty list, because autocomplete will get terminated
     resultsFn(CANCELLED_STATUS);
@@ -51,6 +53,8 @@ function performSearch(
       groups: groupMembersOf,
       topic_allowed_users: allowedUsers,
       include_staged_users: includeStagedUsers,
+      last_seen_users: lastSeenUsers,
+      limit: limit,
     },
   });
 
@@ -93,6 +97,8 @@ let debouncedSearch = function (
   allowedUsers,
   groupMembersOf,
   includeStagedUsers,
+  lastSeenUsers,
+  limit,
   resultsFn
 ) {
   discourseDebounce(
@@ -107,6 +113,8 @@ let debouncedSearch = function (
     allowedUsers,
     groupMembersOf,
     includeStagedUsers,
+    lastSeenUsers,
+    limit,
     resultsFn,
     300
   );
@@ -169,7 +177,10 @@ function organizeResults(r, options) {
 // we also ignore if we notice a double space or a string that is only a space
 const ignoreRegex = /([\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*,\/:;<=>?\[\]^`{|}~])|\s\s|^\s$|^[^+]*\+[^@]*$/;
 
-export function skipSearch(term, allowEmails) {
+export function skipSearch(term, allowEmails, lastSeenUsers = false) {
+  if (lastSeenUsers) {
+    return false;
+  }
   if (term.indexOf("@") > -1 && !allowEmails) {
     return true;
   }
@@ -194,7 +205,9 @@ export default function userSearch(options) {
     topicId = options.topicId,
     categoryId = options.categoryId,
     groupMembersOf = options.groupMembersOf,
-    includeStagedUsers = options.includeStagedUsers;
+    includeStagedUsers = options.includeStagedUsers,
+    lastSeenUsers = options.lastSeenUsers,
+    limit = options.limit || 6;
 
   if (oldSearch) {
     oldSearch.abort();
@@ -217,7 +230,7 @@ export default function userSearch(options) {
       clearPromise = later(() => resolve(CANCELLED_STATUS), 5000);
     }
 
-    if (skipSearch(term, options.allowEmails)) {
+    if (skipSearch(term, options.allowEmails, options.lastSeenUsers)) {
       resolve([]);
       return;
     }
@@ -232,6 +245,8 @@ export default function userSearch(options) {
       allowedUsers,
       groupMembersOf,
       includeStagedUsers,
+      lastSeenUsers,
+      limit,
       function (r) {
         cancel(clearPromise);
         resolve(organizeResults(r, options));
diff --git a/app/assets/javascripts/discourse/app/widgets/search-menu.js b/app/assets/javascripts/discourse/app/widgets/search-menu.js
index 2ea06cd..d91628c 100644
--- a/app/assets/javascripts/discourse/app/widgets/search-menu.js
+++ b/app/assets/javascripts/discourse/app/widgets/search-menu.js
@@ -10,9 +10,7 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
 import userSearch from "discourse/lib/user-search";
 
 const CATEGORY_SLUG_REGEXP = /(\#[a-zA-Z0-9\-:]*)$/gi;
-// The backend user search query returns zero results for a term-free search
-// so the regexp below only matches @ followed by a valid character
-const USERNAME_REGEXP = /(\@[a-zA-Z0-9\-\_]+)$/gi;
+const USERNAME_REGEXP = /(\@[a-zA-Z0-9\-\_]*)$/gi;
 
 const searchData = {};
 const suggestionTriggers = ["in:", "status:", "order:"];
@@ -72,11 +70,19 @@ const SearchHelper = {
           return;
         }
         if (matchSuggestions.type === "username") {
-          userSearch({
-            term: matchSuggestions.usernamesMatch[0],
-            includeGroups: true,
-          }).then((result) => {
-            if (result?.users.length > 0) {
+          const userSearchTerm = matchSuggestions.usernamesMatch[0].replace(
+            "@",
+            ""
+          );
+          const opts = { includeGroups: true, limit: 6 };
+          if (userSearchTerm.length > 0) {
+            opts.term = userSearchTerm;
+          } else {
+            opts.lastSeenUsers = true;
+          }
+
+          userSearch(opts).then((result) => {
+            if (result?.users?.length > 0) {
               searchData.suggestionResults = result.users;
               searchData.suggestionKeyword = "@";
             } else {
diff --git a/app/assets/javascripts/discourse/tests/acceptance/search-test.js b/app/assets/javascripts/discourse/tests/acceptance/search-test.js
index 64224c8..2342d2d 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/search-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/search-test.js
@@ -268,6 +268,33 @@ acceptance("Search - with tagging enabled", function (needs) {
 acceptance("Search - assistant", function (needs) {
   needs.user();
 
+  needs.pretender((server, helper) => {
+    server.get("/u/search/users", () => {
+      return helper.response({
+        users: [
+          {
+            username: "TeaMoe",
+            name: "TeaMoe",
+            avatar_template:
+              "https://avatars.discourse.org/v3/letter/t/41988e/{size}.png",
+          },
+          {
+            username: "TeamOneJ",
+            name: "J Cobb",
+            avatar_template:
+              "https://avatars.discourse.org/v3/letter/t/3d9bf3/{size}.png",
+          },
+          {
+            username: "kudos",
+            name: "Team Blogeto.com",
+            avatar_template:
+              "/user_avatar/meta.discourse.org/kudos/{size}/62185_1.png",
+          },
+        ],
+      });
+    });
+  });
+
   test("shows category shortcuts when typing #", async function (assert) {
     await visit("/");
 
@@ -317,4 +344,21 @@ acceptance("Search - assistant", function (needs) {
     await triggerKeyEvent("#search-term", "keyup", 51);
     assert.equal(query(firstTarget).innerText, "sam in:title");
   });
+
+  test("shows users when typing @", async function (assert) {
+    await visit("/");
+
+    await click("#search-button");
+
+    await fillIn("#search-term", "@");
+    await triggerKeyEvent("#search-term", "keyup", 51);
+
+    const firstUser =
+      ".search-menu .results ul.search-menu-assistant .search-item-user";
+    const firstUsername = query(firstUser).innerText.trim();
+    assert.equal(firstUsername, "TeaMoe");
+
+    await click(query(firstUser));
+    assert.equal(query("#search-term").value, `@${firstUsername} `);
+  });
 });
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 8e77c49..725694f 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1079,6 +1079,8 @@ class UsersController < ApplicationController
     }
 
     options[:include_staged_users] = !!ActiveModel::Type::Boolean.new.cast(params[:include_staged_users])
+    options[:last_seen_users] = !!ActiveModel::Type::Boolean.new.cast(params[:last_seen_users])
+    options[:limit] = params[:limit].to_i if params[:limit].present?
     options[:topic_id] = topic_id if topic_id
     options[:category_id] = category_id if category_id
 
diff --git a/app/models/user_search.rb b/app/models/user_search.rb
index 13d74df..ba53653 100644
--- a/app/models/user_search.rb
+++ b/app/models/user_search.rb
@@ -12,6 +12,7 @@ class UserSearch
     @topic_allowed_users = opts[:topic_allowed_users]
     @searching_user = opts[:searching_user]
     @include_staged_users = opts[:include_staged_users] || false
+    @last_seen_users = opts[:last_seen_users] || false
     @limit = opts[:limit] || 20
     @groups = opts[:groups]
 
@@ -162,6 +163,15 @@ class UserSearch
         .each { |id| users << id }
     end
 

[... diff too long, it was truncated ...]

GitHub sha: 2ce2c83bc9a9cc971f0a47994c4a3db92731122c

This commit appears in #13799 which was approved by ZogStriP. It was merged by pmusaraj.