mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(app): suggest filters and people while typing search text (#1957)
This commit is contained in:
1 parent
e68b5b8b5a
commit
2af4cc98fd
8 files changed
+650
-241
No files matched your search
+16
-6
@@ -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"
|
||||
|
||||
+11
-2
@@ -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;
|
||||
+78
-14
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
+296
-182
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user