Files
fx-test/fluxer_app/src/components/channel/MessageSearchBar/MessageSearchBar.tsx

1490 lines
44 KiB
TypeScript

/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<MessageSearchScope, React.ComponentType<IconProps>> = {
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<SearchSegment>) => void;
onSearch: () => void;
onClear: () => void;
isResultsOpen?: boolean;
onCloseResults?: () => void;
inputRefExternal?: React.Ref<HTMLInputElement>;
}
type AutocompleteType = 'filters' | 'users' | 'channels' | 'values' | 'date' | 'history' | null;
interface SearchHints {
usersByTag: Record<string, string>;
channelsByName: Record<string, string>;
}
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<GuildMemberRecord>): Array<GuildMemberRecord> {
const seen = new Set<string>();
const result: Array<GuildMemberRecord> = [];
for (const member of members) {
if (!seen.has(member.user.id)) {
seen.add(member.user.id);
result.push(member);
}
}
return result;
}
function assignRef<T>(ref: React.Ref<T> | undefined, value: T | null): void {
if (!ref) {
return;
}
if (typeof ref === 'function') {
ref(value);
return;
}
(ref as React.MutableRefObject<T | null>).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<GuildRecord> | null;
priorityGuildId?: string;
workerFilters: {friends?: boolean; guild?: string};
}
function getUserGuildSearchPlan(scope: MessageSearchScope, currentGuildId: string | undefined): UserGuildSearchPlan {
const SCOPES_WITH_GUILDS = new Set<MessageSearchScope>(['current', 'all_guilds', 'all', 'open_dms_and_all_guilds']);
const ALL_GUILDS_SCOPES = new Set<MessageSearchScope>(['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<string, number>;
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<AutocompleteType>(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<SearchFilterOption | null>(null);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [suppressAutoOpen, setSuppressAutoOpen] = React.useState(false);
const suppressAutoOpenRef = React.useRef(false);
const hintsRef = React.useRef<SearchHints>({usersByTag: {}, channelsByName: {}});
const searchContextRef = React.useRef<SearchContext | null>(null);
const [memberSearchResults, setMemberSearchResults] = React.useState<Array<UserRecord>>([]);
const memberFetchDebounceTimerRef = React.useRef<NodeJS.Timeout | null>(null);
const memberFetchQueryRef = React.useRef<string>('');
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<HTMLButtonElement>) => {
ContextMenuActionCreators.openFromElementBottomRight(event, ({onClose}) => (
<ContextMenuCloseProvider value={onClose}>
<MenuGroup>
{scopeOptions.map((option) => (
<MenuItemRadio
key={option.value}
selected={activeScope === option.value}
closeOnSelect
onSelect={() => handleScopeSelect(option.value)}
icon={React.createElement(SCOPE_ICON_COMPONENTS[option.value] ?? HashIcon, {
size: 16,
weight: 'bold',
})}
>
{option.label}
</MenuItemRadio>
))}
</MenuGroup>
</ContextMenuCloseProvider>
));
},
[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<AutocompleteOption> => {
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<string, number>();
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<SearchSegment> = [];
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<FiltersSection
options={getAutocompleteOptions() as Array<SearchFilterOption>}
selectedIndex={keyboardFocusIndex}
hoverIndex={hoverIndexForRender}
onSelect={handleAutocompleteSelect}
onMouseEnter={handleOptionMouseEnter}
onMouseLeave={handleOptionMouseLeave}
listboxId={listboxId}
/>
);
case 'history':
return (
<HistorySection
selectedIndex={keyboardFocusIndex}
hoverIndex={hoverIndexForRender}
onSelect={handleAutocompleteSelect}
onMouseEnter={handleOptionMouseEnter}
onMouseLeave={handleOptionMouseLeave}
listboxId={listboxId}
isInGuild={isInGuildChannel}
channelId={channel?.id}
onHistoryClear={handleHistoryClear}
onFilterSelect={handleFilterSelect}
onFilterMouseEnter={(index) => {
setHoverIndex(index);
setHasInteracted(true);
}}
onFilterMouseLeave={handleOptionMouseLeave}
filterOptions={filterOptions}
/>
);
case 'users':
return (
<UsersSection
options={getAutocompleteOptions() as Array<UserRecord>}
selectedIndex={keyboardFocusIndex}
hoverIndex={hoverIndexForRender}
onSelect={handleAutocompleteSelect}
onMouseEnter={handleOptionMouseEnter}
onMouseLeave={handleOptionMouseLeave}
listboxId={listboxId}
guildId={currentGuildIdForScope}
isInGuild={isInGuildChannel}
/>
);
case 'channels':
return (
<ChannelsSection
options={getAutocompleteOptions() as Array<ChannelRecord>}
selectedIndex={keyboardFocusIndex}
hoverIndex={hoverIndexForRender}
onSelect={handleAutocompleteSelect}
onMouseEnter={handleOptionMouseEnter}
onMouseLeave={handleOptionMouseLeave}
listboxId={listboxId}
/>
);
case 'values':
return (
<ValuesSection
options={getAutocompleteOptions() as Array<{value: string; label: string}>}
selectedIndex={keyboardFocusIndex}
hoverIndex={hoverIndexForRender}
onSelect={handleAutocompleteSelect}
onMouseEnter={handleOptionMouseEnter}
onMouseLeave={handleOptionMouseLeave}
listboxId={listboxId}
/>
);
case 'date':
return (
<DateSection
selectedIndex={keyboardFocusIndex}
hoverIndex={hoverIndexForRender}
onSelect={handleAutocompleteSelect}
onMouseEnter={handleOptionMouseEnter}
onMouseLeave={handleOptionMouseLeave}
listboxId={listboxId}
/>
);
default:
return null;
}
};
const hasValue = value.length > 0;
const ariaActiveDescendant = getAriaActiveDescendant();
return (
<>
<div ref={refs.setReference} className={styles.anchor}>
<div className={styles.inputContainer}>
<Tooltip text={scopeTooltipText} position="bottom">
<button
type="button"
className={styles.scopeButton}
onClick={handleScopeMenuOpen}
aria-label={scopeTooltipText}
>
<MagnifyingGlassIcon className={styles.searchIcon} weight="bold" />
<span className={styles.scopeBadge}>
<ScopeIconComponent size={8} weight="bold" />
</span>
</button>
</Tooltip>
<input
ref={setInputRefs}
type="text"
value={value}
onChange={handleInputChange}
onMouseDown={() => 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 && (
<button
type="button"
onMouseDown={(ev) => ev.preventDefault()}
onClick={() => {
onClear();
setSelectedIndex(-1);
inputRef.current?.focus();
}}
className={styles.clearButton}
aria-label={i18n._(msg`Clear search`)}
>
<XIcon weight="bold" className={styles.optionMetaIcon} />
</button>
)}
</div>
</div>
{isFocused && autocompleteType && hasAnyOptions() && (
<FloatingPortal>
{/* biome-ignore lint/a11y/noStaticElementInteractions: This wrapper intercepts mousedown to preserve input focus for the listbox. */}
<div
ref={refs.setFloating}
style={{...floatingStyles, visibility: isPositioned ? 'visible' : 'hidden'}}
className={styles.popoutContainer}
onMouseDown={(e) => {
if (e.button === 0) e.preventDefault();
}}
>
<div className={styles.popoutInner}>
<div className={`${styles.flex} ${styles.flexCol}`}>
<div
id={listboxId}
role="listbox"
aria-label={i18n._(msg`Search suggestions`)}
className={styles.list}
>
{renderAutocompleteContent()}
</div>
</div>
</div>
</div>
</FloatingPortal>
)}
</>
);
},
);