mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
864 lines
33 KiB
TypeScript
864 lines
33 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import '@app/features/channel/components/ChannelSearchHighlight.css';
|
|
import * as PrivateChannelCommands from '@app/features/channel/commands/PrivateChannelCommands';
|
|
import {Message as MessageComponent} from '@app/features/channel/components/ChannelMessage';
|
|
import styles from '@app/features/channel/components/ChannelSearchResults.module.css';
|
|
import {CollapsedMessageVisibilityProvider} from '@app/features/channel/components/CollapsedMessageVisibilityContext';
|
|
import {
|
|
AN_ERROR_OCCURRED_WHILE_SEARCHING_DESCRIPTOR,
|
|
applyMatureContentToParamsIfNeeded,
|
|
applySortModeToParams,
|
|
type ChannelSearchSortMode,
|
|
DETACHED_MESSAGE_BEHAVIOR,
|
|
EMPTY_SEARCH_CHANNELS,
|
|
EMPTY_SEARCH_MESSAGES,
|
|
getAdaptiveVisiblePageCount,
|
|
getSearchResultChannelRenderData,
|
|
getSortModeOptions,
|
|
RESULTS_PER_PAGE,
|
|
renderScopeIcon,
|
|
renderSortIcon,
|
|
} from '@app/features/channel/components/channel_search_results/ChannelSearchResultsShared';
|
|
import {SearchResultsHeader} from '@app/features/channel/components/channel_search_results/SearchResultsHeader';
|
|
import {SearchResultsPagination} from '@app/features/channel/components/channel_search_results/SearchResultsPagination';
|
|
import {
|
|
SearchEmptyState,
|
|
SearchErrorState,
|
|
SearchIndexingState,
|
|
} from '@app/features/channel/components/channel_search_results/SearchResultsStateViews';
|
|
import type {MessageGroupRenderWrapperProps} from '@app/features/channel/components/MessageGroup';
|
|
import {SearchResultMessageList} from '@app/features/channel/components/SearchResultMessageList';
|
|
import {areSegmentsEqual} from '@app/features/channel/components/SearchResultsUtils';
|
|
import {DEFAULT_SCOPE_VALUE, getScopeOptionsForChannel} from '@app/features/channel/components/SearchScopeOptions';
|
|
import {resolveSearchScope} from '@app/features/channel/components/SearchScopeResolution';
|
|
import type {SearchMachineEvent} from '@app/features/channel/components/SearchStateMachine';
|
|
import {Channel} from '@app/features/channel/models/Channel';
|
|
import ChannelSearch, {getChannelSearchContextId} from '@app/features/channel/state/ChannelSearch';
|
|
import Channels from '@app/features/channel/state/Channels';
|
|
import {getChannelSearchIndexingPollInterval} from '@app/features/channel/utils/ChannelSearchPolling';
|
|
import GuildMatureContentAgree from '@app/features/guild/state/GuildMatureContentAgree';
|
|
import {JUMP_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
|
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
|
import {MessageContextPrefix} from '@app/features/messaging/components/message_context_prefix/MessageContextPrefix';
|
|
import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation';
|
|
import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy';
|
|
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
|
import {
|
|
applyChannelSearchHighlight,
|
|
clearChannelSearchHighlight,
|
|
} from '@app/features/messaging/utils/ChannelSearchHighlight';
|
|
import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils/ChannelTextareaFocusUtils';
|
|
import {getCollapsedMessageGroupKey} from '@app/features/messaging/utils/MessageGroupingUtils';
|
|
import LocalUserSpamOverride from '@app/features/moderation/state/LocalUserSpamOverride';
|
|
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
|
|
import {SearchResultOpenFailedModal} from '@app/features/search/components/alerts/SearchResultOpenFailedModal';
|
|
import {tokenizeSearchQuery} from '@app/features/search/utils/SearchQueryTokenizer';
|
|
import type {SearchSegment} from '@app/features/search/utils/SearchSegmentManager';
|
|
import {
|
|
isIndexing,
|
|
type MessageSearchParams,
|
|
type MessageSearchScope,
|
|
parseSearchQueryWithSegments,
|
|
searchMessages,
|
|
} from '@app/features/search/utils/SearchUtils';
|
|
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 * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
|
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
|
import {modal} from '@app/features/ui/commands/ModalCommands';
|
|
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
|
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
|
import * as FormUtils from '@app/lib/forms';
|
|
import {ME} from '@fluxer/constants/src/AppConstants';
|
|
import {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
|
|
import {MAX_MESSAGES_PER_CHANNEL} from '@fluxer/constants/src/LimitConstants';
|
|
import type {MessageId} from '@fluxer/schema/src/branded/WireIds';
|
|
import {useLingui} from '@lingui/react/macro';
|
|
import {observer} from 'mobx-react-lite';
|
|
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
|
|
|
interface ChannelSearchResultsProps {
|
|
channel: Channel;
|
|
searchQuery: string;
|
|
searchSegments: Array<SearchSegment>;
|
|
onClose: () => void;
|
|
refreshKey?: number;
|
|
}
|
|
|
|
export const ChannelSearchResults = observer(
|
|
({channel, searchQuery, searchSegments, onClose, refreshKey}: ChannelSearchResultsProps) => {
|
|
const {i18n} = useLingui();
|
|
const scrollerRef = useRef<ScrollerHandle | null>(null);
|
|
const pollingTimeout = useRef<number | null>(null);
|
|
const currentChannelId = useRef(channel.id);
|
|
const currentSearchQuery = useRef(searchQuery);
|
|
const currentSearchSegments = useRef(searchSegments);
|
|
const channelId = useRef(channel.id);
|
|
const channelGuildId = useRef(channel.guildId ?? null);
|
|
const channelIsMatureContent = useRef(channel.isMature());
|
|
const lastSearchAttempt = useRef<{
|
|
query: string;
|
|
segments: Array<SearchSegment>;
|
|
refreshKey: number | null;
|
|
scope: MessageSearchScope;
|
|
sortMode: ChannelSearchSortMode;
|
|
} | null>(null);
|
|
const contextId = getChannelSearchContextId(channel);
|
|
const searchContext = contextId ? ChannelSearch.getContext(contextId) : null;
|
|
const machineState = searchContext?.machineState ?? {status: 'loading' as const};
|
|
const scrollPosition = searchContext?.scrollPosition ?? 0;
|
|
const lastKnownScrollPosition = useRef(scrollPosition);
|
|
const successMachineState = machineState.status === 'success' ? machineState : null;
|
|
const indexingMachineState = machineState.status === 'indexing' ? machineState : null;
|
|
const normalizedRefreshKey = refreshKey ?? null;
|
|
const lastObservedRefreshKey = searchContext?.lastSearchRefreshKey ?? null;
|
|
const [visiblePageSlots, setVisiblePageSlots] = useState(() => getAdaptiveVisiblePageCount());
|
|
const [revealedGroupKeys, setRevealedGroupKeys] = useState<Set<string>>(new Set());
|
|
const [sortMode, setSortMode] = useState<ChannelSearchSortMode>(
|
|
() => searchContext?.lastSearchSortMode ?? 'newest',
|
|
);
|
|
const sortModeRef = useRef<ChannelSearchSortMode>(sortMode);
|
|
useMessageListKeyboardNavigation({containerRef: scrollerRef, allowWhenInactive: true});
|
|
const activeScope = searchContext?.scope ?? DEFAULT_SCOPE_VALUE;
|
|
const scopeOptions = useMemo(
|
|
() => getScopeOptionsForChannel(i18n, channel),
|
|
[i18n.locale, channel.id, channel.type, channel.guildId],
|
|
);
|
|
const scopeOptionValues = useMemo(() => new Set(scopeOptions.map((option) => option.value)), [scopeOptions]);
|
|
const sortModeOptions = useMemo(() => getSortModeOptions(i18n), [i18n.locale]);
|
|
const successResults = successMachineState?.results ?? EMPTY_SEARCH_MESSAGES;
|
|
const successChannels = successMachineState?.channels ?? EMPTY_SEARCH_CHANNELS;
|
|
const searchChannelsById = useMemo(
|
|
() => new Map(successChannels.map((searchChannel) => [searchChannel.id, searchChannel])),
|
|
[successChannels],
|
|
);
|
|
const messagesByChannel = useMemo(() => {
|
|
const grouped = new Map<string, Array<Message>>();
|
|
for (const message of successResults) {
|
|
if (!grouped.has(message.channelId)) {
|
|
grouped.set(message.channelId, []);
|
|
}
|
|
grouped.get(message.channelId)!.push(message);
|
|
}
|
|
return grouped;
|
|
}, [successResults]);
|
|
const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(successResults);
|
|
const spammerOverrideVersion = LocalUserSpamOverride.version;
|
|
const collapsedMessageVisibility = useMemo(
|
|
() => ({
|
|
isMessageRevealed: (message: Message) => {
|
|
const channelMessages = messagesByChannel.get(message.channelId);
|
|
if (!channelMessages) return false;
|
|
const messageChannel = searchChannelsById.get(message.channelId) ?? Channels.getChannel(message.channelId);
|
|
if (!messageChannel) return false;
|
|
const groupKey = getCollapsedMessageGroupKey({
|
|
channel: messageChannel,
|
|
messages: channelMessages,
|
|
messageId: message.id,
|
|
treatSpam: true,
|
|
});
|
|
return groupKey != null && revealedGroupKeys.has(groupKey);
|
|
},
|
|
}),
|
|
[messagesByChannel, revealedGroupKeys, searchChannelsById, spammerOverrideVersion],
|
|
);
|
|
const handleCollapsedGroupRevealChange = useCallback((groupKey: string, revealed: boolean) => {
|
|
setRevealedGroupKeys((current) => {
|
|
const next = new Set(current);
|
|
if (revealed) {
|
|
next.add(groupKey);
|
|
} else {
|
|
next.delete(groupKey);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
useEffect(() => {
|
|
if (!scopeOptions.length || !contextId) return;
|
|
const fallbackScope = scopeOptions[0].value;
|
|
const currentScope: MessageSearchScope = activeScope ?? fallbackScope;
|
|
if (!scopeOptionValues.has(currentScope)) {
|
|
ChannelSearch.setScope(contextId, fallbackScope);
|
|
}
|
|
}, [scopeOptions, scopeOptionValues, activeScope, contextId]);
|
|
const applyScopeToParams = useCallback(
|
|
(params: MessageSearchParams, scopeOverride?: MessageSearchScope | null) => {
|
|
const parsedScope = params['scope'];
|
|
const resolvedScope = resolveSearchScope({
|
|
activeScope,
|
|
fallbackScope: DEFAULT_SCOPE_VALUE,
|
|
parsedScope,
|
|
scopeOverride,
|
|
scopeOptionValues,
|
|
});
|
|
if (contextId && activeScope !== resolvedScope) {
|
|
ChannelSearch.setScope(contextId, resolvedScope);
|
|
}
|
|
params['scope'] = resolvedScope;
|
|
},
|
|
[scopeOptionValues, activeScope, contextId],
|
|
);
|
|
useEffect(() => {
|
|
sortModeRef.current = sortMode;
|
|
}, [sortMode]);
|
|
useEffect(() => {
|
|
const cachedSortMode = searchContext?.lastSearchSortMode;
|
|
if (!cachedSortMode || sortModeRef.current === cachedSortMode) return;
|
|
sortModeRef.current = cachedSortMode;
|
|
setSortMode(cachedSortMode);
|
|
}, [contextId, searchContext?.lastSearchSortMode]);
|
|
const getScrollPosition = useCallback((): number => {
|
|
const node = scrollerRef.current?.getScrollerNode();
|
|
return node ? node.scrollTop : 0;
|
|
}, []);
|
|
const updateScrollPosition = useCallback(
|
|
(position: number) => {
|
|
if (!contextId) return;
|
|
ChannelSearch.setScrollPosition(contextId, position);
|
|
},
|
|
[contextId],
|
|
);
|
|
const handleScrollerScroll = useCallback(() => {
|
|
const position = getScrollPosition();
|
|
lastKnownScrollPosition.current = position;
|
|
updateScrollPosition(position);
|
|
}, [getScrollPosition, updateScrollPosition]);
|
|
const resetScrollerToTop = useCallback(() => {
|
|
lastKnownScrollPosition.current = 0;
|
|
updateScrollPosition(0);
|
|
scrollerRef.current?.scrollTo({to: 0, animate: false});
|
|
}, [updateScrollPosition]);
|
|
const sendContextMachineEvent = useCallback(
|
|
(
|
|
event: SearchMachineEvent,
|
|
searchScope?: MessageSearchScope | null,
|
|
searchSortMode?: ChannelSearchSortMode | null,
|
|
) => {
|
|
if (!contextId) return;
|
|
ChannelSearch.sendMachineEvent(
|
|
contextId,
|
|
event,
|
|
currentSearchQuery.current,
|
|
currentSearchSegments.current,
|
|
normalizedRefreshKey,
|
|
{scope: searchScope ?? null, sortMode: searchSortMode ?? null},
|
|
);
|
|
},
|
|
[contextId, normalizedRefreshKey],
|
|
);
|
|
const stopPolling = useCallback(() => {
|
|
if (pollingTimeout.current) {
|
|
clearTimeout(pollingTimeout.current);
|
|
pollingTimeout.current = null;
|
|
}
|
|
}, []);
|
|
const buildSearchParams = useCallback(
|
|
(page: number, scopeOverride?: MessageSearchScope | null): MessageSearchParams => {
|
|
const params: MessageSearchParams = {
|
|
...parseSearchQueryWithSegments(currentSearchQuery.current, currentSearchSegments.current, {
|
|
channelId: channelId.current,
|
|
guildId: channelGuildId.current,
|
|
}),
|
|
page,
|
|
hitsPerPage: RESULTS_PER_PAGE,
|
|
};
|
|
applyScopeToParams(params, scopeOverride);
|
|
applySortModeToParams(params, sortModeRef.current);
|
|
applyMatureContentToParamsIfNeeded(params, channelId.current);
|
|
return params;
|
|
},
|
|
[applyScopeToParams],
|
|
);
|
|
const performSearch = useCallback(
|
|
async (page = 1, sortOverride?: ChannelSearchSortMode, scopeOverride?: MessageSearchScope | null) => {
|
|
if (!currentSearchQuery.current.trim() || !contextId) return;
|
|
if (GuildMatureContentAgree.shouldShowGate({channelId: channelId.current, guildId: channelGuildId.current})) {
|
|
sendContextMachineEvent({
|
|
type: 'channelSearch.succeeded',
|
|
channels: [],
|
|
results: [],
|
|
total: 0,
|
|
hitsPerPage: RESULTS_PER_PAGE,
|
|
page: 1,
|
|
});
|
|
return;
|
|
}
|
|
const params = buildSearchParams(page, scopeOverride);
|
|
const searchScope = params.scope ?? DEFAULT_SCOPE_VALUE;
|
|
const searchSortMode = sortOverride ?? sortModeRef.current;
|
|
if (sortOverride) {
|
|
applySortModeToParams(params, sortOverride);
|
|
}
|
|
const attemptSegments = currentSearchSegments.current.map((segment) => ({...segment}));
|
|
lastSearchAttempt.current = {
|
|
query: currentSearchQuery.current,
|
|
segments: attemptSegments,
|
|
refreshKey: normalizedRefreshKey,
|
|
scope: searchScope,
|
|
sortMode: searchSortMode,
|
|
};
|
|
sendContextMachineEvent({type: 'channelSearch.loading'});
|
|
resetScrollerToTop();
|
|
try {
|
|
const result = await searchMessages(
|
|
i18n,
|
|
{contextChannelId: channelId.current, contextGuildId: channelGuildId.current},
|
|
params,
|
|
);
|
|
if (currentChannelId.current !== channelId.current) return;
|
|
if (isIndexing(result)) {
|
|
sendContextMachineEvent({type: 'channelSearch.indexingStarted'});
|
|
return;
|
|
}
|
|
sendContextMachineEvent(
|
|
{
|
|
type: 'channelSearch.succeeded',
|
|
channels: result.channels,
|
|
results: result.messages,
|
|
total: result.total,
|
|
hitsPerPage: result.hitsPerPage,
|
|
page: result.page,
|
|
},
|
|
searchScope,
|
|
searchSortMode,
|
|
);
|
|
} catch (error) {
|
|
if (currentChannelId.current !== channelId.current) return;
|
|
sendContextMachineEvent({
|
|
type: 'channelSearch.failed',
|
|
error: FormUtils.extractErrorMessage(i18n, error) || i18n._(AN_ERROR_OCCURRED_WHILE_SEARCHING_DESCRIPTOR),
|
|
});
|
|
}
|
|
},
|
|
[contextId, sendContextMachineEvent, resetScrollerToTop, buildSearchParams, i18n, normalizedRefreshKey],
|
|
);
|
|
const poll = useCallback(async () => {
|
|
if (currentChannelId.current !== channelId.current) {
|
|
stopPolling();
|
|
return;
|
|
}
|
|
if (GuildMatureContentAgree.shouldShowGate({channelId: channelId.current, guildId: channelGuildId.current})) {
|
|
stopPolling();
|
|
sendContextMachineEvent({
|
|
type: 'channelSearch.succeeded',
|
|
channels: [],
|
|
results: [],
|
|
total: 0,
|
|
hitsPerPage: RESULTS_PER_PAGE,
|
|
page: 1,
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
const page = successMachineState?.page ?? 1;
|
|
const params = buildSearchParams(page);
|
|
const searchScope = params.scope ?? DEFAULT_SCOPE_VALUE;
|
|
const searchSortMode = sortModeRef.current;
|
|
const result = await searchMessages(
|
|
i18n,
|
|
{contextChannelId: channelId.current, contextGuildId: channelGuildId.current},
|
|
params,
|
|
);
|
|
if (currentChannelId.current !== channelId.current) return;
|
|
if (isIndexing(result)) {
|
|
sendContextMachineEvent({type: 'channelSearch.indexingPolled'});
|
|
return;
|
|
}
|
|
stopPolling();
|
|
sendContextMachineEvent(
|
|
{
|
|
type: 'channelSearch.succeeded',
|
|
channels: result.channels,
|
|
results: result.messages,
|
|
total: result.total,
|
|
hitsPerPage: result.hitsPerPage,
|
|
page: result.page,
|
|
},
|
|
searchScope,
|
|
searchSortMode,
|
|
);
|
|
} catch (error) {
|
|
if (currentChannelId.current !== channelId.current) return;
|
|
stopPolling();
|
|
sendContextMachineEvent({
|
|
type: 'channelSearch.failed',
|
|
error: FormUtils.extractErrorMessage(i18n, error) || i18n._(AN_ERROR_OCCURRED_WHILE_SEARCHING_DESCRIPTOR),
|
|
});
|
|
}
|
|
}, [stopPolling, sendContextMachineEvent, successMachineState?.page, buildSearchParams, i18n]);
|
|
const handleSortSelect = useCallback(
|
|
(mode: ChannelSearchSortMode) => {
|
|
if (sortModeRef.current === mode) return;
|
|
setSortMode(mode);
|
|
performSearch(1, mode);
|
|
},
|
|
[performSearch],
|
|
);
|
|
const handleSortMenuOpen = useCallback(
|
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
ContextMenuCommands.openFromElementBottomRight(event, ({onClose: menuClose}) => (
|
|
<ContextMenuCloseProvider
|
|
value={menuClose}
|
|
data-flx="channel.channel-search-results.handle-sort-menu-open.context-menu-close-provider"
|
|
>
|
|
<MenuGroup data-flx="channel.channel-search-results.handle-sort-menu-open.menu-group">
|
|
{sortModeOptions.map((option) => (
|
|
<MenuItemRadio
|
|
key={option.mode}
|
|
selected={sortMode === option.mode}
|
|
closeOnSelect
|
|
onSelect={() => handleSortSelect(option.mode)}
|
|
icon={renderSortIcon(option.mode)}
|
|
data-flx="channel.channel-search-results.handle-sort-menu-open.menu-item-radio.sort-select"
|
|
>
|
|
{option.label}
|
|
</MenuItemRadio>
|
|
))}
|
|
</MenuGroup>
|
|
</ContextMenuCloseProvider>
|
|
));
|
|
},
|
|
[handleSortSelect, sortMode, sortModeOptions],
|
|
);
|
|
const handleScopeSelect = useCallback(
|
|
(value: MessageSearchParams['scope']) => {
|
|
if (!contextId || activeScope === value) return;
|
|
const nextScope = value ?? DEFAULT_SCOPE_VALUE;
|
|
ChannelSearch.setScope(contextId, nextScope);
|
|
performSearch(1, undefined, nextScope);
|
|
},
|
|
[performSearch, contextId, activeScope],
|
|
);
|
|
const handleScopeMenuOpen = useCallback(
|
|
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
ContextMenuCommands.openFromElementBottomRight(event, ({onClose: menuClose}) => (
|
|
<ContextMenuCloseProvider
|
|
value={menuClose}
|
|
data-flx="channel.channel-search-results.handle-scope-menu-open.context-menu-close-provider"
|
|
>
|
|
<MenuGroup data-flx="channel.channel-search-results.handle-scope-menu-open.menu-group">
|
|
{scopeOptions.map((option) => (
|
|
<MenuItemRadio
|
|
key={option.value}
|
|
selected={activeScope === option.value}
|
|
closeOnSelect
|
|
onSelect={() => handleScopeSelect(option.value)}
|
|
icon={renderScopeIcon(option.value)}
|
|
data-flx="channel.channel-search-results.handle-scope-menu-open.menu-item-radio.scope-select"
|
|
>
|
|
{option.label}
|
|
</MenuItemRadio>
|
|
))}
|
|
</MenuGroup>
|
|
</ContextMenuCloseProvider>
|
|
));
|
|
},
|
|
[handleScopeSelect, scopeOptions, activeScope],
|
|
);
|
|
const startPolling = useCallback(() => {
|
|
if (machineState.status !== 'indexing') return;
|
|
const pollCount = indexingMachineState?.pollCount ?? 0;
|
|
const pollInterval = getChannelSearchIndexingPollInterval(pollCount);
|
|
stopPolling();
|
|
pollingTimeout.current = window.setTimeout(() => {
|
|
pollingTimeout.current = null;
|
|
void poll();
|
|
}, pollInterval);
|
|
}, [machineState.status, indexingMachineState?.pollCount, poll, stopPolling]);
|
|
const setScrollerRef = useCallback((ref: ScrollerHandle | null) => {
|
|
scrollerRef.current = ref;
|
|
}, []);
|
|
useEffect(() => {
|
|
lastKnownScrollPosition.current = scrollPosition;
|
|
}, [contextId, scrollPosition]);
|
|
const transitionToChannel = useCallback((targetChannel: Channel) => {
|
|
if (targetChannel.guildId) {
|
|
NavigationCommands.selectChannel(targetChannel.guildId, targetChannel.id);
|
|
} else {
|
|
NavigationCommands.selectChannel(undefined, targetChannel.id);
|
|
}
|
|
}, []);
|
|
const showSearchResultOpenFailedModal = useCallback(() => {
|
|
ModalCommands.push(
|
|
modal(() => (
|
|
<SearchResultOpenFailedModal data-flx="channel.channel-search-results.show-search-result-open-failed-modal.search-result-open-failed-modal" />
|
|
)),
|
|
);
|
|
}, []);
|
|
const ensureSearchChannelReady = useCallback(
|
|
async (targetChannel: Channel): Promise<{channel: Channel; insertedTemporarily: boolean}> => {
|
|
const existingChannel = Channels.getChannel(targetChannel.id);
|
|
if (existingChannel) {
|
|
return {channel: existingChannel, insertedTemporarily: false};
|
|
}
|
|
if (targetChannel.isDM()) {
|
|
const recipientId = targetChannel.getRecipientId();
|
|
if (!recipientId) {
|
|
throw new Error(`Missing DM recipient for search result channel ${targetChannel.id}`);
|
|
}
|
|
const reopenedChannel = await PrivateChannelCommands.ensureDMChannelResponse(recipientId);
|
|
return {
|
|
channel: Channels.getChannel(reopenedChannel.id) ?? new Channel(reopenedChannel),
|
|
insertedTemporarily: false,
|
|
};
|
|
}
|
|
Channels.handleChannelCreate({channel: targetChannel.toJSON()});
|
|
return {channel: Channels.getChannel(targetChannel.id) ?? targetChannel, insertedTemporarily: true};
|
|
},
|
|
[],
|
|
);
|
|
const navigateToSearchMessage = useCallback((targetChannel: Channel, messageId: string) => {
|
|
const guildId = targetChannel.guildId && targetChannel.guildId !== ME ? targetChannel.guildId : ME;
|
|
NavigationCommands.navigateToMessage(guildId, targetChannel.id, messageId);
|
|
}, []);
|
|
const handleSearchChannelOpen = useCallback(
|
|
async (targetChannel: Channel) => {
|
|
let ensuredChannel: Channel | null = null;
|
|
let insertedTemporarily = false;
|
|
try {
|
|
const ensuredResult = await ensureSearchChannelReady(targetChannel);
|
|
ensuredChannel = ensuredResult.channel;
|
|
insertedTemporarily = ensuredResult.insertedTemporarily;
|
|
await MessageCommands.fetchMessages(ensuredChannel.id, null, null, MAX_MESSAGES_PER_CHANNEL, undefined, {
|
|
throwOnError: true,
|
|
});
|
|
transitionToChannel(ensuredChannel);
|
|
onClose();
|
|
focusChannelTextareaAfterNavigation(ensuredChannel.id);
|
|
} catch {
|
|
if (insertedTemporarily && ensuredChannel) {
|
|
Channels.handleChannelDelete({channel: ensuredChannel.toJSON()});
|
|
}
|
|
showSearchResultOpenFailedModal();
|
|
}
|
|
},
|
|
[ensureSearchChannelReady, onClose, showSearchResultOpenFailedModal, transitionToChannel],
|
|
);
|
|
const handleSearchMessageJump = useCallback(
|
|
async (targetChannel: Channel, message: Message) => {
|
|
let ensuredChannel: Channel | null = null;
|
|
let insertedTemporarily = false;
|
|
try {
|
|
const ensuredResult = await ensureSearchChannelReady(targetChannel);
|
|
ensuredChannel = ensuredResult.channel;
|
|
insertedTemporarily = ensuredResult.insertedTemporarily;
|
|
await MessageCommands.fetchMessages(
|
|
ensuredChannel.id,
|
|
null,
|
|
null,
|
|
MAX_MESSAGES_PER_CHANNEL,
|
|
{messageId: message.id as MessageId, flash: true},
|
|
{throwOnError: true},
|
|
);
|
|
navigateToSearchMessage(ensuredChannel, message.id);
|
|
focusChannelTextareaAfterNavigation(ensuredChannel.id);
|
|
} catch {
|
|
if (insertedTemporarily && ensuredChannel) {
|
|
Channels.handleChannelDelete({channel: ensuredChannel.toJSON()});
|
|
}
|
|
showSearchResultOpenFailedModal();
|
|
}
|
|
},
|
|
[ensureSearchChannelReady, navigateToSearchMessage, showSearchResultOpenFailedModal],
|
|
);
|
|
const handlePaginationJump = useCallback(
|
|
(page: number) => {
|
|
resetScrollerToTop();
|
|
performSearch(page);
|
|
},
|
|
[resetScrollerToTop, performSearch],
|
|
);
|
|
const renderContent = useCallback(() => {
|
|
switch (machineState.status) {
|
|
case 'idle':
|
|
case 'loading':
|
|
return null;
|
|
case 'indexing':
|
|
return <SearchIndexingState data-flx="channel.channel-search-results.render-content.search-indexing-state" />;
|
|
case 'error':
|
|
return (
|
|
<SearchErrorState
|
|
error={machineState.error}
|
|
onRetry={() => performSearch(1)}
|
|
data-flx="channel.channel-search-results.render-content.search-error-state"
|
|
/>
|
|
);
|
|
case 'success': {
|
|
const {results, total, hitsPerPage, page: currentPage} = machineState;
|
|
if (results.length === 0) {
|
|
return <SearchEmptyState data-flx="channel.channel-search-results.render-content.search-empty-state" />;
|
|
}
|
|
const totalPages = Math.max(1, Math.ceil(total / hitsPerPage));
|
|
return (
|
|
<CollapsedMessageVisibilityProvider
|
|
value={collapsedMessageVisibility}
|
|
data-flx="channel.channel-search-results.render-content.collapsed-message-visibility-provider"
|
|
>
|
|
<Scroller
|
|
ref={setScrollerRef}
|
|
className={styles.resultsScroller}
|
|
onScroll={handleScrollerScroll}
|
|
fade={false}
|
|
key="channel-search-results-scroller-desktop"
|
|
onCopy={onCopySelectedMessages}
|
|
data-message-selection-root="true"
|
|
data-flx="channel.channel-search-results.render-content.results-scroller"
|
|
>
|
|
{Array.from(messagesByChannel.entries()).map(([resultChannelId, messages]) => {
|
|
const renderData = getSearchResultChannelRenderData(
|
|
resultChannelId,
|
|
searchChannelsById,
|
|
(activeScope ?? DEFAULT_SCOPE_VALUE) as MessageSearchScope,
|
|
);
|
|
if (!renderData) return null;
|
|
const {messageChannel, showGuildMeta} = renderData;
|
|
const renderMessageActions = (message: Message) => (
|
|
<FocusRing
|
|
offset={-2}
|
|
ringClassName={styles.focusRingTight}
|
|
data-flx="channel.channel-search-results.render-message-actions.focus-ring"
|
|
>
|
|
<button
|
|
type="button"
|
|
className={styles.jumpButton}
|
|
onClick={() => {
|
|
void handleSearchMessageJump(messageChannel, message);
|
|
}}
|
|
data-flx="channel.channel-search-results.render-message-actions.jump-button"
|
|
>
|
|
{i18n._(JUMP_DESCRIPTOR)}
|
|
</button>
|
|
</FocusRing>
|
|
);
|
|
const renderMessageWrapper = ({
|
|
message,
|
|
index,
|
|
isGroupStart,
|
|
children,
|
|
}: MessageGroupRenderWrapperProps) => (
|
|
<div
|
|
data-message-index={index}
|
|
data-message-id={message.id}
|
|
data-is-group-start={isGroupStart}
|
|
className={styles.messageItem}
|
|
data-flx="channel.channel-search-results.render-message-wrapper.message-item"
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
return (
|
|
<React.Fragment key={resultChannelId}>
|
|
<MessageContextPrefix
|
|
channel={messageChannel}
|
|
showGuildMeta={showGuildMeta}
|
|
onClick={() => {
|
|
void handleSearchChannelOpen(messageChannel);
|
|
}}
|
|
data-flx="channel.channel-search-results.render-content.message-context-prefix"
|
|
/>
|
|
<SearchResultMessageList
|
|
channel={messageChannel}
|
|
messages={messages}
|
|
revealedGroupKeys={revealedGroupKeys}
|
|
onGroupRevealChange={handleCollapsedGroupRevealChange}
|
|
collapsedGroupClassName={styles.collapsedMessageGroup}
|
|
messagePreviewContext={MessagePreviewContext.LIST_POPOUT}
|
|
messageBehaviorOverrides={DETACHED_MESSAGE_BEHAVIOR}
|
|
messageActionsClassName={styles.actionButtons}
|
|
renderMessageActions={renderMessageActions}
|
|
renderMessageWrapper={renderMessageWrapper}
|
|
spammerOverrideVersion={spammerOverrideVersion}
|
|
renderMessage={(message) => (
|
|
<div
|
|
className={styles.messageItem}
|
|
data-message-id={message.id}
|
|
data-is-group-start="true"
|
|
data-flx="channel.channel-search-results.render-content.message-item"
|
|
>
|
|
<MessageComponent
|
|
message={message}
|
|
channel={messageChannel}
|
|
previewContext={MessagePreviewContext.LIST_POPOUT}
|
|
behaviorOverrides={DETACHED_MESSAGE_BEHAVIOR}
|
|
data-flx="channel.channel-search-results.render-content.message-component"
|
|
/>
|
|
<div
|
|
className={styles.actionButtons}
|
|
data-flx="channel.channel-search-results.render-content.action-buttons"
|
|
>
|
|
{renderMessageActions(message)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
data-flx="channel.channel-search-results.render-content.search-result-message-list"
|
|
/>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
<div
|
|
className={styles.resultsSpacer}
|
|
data-flx="channel.channel-search-results.render-content.results-spacer"
|
|
/>
|
|
<SearchResultsPagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
visiblePageSlots={visiblePageSlots}
|
|
onJumpToPage={handlePaginationJump}
|
|
data-flx="channel.channel-search-results.render-content.search-results-pagination"
|
|
/>
|
|
</Scroller>
|
|
</CollapsedMessageVisibilityProvider>
|
|
);
|
|
}
|
|
}
|
|
}, [
|
|
machineState,
|
|
performSearch,
|
|
setScrollerRef,
|
|
handleScrollerScroll,
|
|
visiblePageSlots,
|
|
activeScope,
|
|
handleSearchChannelOpen,
|
|
handleSearchMessageJump,
|
|
handlePaginationJump,
|
|
onCopySelectedMessages,
|
|
collapsedMessageVisibility,
|
|
messagesByChannel,
|
|
searchChannelsById,
|
|
revealedGroupKeys,
|
|
handleCollapsedGroupRevealChange,
|
|
spammerOverrideVersion,
|
|
i18n,
|
|
]);
|
|
useEffect(() => {
|
|
currentSearchQuery.current = searchQuery;
|
|
currentSearchSegments.current = searchSegments;
|
|
}, [searchQuery, searchSegments]);
|
|
useEffect(() => {
|
|
if (!contextId || !searchContext) return;
|
|
if (!searchQuery.trim()) {
|
|
lastSearchAttempt.current = null;
|
|
return;
|
|
}
|
|
const segmentsMatch = areSegmentsEqual(searchContext.lastSearchSegments, searchSegments);
|
|
const hasCachedResults =
|
|
searchContext.lastSearchQuery === searchQuery &&
|
|
segmentsMatch &&
|
|
searchContext.lastSearchScope === activeScope &&
|
|
searchContext.lastSearchSortMode === sortMode;
|
|
const shouldRefresh = normalizedRefreshKey !== lastObservedRefreshKey;
|
|
if (hasCachedResults && !shouldRefresh) {
|
|
if (machineState.status === 'success' && scrollPosition > 0 && scrollerRef.current) {
|
|
scrollerRef.current.scrollTo({to: scrollPosition, animate: false});
|
|
}
|
|
return;
|
|
}
|
|
const hasPendingAttempt =
|
|
lastSearchAttempt.current &&
|
|
lastSearchAttempt.current.query === searchQuery &&
|
|
lastSearchAttempt.current.refreshKey === normalizedRefreshKey &&
|
|
lastSearchAttempt.current.scope === activeScope &&
|
|
lastSearchAttempt.current.sortMode === sortMode &&
|
|
areSegmentsEqual(lastSearchAttempt.current.segments, searchSegments);
|
|
if (hasPendingAttempt && machineState.status !== 'success') return;
|
|
resetScrollerToTop();
|
|
sendContextMachineEvent({type: 'channelSearch.loading'});
|
|
performSearch(1);
|
|
}, [
|
|
contextId,
|
|
searchContext?.lastSearchQuery,
|
|
searchContext?.lastSearchSegments,
|
|
searchContext?.lastSearchRefreshKey,
|
|
searchContext?.lastSearchScope,
|
|
searchContext?.lastSearchSortMode,
|
|
searchQuery,
|
|
searchSegments,
|
|
activeScope,
|
|
sortMode,
|
|
normalizedRefreshKey,
|
|
resetScrollerToTop,
|
|
sendContextMachineEvent,
|
|
performSearch,
|
|
machineState.status,
|
|
scrollPosition,
|
|
lastObservedRefreshKey,
|
|
]);
|
|
useEffect(() => {
|
|
if (currentChannelId.current !== channel.id) {
|
|
stopPolling();
|
|
currentChannelId.current = channel.id;
|
|
channelId.current = channel.id;
|
|
channelGuildId.current = channel.guildId ?? null;
|
|
channelIsMatureContent.current = channel.isMature();
|
|
}
|
|
}, [channel.id, channel.guildId, channel.isMature, stopPolling]);
|
|
useEffect(() => {
|
|
if (machineState.status !== 'indexing') {
|
|
stopPolling();
|
|
return;
|
|
}
|
|
startPolling();
|
|
return stopPolling;
|
|
}, [machineState.status, startPolling, stopPolling]);
|
|
useEffect(() => {
|
|
if (machineState.status === 'success' && scrollPosition > 0 && scrollerRef.current) {
|
|
scrollerRef.current.scrollTo({to: scrollPosition, animate: false});
|
|
lastKnownScrollPosition.current = scrollPosition;
|
|
}
|
|
}, [machineState.status, scrollPosition]);
|
|
useEffect(() => {
|
|
const handleResize = () => {
|
|
setVisiblePageSlots(getAdaptiveVisiblePageCount());
|
|
};
|
|
window.addEventListener('resize', handleResize);
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize);
|
|
};
|
|
}, []);
|
|
useEffect(() => {
|
|
setRevealedGroupKeys(new Set());
|
|
}, [successMachineState?.results]);
|
|
useEffect(() => {
|
|
return () => {
|
|
stopPolling();
|
|
updateScrollPosition(lastKnownScrollPosition.current);
|
|
};
|
|
}, [stopPolling, updateScrollPosition]);
|
|
useEffect(() => {
|
|
if (machineState.status !== 'success' || !searchQuery.trim()) {
|
|
clearChannelSearchHighlight();
|
|
return;
|
|
}
|
|
const scrollerNode = scrollerRef.current?.getScrollerNode();
|
|
if (!scrollerNode) return;
|
|
const timer = setTimeout(() => {
|
|
const terms = tokenizeSearchQuery(searchQuery);
|
|
if (terms.length > 0) {
|
|
applyChannelSearchHighlight(scrollerNode, terms);
|
|
}
|
|
}, 50);
|
|
return () => {
|
|
clearTimeout(timer);
|
|
clearChannelSearchHighlight();
|
|
};
|
|
}, [machineState.status, searchQuery, successMachineState?.results]);
|
|
return (
|
|
<div className={styles.container} data-flx="channel.channel-search-results.container">
|
|
<SearchResultsHeader
|
|
machineState={machineState}
|
|
scopeOptions={scopeOptions}
|
|
activeScope={activeScope}
|
|
sortModeOptions={sortModeOptions}
|
|
sortMode={sortMode}
|
|
onScopeMenuOpen={handleScopeMenuOpen}
|
|
onSortMenuOpen={handleSortMenuOpen}
|
|
data-flx="channel.channel-search-results.search-results-header"
|
|
/>
|
|
{renderContent()}
|
|
</div>
|
|
);
|
|
},
|
|
);
|