mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): match member search on accents, display names and ids (#1850)
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
+5
-5
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user