mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(app): suggest recent speakers and stop autocomplete list flicker (#1890)
This commit is contained in:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user