// SPDX-License-Identifier: AGPL-3.0-or-later import {msg} from '@lingui/core/macro'; import '@app/features/channel/components/ChannelSearchHighlight.css'; import sharedStyles from '@app/features/app/components/bottomsheets/shared.module.css'; import {LongPressable} from '@app/features/app/components/LongPressable'; import {Message as MessageComponent} from '@app/features/channel/components/ChannelMessage'; import {CollapsedMessageVisibilityProvider} from '@app/features/channel/components/CollapsedMessageVisibilityContext'; import {MessageActionBottomSheet} from '@app/features/channel/components/MessageActionBottomSheet'; import type {MessageGroupRenderWrapperProps} from '@app/features/channel/components/MessageGroup'; import { buildSearchResultGroups, buildSearchResultGroupsByMessageId, countSearchResultChannels, } from '@app/features/channel/components/SearchResultGrouping'; import {SearchResultMessageList} from '@app/features/channel/components/SearchResultMessageList'; import {type ChannelSearchFilters, useChannelSearch} from '@app/features/channel/hooks/useChannelSearch'; import type {Channel} from '@app/features/channel/models/Channel'; import Channels from '@app/features/channel/state/Channels'; import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils'; import { CLEAR_SEARCH_DESCRIPTOR, NEXT_DESCRIPTOR, TRY_AGAIN_DESCRIPTOR, } from '@app/features/i18n/utils/CommonMessageDescriptors'; import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils'; import * as MessageCommands from '@app/features/messaging/commands/MessageCommands'; import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation'; import {useMessageSelectionCopyForMessages} from '@app/features/messaging/hooks/useMessageSelectionCopy'; import {NearViewportSurfaceContext} from '@app/features/messaging/hooks/useNearViewport'; 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 {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils'; import {getCollapsedMessageGroupKey} from '@app/features/messaging/utils/MessageGroupingUtils'; import {goToMessage} from '@app/features/messaging/utils/MessageNavigator'; import LocalUserSpamOverride from '@app/features/moderation/state/LocalUserSpamOverride'; import {shouldDisableAutofocusOnMobile} from '@app/features/platform/utils/AutofocusUtils'; import {PASSWORD_MANAGER_IGNORE_ATTRIBUTES} from '@app/features/platform/utils/PasswordManagerAutocomplete'; import {ChannelFilterSheet} from '@app/features/search/components/search/ChannelFilterSheet'; import {HasFilterSheet, type HasFilterType} from '@app/features/search/components/search/HasFilterSheet'; import {ScopeSheet} from '@app/features/search/components/search/ScopeSheet'; import {SearchFilterChip} from '@app/features/search/components/search/SearchFilterChip'; import {SortModeSheet} from '@app/features/search/components/search/SortModeSheet'; import {UserFilterSheet} from '@app/features/search/components/search/UserFilterSheet'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import styles from '@app/features/theme/styles/ChannelSearchBottomSheet.module.css'; import { CloseIcon, ExpandChevronIcon, FilterIcon, LoadingIcon, NextIcon, PreviousIcon, SearchIcon, SortIcon, UserFilterIcon, } from '@app/features/ui/action_menu/ContextMenuIcons'; import {BottomSheet} from '@app/features/ui/bottom_sheet/BottomSheet'; import {Button} from '@app/features/ui/button/Button'; import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller'; import Users from '@app/features/user/state/Users'; import * as NicknameUtils from '@app/features/user/utils/NicknameUtils'; import {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants'; import {Trans, useLingui} from '@lingui/react/macro'; import {HashIcon} from '@phosphor-icons/react'; import {observer} from 'mobx-react-lite'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; const NEWEST_DESCRIPTOR = msg({ message: 'Newest', comment: 'Sort mode label in the mobile channel search bottom sheet. Sorts results newest first.', }); const OLDEST_DESCRIPTOR = msg({ message: 'Oldest', comment: 'Sort mode label in the mobile channel search bottom sheet. Sorts results oldest first.', }); const RELEVANT_DESCRIPTOR = msg({ message: 'Relevant', comment: 'Sort mode label in the mobile channel search bottom sheet. Sorts by relevance.', }); const MESSAGE_1_USER_DESCRIPTOR = msg({ message: '1 user', comment: 'Summary chip text in the mobile channel search sheet when exactly one user is selected as a filter.', }); const USERS_DESCRIPTOR = msg({ message: '{length} users', comment: 'Summary chip text in the mobile channel search sheet when more than one user is selected. length is the count.', }); const IMAGE_UPLOAD_DESCRIPTOR = msg({ message: 'image upload', comment: 'Label for the has:image filter option in the mobile channel search sheet.', }); const VIDEO_UPLOAD_DESCRIPTOR = msg({ message: 'video upload', comment: 'Label for the has:video filter option in the mobile channel search sheet.', }); const AUDIO_UPLOAD_DESCRIPTOR = msg({ message: 'audio upload', comment: 'Label for the has:sound filter option in the mobile channel search sheet.', }); const FILE_UPLOAD_DESCRIPTOR = msg({ message: 'file upload', comment: 'Label for the has:file filter option in the mobile channel search sheet.', }); const LINK_PREVIEW_OR_EMBED_DESCRIPTOR = msg({ message: 'link preview or embed', comment: 'Label for the has:embed filter option in the mobile channel search sheet.', }); const LINK_DESCRIPTOR = msg({ message: 'link', comment: 'Label for the has:link filter option in the mobile channel search sheet.', }); const STICKER_DESCRIPTOR = msg({ message: 'sticker', comment: 'Label for the has:sticker filter option in the mobile channel search sheet.', }); const TYPES_DESCRIPTOR = msg({ message: '{length} types', comment: 'Summary chip text in the mobile channel search sheet when multiple content types are selected. length is the count.', }); const SEARCH_DESCRIPTOR = msg({ message: 'Search', comment: 'Title of the mobile channel search bottom sheet.', }); const SEARCH_RESULTS_COUNT_DESCRIPTOR = msg({ message: '{resultCount} results', comment: 'Subtitle in the mobile channel search bottom sheet showing the total number of search results.', }); const SEARCH_MESSAGES_DESCRIPTOR = msg({ message: 'Search messages', comment: 'Placeholder text in the mobile channel search input.', }); const FROM_DESCRIPTOR = msg({ message: 'From', comment: 'Filter category label in the mobile channel search sheet for the from: user filter.', }); const HAS_DESCRIPTOR = msg({ message: 'Has', comment: 'Filter category label in the mobile channel search sheet for the has: content type filter.', }); const IN_DESCRIPTOR = msg({ message: 'In', comment: 'Filter category label in the mobile channel search sheet for the in: channel filter.', }); const SORT_DESCRIPTOR = msg({ message: 'Sort', comment: 'Filter category label in the mobile channel search sheet for the sort mode selector.', }); const SCOPE_DESCRIPTOR = msg({ message: 'Scope', comment: 'Filter category label in the mobile channel search sheet for the search scope selector.', }); const FROM_USER_DESCRIPTOR = msg({ message: 'From user', comment: 'Section header in the mobile channel search user picker. Filters by sending user.', }); const IN_CHANNEL_DESCRIPTOR = msg({ message: 'In channel', comment: 'Section header in the mobile channel search channel picker. Filters by channel.', }); const MESSAGE_1_CHANNEL_DESCRIPTOR = msg({ message: '1 channel', comment: 'Summary chip text in the mobile channel search sheet when exactly one channel is selected as a filter.', }); const CHANNELS_DESCRIPTOR = msg({ message: '{length} channels', comment: 'Summary chip text in the mobile channel search sheet when more than one channel is selected. length is the count.', }); interface ChannelSearchBottomSheetProps { isOpen: boolean; onClose: () => void; channel: Channel; } const EMPTY_SEARCH_MESSAGES: Array = []; const EMPTY_SEARCH_CHANNELS: Array = []; export const ChannelSearchBottomSheet: React.FC = observer( ({isOpen, onClose, channel}) => { const {i18n} = useLingui(); const [contentQuery, setContentQuery] = useState(''); const [menuOpen, setMenuOpen] = useState(false); const [selectedMessage, setSelectedMessage] = useState(null); const [selectedMessageChannel, setSelectedMessageChannel] = useState(null); const scrollerRef = useRef(null); const resolveSearchSheetScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []); const inputRef = useRef(null); const [hasFilters, setHasFilters] = useState>([]); const [fromUserIds, setFromUserIds] = useState>([]); const [inChannelIds, setInChannelIds] = useState>([]); const [revealedGroupKeys, setRevealedGroupKeys] = useState>(new Set()); const [hasSheetOpen, setHasSheetOpen] = useState(false); const [userSheetOpen, setUserSheetOpen] = useState(false); const [channelSheetOpen, setChannelSheetOpen] = useState(false); const [sortSheetOpen, setSortSheetOpen] = useState(false); const [scopeSheetOpen, setScopeSheetOpen] = useState(false); useMessageListKeyboardNavigation({ containerRef: scrollerRef, }); const { machineState, sortMode, scope, scopeOptions, hasSearched, performFilterSearch, goToPage, setSortMode, setScope, reset, } = useChannelSearch({channel}); const successResults = machineState.status === 'success' ? machineState.results : EMPTY_SEARCH_MESSAGES; const successChannels = machineState.status === 'success' ? machineState.channels : EMPTY_SEARCH_CHANNELS; const onCopySelectedMessages = useMessageSelectionCopyForMessages(successResults); const searchChannelsById = React.useMemo( () => new Map(successChannels.map((searchChannel) => [searchChannel.id, searchChannel])), [successChannels], ); const resultGroups = React.useMemo(() => buildSearchResultGroups(successResults), [successResults]); const resultGroupsByMessageId = React.useMemo( () => buildSearchResultGroupsByMessageId(resultGroups), [resultGroups], ); const spammerOverrideVersion = LocalUserSpamOverride.version; const collapsedMessageVisibility = React.useMemo( () => ({ isMessageRevealed: (message: Message) => { const resultGroup = resultGroupsByMessageId.get(message.id); if (!resultGroup) { return false; } const messageChannel = searchChannelsById.get(message.channelId) ?? Channels.getChannel(message.channelId); if (!messageChannel) { return false; } const groupKey = getCollapsedMessageGroupKey({ channel: messageChannel, messages: resultGroup.messages, messageId: message.id, treatSpam: true, }); return groupKey != null && revealedGroupKeys.has(groupKey); }, }), [resultGroupsByMessageId, 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; }); }, []); const sortModeLabel = (() => { if (sortMode === 'newest') return i18n._(NEWEST_DESCRIPTOR); if (sortMode === 'oldest') return i18n._(OLDEST_DESCRIPTOR); return i18n._(RELEVANT_DESCRIPTOR); })(); const buildFilters = useCallback((): ChannelSearchFilters => { return { content: contentQuery.trim() || undefined, has: hasFilters.length > 0 ? hasFilters : undefined, authorIds: fromUserIds.length > 0 ? fromUserIds : undefined, channelIds: inChannelIds.length > 0 ? inChannelIds : undefined, }; }, [contentQuery, hasFilters, fromUserIds, inChannelIds]); const handleSearch = useCallback(() => { const filters = buildFilters(); if (!filters.content && !filters.has?.length && !filters.authorIds?.length && !filters.channelIds?.length) { return; } performFilterSearch(filters); }, [buildFilters, performFilterSearch]); const handleClear = useCallback(() => { setContentQuery(''); setHasFilters([]); setFromUserIds([]); setInChannelIds([]); reset(); }, [reset]); const handleNextPage = useCallback(() => { if (machineState.status !== 'success') return; const totalPages = Math.max(1, Math.ceil(machineState.total / machineState.hitsPerPage)); if (machineState.page < totalPages) { goToPage(machineState.page + 1); } }, [machineState, goToPage]); const handlePrevPage = useCallback(() => { if (machineState.status !== 'success' || machineState.page === 1) return; goToPage(machineState.page - 1); }, [machineState, goToPage]); const handleKeyDown = (e: React.KeyboardEvent) => { if (isIMEComposing(e)) { return; } if (e.key === 'Enter') { e.preventDefault(); handleSearch(); } else if (e.key === 'Escape') { e.preventDefault(); if (contentQuery) { setContentQuery(''); } else { onClose(); } } }; const handleJump = (channelId: string, messageId: string) => { goToMessage(channelId, messageId); onClose(); focusChannelTextareaAfterNavigation(channelId); }; const handleTap = (message: Message) => { handleJump(message.channelId, message.id); }; const handleDelete = useCallback( (bypassConfirm = false) => { if (!selectedMessage) return; if (bypassConfirm) { MessageCommands.remove(selectedMessage.channelId, selectedMessage.id); } else { MessageCommands.showDeleteConfirmation(i18n, { message: selectedMessage, showShiftBypassConfirmationTip: true, }); } }, [selectedMessage, i18n], ); useEffect(() => { if (shouldDisableAutofocusOnMobile()) { return; } if (isOpen && inputRef.current) { setTimeout(() => { inputRef.current?.focus(); }, 100); } }, [isOpen]); useEffect(() => { if (hasSearched) { const filters = buildFilters(); if (filters.content || filters.has?.length || filters.authorIds?.length || filters.channelIds?.length) { performFilterSearch(filters); } } }, [hasFilters, fromUserIds, inChannelIds]); useEffect(() => { setRevealedGroupKeys(new Set()); }, [machineState.status === 'success' ? machineState.results : null]); useEffect(() => { if (machineState.status !== 'success' || machineState.results.length === 0) { clearChannelSearchHighlight(); return; } const trimmedQuery = contentQuery.trim(); if (!trimmedQuery) { clearChannelSearchHighlight(); return; } const container = scrollerRef.current?.getViewportElement(); if (!container) { return; } const searchTerms = trimmedQuery.split(/\s+/).filter((term) => term.length > 0); applyChannelSearchHighlight(container, searchTerms); return () => { clearChannelSearchHighlight(); }; }, [machineState, contentQuery]); const hasActiveFilters = hasFilters.length > 0 || fromUserIds.length > 0 || inChannelIds.length > 0; const canSearch = contentQuery.trim() || hasActiveFilters; const getFromUserLabel = (): string => { if (fromUserIds.length === 0) return ''; if (fromUserIds.length === 1) { const user = Users.getUser(fromUserIds[0]); return user ? NicknameUtils.getDisplayName(user) : i18n._(MESSAGE_1_USER_DESCRIPTOR); } return i18n._(USERS_DESCRIPTOR, {length: fromUserIds.length}); }; const getInChannelLabel = (): string => { if (inChannelIds.length === 0) return ''; if (inChannelIds.length === 1) { const inChannel = Channels.getChannel(inChannelIds[0]); return inChannel?.name ? `#${inChannel.name}` : i18n._(MESSAGE_1_CHANNEL_DESCRIPTOR); } return i18n._(CHANNELS_DESCRIPTOR, {length: inChannelIds.length}); }; const getHasFilterDisplayLabel = (filter: HasFilterType): string => { switch (filter) { case 'image': return i18n._(IMAGE_UPLOAD_DESCRIPTOR); case 'video': return i18n._(VIDEO_UPLOAD_DESCRIPTOR); case 'sound': return i18n._(AUDIO_UPLOAD_DESCRIPTOR); case 'file': return i18n._(FILE_UPLOAD_DESCRIPTOR); case 'embed': return i18n._(LINK_PREVIEW_OR_EMBED_DESCRIPTOR); case 'link': return i18n._(LINK_DESCRIPTOR); case 'sticker': return i18n._(STICKER_DESCRIPTOR); default: return filter; } }; const getHasFilterLabel = (): string => { if (hasFilters.length === 0) return ''; if (hasFilters.length === 1) return getHasFilterDisplayLabel(hasFilters[0]); return i18n._(TYPES_DESCRIPTOR, {length: hasFilters.length}); }; const activeScopeOption = scopeOptions.find((opt) => opt.value === scope) ?? scopeOptions[0]; const SearchResultItem: React.FC<{message: Message; messageChannel: Channel}> = observer( ({message, messageChannel}) => { return ( handleTap(message)} onKeyDown={(e) => { if (isKeyboardActivationKey(e.key)) { e.preventDefault(); handleTap(message); } }} onLongPress={() => { setSelectedMessage(message); setSelectedMessageChannel(messageChannel); setMenuOpen(true); }} data-flx="channel.channel-search-bottom-sheet.search-result-item.search-result-item.tap" > ); }, ); const renderContent = () => { if (!hasSearched) { return (

