diff --git a/fluxer_app/src/features/app/components/dialogs/shared/AddOverridePopout.tsx b/fluxer_app/src/features/app/components/dialogs/shared/AddOverridePopout.tsx index 7e913ea1a..04257f4c2 100644 --- a/fluxer_app/src/features/app/components/dialogs/shared/AddOverridePopout.tsx +++ b/fluxer_app/src/features/app/components/dialogs/shared/AddOverridePopout.tsx @@ -155,11 +155,11 @@ export const AddOverridePopout: React.FC = 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; } diff --git a/fluxer_app/src/features/channel/components/message_search_bar/useMessageSearchAutocomplete.ts b/fluxer_app/src/features/channel/components/message_search_bar/useMessageSearchAutocomplete.ts index 035eadead..499dad7e4 100644 --- a/fluxer_app/src/features/channel/components/message_search_bar/useMessageSearchAutocomplete.ts +++ b/fluxer_app/src/features/channel/components/message_search_bar/useMessageSearchAutocomplete.ts @@ -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 = ''; diff --git a/fluxer_app/src/features/lexical/composer/useAutocompleteMemberSearch.ts b/fluxer_app/src/features/lexical/composer/useAutocompleteMemberSearch.ts index 6409303c3..281cf681d 100644 --- a/fluxer_app/src/features/lexical/composer/useAutocompleteMemberSearch.ts +++ b/fluxer_app/src/features/lexical/composer/useAutocompleteMemberSearch.ts @@ -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; diff --git a/fluxer_app/src/features/member/state/MemberSearch.ts b/fluxer_app/src/features/member/state/MemberSearch.ts index fd4cf6cae..5db6150f2 100644 --- a/fluxer_app/src/features/member/state/MemberSearch.ts +++ b/fluxer_app/src/features/member/state/MemberSearch.ts @@ -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; } interface UpdateMembersMessage extends WorkerMessage { - type: MemberSearchWorkerMessageTypes.UPDATE_USERS; + type: MemberSearchWorkerMessageTypes.INGEST_DIRECTORY; payload: { users: Array; }; } 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(); @@ -102,7 +103,7 @@ function updateMembers(members: Array): 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, guildId?: string): Array } export class SearchContext { - private readonly _uuid: string; - private readonly _callback: (results: Array) => void; - private readonly _limit: number; - private _currentQuery: QueryData | false | null; - private _nextQuery: QueryData | null; + private readonly _contextId: string; + private readonly _deliverResults: (results: Array) => 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) => void; private _attachedWorker: Worker | null = null; constructor(callback: (results: Array) => 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) => { 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>(); + private readonly recentFetches = new Map(); 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, priorityGuildId?: string): Promise { @@ -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((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((resolve) => { + setTimeout(resolve, MEMBER_FETCH_SETTLE_MS); }); - this.inFlightFetches.set(key, promise); - await promise; } } diff --git a/fluxer_app/src/features/member/workers/MemberSearchWorker.ts b/fluxer_app/src/features/member/workers/MemberSearchWorker.ts index 8c905043a..1a4cf4ca1 100644 --- a/fluxer_app/src/features/member/workers/MemberSearchWorker.ts +++ b/fluxer_app/src/features/member/workers/MemberSearchWorker.ts @@ -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; } -const userIndex: Map = new Map(); +interface SearchForm { + value: string; + lower: string; + stripped: string; +} + +interface IndexedUser { + user: TransformedUser; + named: Array; + perGuild: Record | null; +} + +const userIndex: Map = new Map(); const activeQueries: Map = new Map(); const pendingSearches: Set = 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 { - const values: Array = []; - 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 = []; + 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 | 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 { + 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, candidate: SearchResult, limit: number): void { - if (results.length === 0) { - results.push(candidate); +function insertSearchResult(shortlist: Array, 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, generation: number): void { - const payload = results.map((r) => ({ - id: r.id, - username: r.username, - isBot: r.isBot, +function postSearchResults(uuid: string, shortlist: Array, generation: number): void { + const payload = shortlist.map((hit) => ({ + id: hit.id, + username: hit.username, + isBot: hit.isBot, })); const message: WorkerMessage = { 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 = []; + const hits: Array = []; 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): void { +function applyUserUpdates(users: Array): void { let shouldTriggerSearch = false; const updatedGuilds = new Set(); for (const update of users) { @@ -299,11 +353,11 @@ function updateUsers(users: Array): 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): 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): 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) => { } 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; } } diff --git a/fluxer_app/src/features/messaging/hooks/useTextareaAutocomplete.ts b/fluxer_app/src/features/messaging/hooks/useTextareaAutocomplete.ts index 7ecc60939..49b2f8891 100644 --- a/fluxer_app/src/features/messaging/hooks/useTextareaAutocomplete.ts +++ b/fluxer_app/src/features/messaging/hooks/useTextareaAutocomplete.ts @@ -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); } diff --git a/fluxer_app/src/features/search/state/QuickSwitcher.tsx b/fluxer_app/src/features/search/state/QuickSwitcher.tsx index 297c6d5fd..22b1d5be6 100644 --- a/fluxer_app/src/features/search/state/QuickSwitcher.tsx +++ b/fluxer_app/src/features/search/state/QuickSwitcher.tsx @@ -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); }