feat(app): suggest recent speakers and stop autocomplete list flicker (#1890)

This commit is contained in:
Hampus
2026-08-24 12:40:19 +02:00
committed by GitHub
parent 97d559f749
commit 7fe5aad16e
3 changed files with 84 additions and 51 deletions
@@ -13,6 +13,19 @@ export interface GifAutocompleteSearchState {
results: Array<Gif>;
}
const GIF_SEARCH_DEBOUNCE_MS = 250;
const NO_GIF_RESULTS: ReadonlyArray<Gif> = [];
export function selectAutocompleteGifResults(
state: GifAutocompleteSearchState,
searchQuery: string,
): ReadonlyArray<Gif> {
if (searchQuery.length === 0) {
return NO_GIF_RESULTS;
}
return state.results;
}
interface MutableValue<T> {
current: T;
}
@@ -37,7 +50,6 @@ export function useAutocompleteGifSearch({
setState,
}: GifAutocompleteSearchLifecycle): void {
useEffect(() => {
let disposed = false;
if (debounceTimerRef.current != null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
@@ -64,25 +76,24 @@ export function useAutocompleteGifSearch({
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null;
currentSearchRef.current = query;
setState({status: 'loading', query, results: []});
setState((previous) => ({status: 'loading', query, results: previous.results}));
GifCommands.search(query)
.then((gifs) => {
cacheRef.current.set(query, gifs);
if (disposed || currentSearchRef.current !== query) {
if (currentSearchRef.current !== query) {
return;
}
setState({status: 'success', query, results: gifs});
})
.catch((error) => {
if (disposed || currentSearchRef.current !== query) {
if (currentSearchRef.current !== query) {
return;
}
logger.error('GIF search failed', error);
setState({status: 'error', query, results: []});
});
}, 300);
}, GIF_SEARCH_DEBOUNCE_MS);
return () => {
disposed = true;
if (debounceTimerRef.current != null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
@@ -7,9 +7,10 @@ import type {GuildMember} from '@app/features/member/models/GuildMember';
import GuildMembers from '@app/features/member/state/GuildMembers';
import MemberSearch, {type SearchContext} from '@app/features/member/state/MemberSearch';
import {MEMBER_SEARCH_LIMIT} from '@app/features/messaging/utils/AutocompleteOptionBuilders';
import MentionFrecency from '@app/features/notification/state/MentionFrecency';
import {useEffect} from 'react';
const MEMBER_FETCH_DEBOUNCE_MS = 200;
interface MutableValue<T> {
current: T;
}
@@ -33,7 +34,7 @@ interface SlotMemberSearchLifecycle {
setResults: (results: Array<GuildMember>) => void;
}
export function isMemberSearchTrigger(triggerType: string | null): boolean {
function isMemberSearchTrigger(triggerType: string | null): boolean {
return triggerType === 'mention' || triggerType === 'commandArgMention' || triggerType === 'commandArg';
}
@@ -94,14 +95,14 @@ export function useAutocompleteMemberSearch({
return;
}
currentGuildIdRef.current = guildId;
context.beginSearch(matchedText, {guild: guildId}, new Set(), new Set(), MentionFrecency.getBoosters(guildId));
context.beginSearch(matchedText, {guild: guildId}, new Set(), new Set());
if (debounceTimerRef.current != null) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
void MemberSearch.fetchMembersInBackground(matchedText, [guildId]);
debounceTimerRef.current = null;
}, 300);
}, MEMBER_FETCH_DEBOUNCE_MS);
}, [matchedText, triggerType, guildId]);
}
@@ -153,10 +154,10 @@ export function useAutocompleteSlotMemberSearch({
}
const query = normalizeSlotAutocompleteQuery(slotContext);
currentGuildIdRef.current = guildId;
context.beginSearch(query, {guild: guildId}, new Set(), new Set(), MentionFrecency.getBoosters(guildId));
context.beginSearch(query, {guild: guildId}, new Set(), new Set());
debounceTimerRef.current = setTimeout(() => {
void MemberSearch.fetchMembersInBackground(query, [guildId]);
debounceTimerRef.current = null;
}, 300);
}, MEMBER_FETCH_DEBOUNCE_MS);
}, [guildId, slotContext]);
}
@@ -41,14 +41,15 @@ import {normalizeSlotAutocompleteQuery as normalizeSlotQuery} from '@app/feature
import type {SlashOptionalContext, SlashSlotAutocompleteContext} from '@app/features/lexical/composer/slashSlots';
import {
type GifAutocompleteSearchState,
selectAutocompleteGifResults,
useAutocompleteGifSearch,
} from '@app/features/lexical/composer/useAutocompleteGifSearch';
import {
isMemberSearchTrigger,
useAutocompleteMemberSearch,
useAutocompleteSlotMemberSearch,
} from '@app/features/lexical/composer/useAutocompleteMemberSearch';
import type {GuildMember} from '@app/features/member/models/GuildMember';
import GuildMembers from '@app/features/member/state/GuildMembers';
import type {SearchContext} from '@app/features/member/state/MemberSearch';
import * as HighlightCommands from '@app/features/messaging/commands/HighlightCommands';
import * as ReactionCommands from '@app/features/messaging/commands/ReactionCommands';
@@ -62,6 +63,7 @@ import {
buildCommandArgOptions,
buildEmojiAutocompleteOptions,
buildEmojiReactionOptions,
buildMemberSearchRank,
filterDMUsers,
filterGuildMembers,
MENTION_RESULT_LIMIT,
@@ -113,18 +115,45 @@ interface AutocompleteMenuState {
query: string;
}
interface MentionRankingSession {
key: string;
order: Map<string, number>;
nextRank: number;
}
function recordMentionMembers(session: MentionRankingSession, members: ReadonlyArray<GuildMember>): void {
for (const member of members) {
if (!session.order.has(member.user.id)) {
session.order.set(member.user.id, session.nextRank++);
}
function buildRecentSpeakerOptions(
channel: Channel,
limit: number,
): Array<{
type: 'mention';
kind: 'member';
member: GuildMember;
}> {
const guildId = channel.guildId;
const messages = Messages.getCachedMessages(channel.id);
if (guildId == null || messages == null) {
return [];
}
const seen = new Set<string>();
const options: Array<{
type: 'mention';
kind: 'member';
member: GuildMember;
}> = [];
messages.forEach(
(message) => {
if (message.webhookId != null) {
return undefined;
}
const authorId = message.author.id;
if (seen.has(authorId)) {
return undefined;
}
seen.add(authorId);
const member = GuildMembers.getMember(guildId, authorId);
if (member != null) {
options.push({type: 'mention', kind: 'member', member});
}
return options.length < limit;
},
undefined,
true,
);
return options;
}
export type {TriggerType} from '@app/features/messaging/utils/AutocompleteTriggerPolicy';
@@ -154,7 +183,6 @@ export function useLexicalAutocomplete({
const memberSearchContextRef = useRef<SearchContext | null>(null);
const currentGuildIdRef = useRef<string | null>(null);
const memberFetchDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mentionSessionRef = useRef<MentionRankingSession>({key: '', order: new Map(), nextRank: 0});
const [slotAutocompleteContext, setSlotAutocompleteContext] = useState<SlashSlotAutocompleteContext | null>(null);
const slotAutocompleteContextRef = useRef<SlashSlotAutocompleteContext | null>(null);
slotAutocompleteContextRef.current = slotAutocompleteContext;
@@ -231,16 +259,8 @@ export function useLexicalAutocomplete({
return match != null && match.length > 0 && match.length % 2 !== 0;
}, [textUpToCursor]);
const mentionSessionKey =
channel != null && channel.guildId != null && isMemberSearchTrigger(autocompleteTriggerType)
? `${channel.guildId}:${autocompleteTriggerMatchedText}`
: '';
if (mentionSessionRef.current.key !== mentionSessionKey) {
mentionSessionRef.current = {key: mentionSessionKey, order: new Map(), nextRank: 0};
}
const recordCurrentMentionMembers = useCallback((members: ReadonlyArray<GuildMember>): void => {
recordMentionMembers(mentionSessionRef.current, members);
}, []);
const memberSearchRank = useMemo(() => buildMemberSearchRank(memberSearchResults), [memberSearchResults]);
const slotMemberSearchRank = useMemo(() => buildMemberSearchRank(slotMemberSearchResults), [slotMemberSearchResults]);
useAutocompleteMemberSearch({
triggerType: autocompleteTriggerType,
@@ -334,9 +354,8 @@ export function useLexicalAutocomplete({
memberSearchResults,
canManageUser,
canViewChannel,
stableOrder: mentionSessionRef.current.order,
stableOrder: memberSearchRank,
});
recordCurrentMentionMembers(options.filter(isMentionMember).map((option) => option.member));
break;
}
case 'mention': {
@@ -353,14 +372,12 @@ export function useLexicalAutocomplete({
const userOptions = filterDMUsers(users, parsedQuery);
options = channel.isPersonalNotes() ? userOptions : [...userOptions, ...SPECIAL_MENTIONS];
} else {
const members = filterGuildMembers(
memberSearchResults,
parsedQuery,
true,
canViewChannel,
mentionSessionRef.current.order,
);
recordCurrentMentionMembers(members.map((option) => option.member));
const recentSpeakers =
matchedText.length === 0 ? buildRecentSpeakerOptions(channel, MENTION_RESULT_LIMIT) : [];
const members =
recentSpeakers.length > 0
? recentSpeakers
: filterGuildMembers(memberSearchResults, parsedQuery, true, canViewChannel, memberSearchRank);
const mentionableRoles = Guilds.getGuildRoles(channel.guildId).filter(
(role) => canMentionEveryone || role.mentionable,
);
@@ -462,15 +479,15 @@ export function useLexicalAutocomplete({
case 'gif': {
type = 'gif';
const searchQuery = (autocompleteTrigger.match[3] == null ? '' : autocompleteTrigger.match[3]).trim();
if (searchQuery.length > 0 && gifState.status === 'success' && gifState.query === searchQuery) {
options = gifState.results.slice(0, MENTION_RESULT_LIMIT).map((gif) => ({
options = selectAutocompleteGifResults(gifState, searchQuery)
.slice(0, MENTION_RESULT_LIMIT)
.map((gif) => ({
type: 'gif' as const,
gif: {
...gif,
title: gif.title || KlipyUtils.parseTitleFromUrl(gif.url),
},
}));
}
break;
}
case 'sticker': {
@@ -515,9 +532,9 @@ export function useLexicalAutocomplete({
gifState,
hasOpenCodeBlock,
i18n,
memberSearchRank,
memberSearchResults,
permissionVersion,
recordCurrentMentionMembers,
]);
useEffect(() => {
@@ -567,6 +584,7 @@ export function useLexicalAutocomplete({
memberSearchResults: slotMemberSearchResults,
canManageUser,
canViewChannel,
stableOrder: slotMemberSearchRank,
});
case 'channel':
return buildSlotChannelOptions(channel, query);
@@ -575,7 +593,7 @@ export function useLexicalAutocomplete({
default:
return [];
}
}, [canManageUser, canViewChannel, channel, slotAutocompleteContext, slotMemberSearchResults]);
}, [canManageUser, canViewChannel, channel, slotAutocompleteContext, slotMemberSearchRank, slotMemberSearchResults]);
const slotOptionalOptions = useMemo<Array<AutocompleteOption>>(() => {
if (slotOptionalContext == null) {
return [];
@@ -781,6 +799,7 @@ interface BuildSlotUserOptionsParams {
memberSearchResults: Array<GuildMember>;
canManageUser: (otherUserId: string, permission: bigint) => boolean;
canViewChannel: (userId: string) => boolean;
stableOrder: Map<string, number>;
}
function buildSlotUserOptions({
@@ -790,6 +809,7 @@ function buildSlotUserOptions({
memberSearchResults,
canManageUser,
canViewChannel,
stableOrder,
}: BuildSlotUserOptionsParams): Array<AutocompleteOption> {
if (channel == null) {
return [];
@@ -803,6 +823,7 @@ function buildSlotUserOptions({
memberSearchResults,
canManageUser,
canViewChannel,
stableOrder,
});
}
const parsedQuery = parseMentionQuery(query);
@@ -810,7 +831,7 @@ function buildSlotUserOptions({
const users = channel.recipientIds.map((id) => Users.getUser(id)).filter((user): user is User => user != null);
return filterDMUsers(users, parsedQuery);
}
return filterGuildMembers(memberSearchResults, parsedQuery, true, canViewChannel);
return filterGuildMembers(memberSearchResults, parsedQuery, true, canViewChannel, stableOrder);
}
function buildSlotChannelOptions(channel: Channel | null, query: string): Array<AutocompleteOption> {