fix(app): match member search on accents, display names and ids (#1850)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:31:02 +02:00
1 parent 8c431c7562
commit b0e882645e
7 files changed
+256 -200

No files matched your search

@@ -155,11 +155,11 @@ export const AddOverridePopout: React.FC<AddOverridePopoutProps> = observer(func
}
const context = searchContextRef.current;
if (queryForServer.length === 0) {
context?.clearQuery();
context?.cancelSearch();
setServerMemberIds([]);
return;
}
context?.setQuery(queryForServer, {guild: guildId});
context?.beginSearch(queryForServer, {guild: guildId});
if (GuildMembers.isGuildFullyLoaded(guildId)) {
return;
}
@@ -251,7 +251,7 @@ export function useMessageSearchAutocomplete({
memberFetchQueryRef.current = '';
const context = searchContextRef.current;
if (context) {
context.clearQuery();
context.cancelSearch();
}
setMemberSearchResults([]);
return;
@@ -261,7 +261,7 @@ export function useMessageSearchAutocomplete({
memberFetchQueryRef.current = '';
const context = searchContextRef.current;
if (context) {
context.clearQuery();
context.cancelSearch();
}
setMemberSearchResults([]);
return;
@@ -273,14 +273,14 @@ export function useMessageSearchAutocomplete({
if (searchQuery.length === 0) {
memberFetchQueryRef.current = '';
if (context) {
context.clearQuery();
context.cancelSearch();
}
setMemberSearchResults([]);
return;
}
const boosters = buildUserSearchBoosters(channel, currentGuildIdForScope, plan.mode);
if (context) {
context.setQuery(searchQuery, plan.workerFilters, new Set(), new Set(), boosters);
context.beginSearch(searchQuery, plan.workerFilters, new Set(), new Set(), boosters);
}
if (!plan.guildsToSearch || plan.guildsToSearch.length === 0) {
memberFetchQueryRef.current = searchQuery;
@@ -311,7 +311,7 @@ export function useMessageSearchAutocomplete({
if (autocompleteType !== 'users' || !currentFilter) {
const context = searchContextRef.current;
if (context) {
context.clearQuery();
context.cancelSearch();
}
setMemberSearchResults([]);
memberFetchQueryRef.current = '';
@@ -85,7 +85,7 @@ export function useAutocompleteMemberSearch({
}
if (!isMemberSearchTrigger(triggerType) || guildId == null) {
currentGuildIdRef.current = null;
context.clearQuery();
context.cancelSearch();
setResults([]);
if (debounceTimerRef.current != null) {
clearTimeout(debounceTimerRef.current);
@@ -94,7 +94,7 @@ export function useAutocompleteMemberSearch({
return;
}
currentGuildIdRef.current = guildId;
context.setQuery(matchedText, {guild: guildId}, new Set(), new Set(), MentionFrecency.getBoosters(guildId));
context.beginSearch(matchedText, {guild: guildId}, new Set(), new Set(), MentionFrecency.getBoosters(guildId));
if (debounceTimerRef.current != null) {
clearTimeout(debounceTimerRef.current);
}
@@ -147,13 +147,13 @@ export function useAutocompleteSlotMemberSearch({
}
if (slotContext == null || slotContext.optionType !== 'user' || guildId == null) {
currentGuildIdRef.current = null;
context.clearQuery();
context.cancelSearch();
setResults([]);
return;
}
const query = normalizeSlotAutocompleteQuery(slotContext);
currentGuildIdRef.current = guildId;
context.setQuery(query, {guild: guildId}, new Set(), new Set(), MentionFrecency.getBoosters(guildId));
context.beginSearch(query, {guild: guildId}, new Set(), new Set(), MentionFrecency.getBoosters(guildId));
debounceTimerRef.current = setTimeout(() => {
void MemberSearch.fetchMembersInBackground(query, [guildId]);
debounceTimerRef.current = null;
@@ -11,17 +11,17 @@ import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import {makeAutoObservable} from 'mobx';
export enum MemberSearchActionTypes {
UPDATE_USERS = 'UPDATE_USERS',
USER_RESULTS = 'USER_RESULTS',
QUERY_SET = 'QUERY_SET',
QUERY_CLEAR = 'QUERY_CLEAR',
INGEST_DIRECTORY = 'INGEST_DIRECTORY',
MATCHES_READY = 'MATCHES_READY',
SEARCH_BEGIN = 'SEARCH_BEGIN',
SEARCH_CANCEL = 'SEARCH_CANCEL',
}
export enum MemberSearchWorkerMessageTypes {
UPDATE_USERS = 'UPDATE_USERS',
USER_RESULTS = 'USER_RESULTS',
QUERY_SET = 'QUERY_SET',
QUERY_CLEAR = 'QUERY_CLEAR',
INGEST_DIRECTORY = 'INGEST_DIRECTORY',
MATCHES_READY = 'MATCHES_READY',
SEARCH_BEGIN = 'SEARCH_BEGIN',
SEARCH_CANCEL = 'SEARCH_CANCEL',
}
export interface MemberSearchFilters {
@@ -63,32 +63,33 @@ interface WorkerMessage {
}
interface MemberResultsMessage extends WorkerMessage {
type: MemberSearchWorkerMessageTypes.USER_RESULTS;
type: MemberSearchWorkerMessageTypes.MATCHES_READY;
uuid: string;
generation: number;
payload: Array<TransformedMember>;
}
interface UpdateMembersMessage extends WorkerMessage {
type: MemberSearchWorkerMessageTypes.UPDATE_USERS;
type: MemberSearchWorkerMessageTypes.INGEST_DIRECTORY;
payload: {
users: Array<TransformedMember>;
};
}
interface QuerySetMessage extends WorkerMessage {
type: MemberSearchWorkerMessageTypes.QUERY_SET;
type: MemberSearchWorkerMessageTypes.SEARCH_BEGIN;
uuid: string;
payload: QueryData;
}
interface QueryClearMessage extends WorkerMessage {
type: MemberSearchWorkerMessageTypes.QUERY_CLEAR;
type: MemberSearchWorkerMessageTypes.SEARCH_CANCEL;
uuid: string;
}
const DEFAULT_LIMIT = 10;
const BACKGROUND_FETCH_DEDUP_WINDOW_MS = 750;
const MEMBER_FETCH_DEDUPE_MS = 60_000;
const MEMBER_FETCH_SETTLE_MS = 750;
let worker: Worker | null = null;
const searchContexts = new Set<SearchContext>();
@@ -102,7 +103,7 @@ function updateMembers(members: Array<TransformedMember>): void {
return;
}
worker.postMessage({
type: MemberSearchWorkerMessageTypes.UPDATE_USERS,
type: MemberSearchWorkerMessageTypes.INGEST_DIRECTORY,
payload: {users: filtered},
} as UpdateMembersMessage);
}
@@ -159,41 +160,41 @@ function updateMembersList(members: Array<GuildMember>, guildId?: string): Array
}
export class SearchContext {
private readonly _uuid: string;
private readonly _callback: (results: Array<TransformedMember>) => void;
private readonly _limit: number;
private _currentQuery: QueryData | false | null;
private _nextQuery: QueryData | null;
private readonly _contextId: string;
private readonly _deliverResults: (results: Array<TransformedMember>) => void;
private readonly _maxResults: number;
private _inFlightQuery: QueryData | false | null;
private _queuedQuery: QueryData | null;
private _latestGeneration: number;
private _nextGeneration: number;
private readonly _handleMessages: (event: MessageEvent<WorkerMessage>) => void;
private _attachedWorker: Worker | null = null;
constructor(callback: (results: Array<TransformedMember>) => void, limit: number = DEFAULT_LIMIT) {
this._uuid = crypto.randomUUID();
this._callback = callback;
this._limit = limit;
this._currentQuery = null;
this._nextQuery = null;
this._contextId = crypto.randomUUID();
this._deliverResults = callback;
this._maxResults = limit;
this._inFlightQuery = null;
this._queuedQuery = null;
this._latestGeneration = 0;
this._nextGeneration = 1;
this._handleMessages = (event: MessageEvent<WorkerMessage>) => {
const data = event.data;
if (!data || data.type !== MemberSearchWorkerMessageTypes.USER_RESULTS) {
if (!data || data.type !== MemberSearchWorkerMessageTypes.MATCHES_READY) {
return;
}
const resultsMessage = data as MemberResultsMessage;
if (resultsMessage.uuid !== this._uuid) {
if (resultsMessage.uuid !== this._contextId) {
return;
}
const isLatestGeneration = resultsMessage.generation === this._latestGeneration;
if (isLatestGeneration && this._currentQuery !== false) {
this._callback(resultsMessage.payload);
if (isLatestGeneration && this._inFlightQuery !== false) {
this._deliverResults(resultsMessage.payload);
}
const currentQuery = this._currentQuery;
const currentQuery = this._inFlightQuery;
if (currentQuery !== null && currentQuery !== false && currentQuery.generation === resultsMessage.generation) {
this._currentQuery = null;
this._setNextQuery();
this._inFlightQuery = null;
this._flushQueuedQuery();
}
};
searchContexts.add(this);
@@ -201,7 +202,7 @@ export class SearchContext {
}
destroy(): void {
this.clearQuery();
this.cancelSearch();
searchContexts.delete(this);
this.attachToWorker(null);
}
@@ -218,36 +219,36 @@ export class SearchContext {
return;
}
nextWorker.addEventListener('message', this._handleMessages);
if (this._currentQuery === false) {
if (this._inFlightQuery === false) {
nextWorker.postMessage({
uuid: this._uuid,
type: MemberSearchWorkerMessageTypes.QUERY_CLEAR,
uuid: this._contextId,
type: MemberSearchWorkerMessageTypes.SEARCH_CANCEL,
} as QueryClearMessage);
return;
}
if (this._currentQuery != null) {
if (this._inFlightQuery != null) {
nextWorker.postMessage({
uuid: this._uuid,
type: MemberSearchWorkerMessageTypes.QUERY_SET,
payload: this._currentQuery,
uuid: this._contextId,
type: MemberSearchWorkerMessageTypes.SEARCH_BEGIN,
payload: this._inFlightQuery,
} as QuerySetMessage);
return;
}
this._setNextQuery();
this._flushQueuedQuery();
}
clearQuery(): void {
this._currentQuery = false;
this._nextQuery = null;
cancelSearch(): void {
this._inFlightQuery = false;
this._queuedQuery = null;
if (this._attachedWorker != null) {
this._attachedWorker.postMessage({
uuid: this._uuid,
type: MemberSearchWorkerMessageTypes.QUERY_CLEAR,
uuid: this._contextId,
type: MemberSearchWorkerMessageTypes.SEARCH_CANCEL,
} as QueryClearMessage);
}
}
setQuery(
beginSearch(
query: string,
filters: MemberSearchFilters = {},
blacklist: QueryBlacklist = new Set(),
@@ -259,29 +260,29 @@ export class SearchContext {
}
const generation = this._nextGeneration++;
this._latestGeneration = generation;
this._nextQuery = {
this._queuedQuery = {
query,
filters,
blacklist: Array.from(blacklist),
whitelist: Array.from(whitelist),
boosters,
limit: this._limit,
limit: this._maxResults,
generation,
};
this._setNextQuery();
this._flushQueuedQuery();
}
private _setNextQuery(): void {
if (this._currentQuery || !this._nextQuery) {
private _flushQueuedQuery(): void {
if (this._inFlightQuery || !this._queuedQuery) {
return;
}
this._currentQuery = this._nextQuery;
this._nextQuery = null;
this._inFlightQuery = this._queuedQuery;
this._queuedQuery = null;
if (this._attachedWorker != null) {
this._attachedWorker.postMessage({
uuid: this._uuid,
type: MemberSearchWorkerMessageTypes.QUERY_SET,
payload: this._currentQuery,
uuid: this._contextId,
type: MemberSearchWorkerMessageTypes.SEARCH_BEGIN,
payload: this._inFlightQuery,
} as QuerySetMessage);
}
}
@@ -290,7 +291,7 @@ export class SearchContext {
class MemberSearch {
private logger = new Logger('MemberSearch');
private initialized: boolean = false;
private readonly inFlightFetches = new Map<string, Promise<void>>();
private readonly recentFetches = new Map<string, number>();
constructor() {
makeAutoObservable(this);
@@ -455,7 +456,7 @@ class MemberSearch {
cleanup(): void {
this.terminate();
this.initialized = false;
this.inFlightFetches.clear();
this.recentFetches.clear();
}
async fetchMembersInBackground(query: string, guildIds: Array<string>, priorityGuildId?: string): Promise<void> {
@@ -483,24 +484,25 @@ class MemberSearch {
return;
}
const key = `${eligibleGuildIds.join(',')}:${trimmed.toLowerCase()}`;
const existing = this.inFlightFetches.get(key);
if (existing) {
await existing;
const now = Date.now();
for (const [previousKey, requestedAt] of this.recentFetches) {
if (now - requestedAt >= MEMBER_FETCH_DEDUPE_MS) {
this.recentFetches.delete(previousKey);
}
}
if (this.recentFetches.has(key)) {
return;
}
const promise = new Promise<void>((resolve) => {
GuildMembers.requestMembersInBackground({
guildIds: eligibleGuildIds,
query: trimmed,
limit: 25,
presences: true,
});
setTimeout(resolve, BACKGROUND_FETCH_DEDUP_WINDOW_MS);
}).finally(() => {
this.inFlightFetches.delete(key);
this.recentFetches.set(key, now);
GuildMembers.requestMembersInBackground({
guildIds: eligibleGuildIds,
query: trimmed,
limit: 25,
presences: true,
});
await new Promise<void>((resolve) => {
setTimeout(resolve, MEMBER_FETCH_SETTLE_MS);
});
this.inFlightFetches.set(key, promise);
await promise;
}
}
@@ -1,10 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export enum MessageTypes {
UPDATE_USERS = 'UPDATE_USERS',
USER_RESULTS = 'USER_RESULTS',
QUERY_SET = 'QUERY_SET',
QUERY_CLEAR = 'QUERY_CLEAR',
INGEST_DIRECTORY = 'INGEST_DIRECTORY',
MATCHES_READY = 'MATCHES_READY',
SEARCH_BEGIN = 'SEARCH_BEGIN',
SEARCH_CANCEL = 'SEARCH_CANCEL',
}
interface TransformedUser {
@@ -23,7 +23,7 @@ interface TransformedUser {
interface SearchResult {
id: string;
username: string;
comparator: string;
rankLabel: string;
score: number;
isBot?: boolean;
}
@@ -54,7 +54,19 @@ interface UpdateUsersPayload {
users: Array<TransformedUser>;
}
const userIndex: Map<string, TransformedUser> = new Map();
interface SearchForm {
value: string;
lower: string;
stripped: string;
}
interface IndexedUser {
user: TransformedUser;
named: Array<SearchForm>;
perGuild: Record<string, SearchForm> | null;
}
const userIndex: Map<string, IndexedUser> = new Map();
const activeQueries: Map<string, SearchQuery> = new Map();
const pendingSearches: Set<string> = new Set();
const SCORE_EXACT_PREFIX = 10;
@@ -65,43 +77,59 @@ const FRIEND_KEY = 'isFriend';
const BOT_KEY = 'isBot';
const USERNAME_KEY = 'username';
const IGNORED_KEYS = new Set([BOT_KEY, FRIEND_KEY, USERNAME_KEY, 'guildIds', 'guildNicknames']);
const COMBINING_MARKS = /[\u0300-\u036f]/g;
function getSearchValues(user: TransformedUser, filters?: SearchFilters): Array<string> {
const values: Array<string> = [];
if (user.username.length > 0) {
values.push(user.username);
function stripCombiningMarks(text: string): string {
const decomposed = text.normalize('NFD');
const withoutMarks = decomposed.replace(COMBINING_MARKS, '');
if (withoutMarks.length === decomposed.length) {
return text;
}
for (const key of Object.keys(user)) {
if (IGNORED_KEYS.has(key) || key === '_delete' || key === '_removeGuild') {
continue;
}
const value = user[key];
if (typeof value === 'string' && value.length > 0) {
values.push(value);
}
return withoutMarks.normalize('NFC');
}
function toSearchForm(value: string): SearchForm {
const lower = value.toLowerCase();
return {value, lower, stripped: stripCombiningMarks(lower)};
}
function indexUser(user: TransformedUser): IndexedUser {
const named: Array<SearchForm> = [];
if (user.username.length > 0) {
named.push(toSearchForm(user.username));
}
const globalName = user.globalName;
if (typeof globalName === 'string' && globalName.length > 0) {
named.push(toSearchForm(globalName));
}
const guildNicknames = user.guildNicknames;
if (guildNicknames == null) {
return values;
return {user, named, perGuild: null};
}
let perGuild: Record<string, SearchForm> | null = null;
for (const [guildId, nickname] of Object.entries(guildNicknames)) {
if (typeof nickname !== 'string' || nickname.length === 0) {
continue;
}
if (perGuild == null) {
perGuild = {};
}
perGuild[guildId] = toSearchForm(nickname);
}
return {user, named, perGuild};
}
function getSearchForms(entry: IndexedUser, filters?: SearchFilters): Array<SearchForm> {
const {named, perGuild} = entry;
if (perGuild == null) {
return named;
}
const guildId = filters == null ? null : filters.guild;
if (guildId != null && guildId.length > 0) {
const nickname = guildNicknames[guildId];
if (typeof nickname === 'string' && nickname.length > 0) {
values.push(nickname);
}
return values;
const nickname = perGuild[guildId];
return nickname == null ? named : [...named, nickname];
}
for (const nickname of Object.values(guildNicknames)) {
if (typeof nickname === 'string' && nickname.length > 0) {
values.push(nickname);
}
}
return values;
}
function escapeRegex(text: string): string {
return text.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
return [...named, ...Object.values(perGuild)];
}
function fuzzyMatch(needle: string, haystack: string): boolean {
@@ -121,10 +149,10 @@ function fuzzyMatch(needle: string, haystack: string): boolean {
function sortByMatchScore(a: SearchResult, b: SearchResult): number {
if (a.score === b.score) {
const aComp = a.comparator.toLowerCase();
const bComp = b.comparator.toLowerCase();
if (aComp < bComp) return -1;
if (aComp > bComp) return 1;
const aLabel = a.rankLabel.toLowerCase();
const bLabel = b.rankLabel.toLowerCase();
if (aLabel < bLabel) return -1;
if (aLabel > bLabel) return 1;
return 0;
}
return b.score - a.score;
@@ -141,24 +169,24 @@ function normalizeSearchLimit(limit: number): number {
return Math.min(normalizedLimit, MAX_SEARCH_RESULTS);
}
function insertSearchResult(results: Array<SearchResult>, candidate: SearchResult, limit: number): void {
if (results.length === 0) {
results.push(candidate);
function insertSearchResult(shortlist: Array<SearchResult>, candidate: SearchResult, limit: number): void {
if (shortlist.length === 0) {
shortlist.push(candidate);
return;
}
let insertIndex = results.length;
for (let index = 0; index < results.length; index += 1) {
if (sortByMatchScore(candidate, results[index]) < 0) {
let insertIndex = shortlist.length;
for (let index = 0; index < shortlist.length; index += 1) {
if (sortByMatchScore(candidate, shortlist[index]) < 0) {
insertIndex = index;
break;
}
}
if (insertIndex === results.length && results.length >= limit) {
if (insertIndex === shortlist.length && shortlist.length >= limit) {
return;
}
results.splice(insertIndex, 0, candidate);
if (results.length > limit) {
results.pop();
shortlist.splice(insertIndex, 0, candidate);
if (shortlist.length > limit) {
shortlist.pop();
}
}
@@ -180,26 +208,26 @@ function shouldIncludeUser(
return true;
}
function calculateScore(baseScore: number, booster?: number): number {
return baseScore * (booster ?? 1);
function calculateScore(tierScore: number, booster?: number): number {
return tierScore * (booster ?? 1);
}
function postSearchResults(uuid: string, results: Array<SearchResult>, generation: number): void {
const payload = results.map((r) => ({
id: r.id,
username: r.username,
isBot: r.isBot,
function postSearchResults(uuid: string, shortlist: Array<SearchResult>, generation: number): void {
const payload = shortlist.map((hit) => ({
id: hit.id,
username: hit.username,
isBot: hit.isBot,
}));
const message: WorkerMessage<typeof payload> = {
uuid,
type: MessageTypes.USER_RESULTS,
type: MessageTypes.MATCHES_READY,
payload,
generation,
};
postMessage(message);
}
function getEmptyQueryComparator(user: TransformedUser, filters?: SearchFilters): string {
function getFallbackLabel(user: TransformedUser, filters?: SearchFilters): string {
const guildNicknames = user.guildNicknames;
const guildId = filters == null ? null : filters.guild;
if (guildNicknames != null && guildId != null && guildId.length > 0) {
@@ -214,82 +242,108 @@ function getEmptyQueryComparator(user: TransformedUser, filters?: SearchFilters)
return user.username;
}
function scoreSearchForm(form: SearchForm, loweredQuery: string, strippedQuery: string): number {
const needsStrippedPass = form.stripped !== form.lower || strippedQuery !== loweredQuery;
if (form.lower.startsWith(loweredQuery)) {
return SCORE_EXACT_PREFIX;
}
if (needsStrippedPass && form.stripped.startsWith(strippedQuery)) {
return SCORE_EXACT_PREFIX;
}
if (form.lower.includes(loweredQuery)) {
return SCORE_CONTAINS;
}
if (needsStrippedPass && form.stripped.includes(strippedQuery)) {
return SCORE_CONTAINS;
}
if (fuzzyMatch(loweredQuery, form.lower)) {
return SCORE_FUZZY;
}
if (needsStrippedPass && fuzzyMatch(strippedQuery, form.stripped)) {
return SCORE_FUZZY;
}
return 0;
}
function executeSearch(uuid: string, searchQuery: SearchQuery): void {
const {query, limit, filters, blacklist, whitelist, boosters, generation = 0} = searchQuery;
const normalizedLimit = normalizeSearchLimit(limit);
const results: Array<SearchResult> = [];
const hits: Array<SearchResult> = [];
if (normalizedLimit === 0) {
postSearchResults(uuid, results, generation);
postSearchResults(uuid, hits, generation);
return;
}
if (query === '') {
userIndex.forEach((user, userId) => {
userIndex.forEach((entry, userId) => {
const user = entry.user;
if (!shouldIncludeUser(userId, user, filters, blacklist, whitelist)) {
return;
}
insertSearchResult(
results,
hits,
{
id: userId,
username: user.username,
comparator: getEmptyQueryComparator(user, filters),
rankLabel: getFallbackLabel(user, filters),
score: 0,
isBot: user.isBot,
},
normalizedLimit,
);
});
postSearchResults(uuid, results, generation);
postSearchResults(uuid, hits, generation);
return;
}
const exactPrefixRegex = new RegExp(`^${escapeRegex(query)}`, 'i');
const containsRegex = new RegExp(escapeRegex(query), 'i');
const queryLower = query.toLowerCase();
userIndex.forEach((user, userId) => {
const loweredQuery = query.toLowerCase();
const strippedQuery = stripCombiningMarks(loweredQuery);
userIndex.forEach((entry, userId) => {
const user = entry.user;
if (!shouldIncludeUser(userId, user, filters, blacklist, whitelist)) {
return;
}
const username = user.username;
let bestMatch: SearchResult | null = null;
for (const value of getSearchValues(user, filters)) {
let matchResult: SearchResult | null = null;
if (exactPrefixRegex.test(value)) {
matchResult = {
const booster = boosters == null ? undefined : boosters[userId];
if (query === userId) {
insertSearchResult(
hits,
{
id: userId,
username,
comparator: value,
score: calculateScore(SCORE_EXACT_PREFIX, boosters?.[userId]),
username: user.username,
rankLabel: getFallbackLabel(user, filters),
score: calculateScore(SCORE_EXACT_PREFIX, booster),
isBot: user.isBot,
};
} else if (containsRegex.test(value)) {
matchResult = {
id: userId,
username,
comparator: value,
score: calculateScore(SCORE_CONTAINS, boosters?.[userId]),
isBot: user.isBot,
};
} else if (fuzzyMatch(queryLower, value.toLowerCase())) {
matchResult = {
id: userId,
username,
comparator: value,
score: calculateScore(SCORE_FUZZY, boosters?.[userId]),
isBot: user.isBot,
};
}
if (matchResult && (!bestMatch || bestMatch.score < matchResult.score)) {
bestMatch = matchResult;
},
normalizedLimit,
);
return;
}
let bestScore = 0;
let bestForm: SearchForm | null = null;
for (const form of getSearchForms(entry, filters)) {
const score = scoreSearchForm(form, loweredQuery, strippedQuery);
if (score > bestScore) {
bestScore = score;
bestForm = form;
}
}
if (bestMatch) {
insertSearchResult(results, bestMatch, normalizedLimit);
if (bestForm == null) {
return;
}
insertSearchResult(
hits,
{
id: userId,
username: user.username,
rankLabel: bestForm.value,
score: calculateScore(bestScore, booster),
isBot: user.isBot,
},
normalizedLimit,
);
});
postSearchResults(uuid, results, generation);
postSearchResults(uuid, hits, generation);
}
function updateUsers(users: Array<TransformedUser>): void {
function applyUserUpdates(users: Array<TransformedUser>): void {
let shouldTriggerSearch = false;
const updatedGuilds = new Set<string>();
for (const update of users) {
@@ -299,11 +353,11 @@ function updateUsers(users: Array<TransformedUser>): void {
shouldTriggerSearch = true;
continue;
}
const existingUser = userIndex.get(userId);
if (update._removeGuild && existingUser == null) {
const existingEntry = userIndex.get(userId);
if (update._removeGuild && existingEntry == null) {
continue;
}
const baseUser: TransformedUser = existingUser == null ? {id: userId, username: ''} : existingUser;
const baseUser: TransformedUser = existingEntry == null ? {id: userId, username: ''} : existingEntry.user;
const mergedUser: TransformedUser = {...baseUser, ...update};
if (update._removeGuild && baseUser.username.length > 0) {
mergedUser.username = baseUser.username;
@@ -345,7 +399,7 @@ function updateUsers(users: Array<TransformedUser>): void {
}
const wasFriend = Boolean(baseUser.isFriend);
const isFriendNow = Boolean(mergedUser.isFriend);
userIndex.set(userId, mergedUser);
userIndex.set(userId, indexUser(mergedUser));
if (activeQueries.size > 0) {
if (isFriendNow || wasFriend !== isFriendNow) {
shouldTriggerSearch = true;
@@ -374,12 +428,12 @@ function updateUsers(users: Array<TransformedUser>): void {
}
}
function setQuery(uuid: string, query: SearchQuery): void {
function registerQuery(uuid: string, query: SearchQuery): void {
activeQueries.set(uuid, query);
executeSearch(uuid, query);
}
function clearQuery(uuid: string): void {
function unregisterQuery(uuid: string): void {
activeQueries.delete(uuid);
pendingSearches.delete(uuid);
}
@@ -409,21 +463,21 @@ addEventListener('message', (event: MessageEvent<WorkerMessage>) => {
}
const {uuid, type, payload} = data;
switch (type) {
case MessageTypes.UPDATE_USERS: {
case MessageTypes.INGEST_DIRECTORY: {
const p = payload as UpdateUsersPayload | undefined;
if (p?.users) {
updateUsers(p.users);
applyUserUpdates(p.users);
}
break;
}
case MessageTypes.QUERY_SET: {
case MessageTypes.SEARCH_BEGIN: {
if (!uuid) return;
setQuery(uuid, payload as SearchQuery);
registerQuery(uuid, payload as SearchQuery);
break;
}
case MessageTypes.QUERY_CLEAR: {
case MessageTypes.SEARCH_CANCEL: {
if (!uuid) return;
clearQuery(uuid);
unregisterQuery(uuid);
break;
}
}
@@ -291,7 +291,7 @@ export function useTextareaAutocomplete({
autocompleteTriggerType === 'commandArg';
if (!isMentionTrigger || !channel?.guildId) {
currentGuildIdRef.current = null;
context.clearQuery();
context.cancelSearch();
setMemberSearchResults([]);
setIsMemberSearchLoading(false);
if (memberFetchDebounceTimerRef.current) {
@@ -309,7 +309,7 @@ export function useTextareaAutocomplete({
}
setIsMemberSearchLoading(true);
const boosters = MentionFrecency.getBoosters(guildId);
context.setQuery(searchQuery, {guild: guildId}, new Set(), new Set(), boosters);
context.beginSearch(searchQuery, {guild: guildId}, new Set(), new Set(), boosters);
if (memberFetchDebounceTimerRef.current) {
clearTimeout(memberFetchDebounceTimerRef.current);
}
@@ -328,7 +328,7 @@ class QuickSwitcher {
const rawSearch = query['slice'](1).trim();
if (rawSearch.length === 0) {
if (this.memberSearchContext) {
this.memberSearchContext.clearQuery();
this.memberSearchContext.cancelSearch();
}
if (this.memberFetchDebounceTimer) {
clearTimeout(this.memberFetchDebounceTimer);
@@ -351,7 +351,7 @@ class QuickSwitcher {
});
}, MEMBER_SEARCH_LIMIT);
}
this.memberSearchContext.setQuery(rawSearch);
this.memberSearchContext.beginSearch(rawSearch);
if (this.memberFetchDebounceTimer) {
clearTimeout(this.memberFetchDebounceTimer);
}