feat(app): suggest filters and people while typing search text (#1957)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:52:58 +02:00
1 parent e68b5b8b5a
commit 2af4cc98fd
8 files changed
+650 -241

No files matched your search

@@ -16,10 +16,11 @@ interface AutocompleteOptionProps {
onMouseLeave?: () => void;
children: React.ReactNode;
listboxId: string;
metaIcon?: React.ReactNode;
}
export const AutocompleteOption: React.FC<AutocompleteOptionProps> = observer(
({index, isSelected, isHovered, onSelect, onMouseEnter, onMouseLeave, children, listboxId}) => {
({index, isSelected, isHovered, onSelect, onMouseEnter, onMouseLeave, children, listboxId, metaIcon}) => {
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isKeyboardActivationKey(e.key)) {
@@ -48,11 +49,20 @@ export const AutocompleteOption: React.FC<AutocompleteOptionProps> = observer(
data-flx="channel.message-search-bar.autocomplete-option.option.select"
>
{children}
<PlusIcon
weight="bold"
className={`${styles.optionMetaIcon} ${showIcon ? '' : styles.optionMetaIconInactive}`}
data-flx="channel.message-search-bar.autocomplete-option.option-meta-icon"
/>
{metaIcon == null ? (
<PlusIcon
weight="bold"
className={`${styles.optionMetaIcon} ${showIcon ? '' : styles.optionMetaIconInactive}`}
data-flx="channel.message-search-bar.autocomplete-option.option-meta-icon"
/>
) : (
<span
className={styles.optionMetaIcon}
data-flx="channel.message-search-bar.autocomplete-option.option-meta-slot"
>
{metaIcon}
</span>
)}
</div>
);
},
@@ -4,6 +4,7 @@ import {AutocompleteOption} from '@app/features/channel/components/message_searc
import styles from '@app/features/channel/components/message_search_bar/MessageSearchBar.module.css';
import type {Channel} from '@app/features/channel/models/Channel';
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
import {resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
@@ -71,7 +72,7 @@ export const ChannelsSection: React.FC<ChannelsSectionProps> = observer(
className={styles.channelName}
data-flx="channel.message-search-bar.channels-section.channel-name"
>
{channelOption.name || 'Unnamed Channel'}
{resolveSearchChannelDisplayName(channelOption) || 'Unnamed Channel'}
</span>
</div>
</div>
@@ -3,10 +3,9 @@
import {AutocompleteOption} from '@app/features/channel/components/message_search_bar/AutocompleteOption';
import {FilterOption} from '@app/features/channel/components/message_search_bar/FilterOption';
import styles from '@app/features/channel/components/message_search_bar/MessageSearchBar.module.css';
import type {HistoryFilterRow} from '@app/features/channel/components/message_search_bar/MessageSearchBarTypes';
import type {SearchHistoryEntry} from '@app/features/search/state/SearchHistory';
import SearchHistory from '@app/features/search/state/SearchHistory';
import {formatSearchHistoryEntryForStreamerMode} from '@app/features/search/utils/SearchPrivacyUtils';
import type {SearchFilterOption} from '@app/features/search/utils/SearchUtils';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
@@ -34,13 +33,12 @@ interface HistorySectionProps {
onMouseEnter: (index: number) => void;
onMouseLeave?: () => void;
listboxId: string;
isInGuild: boolean;
channelId?: string;
onHistoryClear: () => void;
onFilterSelect: (filter: SearchFilterOption, index: number) => void;
onFilterSelect: (row: HistoryFilterRow, index: number) => void;
onFilterMouseEnter: (index: number) => void;
onFilterMouseLeave?: () => void;
filterOptions: Array<SearchFilterOption>;
filterRows: Array<HistoryFilterRow>;
historyOptions: Array<SearchHistoryEntry>;
}
export const HistorySection: React.FC<HistorySectionProps> = observer(
@@ -51,19 +49,14 @@ export const HistorySection: React.FC<HistorySectionProps> = observer(
onMouseEnter,
onMouseLeave,
listboxId,
isInGuild,
channelId,
onHistoryClear,
onFilterSelect,
onFilterMouseEnter,
onFilterMouseLeave,
filterOptions,
filterRows,
historyOptions,
}) => {
const {i18n} = useLingui();
const historyOptions = SearchHistory.search('', channelId).slice(0, 5);
const commonFilters = filterOptions
.filter((opt) => !opt.requiresGuild || isInGuild)
.filter((opt) => !opt.key.startsWith('-'));
return (
<>
<div className={styles.popoutSection} data-flx="channel.message-search-bar.history-section.popout-section">
@@ -83,20 +76,25 @@ export const HistorySection: React.FC<HistorySectionProps> = observer(
{i18n._(SEARCH_FILTERS_DESCRIPTOR)}
</span>
</div>
{commonFilters.map((option: SearchFilterOption, index) => (
<FilterOption
key={option.key}
option={option}
index={index}
isSelected={selectedIndex === index}
isHovered={index === hoverIndex}
onSelect={() => onFilterSelect(option, index)}
onMouseEnter={() => onFilterMouseEnter(index)}
onMouseLeave={onFilterMouseLeave}
listboxId={listboxId}
data-flx="channel.message-search-bar.history-section.filter-option.filter-select"
/>
))}
{filterRows.map((row: HistoryFilterRow, index) => {
if (row.kind === 'filter') {
return (
<FilterOption
key={row.option.key}
option={row.option}
index={index}
isSelected={selectedIndex === index}
isHovered={index === hoverIndex}
onSelect={() => onFilterSelect(row, index)}
onMouseEnter={() => onFilterMouseEnter(index)}
onMouseLeave={onFilterMouseLeave}
listboxId={listboxId}
data-flx="channel.message-search-bar.history-section.filter-option.filter-select"
/>
);
}
return null;
})}
</div>
{historyOptions.length > 0 && (
<div className={styles.popoutSection} data-flx="channel.message-search-bar.history-section.popout-section--2">
@@ -138,11 +136,11 @@ export const HistorySection: React.FC<HistorySectionProps> = observer(
return (
<AutocompleteOption
key={`${entry.query}:${entry.ts}`}
index={commonFilters.length + index}
isSelected={selectedIndex === commonFilters.length + index}
isHovered={commonFilters.length + index === hoverIndex}
index={filterRows.length + index}
isSelected={selectedIndex === filterRows.length + index}
isHovered={filterRows.length + index === hoverIndex}
onSelect={() => onSelect(entry)}
onMouseEnter={() => onMouseEnter(commonFilters.length + index)}
onMouseEnter={() => onMouseEnter(filterRows.length + index)}
onMouseLeave={onMouseLeave}
listboxId={listboxId}
data-flx="channel.message-search-bar.history-section.autocomplete-option.select"
@@ -5,8 +5,12 @@ import {DateSection} from '@app/features/channel/components/message_search_bar/D
import {FiltersSection} from '@app/features/channel/components/message_search_bar/FilterOption';
import {HistorySection} from '@app/features/channel/components/message_search_bar/HistorySection';
import styles from '@app/features/channel/components/message_search_bar/MessageSearchBar.module.css';
import type {SearchBarProps} from '@app/features/channel/components/message_search_bar/MessageSearchBarTypes';
import type {
PlaintextAutocompleteRow,
SearchBarProps,
} from '@app/features/channel/components/message_search_bar/MessageSearchBarTypes';
import {SCOPE_ICON_COMPONENTS} from '@app/features/channel/components/message_search_bar/MessageSearchBarUtils';
import {PlaintextSection} from '@app/features/channel/components/message_search_bar/PlaintextSection';
import {UsersSection} from '@app/features/channel/components/message_search_bar/UsersSection';
import {useMessageSearchAutocomplete} from '@app/features/channel/components/message_search_bar/useMessageSearchAutocomplete';
import {ValuesSection} from '@app/features/channel/components/message_search_bar/ValuesSection';
@@ -15,14 +19,17 @@ import type {Channel} from '@app/features/channel/models/Channel';
import ChannelSearch, {getChannelSearchContextId} from '@app/features/channel/state/ChannelSearch';
import {CLEAR_SEARCH_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {LexicalSearchInput} from '@app/features/lexical/search/LexicalSearchInput';
import {SEARCH_INPUT_MAX_LENGTH, shouldShowSearchInputCounter} from '@app/features/lexical/search/SearchEditorModel';
import SelectedGuild from '@app/features/navigation/state/SelectedGuild';
import {useParams} from '@app/features/platform/components/router/RouterReact';
import {PASSWORD_MANAGER_IGNORE_ATTRIBUTES} from '@app/features/platform/utils/PasswordManagerAutocomplete';
import type {SearchHistoryEntry} from '@app/features/search/state/SearchHistory';
import type {MessageSearchScope, SearchFilterOption} from '@app/features/search/utils/SearchUtils';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {ContextMenuCloseProvider} from '@app/features/ui/action_menu/ContextMenu';
import {MenuGroup} from '@app/features/ui/action_menu/MenuGroup';
import {MenuItemRadio} from '@app/features/ui/action_menu/MenuItemRadio';
import {CharacterCounter} from '@app/features/ui/character_counter/CharacterCounter';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext';
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
@@ -73,6 +80,8 @@ const ACTIVE_SUGGESTION_POSITION_DESCRIPTOR = msg({
comment:
'Screen-reader live-region announcement of the currently highlighted message search suggestion position. Preserve {position} and {total}; they are inserted by code.',
});
const noopUpgradeClick = () => {};
export const MessageSearchBar = observer(
({
channel,
@@ -177,7 +186,7 @@ export const MessageSearchBar = observer(
inputRef,
setInputRefs,
setSuppressAutoOpen,
filterOptions,
historyFilterRows,
listboxId,
keyboardFocusIndex,
hoverIndexForRender,
@@ -208,6 +217,7 @@ export const MessageSearchBar = observer(
contextId,
inputRefExternal,
isResultsOpen,
onCloseResults,
});
const portalHost = usePortalHost();
const {refs, floatingStyles, isPositioned} = useFloating({
@@ -254,8 +264,6 @@ export const MessageSearchBar = observer(
onMouseEnter={handleOptionMouseEnter}
onMouseLeave={handleOptionMouseLeave}
listboxId={listboxId}
isInGuild={isInGuildChannel}
channelId={channel?.id}
onHistoryClear={handleHistoryClear}
onFilterSelect={handleFilterSelect}
onFilterMouseEnter={(index) => {
@@ -263,7 +271,8 @@ export const MessageSearchBar = observer(
setHasInteracted(true);
}}
onFilterMouseLeave={handleOptionMouseLeave}
filterOptions={filterOptions}
filterRows={historyFilterRows}
historyOptions={getAutocompleteOptions() as Array<SearchHistoryEntry>}
data-flx="channel.message-search-bar.message-search-bar.render-autocomplete-content.history-section.autocomplete-select"
/>
);
@@ -308,6 +317,19 @@ export const MessageSearchBar = observer(
data-flx="channel.message-search-bar.message-search-bar.render-autocomplete-content.values-section.autocomplete-select"
/>
);
case 'plaintext':
return (
<PlaintextSection
rows={getAutocompleteOptions() as Array<PlaintextAutocompleteRow>}
selectedIndex={keyboardFocusIndex}
hoverIndex={hoverIndexForRender}
onSelect={handleAutocompleteSelect}
onMouseEnter={handleOptionMouseEnter}
onMouseLeave={handleOptionMouseLeave}
listboxId={listboxId}
data-flx="channel.message-search-bar.message-search-bar.render-autocomplete-content.plaintext-section.autocomplete-select"
/>
);
case 'date':
return (
<DateSection
@@ -415,6 +437,16 @@ export const MessageSearchBar = observer(
}}
data-flx="channel.message-search-bar.message-search-bar.combobox.key-down"
/>
{shouldShowSearchInputCounter(value.length) && (
<CharacterCounter
currentLength={value.length}
maxLength={SEARCH_INPUT_MAX_LENGTH}
canUpgrade={false}
premiumMaxLength={SEARCH_INPUT_MAX_LENGTH}
onUpgradeClick={noopUpgradeClick}
data-flx="channel.message-search-bar.message-search-bar.character-counter"
/>
)}
{hasValue && (
<button
type="button"
@@ -19,13 +19,20 @@ export interface SearchBarProps {
highContrast?: boolean;
}
export type AutocompleteType = 'filters' | 'users' | 'channels' | 'values' | 'date' | 'history' | null;
export type AutocompleteType = 'filters' | 'users' | 'channels' | 'values' | 'date' | 'history' | 'plaintext' | null;
export interface SearchHints {
usersByTag: Record<string, string>;
channelsByName: Record<string, string>;
}
export type HistoryFilterRow = {kind: 'filter'; option: SearchFilterOption};
export type PlaintextAutocompleteRow =
| {kind: 'user-suggestion'; filterKey: string; user: User}
| {kind: 'channel-suggestion'; filterKey: string; channel: Channel}
| {kind: 'filter-key'; filter: SearchFilterOption};
export type AutocompleteOption =
| SearchFilterOption
| User
@@ -35,4 +42,6 @@ export type AutocompleteOption =
label: string;
}
| string
| SearchHistoryEntry;
| SearchHistoryEntry
| PlaintextAutocompleteRow
| HistoryFilterRow;
@@ -1,5 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {
AutocompleteType,
HistoryFilterRow,
} from '@app/features/channel/components/message_search_bar/MessageSearchBarTypes';
import type {Channel} from '@app/features/channel/models/Channel';
import type {Guild} from '@app/features/guild/models/Guild';
import Guilds from '@app/features/guild/state/Guilds';
@@ -24,6 +28,63 @@ export function filterRequiresValue(filter: SearchFilterOption): boolean {
return Boolean(filter.requiresValue) || (filter.values?.length ?? 0) > 0;
}
export const PLAINTEXT_SUGGESTION_FILTER_KEYS: ReadonlyArray<string> = ['from', 'in', 'mentions'];
export const PLAINTEXT_SUGGESTIONS_PER_FILTER = 3;
export const INLINE_FILTER_KEYS: ReadonlyArray<string> = ['from', 'in', 'has', 'mentions'];
export interface SearchFilterEligibility {
isInGuildChannel: boolean;
hidePersonalInformation: boolean;
}
export function isSearchFilterOptionEligible(
option: SearchFilterOption,
{isInGuildChannel, hidePersonalInformation}: SearchFilterEligibility,
): boolean {
if (!option.requiresGuild) return true;
if (isInGuildChannel) return true;
return option.dmEligible === true && !hidePersonalInformation;
}
export function orderInlineFilterOptions(
options: ReadonlyArray<SearchFilterOption>,
eligibility: SearchFilterEligibility,
): Array<SearchFilterOption> {
const eligible = options.filter((option) => isSearchFilterOptionEligible(option, eligibility));
const byKey = new Map(eligible.map((option) => [option.key, option]));
const ordered: Array<SearchFilterOption> = [];
for (const key of INLINE_FILTER_KEYS) {
const option = byKey.get(key);
if (option != null) {
ordered.push(option);
}
}
return ordered;
}
export function listDiscoverableFilterOptions(
options: ReadonlyArray<SearchFilterOption>,
eligibility: SearchFilterEligibility,
): Array<SearchFilterOption> {
return options.filter((option) => !option.key.startsWith('-') && isSearchFilterOptionEligible(option, eligibility));
}
export function buildHistoryFilterRows(
options: ReadonlyArray<SearchFilterOption>,
eligibility: SearchFilterEligibility,
): Array<HistoryFilterRow> {
const promoted = orderInlineFilterOptions(options, eligibility);
const promotedKeys = new Set(promoted.map((option) => option.key));
const rest = listDiscoverableFilterOptions(options, eligibility).filter((option) => !promotedKeys.has(option.key));
return [...promoted, ...rest].map((option) => ({kind: 'filter', option}));
}
const SEARCH_VALUE_MODES = new Set<AutocompleteType>(['users', 'channels', 'values', 'date']);
export function isSearchValueMode(autocompleteType: AutocompleteType): boolean {
return SEARCH_VALUE_MODES.has(autocompleteType);
}
export interface MessageSearchCurrentWordRequest {
value: string;
cursorPosition: number;
@@ -60,6 +121,14 @@ export interface TokenInsertionInput {
addSpaceAfter: boolean;
}
const SEARCH_TOKEN_QUOTE_TRIGGER = /[\\" ]/;
const SEARCH_TOKEN_ESCAPE = /[\\"]/g;
export function quoteSearchTokenValue(value: string): string {
if (!SEARCH_TOKEN_QUOTE_TRIGGER.test(value)) return value;
return `"${value.replace(SEARCH_TOKEN_ESCAPE, (match) => `\\${match}`)}"`;
}
export function computeTokenInsertion({
textBeforeCursor,
textAfterCursor,
@@ -68,27 +137,22 @@ export function computeTokenInsertion({
tokenValue,
addSpaceAfter,
}: TokenInsertionInput): TokenInsertionResult {
const needsQuotes = /\s/.test(tokenValue);
let display: string;
if (needsQuotes) {
display = `${syntax}"${tokenValue}"`;
} else {
display = `${syntax}${tokenValue}`;
}
const display = `${syntax}${quoteSearchTokenValue(tokenValue)}`;
const before = textBeforeCursor.slice(0, lastWordStart);
const isAlreadyFollowedBySpace = SEARCH_TOKEN_WHITESPACE.test(textAfterCursor.charAt(0));
let space = '';
if (addSpaceAfter) {
if (!isAlreadyFollowedBySpace && (addSpaceAfter || textAfterCursor.length > 0)) {
space = ' ';
}
let separator = '';
if (!addSpaceAfter && textAfterCursor.length > 0 && !/^\s/.test(textAfterCursor)) {
separator = ' ';
let caretAdvance = 0;
if (addSpaceAfter) {
caretAdvance = 1;
}
return {
newText: [before, display, space, separator, textAfterCursor].join(''),
newCursorPos: (before + display).length + space.length,
newText: [before, display, space, textAfterCursor].join(''),
newCursorPos: (before + display).length + caretAdvance,
insertedDisplay: display,
insertedLength: display.length + space.length + separator.length,
insertedLength: display.length + space.length,
};
}
@@ -0,0 +1,181 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {AutocompleteOption} from '@app/features/channel/components/message_search_bar/AutocompleteOption';
import {FilterOption} from '@app/features/channel/components/message_search_bar/FilterOption';
import styles from '@app/features/channel/components/message_search_bar/MessageSearchBar.module.css';
import type {PlaintextAutocompleteRow} from '@app/features/channel/components/message_search_bar/MessageSearchBarTypes';
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
import {resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {StatusAwareAvatar} from '@app/features/ui/components/StatusAwareAvatar';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {FunnelIcon, MagnifyingGlassIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
const SEARCH_FILTERS_DESCRIPTOR = msg({
message: 'Search filters',
comment: 'Section header in the message search popout listing available filter operators.',
});
interface PlaintextSectionProps {
rows: Array<PlaintextAutocompleteRow>;
selectedIndex: number;
hoverIndex: number;
onSelect: (row: PlaintextAutocompleteRow) => void;
onMouseEnter: (index: number) => void;
onMouseLeave?: () => void;
listboxId: string;
}
const resolveRowGroup = (row: PlaintextAutocompleteRow): string => {
switch (row.kind) {
case 'filter-key':
return 'filter-key';
default:
return row.filterKey;
}
};
interface PlaintextRowGroup {
group: string;
entries: Array<{row: PlaintextAutocompleteRow; index: number}>;
}
const groupConsecutiveRows = (rows: Array<PlaintextAutocompleteRow>): Array<PlaintextRowGroup> => {
const groups: Array<PlaintextRowGroup> = [];
rows.forEach((row, index) => {
const group = resolveRowGroup(row);
const current = groups.at(-1);
if (current != null && current.group === group) {
current.entries.push({row, index});
return;
}
groups.push({group, entries: [{row, index}]});
});
return groups;
};
export const PlaintextSection: React.FC<PlaintextSectionProps> = observer(
({rows, selectedIndex, hoverIndex, onSelect, onMouseEnter, onMouseLeave, listboxId}) => {
const {i18n} = useLingui();
if (rows.length === 0) return null;
return (
<>
{groupConsecutiveRows(rows).map(({group, entries}) => (
<div
key={group}
className={styles.popoutSection}
data-flx="channel.message-search-bar.plaintext-section.popout-section"
>
<div
className={styles.popoutSectionHeader}
data-flx="channel.message-search-bar.plaintext-section.popout-section-header"
>
<span
className={`${styles.flex} ${styles.itemsCenter} ${styles.gap2}`}
data-flx="channel.message-search-bar.plaintext-section.flex"
>
{group === 'filter-key' ? (
<FunnelIcon
weight="regular"
size={remFromPx(12)}
data-flx="channel.message-search-bar.plaintext-section.funnel-icon"
/>
) : (
<MagnifyingGlassIcon
weight="regular"
size={remFromPx(14)}
data-flx="channel.message-search-bar.plaintext-section.magnifying-glass-icon"
/>
)}
{group === 'filter-key' ? i18n._(SEARCH_FILTERS_DESCRIPTOR) : `${group}:`}
</span>
</div>
{entries.map(({row, index}) => {
if (row.kind === 'filter-key') {
return (
<FilterOption
key={`filter-${row.filter.key}`}
option={row.filter}
index={index}
isSelected={selectedIndex === index}
isHovered={hoverIndex === index}
onSelect={() => onSelect(row)}
onMouseEnter={() => onMouseEnter(index)}
onMouseLeave={onMouseLeave}
listboxId={listboxId}
data-flx="channel.message-search-bar.plaintext-section.filter-option.select"
/>
);
}
const key =
row.kind === 'user-suggestion'
? `${row.filterKey}-user-${row.user.id}`
: `${row.filterKey}-channel-${row.channel.id}`;
const content =
row.kind === 'user-suggestion' ? (
<span
className={`${styles.userRow} ${styles.gap2}`}
data-flx="channel.message-search-bar.plaintext-section.user-row"
>
<StatusAwareAvatar
user={row.user}
size={16}
data-flx="channel.message-search-bar.plaintext-section.status-aware-avatar"
/>
<span
className={`${styles.minW0} ${styles.overflowHidden}`}
data-flx="channel.message-search-bar.plaintext-section.min-w0"
>
{NicknameUtils.formatUserTagForStreamerMode(row.user)}
</span>
</span>
) : (
<span
className={styles.channelRow}
data-flx="channel.message-search-bar.plaintext-section.channel-row"
>
{ChannelUtils.getIcon(row.channel, {className: styles.channelIcon})}
<span
className={styles.channelName}
data-flx="channel.message-search-bar.plaintext-section.channel-name"
>
{resolveSearchChannelDisplayName(row.channel)}
</span>
</span>
);
return (
<AutocompleteOption
key={key}
index={index}
isSelected={selectedIndex === index}
isHovered={hoverIndex === index}
onSelect={() => onSelect(row)}
onMouseEnter={() => onMouseEnter(index)}
onMouseLeave={onMouseLeave}
listboxId={listboxId}
data-flx="channel.message-search-bar.plaintext-section.autocomplete-option.select"
>
<div
className={styles.optionLabel}
data-flx="channel.message-search-bar.plaintext-section.option-label"
>
<div
className={styles.optionContent}
data-flx="channel.message-search-bar.plaintext-section.option-content"
>
{content}
</div>
</div>
</AutocompleteOption>
);
})}
</div>
))}
</>
);
},
);
@@ -3,16 +3,24 @@
import type {
AutocompleteOption,
AutocompleteType,
HistoryFilterRow,
PlaintextAutocompleteRow,
SearchHints,
} from '@app/features/channel/components/message_search_bar/MessageSearchBarTypes';
import {
assignRef,
buildHistoryFilterRows,
buildUserSearchBoosters,
filterRequiresValue,
getUserGuildSearchPlan,
isDateFilterKey,
isSearchFilterOptionEligible,
isSearchValueMode,
isUserFilterKey,
normalizeFilterKey,
orderInlineFilterOptions,
PLAINTEXT_SUGGESTION_FILTER_KEYS,
PLAINTEXT_SUGGESTIONS_PER_FILTER,
replaceSearchTokenAtCursor,
resolveMessageSearchCurrentWord,
type SearchTokenReplacementResult,
@@ -25,19 +33,25 @@ import MemberSearch, {type SearchContext} from '@app/features/member/state/Membe
import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils';
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import SearchHistory, {type SearchHistoryEntry} from '@app/features/search/state/SearchHistory';
import SearchHistory, {
SEARCH_HISTORY_DISPLAY_LIMIT,
type SearchHistoryEntry,
} from '@app/features/search/state/SearchHistory';
import {
buildSearchSegmentsFromHints,
formatSearchHistoryEntryForStreamerMode,
} from '@app/features/search/utils/SearchPrivacyUtils';
import {matchSearchDateWords, resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
import type {SearchSegment} from '@app/features/search/utils/SearchSegmentManager';
import type {MessageSearchScope, SearchFilterOption} from '@app/features/search/utils/SearchUtils';
import {getSearchFilterOptions} from '@app/features/search/utils/SearchUtils';
import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
import {getRelativeDayLabelCapitalized, getRelativeTimeFormat} from '@app/features/ui/utils/RelativeDayLabels';
import type {User} from '@app/features/user/models/User';
import Users from '@app/features/user/state/Users';
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {ME} from '@fluxer/constants/src/AppConstants';
import {GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
@@ -45,6 +59,17 @@ import {DateTime} from 'luxon';
import {matchSorter} from 'match-sorter';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
const MAX_FILTER_VALUE_SUGGESTIONS = 12;
const MAX_MEMBER_SEARCH_RESULTS = 25;
const MAX_DATE_WORD_SUGGESTIONS = 10;
function isHistoryFilterRow(option: AutocompleteOption): option is HistoryFilterRow {
if (typeof option !== 'object' || option === null || !('kind' in option)) {
return false;
}
return option.kind === 'filter';
}
const UNNAMED_DESCRIPTOR = msg({
message: 'Unnamed',
comment: 'Short label in the channel and chat use message search autocomplete. Keep it concise.',
@@ -76,13 +101,6 @@ function resolveCurrentSearchSegments(contextId: string | null): ReadonlyArray<S
return context.searchSegments;
}
function resolveHistoryChannelId(channel: Channel | undefined): string | undefined {
if (channel == null) {
return undefined;
}
return channel.id;
}
interface UseMessageSearchAutocompleteParams {
channel: Channel | undefined;
value: string;
@@ -95,7 +113,7 @@ interface UseMessageSearchAutocompleteParams {
isInGuildChannel: boolean;
contextId: string | null;
inputRefExternal?: React.Ref<LexicalSearchInputHandle>;
isResultsOpen: boolean;
isResultsOpen?: boolean;
onCloseResults?: () => void;
}
@@ -111,7 +129,8 @@ export function useMessageSearchAutocomplete({
isInGuildChannel,
contextId,
inputRefExternal,
isResultsOpen,
isResultsOpen = false,
onCloseResults,
}: UseMessageSearchAutocompleteParams) {
const {i18n} = useLingui();
const [isFocused, setIsFocused] = useState(false);
@@ -130,6 +149,12 @@ export function useMessageSearchAutocomplete({
const memberFetchDebounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const memberFetchQueryRef = useRef<string>('');
const filterOptions = useMemo(() => [...getSearchFilterOptions(i18n)], [i18n.locale]);
const hidePersonalInformation = StreamerMode.shouldHidePersonalInformation;
const filterEligibility = useMemo(
() => ({isInGuildChannel, hidePersonalInformation}),
[isInGuildChannel, hidePersonalInformation],
);
const historyKey = contextId ?? undefined;
const listboxId = useMemo(() => `message-search-listbox-${channel?.id ?? 'global'}`, [channel?.id]);
const openSearchInput = useCallback(() => {
const el = inputRef.current;
@@ -217,6 +242,13 @@ export function useMessageSearchAutocomplete({
openSearchInput();
});
}, [openSearchInput]);
useEffect(() => {
if (contextId == null || channelGuildId == null || contextId !== channelGuildId) return;
SearchHistory.adoptLegacyEntries(
contextId,
Channels.getGuildChannels(channelGuildId).map((guildChannel) => guildChannel.id),
);
}, [contextId, channelGuildId]);
useEffect(() => {
const handleGlobalKeydown = (event: KeyboardEvent) => {
const isFind = (event.key === 'f' || event.key === 'F') && (event.metaKey || event.ctrlKey);
@@ -235,7 +267,7 @@ export function useMessageSearchAutocomplete({
const context = MemberSearch.getSearchContext((results) => {
const users = results.map((result) => Users.getUser(result.id)).filter((u): u is User => u != null);
setMemberSearchResults(users);
}, 25);
}, MAX_MEMBER_SEARCH_RESULTS);
searchContextRef.current = context;
return () => {
context.destroy();
@@ -247,7 +279,10 @@ export function useMessageSearchAutocomplete({
clearTimeout(memberFetchDebounceTimerRef.current);
memberFetchDebounceTimerRef.current = null;
}
if (autocompleteType !== 'users' || !currentFilter || !isUserFilterKey(currentFilter.key)) {
const isPlaintextSuggestionMode = autocompleteType === 'plaintext';
const isUserFilterMode =
autocompleteType === 'users' && currentFilter != null && isUserFilterKey(currentFilter.key);
if (!isPlaintextSuggestionMode && !isUserFilterMode) {
memberFetchQueryRef.current = '';
const context = searchContextRef.current;
if (context) {
@@ -268,7 +303,9 @@ export function useMessageSearchAutocomplete({
}
const cursorPos = resolveInputCursorPosition(inputRef, value);
const currentWord = resolveMessageSearchCurrentWord({value, cursorPosition: cursorPos});
const searchQuery = currentWord.slice(currentFilter.syntax.length).trim();
const searchQuery = isPlaintextSuggestionMode
? currentWord.trim()
: currentWord.slice(currentFilter?.syntax.length ?? 0).trim();
const context = searchContextRef.current;
if (searchQuery.length === 0) {
memberFetchQueryRef.current = '';
@@ -290,7 +327,7 @@ export function useMessageSearchAutocomplete({
const scheduledQuery = searchQuery;
memberFetchDebounceTimerRef.current = setTimeout(() => {
memberFetchDebounceTimerRef.current = null;
if (autocompleteType !== 'users' || !currentFilter || !isUserFilterKey(currentFilter.key)) {
if (!isPlaintextSuggestionMode && !isUserFilterMode) {
return;
}
if (memberFetchQueryRef.current !== scheduledQuery) {
@@ -308,7 +345,7 @@ export function useMessageSearchAutocomplete({
};
}, [autocompleteType, currentFilter, value, activeScope, channel, currentGuildIdForScope]);
useEffect(() => {
if (autocompleteType !== 'users' || !currentFilter) {
if (autocompleteType !== 'plaintext' && (autocompleteType !== 'users' || !currentFilter)) {
const context = searchContextRef.current;
if (context) {
context.cancelSearch();
@@ -334,20 +371,104 @@ export function useMessageSearchAutocomplete({
case 'mentions':
return 'users';
case 'in':
return isInGuildChannel ? 'channels' : 'values';
return 'channels';
default:
return 'values';
}
},
[isInGuildChannel],
);
const resolveUserSuggestions = useCallback(
(searchTerm: string, limit: number): Array<User> => {
const plan = getUserGuildSearchPlan(activeScope, currentGuildIdForScope);
if (plan.mode !== 'none') {
return memberSearchResults.slice(0, limit);
}
if (channel) {
const users = channel.recipientIds.map((id) => Users.getUser(id)).filter((u): u is User => u != null);
return matchSorter(users, searchTerm, {
keys: ['username', 'tag'],
}).slice(0, limit);
}
return [];
},
[activeScope, currentGuildIdForScope, memberSearchResults, channel],
);
const resolveChannelSuggestions = useCallback(
(searchTerm: string, limit: number): Array<Channel> => {
const guildIdForChannels = channelGuildId ?? (routeGuildId === ME ? undefined : routeGuildId);
if (!guildIdForChannels) {
const privateChannels = Channels.getPrivateChannels().filter((c) => resolveSearchChannelDisplayName(c) !== '');
return matchSorter(privateChannels, searchTerm, {
keys: [(c: Channel) => resolveSearchChannelDisplayName(c)],
}).slice(0, limit);
}
const channels = Channels.getGuildChannels(guildIdForChannels).filter((c) =>
GUILD_TEXT_BASED_CHANNEL_TYPES.has(c.type),
);
const recentVisitsForGuild = SelectedChannel.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: Channel) => {
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, limit);
},
[channelGuildId, routeGuildId, channel],
);
const getPlaintextRows = useCallback((): Array<PlaintextAutocompleteRow> => {
const cursorPosition = resolveInputCursorPosition(inputRef, value);
const currentWord = resolveMessageSearchCurrentWord({value, cursorPosition});
const rows: Array<PlaintextAutocompleteRow> = [];
const searchTerm = currentWord.trim();
if (searchTerm === '') return rows;
let suggestionCount = 0;
for (const filterKey of PLAINTEXT_SUGGESTION_FILTER_KEYS) {
const filter = filterOptions.find((opt) => opt.key === filterKey);
if (filter == null) continue;
if (!isSearchFilterOptionEligible(filter, filterEligibility)) continue;
if (filterKey === 'in') {
for (const suggestion of resolveChannelSuggestions(searchTerm, PLAINTEXT_SUGGESTIONS_PER_FILTER)) {
rows.push({kind: 'channel-suggestion', filterKey, channel: suggestion});
suggestionCount += 1;
}
continue;
}
for (const suggestion of resolveUserSuggestions(searchTerm, PLAINTEXT_SUGGESTIONS_PER_FILTER)) {
rows.push({kind: 'user-suggestion', filterKey, user: suggestion});
suggestionCount += 1;
}
}
if (suggestionCount === 0) {
for (const filter of orderInlineFilterOptions(filterOptions, filterEligibility)) {
rows.push({kind: 'filter-key', filter});
}
}
return rows;
}, [value, filterOptions, filterEligibility, resolveChannelSuggestions, resolveUserSuggestions]);
const getAutocompleteOptions = useCallback((): Array<AutocompleteOption> => {
const cursorPos = resolveInputCursorPosition(inputRef, value);
const currentWord = resolveMessageSearchCurrentWord({value, cursorPosition: cursorPos});
switch (autocompleteType) {
case 'filters': {
const filtered = filterOptions.filter((opt) => {
if (opt.requiresGuild && !isInGuildChannel) return false;
if (!isSearchFilterOptionEligible(opt, filterEligibility)) return false;
if (!currentWord) {
return !opt.key.startsWith('-');
}
@@ -367,54 +488,17 @@ export function useMessageSearchAutocomplete({
return currentWord ? filtered.slice(0, MAX_TYPED_FILTERS) : filtered;
}
case 'history': {
return SearchHistory.search(currentWord, channel?.id).slice(0, 5);
return [...SearchHistory.search(currentWord, historyKey)].slice(0, SEARCH_HISTORY_DISPLAY_LIMIT);
}
case 'users': {
if (!currentFilter) return [];
const searchTerm = currentWord.slice(currentFilter.syntax.length);
const plan = getUserGuildSearchPlan(activeScope, currentGuildIdForScope);
if (plan.mode !== 'none') {
return memberSearchResults.slice(0, 12);
}
if (channel) {
const users = channel.recipientIds.map((id) => Users.getUser(id)).filter((u): u is User => u != null);
return matchSorter(users, searchTerm, {
keys: ['username', 'tag'],
}).slice(0, 12);
}
return [];
return resolveUserSuggestions(searchTerm, MAX_FILTER_VALUE_SUGGESTIONS);
}
case 'channels': {
if (!currentFilter) return [];
const guildIdForChannels = channelGuildId ?? routeGuildId;
if (!guildIdForChannels) return [];
const searchTerm = currentWord.slice(currentFilter.syntax.length);
const channels = Channels.getGuildChannels(guildIdForChannels).filter((c) =>
GUILD_TEXT_BASED_CHANNEL_TYPES.has(c.type),
);
const recentVisitsForGuild = SelectedChannel.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: Channel) => {
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);
return resolveChannelSuggestions(searchTerm, MAX_FILTER_VALUE_SUGGESTIONS);
}
case 'values': {
if (!currentFilter?.values) return [];
@@ -426,6 +510,13 @@ export function useMessageSearchAutocomplete({
return currentFilter.values.filter((option) => matchValues.has(option.value));
}
case 'date': {
const dateTerm = currentFilter == null ? '' : currentWord.slice(currentFilter.syntax.length).trim();
if (dateTerm !== '') {
return matchSearchDateWords(dateTerm, MAX_DATE_WORD_SUGGESTIONS).map((word) => ({
label: word,
value: word,
}));
}
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");
@@ -436,6 +527,8 @@ export function useMessageSearchAutocomplete({
{label: getRelativeTimeFormat(locale).format(0, 'second'), value: fmtDateTime(now)},
];
}
case 'plaintext':
return getPlaintextRows();
default:
return [];
}
@@ -445,43 +538,39 @@ export function useMessageSearchAutocomplete({
filterOptions,
isInGuildChannel,
currentFilter,
channelGuildId,
routeGuildId,
channel,
memberSearchResults,
i18n,
activeScope,
currentGuildIdForScope,
getPlaintextRows,
resolveChannelSuggestions,
resolveUserSuggestions,
]);
const getHistoryCommonFilters = useCallback(() => {
return filterOptions
.filter((opt) => !opt.requiresGuild || isInGuildChannel)
.filter((opt) => !opt.key.startsWith('-'));
}, [filterOptions, isInGuildChannel]);
const historyFilterRows = useMemo(
() => buildHistoryFilterRows(filterOptions, filterEligibility),
[filterOptions, filterEligibility],
);
const getTotalOptions = useCallback((): number => {
if (!autocompleteType) return 0;
if (autocompleteType === 'history') {
return getHistoryCommonFilters().length + getAutocompleteOptions().length;
return historyFilterRows.length + getAutocompleteOptions().length;
}
return getAutocompleteOptions().length;
}, [autocompleteType, getAutocompleteOptions, getHistoryCommonFilters]);
}, [autocompleteType, getAutocompleteOptions, historyFilterRows]);
const hasAnyOptions = useCallback((): boolean => {
return getTotalOptions() > 0;
}, [getTotalOptions]);
const getSelectedOption = useCallback((): AutocompleteOption | null => {
if (selectedIndex < 0) return null;
if (autocompleteType === 'history') {
const commonFilters = getHistoryCommonFilters();
if (selectedIndex < commonFilters.length) {
return commonFilters[selectedIndex] ?? null;
if (selectedIndex < historyFilterRows.length) {
return historyFilterRows[selectedIndex] ?? null;
}
const historyOptions = getAutocompleteOptions();
const historyIndex = selectedIndex - commonFilters.length;
const historyIndex = selectedIndex - historyFilterRows.length;
return historyOptions[historyIndex] ?? null;
}
const options = getAutocompleteOptions();
return options[selectedIndex] ?? null;
}, [selectedIndex, autocompleteType, getAutocompleteOptions, getHistoryCommonFilters]);
}, [selectedIndex, autocompleteType, getAutocompleteOptions, historyFilterRows]);
useEffect(() => {
if (!isFocused || suppressAutoOpen) {
setAutocompleteType(null);
@@ -498,7 +587,7 @@ export function useMessageSearchAutocomplete({
if (matchingFilter) {
const afterColon = currentWord.slice(matchingFilter.syntax.length);
const filterKeyBase = normalizeFilterKey(matchingFilter.key);
if (matchingFilter.requiresGuild && !isInGuildChannel) {
if (!isSearchFilterOptionEligible(matchingFilter, filterEligibility)) {
setAutocompleteType(null);
setCurrentFilter(null);
return;
@@ -506,11 +595,15 @@ export function useMessageSearchAutocomplete({
if (isDateFilterKey(filterKeyBase)) {
setAutocompleteType('date');
setCurrentFilter(matchingFilter);
setSelectedIndex(0);
setHasNavigated(false);
return;
}
if (matchingFilter.values && afterColon.length === 0) {
setAutocompleteType('values');
setCurrentFilter(matchingFilter);
setSelectedIndex(0);
setHasNavigated(false);
return;
}
if (filterKeyBase === 'from' || filterKeyBase === 'mentions') {
@@ -520,7 +613,7 @@ export function useMessageSearchAutocomplete({
setHasNavigated(false);
return;
}
if (filterKeyBase === 'in' && isInGuildChannel) {
if (filterKeyBase === 'in') {
setAutocompleteType('channels');
setCurrentFilter(matchingFilter);
setSelectedIndex(0);
@@ -530,6 +623,8 @@ export function useMessageSearchAutocomplete({
if (matchingFilter.values) {
setAutocompleteType('values');
setCurrentFilter(matchingFilter);
setSelectedIndex(0);
setHasNavigated(false);
return;
}
setAutocompleteType(null);
@@ -542,11 +637,11 @@ export function useMessageSearchAutocomplete({
return;
}
const partialMatch = filterOptions.some((opt) => {
return opt.syntax.includes(currentWord) || currentWord.includes(opt.key) || opt.key.includes(currentWord);
return opt.syntax.includes(currentWord) || opt.key.includes(currentWord);
});
setAutocompleteType(partialMatch ? 'filters' : null);
setAutocompleteType(partialMatch ? 'filters' : 'plaintext');
setCurrentFilter(null);
}, [value, isFocused, isInGuildChannel, suppressAutoOpen, filterOptions]);
}, [value, isFocused, isInGuildChannel, suppressAutoOpen, filterOptions, filterEligibility]);
useEffect(() => {
const totalOptions = getTotalOptions();
if (totalOptions > 0 && (selectedIndex >= totalOptions || selectedIndex < -1)) {
@@ -560,7 +655,7 @@ export function useMessageSearchAutocomplete({
const handleOptionMouseLeave = () => {
setHoverIndex(-1);
};
const shouldShowKeyboardFocus = hasNavigated || autocompleteType === 'users' || autocompleteType === 'channels';
const shouldShowKeyboardFocus = hasNavigated || isSearchValueMode(autocompleteType);
const shouldShowHover = hasInteracted;
const keyboardFocusIndex = shouldShowKeyboardFocus ? selectedIndex : -1;
const hoverIndexForRender = shouldShowHover ? hoverIndex : -1;
@@ -624,7 +719,7 @@ export function useMessageSearchAutocomplete({
currentSegments,
syntax: filter.syntax,
tokenValue: tag,
addSpaceAfter: false,
addSpaceAfter: true,
replacementSegment: {type: 'user', filterKey: filter.key, id: user.id},
});
hintsRef.current.usersByTag[tag] = user.id;
@@ -634,19 +729,15 @@ export function useMessageSearchAutocomplete({
case 'channels': {
const ch = option as Channel;
const filter = requireCurrentFilter();
let name: string;
if (ch.name == null || ch.name === '') {
name = i18n._(UNNAMED_DESCRIPTOR);
} else {
name = ch.name;
}
const displayName = resolveSearchChannelDisplayName(ch);
const name = displayName === '' ? i18n._(UNNAMED_DESCRIPTOR) : displayName;
replacement = replaceSearchTokenAtCursor({
value,
cursorPosition,
currentSegments,
syntax: filter.syntax,
tokenValue: name,
addSpaceAfter: false,
addSpaceAfter: true,
replacementSegment: {type: 'channel', filterKey: filter.key, id: ch.id},
});
hintsRef.current.channelsByName[name] = ch.id;
@@ -665,7 +756,7 @@ export function useMessageSearchAutocomplete({
currentSegments,
syntax: filter.syntax,
tokenValue: valueOption.value,
addSpaceAfter: false,
addSpaceAfter: true,
replacementSegment: null,
});
shouldSubmit = true;
@@ -683,19 +774,69 @@ export function useMessageSearchAutocomplete({
currentSegments,
syntax: filter.syntax,
tokenValue: dateOption.value,
addSpaceAfter: false,
addSpaceAfter: true,
replacementSegment: null,
});
shouldSubmit = true;
break;
}
case 'plaintext': {
const row = option as PlaintextAutocompleteRow;
const filter = filterOptions.find(
(opt) => opt.key === (row.kind === 'filter-key' ? row.filter.key : row.filterKey),
);
if (filter == null) return;
if (row.kind === 'filter-key') {
const requiresValue = filterRequiresValue(filter);
replacement = replaceSearchTokenAtCursor({
value,
cursorPosition,
currentSegments,
syntax: filter.syntax,
tokenValue: '',
addSpaceAfter: !requiresValue,
replacementSegment: null,
});
shouldSubmit = !requiresValue;
break;
}
if (row.kind === 'user-suggestion') {
const tag = NicknameUtils.formatUserTagForStreamerMode(row.user);
replacement = replaceSearchTokenAtCursor({
value,
cursorPosition,
currentSegments,
syntax: filter.syntax,
tokenValue: tag,
addSpaceAfter: true,
replacementSegment: {type: 'user', filterKey: filter.key, id: row.user.id},
});
hintsRef.current.usersByTag[tag] = row.user.id;
shouldSubmit = true;
break;
}
const rowDisplayName = resolveSearchChannelDisplayName(row.channel);
const channelName = rowDisplayName === '' ? i18n._(UNNAMED_DESCRIPTOR) : rowDisplayName;
replacement = replaceSearchTokenAtCursor({
value,
cursorPosition,
currentSegments,
syntax: filter.syntax,
tokenValue: channelName,
addSpaceAfter: true,
replacementSegment: {type: 'channel', filterKey: filter.key, id: row.channel.id},
});
hintsRef.current.channelsByName[channelName] = row.channel.id;
shouldSubmit = true;
break;
}
case 'history': {
const entry = formatSearchHistoryEntryForStreamerMode(option as SearchHistoryEntry);
const newText = entry.query;
const newCursorPos = newText.length;
const segments = buildSearchSegmentsFromHints(newText, entry.hints);
onChange(newText, segments);
SearchHistory.add(newText, channel?.id, entry.hints);
SearchHistory.add(newText, historyKey, entry.hints);
const historyInput = inputRef.current;
if (historyInput != null) {
historyInput.focus();
@@ -721,11 +862,46 @@ export function useMessageSearchAutocomplete({
setAutocompleteType(null);
setCurrentFilter(null);
if (shouldSubmit && replacement.newText.trim().length > 0) {
SearchHistory.add(replacement.newText, resolveHistoryChannelId(channel), hintsRef.current);
SearchHistory.add(replacement.newText, historyKey, hintsRef.current);
setSuppressAutoOpen(true);
setTimeout(() => onSearch(), 0);
}
};
const applyHistoryFilterRow = (row: HistoryFilterRow) => {
const filter = row.option;
const cursorPos = resolveInputCursorPosition(inputRef, value);
const requiresValue = filterRequiresValue(filter);
const currentSegments = resolveCurrentSearchSegments(contextId);
const replacement = replaceSearchTokenAtCursor({
value,
cursorPosition: cursorPos,
currentSegments,
syntax: filter.syntax,
tokenValue: '',
addSpaceAfter: !requiresValue,
replacementSegment: null,
});
onChange(replacement.newText, replacement.newSegments);
const filterInput = inputRef.current;
if (filterInput != null) {
filterInput.focus();
filterInput.setSelectionRange(replacement.newCursorPosition, replacement.newCursorPosition);
}
if (!requiresValue) {
setTimeout(() => {
SearchHistory.add(replacement.newText, historyKey, hintsRef.current);
setSuppressAutoOpen(true);
setTimeout(() => onSearch(), 0);
setAutocompleteType(null);
setCurrentFilter(null);
}, 10);
return;
}
setCurrentFilter(filter);
setAutocompleteType(getAutocompleteTypeForFilter(filter));
setSelectedIndex(-1);
setHasNavigated(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
if (isIMEComposing(e)) {
return;
@@ -734,9 +910,9 @@ export function useMessageSearchAutocomplete({
e.preventDefault();
return;
}
if (e.key === 'Enter' && autocompleteType === null) {
if (e.key === 'Enter' && (autocompleteType === null || (autocompleteType === 'plaintext' && !hasNavigated))) {
e.preventDefault();
SearchHistory.add(value, channel?.id, hintsRef.current);
SearchHistory.add(value, historyKey, hintsRef.current);
setAutocompleteType(null);
setCurrentFilter(null);
setHasNavigated(false);
@@ -747,16 +923,29 @@ export function useMessageSearchAutocomplete({
}
if (e.key === 'Escape') {
e.preventDefault();
if (value.trim().length > 0) {
onChange('', []);
if (autocompleteType !== null && getTotalOptions() > 0) {
setAutocompleteType(null);
setCurrentFilter(null);
setSelectedIndex(-1);
setHasNavigated(false);
setSuppressAutoOpen(false);
setSuppressAutoOpen(true);
suppressAutoOpenRef.current = true;
return;
}
if (value.length > 0) {
onChange('', []);
setAutocompleteType(null);
setCurrentFilter(null);
setSelectedIndex(-1);
setHasNavigated(false);
setSuppressAutoOpen(true);
suppressAutoOpenRef.current = true;
return;
}
if (isResultsOpen) {
setAutocompleteType(null);
setCurrentFilter(null);
onCloseResults?.();
return;
}
setAutocompleteType(null);
@@ -771,7 +960,7 @@ export function useMessageSearchAutocomplete({
if (totalOptions <= 0) {
if (e.key === 'Enter') {
e.preventDefault();
SearchHistory.add(value, channel?.id, hintsRef.current);
SearchHistory.add(value, historyKey, hintsRef.current);
setAutocompleteType(null);
setCurrentFilter(null);
setHasNavigated(false);
@@ -805,22 +994,9 @@ export function useMessageSearchAutocomplete({
}
case 'Enter': {
e.preventDefault();
let shouldAutoSelect = hasNavigated;
if (autocompleteType === 'users' || autocompleteType === 'channels') {
shouldAutoSelect = true;
}
const cursorPos = resolveInputCursorPosition(inputRef, value);
const currentWord = resolveMessageSearchCurrentWord({value, cursorPosition: cursorPos});
const matchingFilter = filterOptions.find((opt) => currentWord.startsWith(opt.syntax));
const afterColon = matchingFilter ? currentWord.slice(matchingFilter.syntax.length) : '';
if (matchingFilter) {
const requiresValue = filterRequiresValue(matchingFilter);
if (!shouldAutoSelect && requiresValue && afterColon.length === 0) {
return;
}
}
const shouldAutoSelect = hasNavigated || isSearchValueMode(autocompleteType);
if (!shouldAutoSelect) {
SearchHistory.add(value, channel?.id, hintsRef.current);
SearchHistory.add(value, historyKey, hintsRef.current);
setAutocompleteType(null);
setCurrentFilter(null);
setHasNavigated(false);
@@ -831,42 +1007,8 @@ export function useMessageSearchAutocomplete({
}
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 = resolveInputCursorPosition(inputRef, value);
const requiresValue = filterRequiresValue(filter);
const currentSegmentsInner = resolveCurrentSearchSegments(contextId);
const replacement = replaceSearchTokenAtCursor({
value,
cursorPosition: cursorPosInner,
currentSegments: currentSegmentsInner,
syntax: filter.syntax,
tokenValue: '',
addSpaceAfter: !requiresValue,
replacementSegment: null,
});
onChange(replacement.newText, replacement.newSegments);
const historyFilterInput = inputRef.current;
if (historyFilterInput != null) {
historyFilterInput.focus();
historyFilterInput.setSelectionRange(replacement.newCursorPosition, replacement.newCursorPosition);
}
if (!requiresValue) {
setTimeout(() => {
SearchHistory.add(replacement.newText, resolveHistoryChannelId(channel), hintsRef.current);
setSuppressAutoOpen(true);
setTimeout(() => onSearch(), 0);
setAutocompleteType(null);
setCurrentFilter(null);
}, 10);
return;
}
setCurrentFilter(filter);
setAutocompleteType(getAutocompleteTypeForFilter(filter));
setSelectedIndex(-1);
setHasNavigated(false);
if (autocompleteType === 'history' && isHistoryFilterRow(selected)) {
applyHistoryFilterRow(selected);
return;
}
handleAutocompleteSelect(selected);
@@ -884,42 +1026,13 @@ export function useMessageSearchAutocomplete({
setHasInteracted(false);
};
const handleHistoryClear = () => {
SearchHistory.clear(channel?.id);
setAutocompleteType('filters');
SearchHistory.clear(historyKey);
setAutocompleteType('history');
setSelectedIndex(-1);
};
const handleFilterSelect = (filter: SearchFilterOption, index: number) => {
const handleFilterSelect = (row: HistoryFilterRow, index: number) => {
setSelectedIndex(index);
const cursorPos = resolveInputCursorPosition(inputRef, value);
const requiresValue = filterRequiresValue(filter);
const currentSegments = resolveCurrentSearchSegments(contextId);
const replacement = replaceSearchTokenAtCursor({
value,
cursorPosition: cursorPos,
currentSegments,
syntax: filter.syntax,
tokenValue: '',
addSpaceAfter: !requiresValue,
replacementSegment: null,
});
onChange(replacement.newText, replacement.newSegments);
const filterInput = inputRef.current;
if (filterInput != null) {
filterInput.focus();
filterInput.setSelectionRange(replacement.newCursorPosition, replacement.newCursorPosition);
}
if (!requiresValue) {
setTimeout(() => {
SearchHistory.add(replacement.newText, resolveHistoryChannelId(channel), hintsRef.current);
setSuppressAutoOpen(true);
setTimeout(() => onSearch(), 0);
setAutocompleteType(null);
setCurrentFilter(null);
}, 10);
return;
}
setCurrentFilter(filter);
setAutocompleteType(getAutocompleteTypeForFilter(filter));
applyHistoryFilterRow(row);
};
const setInputRefs = useCallback(
(node: LexicalSearchInputHandle | null) => {
@@ -938,6 +1051,7 @@ export function useMessageSearchAutocomplete({
suppressAutoOpen,
setSuppressAutoOpen,
filterOptions,
historyFilterRows,
listboxId,
keyboardFocusIndex,
hoverIndexForRender,