/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {autoUpdate, FloatingPortal, flip, offset, size, useFloating} from '@floating-ui/react'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; import type {IconProps} from '@phosphor-icons/react'; import { ChatCenteredDotsIcon, EnvelopeSimpleIcon, GlobeIcon, HashIcon, MagnifyingGlassIcon, UsersIcon, XIcon, } from '@phosphor-icons/react'; import {DateTime} from 'luxon'; import {matchSorter} from 'match-sorter'; import {observer} from 'mobx-react-lite'; import React from 'react'; import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators'; import {ChannelTypes} from '~/Constants'; import {ContextMenuCloseProvider} from '~/components/uikit/ContextMenu/ContextMenu'; import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup'; import {MenuItemRadio} from '~/components/uikit/ContextMenu/MenuItemRadio'; import {Tooltip} from '~/components/uikit/Tooltip/Tooltip'; import {useParams} from '~/lib/router'; import type {ChannelRecord} from '~/records/ChannelRecord'; import type {GuildMemberRecord} from '~/records/GuildMemberRecord'; import type {GuildRecord} from '~/records/GuildRecord'; import type {UserRecord} from '~/records/UserRecord'; import ChannelSearchStore, {getChannelSearchContextId} from '~/stores/ChannelSearchStore'; import ChannelStore from '~/stores/ChannelStore'; import GuildMemberStore from '~/stores/GuildMemberStore'; import GuildStore from '~/stores/GuildStore'; import KeyboardModeStore from '~/stores/KeyboardModeStore'; import MemberSearchStore, {type SearchContext} from '~/stores/MemberSearchStore'; import type {SearchHistoryEntry} from '~/stores/SearchHistoryStore'; import SearchHistoryStore from '~/stores/SearchHistoryStore'; import SelectedChannelStore from '~/stores/SelectedChannelStore'; import SelectedGuildStore from '~/stores/SelectedGuildStore'; import UserStore from '~/stores/UserStore'; import * as NicknameUtils from '~/utils/NicknameUtils'; import type {SearchSegment} from '~/utils/SearchSegmentManager'; import type {MessageSearchScope, SearchFilterOption} from '~/utils/SearchUtils'; import {getSearchFilterOptions} from '~/utils/SearchUtils'; import {DEFAULT_SCOPE_VALUE, getScopeOptionsForChannel} from '../searchScopeOptions'; import {ChannelsSection} from './ChannelsSection'; import {DateSection} from './DateSection'; import {FiltersSection} from './FilterOption'; import {HistorySection} from './HistorySection'; import styles from './MessageSearchBar.module.css'; import {UsersSection} from './UsersSection'; import {ValuesSection} from './ValuesSection'; const SCOPE_ICON_COMPONENTS: Record> = { current: HashIcon, all_dms: EnvelopeSimpleIcon, open_dms: ChatCenteredDotsIcon, all_guilds: GlobeIcon, all: UsersIcon, open_dms_and_all_guilds: UsersIcon, }; const USER_FILTER_KEYS = new Set(['from', 'mentions', '-from', '-mentions']); export interface SearchBarProps { channel?: ChannelRecord; value: string; onChange: (value: string, segments: Array) => void; onSearch: () => void; onClear: () => void; isResultsOpen?: boolean; onCloseResults?: () => void; inputRefExternal?: React.Ref; } type AutocompleteType = 'filters' | 'users' | 'channels' | 'values' | 'date' | 'history' | null; interface SearchHints { usersByTag: Record; channelsByName: Record; } type AutocompleteOption = | SearchFilterOption | UserRecord | ChannelRecord | {value: string; label: string} | string | SearchHistoryEntry; const filterRequiresValue = (filter: SearchFilterOption): boolean => { return Boolean(filter.requiresValue) || (filter.values?.length ?? 0) > 0; }; function _deduplicateMembers(members: Array): Array { const seen = new Set(); const result: Array = []; for (const member of members) { if (!seen.has(member.user.id)) { seen.add(member.user.id); result.push(member); } } return result; } function assignRef(ref: React.Ref | undefined, value: T | null): void { if (!ref) { return; } if (typeof ref === 'function') { ref(value); return; } (ref as React.MutableRefObject).current = value; } function normalizeFilterKey(filterKey: string): string { return filterKey.replace(/^-/, ''); } function isDateFilterKey(filterKey: string): boolean { switch (normalizeFilterKey(filterKey)) { case 'before': case 'after': case 'during': case 'on': return true; default: return false; } } function _isUserFilterKey(filterKey: string): boolean { switch (normalizeFilterKey(filterKey)) { case 'from': case 'mentions': return true; default: return false; } } function _isChannelFilterKey(filterKey: string): boolean { return normalizeFilterKey(filterKey) === 'in'; } type GuildSearchMode = 'none' | 'current_guild' | 'all_guilds'; interface UserGuildSearchPlan { mode: GuildSearchMode; guildsToSearch: Array | null; priorityGuildId?: string; workerFilters: {friends?: boolean; guild?: string}; } function getUserGuildSearchPlan(scope: MessageSearchScope, currentGuildId: string | undefined): UserGuildSearchPlan { const SCOPES_WITH_GUILDS = new Set(['current', 'all_guilds', 'all', 'open_dms_and_all_guilds']); const ALL_GUILDS_SCOPES = new Set(['all_guilds', 'all', 'open_dms_and_all_guilds']); if (!SCOPES_WITH_GUILDS.has(scope)) { return { mode: 'none', guildsToSearch: null, priorityGuildId: undefined, workerFilters: {}, }; } if (scope === 'current') { if (!currentGuildId) { return { mode: 'none', guildsToSearch: null, priorityGuildId: undefined, workerFilters: {}, }; } const guild = GuildStore.getGuild(currentGuildId); return { mode: 'current_guild', guildsToSearch: guild ? [guild] : [], priorityGuildId: currentGuildId, workerFilters: {guild: currentGuildId}, }; } if (ALL_GUILDS_SCOPES.has(scope)) { return { mode: 'all_guilds', guildsToSearch: GuildStore.getGuilds(), priorityGuildId: currentGuildId, workerFilters: {}, }; } return { mode: 'none', guildsToSearch: null, priorityGuildId: undefined, workerFilters: {}, }; } type MemberSearchBoosters = Record; function buildUserSearchBoosters( channel: ChannelRecord | undefined, currentGuildId: string | undefined, mode: GuildSearchMode, ) { const boosters: MemberSearchBoosters = {}; if ( channel && (channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM || channel.type === ChannelTypes.DM_PERSONAL_NOTES) ) { for (const id of channel.recipientIds) { boosters[id] = Math.max(boosters[id] ?? 1, 3); } } if (mode === 'all_guilds' && currentGuildId) { const members = GuildMemberStore.getMembers(currentGuildId); const MAX_BOOSTED_MEMBERS = 300; for (let i = 0; i < members.length && i < MAX_BOOSTED_MEMBERS; i += 1) { const id = members[i]!.user.id; boosters[id] = Math.max(boosters[id] ?? 1, 2); } } return boosters; } export const MessageSearchBar = observer( ({ channel, value, onChange, onSearch, onClear, isResultsOpen = false, onCloseResults, inputRefExternal, }: SearchBarProps) => { const {i18n} = useLingui(); const {guildId: routeGuildId} = useParams() as {guildId?: string}; const [isFocused, setIsFocused] = React.useState(false); const [autocompleteType, setAutocompleteType] = React.useState(null); const [selectedIndex, setSelectedIndex] = React.useState(-1); const [hoverIndex, setHoverIndex] = React.useState(-1); const [hasNavigated, setHasNavigated] = React.useState(false); const [hasInteracted, setHasInteracted] = React.useState(false); const [currentFilter, setCurrentFilter] = React.useState(null); const inputRef = React.useRef(null); const [suppressAutoOpen, setSuppressAutoOpen] = React.useState(false); const suppressAutoOpenRef = React.useRef(false); const hintsRef = React.useRef({usersByTag: {}, channelsByName: {}}); const searchContextRef = React.useRef(null); const [memberSearchResults, setMemberSearchResults] = React.useState>([]); const memberFetchDebounceTimerRef = React.useRef(null); const memberFetchQueryRef = React.useRef(''); const selectedGuildId = SelectedGuildStore.selectedGuildId; const channelGuildId = channel?.guildId ?? undefined; const isInGuildChannel = Boolean(channelGuildId); const currentGuildIdForScope = channelGuildId ?? routeGuildId ?? selectedGuildId ?? undefined; const filterOptions = React.useMemo(() => [...getSearchFilterOptions(i18n)], [i18n]); const contextId = React.useMemo( () => getChannelSearchContextId(channel ?? null, selectedGuildId), [channel?.guildId, channel?.id, selectedGuildId], ); const searchContext = contextId ? ChannelSearchStore.getContext(contextId) : null; const activeScope = searchContext?.scope ?? DEFAULT_SCOPE_VALUE; const scopeOptions = React.useMemo( () => getScopeOptionsForChannel(i18n, channel), [i18n, channel?.id, channel?.type, channel?.guildId], ); const scopeOptionValues = React.useMemo(() => new Set(scopeOptions.map((option) => option.value)), [scopeOptions]); React.useEffect(() => { if (!scopeOptions.length || !contextId) { return; } const fallbackScope = scopeOptions[0].value; const currentScope: MessageSearchScope = activeScope ?? fallbackScope; if (!scopeOptionValues.has(currentScope)) { ChannelSearchStore.setScope(contextId, fallbackScope); } }, [scopeOptions, scopeOptionValues, activeScope, contextId]); const handleScopeSelect = React.useCallback( (scope: MessageSearchScope) => { if (!contextId) { return; } ChannelSearchStore.setScope(contextId, scope); }, [contextId], ); const handleScopeMenuOpen = React.useCallback( (event: React.MouseEvent) => { ContextMenuActionCreators.openFromElementBottomRight(event, ({onClose}) => ( {scopeOptions.map((option) => ( handleScopeSelect(option.value)} icon={React.createElement(SCOPE_ICON_COMPONENTS[option.value] ?? HashIcon, { size: 16, weight: 'bold', })} > {option.label} ))} )); }, [handleScopeSelect, scopeOptions, activeScope], ); const activeScopeOption = React.useMemo(() => { if (!scopeOptions.length) { return null; } return scopeOptions.find((opt) => opt.value === activeScope) ?? scopeOptions[0]; }, [scopeOptions, activeScope]); const ScopeIconComponent = React.useMemo(() => { if (!activeScope) { return HashIcon; } return SCOPE_ICON_COMPONENTS[activeScope] ?? HashIcon; }, [activeScope]); const scopeTooltipText = React.useMemo(() => { if (activeScopeOption?.label) { return i18n._(msg`Search scope: ${activeScopeOption.label}`); } return i18n._(msg`Search scope`); }, [i18n, activeScopeOption?.label]); React.useEffect(() => { const handleGlobalKeydown = (event: KeyboardEvent) => { const isFind = (event.key === 'f' || event.key === 'F') && (event.metaKey || event.ctrlKey); if (!isFind) return; event.preventDefault(); event.stopPropagation(); const el = inputRef.current; if (!el) return; el.focus(); const pos = el.value.length; try { el.setSelectionRange(pos, pos); } catch {} }; document.addEventListener('keydown', handleGlobalKeydown, true); return () => document.removeEventListener('keydown', handleGlobalKeydown, true); }, []); React.useEffect(() => { suppressAutoOpenRef.current = suppressAutoOpen; }, [suppressAutoOpen]); React.useEffect(() => { const context = MemberSearchStore.getSearchContext((results) => { const users = results.map((result) => UserStore.getUser(result.id)).filter((u): u is UserRecord => u !== null); setMemberSearchResults(users); }, 25); searchContextRef.current = context; return () => { context.destroy(); searchContextRef.current = null; }; }, []); React.useEffect(() => { if (memberFetchDebounceTimerRef.current) { clearTimeout(memberFetchDebounceTimerRef.current); memberFetchDebounceTimerRef.current = null; } if (autocompleteType !== 'users' || !currentFilter || !_isUserFilterKey(currentFilter.key)) { memberFetchQueryRef.current = ''; const context = searchContextRef.current; if (context) { context.clearQuery(); } setMemberSearchResults([]); return; } const plan = getUserGuildSearchPlan(activeScope, currentGuildIdForScope); if (plan.mode === 'none') { memberFetchQueryRef.current = ''; const context = searchContextRef.current; if (context) { context.clearQuery(); } setMemberSearchResults([]); return; } const cursorPos = inputRef.current?.selectionStart ?? value.length; const textBeforeCursor = value.slice(0, cursorPos); const words = textBeforeCursor.split(/\s+/); const currentWord = words[words.length - 1] || ''; const searchQuery = currentWord.slice(currentFilter.syntax.length).trim(); const context = searchContextRef.current; if (searchQuery.length === 0) { memberFetchQueryRef.current = ''; if (context) { context.clearQuery(); } setMemberSearchResults([]); return; } const fallbackGuildId = currentGuildIdForScope; if (fallbackGuildId) { const cachedMembers = _deduplicateMembers(GuildMemberStore.getMembers(fallbackGuildId)); if (cachedMembers.length > 0) { const localResults = matchSorter(cachedMembers, searchQuery, { keys: [ (member) => NicknameUtils.getNickname(member.user, fallbackGuildId), (member) => member.user.username, (member) => member.user.tag, ], }) .slice(0, 12) .map((m) => m.user); setMemberSearchResults(localResults); } else { setMemberSearchResults([]); } } const boosters = buildUserSearchBoosters(channel, currentGuildIdForScope, plan.mode); if (context) { context.setQuery(searchQuery, plan.workerFilters, new Set(), new Set(), boosters); } if (!plan.guildsToSearch || plan.guildsToSearch.length === 0) { memberFetchQueryRef.current = searchQuery; return; } memberFetchQueryRef.current = searchQuery; const scheduledQuery = searchQuery; memberFetchDebounceTimerRef.current = setTimeout(() => { memberFetchDebounceTimerRef.current = null; if (autocompleteType !== 'users' || !currentFilter || !_isUserFilterKey(currentFilter.key)) { return; } if (memberFetchQueryRef.current !== scheduledQuery) { return; } const guildIds = plan.guildsToSearch?.map((g) => g.id) ?? []; const priorityGuildId = plan.priorityGuildId; void MemberSearchStore.fetchMembersInBackground(scheduledQuery, guildIds, priorityGuildId); }, 300); return () => { if (memberFetchDebounceTimerRef.current) { clearTimeout(memberFetchDebounceTimerRef.current); memberFetchDebounceTimerRef.current = null; } }; }, [autocompleteType, currentFilter, value, activeScope, channel, currentGuildIdForScope]); React.useEffect(() => { if (autocompleteType !== 'users' || !currentFilter) { const context = searchContextRef.current; if (context) { context.clearQuery(); } setMemberSearchResults([]); memberFetchQueryRef.current = ''; if (memberFetchDebounceTimerRef.current) { clearTimeout(memberFetchDebounceTimerRef.current); memberFetchDebounceTimerRef.current = null; } } }, [autocompleteType, currentFilter]); const listboxId = React.useMemo(() => `message-search-listbox-${channel?.id ?? 'global'}`, [channel?.id]); const {refs, floatingStyles, isPositioned} = useFloating({ placement: 'bottom-start', open: isFocused && autocompleteType !== null, whileElementsMounted: autoUpdate, middleware: [ offset(8), flip({padding: 16}), size({ apply({rects, elements}) { const minWidth = 380; const maxWidth = Math.min(window.innerWidth - 32, 480); const width = Math.min(maxWidth, Math.max(rects.reference.width, minWidth)); Object.assign(elements.floating.style, { width: `${width}px`, }); }, padding: 16, }), ], }); const getAutocompleteTypeForFilter = React.useCallback( (filter: SearchFilterOption): AutocompleteType => { const keyBase = normalizeFilterKey(filter.key); switch (keyBase) { case 'before': case 'after': case 'during': case 'on': return 'date'; case 'from': case 'mentions': return 'users'; case 'in': return isInGuildChannel ? 'channels' : 'values'; default: return 'values'; } }, [isInGuildChannel], ); const getAutocompleteOptions = React.useCallback((): Array => { const cursorPos = inputRef.current?.selectionStart ?? value.length; const textBeforeCursor = value.slice(0, cursorPos); const words = textBeforeCursor.split(/\s+/); const currentWord = words[words.length - 1] || ''; switch (autocompleteType) { case 'filters': { const filtered = filterOptions.filter((opt) => { if (opt.requiresGuild && !isInGuildChannel) return false; if (!currentWord) { return !opt.key.startsWith('-'); } const currentWordLower = currentWord.toLowerCase(); if (currentWordLower.startsWith('-')) { return ( (opt.key.startsWith('-') && currentWordLower === '-') || currentWordLower.startsWith(opt.syntax.toLowerCase()) ); } if (opt.key.startsWith('-')) { return false; } return opt.syntax.toLowerCase().includes(currentWordLower); }); const MAX_TYPED_FILTERS = 15; return currentWord ? filtered.slice(0, MAX_TYPED_FILTERS) : filtered; } case 'history': { return SearchHistoryStore.search(currentWord, channel?.id).slice(0, 5); } case 'users': { if (!currentFilter) return []; const searchTerm = currentWord.slice(currentFilter.syntax.length); const plan = getUserGuildSearchPlan(activeScope, currentGuildIdForScope); if (plan.mode !== 'none') { if (memberSearchResults.length > 0) { return memberSearchResults.slice(0, 12); } const fallbackGuildId = currentGuildIdForScope; if (fallbackGuildId) { const isGuildFullyLoaded = GuildMemberStore.isGuildFullyLoaded(fallbackGuildId); if (isGuildFullyLoaded) { const cachedMembers = GuildMemberStore.getMembers(fallbackGuildId); return matchSorter(cachedMembers, searchTerm, { keys: [ (member) => NicknameUtils.getNickname(member.user, fallbackGuildId), (member) => member.user.username, (member) => member.user.tag, ], }) .slice(0, 12) .map((m) => m.user); } } return []; } if (channel) { const users = channel.recipientIds .map((id) => UserStore.getUser(id)) .filter((u): u is UserRecord => u !== null); return matchSorter(users, searchTerm, {keys: ['username', 'tag']}).slice(0, 12); } return []; } case 'channels': { if (!currentFilter) return []; const guildIdForChannels = channelGuildId ?? routeGuildId; if (!guildIdForChannels) return []; const searchTerm = currentWord.slice(currentFilter.syntax.length); const channels = ChannelStore.getGuildChannels(guildIdForChannels).filter( (c) => c.type === ChannelTypes.GUILD_TEXT, ); const recentVisitsForGuild = SelectedChannelStore.recentlyVisitedChannels .filter((visit) => visit.guildId === guildIdForChannels) .sort((a, b) => b.timestamp - a.timestamp); const recencyRank = new Map(); recentVisitsForGuild.forEach((visit, index) => { if (!recencyRank.has(visit.channelId)) { recencyRank.set(visit.channelId, index); } }); const currentChannelId = channel?.id; const matches = matchSorter(channels, searchTerm, {keys: ['name']}); const orderedMatches = [...matches].sort((a, b) => { const resolveRank = (ch: ChannelRecord) => { if (ch.id === currentChannelId) return -1; return recencyRank.get(ch.id) ?? Number.MAX_SAFE_INTEGER; }; const rankDifference = resolveRank(a) - resolveRank(b); if (rankDifference !== 0) { return rankDifference; } return (a.name ?? '').localeCompare(b.name ?? ''); }); return orderedMatches.slice(0, 12); } case 'values': { if (!currentFilter?.values) return []; const searchTerm = currentWord.slice(currentFilter.syntax.length); const matches = matchSorter(currentFilter.values, searchTerm, { keys: ['value', 'label', 'description'], }); const matchValues = new Set(matches.map((option) => option.value)); return currentFilter.values.filter((option) => matchValues.has(option.value)); } case 'date': { const now = DateTime.local(); const fmtDate = (dt: DateTime) => dt.toFormat('yyyy-MM-dd'); const fmtDateTime = (dt: DateTime) => dt.toFormat("yyyy-MM-dd'T'HH:mm"); return [ {label: i18n._(msg`Today`), value: fmtDate(now)}, {label: i18n._(msg`Yesterday`), value: fmtDate(now.minus({days: 1}))}, {label: i18n._(msg`Now`), value: fmtDateTime(now)}, ]; } default: return []; } }, [ autocompleteType, value, filterOptions, isInGuildChannel, currentFilter, channelGuildId, routeGuildId, channel, memberSearchResults, i18n, activeScope, currentGuildIdForScope, ]); const getHistoryCommonFilters = React.useCallback(() => { return filterOptions .filter((opt) => !opt.requiresGuild || isInGuildChannel) .filter((opt) => !opt.key.startsWith('-')); }, [filterOptions, isInGuildChannel]); const getTotalOptions = React.useCallback((): number => { if (!autocompleteType) return 0; if (autocompleteType === 'history') { return getHistoryCommonFilters().length + getAutocompleteOptions().length; } return getAutocompleteOptions().length; }, [autocompleteType, getAutocompleteOptions, getHistoryCommonFilters]); const hasAnyOptions = React.useCallback((): boolean => { return getTotalOptions() > 0; }, [getTotalOptions]); const getSelectedOption = React.useCallback((): AutocompleteOption | null => { if (selectedIndex < 0) return null; if (autocompleteType === 'history') { const commonFilters = getHistoryCommonFilters(); if (selectedIndex < commonFilters.length) { return commonFilters[selectedIndex] ?? null; } const historyOptions = getAutocompleteOptions(); const historyIndex = selectedIndex - commonFilters.length; return historyOptions[historyIndex] ?? null; } const options = getAutocompleteOptions(); return options[selectedIndex] ?? null; }, [selectedIndex, autocompleteType, getAutocompleteOptions, getHistoryCommonFilters]); React.useEffect(() => { if (!isFocused || suppressAutoOpen) { setAutocompleteType(null); setCurrentFilter(null); setSelectedIndex(-1); setHoverIndex(-1); setHasNavigated(false); setHasInteracted(false); return; } const cursorPos = inputRef.current?.selectionStart ?? value.length; const textBeforeCursor = value.slice(0, cursorPos); const words = textBeforeCursor.split(/\s+/); const currentWord = words[words.length - 1] || ''; const matchingFilter = filterOptions.find((opt) => currentWord.startsWith(opt.syntax)); if (matchingFilter) { const afterColon = currentWord.slice(matchingFilter.syntax.length); const filterKeyBase = normalizeFilterKey(matchingFilter.key); if (matchingFilter.requiresGuild && !isInGuildChannel) { setAutocompleteType(null); setCurrentFilter(null); return; } if (isDateFilterKey(filterKeyBase)) { setAutocompleteType('date'); setCurrentFilter(matchingFilter); return; } if (matchingFilter.values && afterColon.length === 0) { setAutocompleteType('values'); setCurrentFilter(matchingFilter); return; } if (filterKeyBase === 'from' || filterKeyBase === 'mentions') { setAutocompleteType('users'); setCurrentFilter(matchingFilter); setSelectedIndex(0); setHasNavigated(false); return; } if (filterKeyBase === 'in' && isInGuildChannel) { setAutocompleteType('channels'); setCurrentFilter(matchingFilter); setSelectedIndex(0); setHasNavigated(false); return; } if (matchingFilter.values) { setAutocompleteType('values'); setCurrentFilter(matchingFilter); return; } setAutocompleteType(null); setCurrentFilter(null); return; } if (currentWord === '') { setAutocompleteType('history'); setCurrentFilter(null); return; } const partialMatch = filterOptions.some((opt) => { return opt.syntax.includes(currentWord) || currentWord.includes(opt.key) || opt.key.includes(currentWord); }); setAutocompleteType(partialMatch ? 'filters' : null); setCurrentFilter(null); }, [value, isFocused, isInGuildChannel, suppressAutoOpen, filterOptions]); React.useEffect(() => { const totalOptions = getTotalOptions(); if (totalOptions > 0 && (selectedIndex >= totalOptions || selectedIndex < -1)) { setSelectedIndex(-1); } }, [autocompleteType, selectedIndex, getTotalOptions]); const setInputRefs = (node: HTMLInputElement | null) => { inputRef.current = node; assignRef(inputRefExternal, node); }; const handleOptionMouseEnter = (index: number) => { setHoverIndex(index); setHasInteracted(true); }; const handleOptionMouseLeave = () => { setHoverIndex(-1); }; const shouldShowKeyboardFocus = hasNavigated || autocompleteType === 'users' || autocompleteType === 'channels'; const shouldShowHover = hasInteracted; const keyboardFocusIndex = shouldShowKeyboardFocus ? selectedIndex : -1; const hoverIndexForRender = shouldShowHover ? hoverIndex : -1; const getAriaActiveDescendant = React.useCallback((): string | undefined => { if (!isFocused || autocompleteType === null) return undefined; const totalOptions = getTotalOptions(); if (totalOptions <= 0) return undefined; const showFocus = shouldShowKeyboardFocus || shouldShowHover; if (!showFocus) return undefined; if (selectedIndex < 0) return undefined; return `${listboxId}-opt-${selectedIndex}`; }, [ isFocused, autocompleteType, getTotalOptions, shouldShowKeyboardFocus, shouldShowHover, selectedIndex, listboxId, ]); const handleAutocompleteSelect = (option: AutocompleteOption) => { const cursorPos = inputRef.current?.selectionStart ?? value.length; const textBeforeCursor = value.slice(0, cursorPos); const textAfterCursor = value.slice(cursorPos); const words = textBeforeCursor.split(/\s+/); const currentWord = words[words.length - 1] || ''; const lastWordStart = textBeforeCursor.length - currentWord.length; let newText = ''; let newCursorPos = 0; let shouldSubmit = false; const insertToken = (syntax: string, tokenValue: string, addSpaceAfter = true) => { const needsQuotes = /\s/.test(tokenValue); const display = needsQuotes ? `${syntax}"${tokenValue}"` : `${syntax}${tokenValue}`; const before = textBeforeCursor.slice(0, lastWordStart); const space = addSpaceAfter ? ' ' : ''; newText = `${before}${display}${space}${textAfterCursor}`; newCursorPos = (before + display).length + space.length; }; switch (autocompleteType) { case 'filters': { const filter = option as SearchFilterOption; const requiresValue = filterRequiresValue(filter); insertToken(filter.syntax, '', !requiresValue); shouldSubmit = !requiresValue; break; } case 'users': { const user = option as UserRecord; const tag = `${user.username}#${user.discriminator}`; insertToken(currentFilter!.syntax, tag); hintsRef.current.usersByTag[tag] = user.id; shouldSubmit = true; break; } case 'channels': { const ch = option as ChannelRecord; const name = ch.name || i18n._(msg`Unnamed`); insertToken(currentFilter!.syntax, name); hintsRef.current.channelsByName[name] = ch.id; shouldSubmit = true; break; } case 'values': { const valueOption = option as {value: string; label: string}; const before = textBeforeCursor.slice(0, lastWordStart); const display = `${currentFilter!.syntax}${valueOption.value}`; newText = `${before}${display} ${textAfterCursor}`; newCursorPos = (before + display).length + 1; shouldSubmit = true; break; } case 'date': { const dateOption = option as {value: string; label: string}; const before = textBeforeCursor.slice(0, lastWordStart); const display = `${currentFilter!.syntax}${dateOption.value}`; newText = `${before}${display} ${textAfterCursor}`; newCursorPos = (before + display).length + 1; shouldSubmit = true; break; } case 'history': { const entry = option as SearchHistoryEntry; newText = entry.query; newCursorPos = newText.length; const segments: Array = []; if (entry.hints?.usersByTag) { for (const [tag, id] of Object.entries(entry.hints.usersByTag)) { segments.push({type: 'user', filterKey: 'from', id, displayText: `from:${tag}`, start: 0, end: 0}); segments.push({ type: 'user', filterKey: 'mentions', id, displayText: `mentions:${tag}`, start: 0, end: 0, }); } } if (entry.hints?.channelsByName) { for (const [name, id] of Object.entries(entry.hints.channelsByName)) { const disp = /\s/.test(name) ? `in:"${name}"` : `in:${name}`; segments.push({type: 'channel', filterKey: 'in', id, displayText: disp, start: 0, end: 0}); } } onChange(newText, segments); SearchHistoryStore.add(newText, channel?.id, entry.hints); setTimeout(() => { inputRef.current?.focus(); inputRef.current?.setSelectionRange(newCursorPos, newCursorPos); }, 0); setSelectedIndex(-1); setAutocompleteType(null); setCurrentFilter(null); setSuppressAutoOpen(true); setTimeout(() => onSearch(), 0); return; } default: return; } onChange(newText, []); setTimeout(() => { inputRef.current?.focus(); inputRef.current?.setSelectionRange(newCursorPos, newCursorPos); }, 0); setSelectedIndex(-1); setAutocompleteType(null); setCurrentFilter(null); if (shouldSubmit && newText.trim().length > 0) { SearchHistoryStore.add(newText, channel?.id, hintsRef.current); setSuppressAutoOpen(true); setTimeout(() => onSearch(), 0); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === '?' && autocompleteType === null) { e.preventDefault(); return; } if (e.key === 'Enter' && autocompleteType === null) { e.preventDefault(); SearchHistoryStore.add(value, channel?.id, hintsRef.current); setAutocompleteType(null); setCurrentFilter(null); setHasNavigated(false); setSuppressAutoOpen(true); suppressAutoOpenRef.current = true; onSearch(); return; } if (e.key === 'Escape') { e.preventDefault(); if (value.trim().length > 0) { onChange('', []); setSelectedIndex(-1); setHasNavigated(false); setSuppressAutoOpen(false); return; } if (isResultsOpen) { setAutocompleteType(null); setCurrentFilter(null); return; } setAutocompleteType(null); setCurrentFilter(null); inputRef.current?.blur(); return; } if (!autocompleteType) { return; } const totalOptions = getTotalOptions(); if (totalOptions <= 0) { return; } switch (e.key) { case 'ArrowDown': { e.preventDefault(); setSelectedIndex((prev) => (prev + 1) % totalOptions); setHasNavigated(true); return; } case 'ArrowUp': { e.preventDefault(); setSelectedIndex((prev) => { if (prev === -1) return totalOptions - 1; return (prev - 1 + totalOptions) % totalOptions; }); setHasNavigated(true); return; } case 'Home': { e.preventDefault(); setSelectedIndex(0); setHasNavigated(true); return; } case 'End': { e.preventDefault(); setSelectedIndex(totalOptions - 1); setHasNavigated(true); return; } case 'Enter': case 'Tab': { e.preventDefault(); let shouldAutoSelect = hasNavigated; if (autocompleteType === 'users' || autocompleteType === 'channels') { shouldAutoSelect = true; } const cursorPos = inputRef.current?.selectionStart ?? value.length; const textBeforeCursor = value.slice(0, cursorPos); const words = textBeforeCursor.split(/\s+/); const currentWord = words[words.length - 1] || ''; const matchingFilter = filterOptions.find((opt) => currentWord.startsWith(opt.syntax)); const afterColon = matchingFilter ? currentWord.slice(matchingFilter.syntax.length) : ''; if ( e.key === 'Enter' && autocompleteType === 'users' && matchingFilter && USER_FILTER_KEYS.has(matchingFilter.key) && afterColon.length > 0 && getAutocompleteOptions().length === 0 ) { SearchHistoryStore.add(value, channel?.id, hintsRef.current); setAutocompleteType(null); setCurrentFilter(null); setHasNavigated(false); setSuppressAutoOpen(true); suppressAutoOpenRef.current = true; onSearch(); return; } if (matchingFilter) { const requiresValue = filterRequiresValue(matchingFilter); if (!shouldAutoSelect && requiresValue && afterColon.length === 0) { return; } } if (!shouldAutoSelect) { SearchHistoryStore.add(value, channel?.id, hintsRef.current); setAutocompleteType(null); setCurrentFilter(null); setHasNavigated(false); setSuppressAutoOpen(true); suppressAutoOpenRef.current = true; setTimeout(() => onSearch(), 0); return; } const selected = getSelectedOption(); if (selected) { const isFilterOptionInHistory = autocompleteType === 'history' && typeof selected === 'object' && selected !== null && 'key' in selected; if (isFilterOptionInHistory) { const filter = selected as SearchFilterOption; const cursorPosInner = inputRef.current?.selectionStart ?? value.length; const textBeforeCursorInner = value.slice(0, cursorPosInner); const textAfterCursorInner = value.slice(cursorPosInner); const wordsInner = textBeforeCursorInner.split(/\s+/); const currentWordInner = wordsInner[wordsInner.length - 1] || ''; const lastWordStartInner = textBeforeCursorInner.length - currentWordInner.length; const display = filter.syntax; const before = textBeforeCursorInner.slice(0, lastWordStartInner); const requiresValue = filterRequiresValue(filter); const space = requiresValue ? '' : ' '; const newText = `${before}${display}${space}${textAfterCursorInner}`; const newCursorPos = (before + display).length + space.length; onChange(newText, []); setTimeout(() => { inputRef.current?.setSelectionRange(newCursorPos, newCursorPos); }, 0); if (!requiresValue) { setTimeout(() => { SearchHistoryStore.add(newText, channel?.id, hintsRef.current); setSuppressAutoOpen(true); setTimeout(() => onSearch(), 0); setAutocompleteType(null); setCurrentFilter(null); }, 10); return; } setCurrentFilter(filter); setAutocompleteType(getAutocompleteTypeForFilter(filter)); setSelectedIndex(-1); setHasNavigated(false); return; } handleAutocompleteSelect(selected); } return; } default: return; } }; const handleInputChange = (e: React.ChangeEvent) => { onChange(e.target.value, []); setHasNavigated(false); setSuppressAutoOpen(false); setHasInteracted(false); }; const handleHistoryClear = () => { SearchHistoryStore.clear(channel?.id); setAutocompleteType('filters'); setSelectedIndex(-1); }; const handleFilterSelect = (filter: SearchFilterOption, index: number) => { setSelectedIndex(index); const cursorPos = inputRef.current?.selectionStart ?? value.length; const textBeforeCursor = value.slice(0, cursorPos); const textAfterCursor = value.slice(cursorPos); const words = textBeforeCursor.split(/\s+/); const currentWord = words[words.length - 1] || ''; const lastWordStart = textBeforeCursor.length - currentWord.length; const display = filter.syntax; const before = textBeforeCursor.slice(0, lastWordStart); const requiresValue = filterRequiresValue(filter); const space = requiresValue ? '' : ' '; const newText = `${before}${display}${space}${textAfterCursor}`; const newCursorPos = (before + display).length + space.length; onChange(newText, []); setTimeout(() => { inputRef.current?.setSelectionRange(newCursorPos, newCursorPos); }, 0); if (!requiresValue) { setTimeout(() => { SearchHistoryStore.add(newText, channel?.id, hintsRef.current); setSuppressAutoOpen(true); setTimeout(() => onSearch(), 0); setAutocompleteType(null); setCurrentFilter(null); }, 10); return; } setCurrentFilter(filter); setAutocompleteType(getAutocompleteTypeForFilter(filter)); }; const renderAutocompleteContent = () => { switch (autocompleteType) { case 'filters': return ( } selectedIndex={keyboardFocusIndex} hoverIndex={hoverIndexForRender} onSelect={handleAutocompleteSelect} onMouseEnter={handleOptionMouseEnter} onMouseLeave={handleOptionMouseLeave} listboxId={listboxId} /> ); case 'history': return ( { setHoverIndex(index); setHasInteracted(true); }} onFilterMouseLeave={handleOptionMouseLeave} filterOptions={filterOptions} /> ); case 'users': return ( } selectedIndex={keyboardFocusIndex} hoverIndex={hoverIndexForRender} onSelect={handleAutocompleteSelect} onMouseEnter={handleOptionMouseEnter} onMouseLeave={handleOptionMouseLeave} listboxId={listboxId} guildId={currentGuildIdForScope} isInGuild={isInGuildChannel} /> ); case 'channels': return ( } selectedIndex={keyboardFocusIndex} hoverIndex={hoverIndexForRender} onSelect={handleAutocompleteSelect} onMouseEnter={handleOptionMouseEnter} onMouseLeave={handleOptionMouseLeave} listboxId={listboxId} /> ); case 'values': return ( } selectedIndex={keyboardFocusIndex} hoverIndex={hoverIndexForRender} onSelect={handleAutocompleteSelect} onMouseEnter={handleOptionMouseEnter} onMouseLeave={handleOptionMouseLeave} listboxId={listboxId} /> ); case 'date': return ( ); default: return null; } }; const hasValue = value.length > 0; const ariaActiveDescendant = getAriaActiveDescendant(); return ( <>
setSuppressAutoOpen(false)} onKeyDown={handleKeyDown} onFocus={() => { setIsFocused(true); if (KeyboardModeStore.keyboardModeEnabled) { setSuppressAutoOpen(false); } }} onBlur={() => { setIsFocused(false); if (isResultsOpen && value.trim().length === 0) { onCloseResults?.(); } }} role="combobox" aria-autocomplete="list" aria-expanded={isFocused && autocompleteType !== null} aria-controls={isFocused && autocompleteType !== null ? listboxId : undefined} aria-activedescendant={ariaActiveDescendant} placeholder={i18n._(msg`Search messages`)} className={styles.input} /> {hasValue && ( )}
{isFocused && autocompleteType && hasAnyOptions() && ( {/* biome-ignore lint/a11y/noStaticElementInteractions: This wrapper intercepts mousedown to preserve input focus for the listbox. */}
{ if (e.button === 0) e.preventDefault(); }} >
{renderAutocompleteContent()}
)} ); }, );