diff --git a/app/assets/javascripts/discourse/app/lib/user-search.js b/app/assets/javascripts/discourse/app/lib/user-search.js index 0f14ca40b45..79b3c5800b5 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 2ea06cdab68..d91628cee59 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 64224c8ceee..2342d2d339c 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 8e77c490905..725694f2b65 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 13d74df0690..ba536535946 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 + # 5. last seen users (for search auto-suggestions) + if @last_seen_users + scoped_users + .order('last_seen_at DESC NULLS LAST') + .limit(@limit - users.size) + .pluck(:id) + .each { |id| users << id } + end + users.to_a end diff --git a/spec/models/user_search_spec.rb b/spec/models/user_search_spec.rb index c72abc84686..e9d1a6ac3e5 100644 --- a/spec/models/user_search_spec.rb +++ b/spec/models/user_search_spec.rb @@ -238,5 +238,14 @@ describe UserSearch do results = search_for("", topic_id: topic.id, searching_user: mr_b) expect(results).to eq [mr_pink, mr_orange].map(&:username) end + + it "works with last_seen_users option" do + results = search_for("", last_seen_users: true) + + expect(results).not_to be_blank + expect(results[0]).to eq("mrbrown") + expect(results[1]).to eq("mrpink") + expect(results[2]).to eq("mrorange") + end end end diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index 57c414ec28b..5fa9eb0a714 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -4022,6 +4022,24 @@ describe UsersController do expect(json["users"].map { |u| u["name"] }).not_to include(staged_user.name) end end + + context '`last_seen_users`' do + it "returns results when the param is true" do + get "/u/search/users.json", params: { last_seen_users: true } + + json = response.parsed_body + expect(json["users"]).not_to be_empty + end + + it "respects limit parameter at the same time" do + limit = 3 + get "/u/search/users.json", params: { last_seen_users: true, limit: limit } + + json = response.parsed_body + expect(json["users"]).not_to be_empty + expect(json["users"].size).to eq(limit) + end + end end describe '#email_login' do