From 7fe5aad16e8975d9275545f29161a820b9c7cbfd Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:40:19 +0200 Subject: [PATCH] feat(app): suggest recent speakers and stop autocomplete list flicker (#1890) --- .../composer/useAutocompleteGifSearch.ts | 23 +++-- .../composer/useAutocompleteMemberSearch.ts | 13 +-- .../composer/useLexicalAutocomplete.ts | 99 +++++++++++-------- 3 files changed, 84 insertions(+), 51 deletions(-) diff --git a/fluxer_app/src/features/lexical/composer/useAutocompleteGifSearch.ts b/fluxer_app/src/features/lexical/composer/useAutocompleteGifSearch.ts index ca27ea8d0..540b177f3 100644 --- a/fluxer_app/src/features/lexical/composer/useAutocompleteGifSearch.ts +++ b/fluxer_app/src/features/lexical/composer/useAutocompleteGifSearch.ts @@ -13,6 +13,19 @@ export interface GifAutocompleteSearchState { results: Array; } +const GIF_SEARCH_DEBOUNCE_MS = 250; +const NO_GIF_RESULTS: ReadonlyArray = []; + +export function selectAutocompleteGifResults( + state: GifAutocompleteSearchState, + searchQuery: string, +): ReadonlyArray { + if (searchQuery.length === 0) { + return NO_GIF_RESULTS; + } + return state.results; +} + interface MutableValue { 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; diff --git a/fluxer_app/src/features/lexical/composer/useAutocompleteMemberSearch.ts b/fluxer_app/src/features/lexical/composer/useAutocompleteMemberSearch.ts index 281cf681d..4e4a9985b 100644 --- a/fluxer_app/src/features/lexical/composer/useAutocompleteMemberSearch.ts +++ b/fluxer_app/src/features/lexical/composer/useAutocompleteMemberSearch.ts @@ -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 { current: T; } @@ -33,7 +34,7 @@ interface SlotMemberSearchLifecycle { setResults: (results: Array) => 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]); } diff --git a/fluxer_app/src/features/lexical/composer/useLexicalAutocomplete.ts b/fluxer_app/src/features/lexical/composer/useLexicalAutocomplete.ts index e0dab0786..30403dc85 100644 --- a/fluxer_app/src/features/lexical/composer/useLexicalAutocomplete.ts +++ b/fluxer_app/src/features/lexical/composer/useLexicalAutocomplete.ts @@ -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; - nextRank: number; -} - -function recordMentionMembers(session: MentionRankingSession, members: ReadonlyArray): 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(); + 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(null); const currentGuildIdRef = useRef(null); const memberFetchDebounceTimerRef = useRef | null>(null); - const mentionSessionRef = useRef({key: '', order: new Map(), nextRank: 0}); const [slotAutocompleteContext, setSlotAutocompleteContext] = useState(null); const slotAutocompleteContextRef = useRef(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): 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>(() => { if (slotOptionalContext == null) { return []; @@ -781,6 +799,7 @@ interface BuildSlotUserOptionsParams { memberSearchResults: Array; canManageUser: (otherUserId: string, permission: bigint) => boolean; canViewChannel: (userId: string) => boolean; + stableOrder: Map; } function buildSlotUserOptions({ @@ -790,6 +809,7 @@ function buildSlotUserOptions({ memberSearchResults, canManageUser, canViewChannel, + stableOrder, }: BuildSlotUserOptionsParams): Array { 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 {