Search messages

Use filters or enter keywords to find messages

); } switch (machineState.status) { case 'idle': case 'loading': return (
); case 'indexing': return (

Indexing channel

We're indexing this channel for the first time. This might take a little while...

); case 'error': return (

Error

{machineState.error}

); case 'success': { const {results, total, hitsPerPage, page: currentPage} = machineState; if (results.length === 0) { return (

Nothing matches

Try different filters or search terms.

); } const totalPages = Math.max(1, Math.ceil(total / hitsPerPage)); const hasMultipleChannels = countSearchResultChannels(resultGroups) > 1; return ( <> {resultGroups.map((resultGroup) => { const messageChannel = searchChannelsById.get(resultGroup.channelId) ?? Channels.getChannel(resultGroup.channelId); if (!messageChannel) { return null; } const renderMessageWrapper = ({ message, index, isGroupStart, children, }: MessageGroupRenderWrapperProps) => ( handleTap(message)} onKeyDown={(e) => { if (isKeyboardActivationKey(e.key)) { e.preventDefault(); handleTap(message); } }} onLongPress={() => { setSelectedMessage(message); setMenuOpen(true); }} data-flx="channel.channel-search-bottom-sheet.render-message-wrapper.search-result-item.tap" > {children} ); return ( {hasMultipleChannels && (
{ChannelUtils.getIcon(messageChannel, { className: styles.channelIcon, })} {messageChannel.name || 'Unnamed Channel'}
)} ( )} data-flx="channel.channel-search-bottom-sheet.render-content.search-result-message-list" />
); })}
{totalPages > 1 && (
Page {currentPage} of {totalPages}
)} ); } } }; const headerSubtitle = machineState.status === 'success' && machineState.total > 0 ? i18n._(SEARCH_RESULTS_COUNT_DESCRIPTOR, {resultCount: machineState.total}) : null; return ( <>
setContentQuery(e.target.value)} onKeyDown={handleKeyDown} placeholder={i18n._(SEARCH_MESSAGES_DESCRIPTOR)} aria-label={i18n._(SEARCH_MESSAGES_DESCRIPTOR)} className={styles.searchInput} /> {(contentQuery || hasActiveFilters) && ( )}
} onPress={() => setUserSheetOpen(true)} onRemove={fromUserIds.length > 0 ? () => setFromUserIds([]) : undefined} isActive={fromUserIds.length > 0} data-flx="channel.channel-search-bottom-sheet.search-filter-chip.set-user-sheet-open" /> {channel.guildId && ( } onPress={() => setChannelSheetOpen(true)} onRemove={inChannelIds.length > 0 ? () => setInChannelIds([]) : undefined} isActive={inChannelIds.length > 0} data-flx="channel.channel-search-bottom-sheet.search-filter-chip.set-channel-sheet-open" /> )} } onPress={() => setHasSheetOpen(true)} onRemove={hasFilters.length > 0 ? () => setHasFilters([]) : undefined} isActive={hasFilters.length > 0} data-flx="channel.channel-search-bottom-sheet.search-filter-chip.set-has-sheet-open" /> } onPress={() => setSortSheetOpen(true)} isActive={false} data-flx="channel.channel-search-bottom-sheet.search-filter-chip.set-sort-sheet-open" /> } onPress={() => setScopeSheetOpen(true)} isActive={false} data-flx="channel.channel-search-bottom-sheet.search-filter-chip.set-scope-sheet-open" />
{headerSubtitle && (

{headerSubtitle}

)}
{renderContent()}
setHasSheetOpen(false)} selectedFilters={hasFilters} onFiltersChange={setHasFilters} data-flx="channel.channel-search-bottom-sheet.has-filter-sheet" /> setUserSheetOpen(false)} channel={channel} selectedUserIds={fromUserIds} onUsersChange={setFromUserIds} title={i18n._(FROM_USER_DESCRIPTOR)} data-flx="channel.channel-search-bottom-sheet.user-filter-sheet" /> {channel.guildId && ( setChannelSheetOpen(false)} guildId={channel.guildId} selectedChannelIds={inChannelIds} onChannelsChange={setInChannelIds} title={i18n._(IN_CHANNEL_DESCRIPTOR)} data-flx="channel.channel-search-bottom-sheet.channel-filter-sheet" /> )} setSortSheetOpen(false)} selectedMode={sortMode} onModeChange={setSortMode} data-flx="channel.channel-search-bottom-sheet.sort-mode-sheet" /> setScopeSheetOpen(false)} selectedScope={scope} scopeOptions={scopeOptions} onScopeChange={setScope} data-flx="channel.channel-search-bottom-sheet.scope-sheet" /> {selectedMessage && ( { setMenuOpen(false); setSelectedMessage(null); setSelectedMessageChannel(null); }} message={selectedMessage} sourceChannel={selectedMessageChannel} handleDelete={handleDelete} data-flx="channel.channel-search-bottom-sheet.message-action-bottom-sheet" /> )} ); }, );