Files
fluxer/fluxer_app/src/features/channel/components/bottomsheets/ChannelSearchBottomSheet.tsx
T

923 lines
34 KiB
TypeScript

// 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<Message> = [];
const EMPTY_SEARCH_CHANNELS: Array<Channel> = [];
export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> = observer(
({isOpen, onClose, channel}) => {
const {i18n} = useLingui();
const [contentQuery, setContentQuery] = useState('');
const [menuOpen, setMenuOpen] = useState(false);
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
const [selectedMessageChannel, setSelectedMessageChannel] = useState<Channel | null>(null);
const scrollerRef = useRef<ScrollerHandle | null>(null);
const resolveSearchSheetScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const inputRef = useRef<HTMLInputElement>(null);
const [hasFilters, setHasFilters] = useState<Array<HasFilterType>>([]);
const [fromUserIds, setFromUserIds] = useState<Array<string>>([]);
const [inChannelIds, setInChannelIds] = useState<Array<string>>([]);
const [revealedGroupKeys, setRevealedGroupKeys] = useState<Set<string>>(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<HTMLDivElement>(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<HTMLInputElement>) => {
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 (
<LongPressable
className={styles.searchResultItem}
data-message-id={message.id}
data-is-group-start="true"
role="button"
tabIndex={0}
onClick={() => 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"
>
<MessageComponent
message={message}
channel={messageChannel}
previewContext={MessagePreviewContext.LIST_POPOUT}
data-flx="channel.channel-search-bottom-sheet.search-result-item.message-component"
/>
</LongPressable>
);
},
);
const renderContent = () => {
if (!hasSearched) {
return (
<div
className={styles.emptyStateContainer}
data-flx="channel.channel-search-bottom-sheet.render-content.empty-state-container"
>
<div
className={styles.emptyStateContent}
data-flx="channel.channel-search-bottom-sheet.render-content.empty-state-content"
>
<SearchIcon
className={styles.emptyStateIcon}
data-flx="channel.channel-search-bottom-sheet.render-content.empty-state-icon"
/>
<h3
className={styles.emptyStateTitle}
data-flx="channel.channel-search-bottom-sheet.render-content.empty-state-title"
>
<Trans>Search messages</Trans>
</h3>
<p
className={styles.emptyStateDescription}
data-flx="channel.channel-search-bottom-sheet.render-content.empty-state-description"
>
<Trans>Use filters or enter keywords to find messages</Trans>
</p>
</div>
</div>
);
}
switch (machineState.status) {
case 'idle':
case 'loading':
return (
<div
className={styles.loadingContainer}
data-flx="channel.channel-search-bottom-sheet.render-content.loading-container"
>
<LoadingIcon
className={styles.loadingIcon}
data-flx="channel.channel-search-bottom-sheet.render-content.loading-icon"
/>
</div>
);
case 'indexing':
return (
<div
className={styles.indexingContainer}
data-flx="channel.channel-search-bottom-sheet.render-content.indexing-container"
>
<LoadingIcon
className={styles.indexingIcon}
data-flx="channel.channel-search-bottom-sheet.render-content.indexing-icon"
/>
<div
className={styles.indexingContent}
data-flx="channel.channel-search-bottom-sheet.render-content.indexing-content"
>
<h3
className={styles.indexingTitle}
data-flx="channel.channel-search-bottom-sheet.render-content.indexing-title"
>
<Trans>Indexing channel</Trans>
</h3>
<p
className={styles.indexingDescription}
data-flx="channel.channel-search-bottom-sheet.render-content.indexing-description"
>
<Trans>We're indexing this channel for the first time. This might take a little while...</Trans>
</p>
</div>
</div>
);
case 'error':
return (
<div
className={styles.errorContainer}
data-flx="channel.channel-search-bottom-sheet.render-content.error-container"
>
<div
className={styles.errorContent}
data-flx="channel.channel-search-bottom-sheet.render-content.error-content"
>
<h3
className={styles.errorTitle}
data-flx="channel.channel-search-bottom-sheet.render-content.error-title"
>
<Trans>Error</Trans>
</h3>
<p
className={styles.errorMessage}
data-flx="channel.channel-search-bottom-sheet.render-content.error-message"
>
{machineState.error}
</p>
<Button
variant="secondary"
onClick={handleSearch}
data-flx="channel.channel-search-bottom-sheet.render-content.button.search"
>
{i18n._(TRY_AGAIN_DESCRIPTOR)}
</Button>
</div>
</div>
);
case 'success': {
const {results, total, hitsPerPage, page: currentPage} = machineState;
if (results.length === 0) {
return (
<div
className={styles.emptyStateContainer}
data-flx="channel.channel-search-bottom-sheet.render-content.empty-state-container--2"
>
<div
className={styles.emptyStateContent}
data-flx="channel.channel-search-bottom-sheet.render-content.empty-state-content--2"
>
<SearchIcon
className={styles.emptyStateIcon}
data-flx="channel.channel-search-bottom-sheet.render-content.empty-state-icon--2"
/>
<div
className={styles.emptyStateContent}
data-flx="channel.channel-search-bottom-sheet.render-content.empty-state-content--3"
>
<h3
className={styles.emptyStateTitle}
data-flx="channel.channel-search-bottom-sheet.render-content.empty-state-title--2"
>
<Trans>Nothing matches</Trans>
</h3>
<p
className={styles.emptyStateDescription}
data-flx="channel.channel-search-bottom-sheet.render-content.empty-state-description--2"
>
<Trans>Try different filters or search terms.</Trans>
</p>
</div>
</div>
</div>
);
}
const totalPages = Math.max(1, Math.ceil(total / hitsPerPage));
const hasMultipleChannels = countSearchResultChannels(resultGroups) > 1;
return (
<>
<CollapsedMessageVisibilityProvider
value={collapsedMessageVisibility}
data-flx="channel.channel-search-bottom-sheet.render-content.collapsed-message-visibility-provider"
>
<NearViewportSurfaceContext.Provider value={resolveSearchSheetScrollSurface}>
<Scroller
ref={scrollerRef}
className={styles.resultsScroller}
key="channel-search-results-scroller"
onCopy={onCopySelectedMessages}
data-message-selection-root="true"
data-flx="channel.channel-search-bottom-sheet.render-content.results-scroller"
>
{resultGroups.map((resultGroup) => {
const messageChannel =
searchChannelsById.get(resultGroup.channelId) ?? Channels.getChannel(resultGroup.channelId);
if (!messageChannel) {
return null;
}
const renderMessageWrapper = ({
message,
index,
isGroupStart,
children,
}: MessageGroupRenderWrapperProps) => (
<LongPressable
data-message-index={index}
data-message-id={message.id}
data-is-group-start={isGroupStart}
className={styles.searchResultItem}
role="button"
tabIndex={0}
onClick={() => 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}
</LongPressable>
);
return (
<React.Fragment key={resultGroup.key}>
{hasMultipleChannels && (
<div
className={styles.channelSection}
data-flx="channel.channel-search-bottom-sheet.render-content.channel-section"
>
{ChannelUtils.getIcon(messageChannel, {
className: styles.channelIcon,
})}
<span
className={styles.channelName}
data-flx="channel.channel-search-bottom-sheet.render-content.channel-name"
>
{messageChannel.name || 'Unnamed Channel'}
</span>
</div>
)}
<SearchResultMessageList
channel={messageChannel}
messages={resultGroup.messages}
revealedGroupKeys={revealedGroupKeys}
onGroupRevealChange={handleCollapsedGroupRevealChange}
collapsedGroupClassName={styles.collapsedMessageGroup}
messagePreviewContext={MessagePreviewContext.LIST_POPOUT}
renderMessageWrapper={renderMessageWrapper}
spammerOverrideVersion={spammerOverrideVersion}
renderMessage={(message) => (
<SearchResultItem
message={message}
messageChannel={messageChannel}
data-flx="channel.channel-search-bottom-sheet.render-content.search-result-item"
/>
)}
data-flx="channel.channel-search-bottom-sheet.render-content.search-result-message-list"
/>
</React.Fragment>
);
})}
</Scroller>
</NearViewportSurfaceContext.Provider>
</CollapsedMessageVisibilityProvider>
{totalPages > 1 && (
<div
className={styles.paginationContainer}
data-flx="channel.channel-search-bottom-sheet.render-content.pagination-container"
>
<button
type="button"
onClick={handlePrevPage}
disabled={currentPage === 1}
className={styles.paginationButton}
data-flx="channel.channel-search-bottom-sheet.render-content.pagination-button.prev-page"
>
<PreviousIcon
className={sharedStyles.iconSmall}
data-flx="channel.channel-search-bottom-sheet.render-content.previous-icon"
/>
<Trans>Previous</Trans>
</button>
<span
className={styles.paginationText}
data-flx="channel.channel-search-bottom-sheet.render-content.pagination-text"
>
<Trans>
Page {currentPage} of {totalPages}
</Trans>
</span>
<button
type="button"
onClick={handleNextPage}
disabled={currentPage === totalPages}
className={styles.paginationButton}
data-flx="channel.channel-search-bottom-sheet.render-content.pagination-button.next-page"
>
{i18n._(NEXT_DESCRIPTOR)}
<NextIcon
className={sharedStyles.iconSmall}
data-flx="channel.channel-search-bottom-sheet.render-content.next-icon"
/>
</button>
</div>
)}
</>
);
}
}
};
const headerSubtitle =
machineState.status === 'success' && machineState.total > 0
? i18n._(SEARCH_RESULTS_COUNT_DESCRIPTOR, {resultCount: machineState.total})
: null;
return (
<>
<BottomSheet
isOpen={isOpen}
onClose={onClose}
snapPoints={[0, 1]}
initialSnap={1}
disablePadding={true}
title={i18n._(SEARCH_DESCRIPTOR)}
data-flx="channel.channel-search-bottom-sheet.bottom-sheet"
>
<div className={styles.container} data-flx="channel.channel-search-bottom-sheet.container">
<div className={styles.searchContainer} data-flx="channel.channel-search-bottom-sheet.search-container">
<div
className={styles.searchInputWrapper}
data-flx="channel.channel-search-bottom-sheet.search-input-wrapper"
>
<SearchIcon className={styles.searchIcon} data-flx="channel.channel-search-bottom-sheet.search-icon" />
<input
ref={inputRef}
type="text"
data-flx="channel.channel-search-bottom-sheet.search-input.set-content-query.text"
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
value={contentQuery}
onChange={(e) => setContentQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={i18n._(SEARCH_MESSAGES_DESCRIPTOR)}
aria-label={i18n._(SEARCH_MESSAGES_DESCRIPTOR)}
className={styles.searchInput}
/>
{(contentQuery || hasActiveFilters) && (
<button
type="button"
onClick={handleClear}
className={styles.clearButton}
aria-label={i18n._(CLEAR_SEARCH_DESCRIPTOR)}
data-flx="channel.channel-search-bottom-sheet.clear-button"
>
<CloseIcon
className={sharedStyles.icon}
data-flx="channel.channel-search-bottom-sheet.close-icon"
/>
</button>
)}
</div>
<div className={styles.filterChipsRow} data-flx="channel.channel-search-bottom-sheet.filter-chips-row">
<SearchFilterChip
label={i18n._(FROM_DESCRIPTOR)}
value={getFromUserLabel()}
icon={<UserFilterIcon size={14} data-flx="channel.channel-search-bottom-sheet.user-filter-icon" />}
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 && (
<SearchFilterChip
label={i18n._(IN_DESCRIPTOR)}
value={getInChannelLabel()}
icon={
<HashIcon
size={remFromPx(14)}
weight="bold"
data-flx="channel.channel-search-bottom-sheet.hash-icon"
/>
}
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"
/>
)}
<SearchFilterChip
label={i18n._(HAS_DESCRIPTOR)}
value={getHasFilterLabel()}
icon={<FilterIcon size={14} data-flx="channel.channel-search-bottom-sheet.filter-icon" />}
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"
/>
<SearchFilterChip
label={i18n._(SORT_DESCRIPTOR)}
value={sortModeLabel}
icon={<SortIcon size={14} data-flx="channel.channel-search-bottom-sheet.sort-icon" />}
onPress={() => setSortSheetOpen(true)}
isActive={false}
data-flx="channel.channel-search-bottom-sheet.search-filter-chip.set-sort-sheet-open"
/>
<SearchFilterChip
label={activeScopeOption?.label ?? i18n._(SCOPE_DESCRIPTOR)}
icon={
<ExpandChevronIcon size={14} data-flx="channel.channel-search-bottom-sheet.expand-chevron-icon" />
}
onPress={() => setScopeSheetOpen(true)}
isActive={false}
data-flx="channel.channel-search-bottom-sheet.search-filter-chip.set-scope-sheet-open"
/>
</div>
<Button
variant="primary"
onClick={handleSearch}
disabled={!canSearch}
className={styles.searchButton}
data-flx="channel.channel-search-bottom-sheet.search-button"
>
<Trans>Search</Trans>
</Button>
{headerSubtitle && (
<p className={styles.searchResults} data-flx="channel.channel-search-bottom-sheet.search-results">
{headerSubtitle}
</p>
)}
</div>
{renderContent()}
</div>
</BottomSheet>
<HasFilterSheet
isOpen={hasSheetOpen}
onClose={() => setHasSheetOpen(false)}
selectedFilters={hasFilters}
onFiltersChange={setHasFilters}
data-flx="channel.channel-search-bottom-sheet.has-filter-sheet"
/>
<UserFilterSheet
isOpen={userSheetOpen}
onClose={() => 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 && (
<ChannelFilterSheet
isOpen={channelSheetOpen}
onClose={() => setChannelSheetOpen(false)}
guildId={channel.guildId}
selectedChannelIds={inChannelIds}
onChannelsChange={setInChannelIds}
title={i18n._(IN_CHANNEL_DESCRIPTOR)}
data-flx="channel.channel-search-bottom-sheet.channel-filter-sheet"
/>
)}
<SortModeSheet
isOpen={sortSheetOpen}
onClose={() => setSortSheetOpen(false)}
selectedMode={sortMode}
onModeChange={setSortMode}
data-flx="channel.channel-search-bottom-sheet.sort-mode-sheet"
/>
<ScopeSheet
isOpen={scopeSheetOpen}
onClose={() => setScopeSheetOpen(false)}
selectedScope={scope}
scopeOptions={scopeOptions}
onScopeChange={setScope}
data-flx="channel.channel-search-bottom-sheet.scope-sheet"
/>
{selectedMessage && (
<MessageActionBottomSheet
isOpen={menuOpen}
onClose={() => {
setMenuOpen(false);
setSelectedMessage(null);
setSelectedMessageChannel(null);
}}
message={selectedMessage}
sourceChannel={selectedMessageChannel}
handleDelete={handleDelete}
data-flx="channel.channel-search-bottom-sheet.message-action-bottom-sheet"
/>
)}
</>
);
},
);