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