diff --git a/fluxer_app/src/features/channel/components/ChannelSearchResults.tsx b/fluxer_app/src/features/channel/components/ChannelSearchResults.tsx index 3f5d41e7d..de59517c7 100644 --- a/fluxer_app/src/features/channel/components/ChannelSearchResults.tsx +++ b/fluxer_app/src/features/channel/components/ChannelSearchResults.tsx @@ -28,6 +28,10 @@ import { SearchIndexingState, } from '@app/features/channel/components/channel_search_results/SearchResultsStateViews'; import type {MessageGroupRenderWrapperProps} from '@app/features/channel/components/MessageGroup'; +import { + buildSearchResultGroups, + buildSearchResultGroupsByMessageId, +} from '@app/features/channel/components/SearchResultGrouping'; 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'; @@ -134,35 +138,27 @@ export const ChannelSearchResults = observer( () => new Map(successChannels.map((searchChannel) => [searchChannel.id, searchChannel])), [successChannels], ); - const messagesByChannel = useMemo(() => { - const grouped = new Map>(); - for (const message of successResults) { - if (!grouped.has(message.channelId)) { - grouped.set(message.channelId, []); - } - grouped.get(message.channelId)!.push(message); - } - return grouped; - }, [successResults]); + const resultGroups = useMemo(() => buildSearchResultGroups(successResults), [successResults]); + const resultGroupsByMessageId = useMemo(() => buildSearchResultGroupsByMessageId(resultGroups), [resultGroups]); const onCopySelectedMessages = useMessageSelectionCopyForMessages(successResults); const spammerOverrideVersion = LocalUserSpamOverride.version; const collapsedMessageVisibility = useMemo( () => ({ isMessageRevealed: (message: Message) => { - const channelMessages = messagesByChannel.get(message.channelId); - if (!channelMessages) return false; + 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: channelMessages, + messages: resultGroup.messages, messageId: message.id, treatSpam: true, }); return groupKey != null && revealedGroupKeys.has(groupKey); }, }), - [messagesByChannel, revealedGroupKeys, searchChannelsById, spammerOverrideVersion], + [resultGroupsByMessageId, revealedGroupKeys, searchChannelsById, spammerOverrideVersion], ); const handleCollapsedGroupRevealChange = useCallback((groupKey: string, revealed: boolean) => { setRevealedGroupKeys((current) => { @@ -606,9 +602,9 @@ export const ChannelSearchResults = observer( data-message-selection-root="true" data-flx="channel.channel-search-results.render-content.results-scroller" > - {Array.from(messagesByChannel.entries()).map(([resultChannelId, messages]) => { + {resultGroups.map((resultGroup) => { const renderData = getSearchResultChannelRenderData( - resultChannelId, + resultGroup.channelId, searchChannelsById, (activeScope ?? DEFAULT_SCOPE_VALUE) as MessageSearchScope, ); @@ -649,7 +645,7 @@ export const ChannelSearchResults = observer( ); return ( - + ({id, channelId}) as Message; + +describe('SearchResultGrouping', () => { + it('keeps the order the server returned instead of bucketing per channel', () => { + const groups = buildSearchResultGroups([ + message('500', 'general'), + message('400', 'random'), + message('300', 'general'), + ]); + expect(groups.map((group) => group.messages.map((result) => result.id))).toEqual([['500'], ['400'], ['300']]); + expect(groups.map((group) => group.channelId)).toEqual(['general', 'random', 'general']); + }); + + it('merges consecutive results from the same channel into one group', () => { + const groups = buildSearchResultGroups([ + message('500', 'general'), + message('490', 'general'), + message('400', 'random'), + ]); + expect(groups).toHaveLength(2); + expect(groups[0].messages.map((result) => result.id)).toEqual(['500', '490']); + expect(groups[1].messages.map((result) => result.id)).toEqual(['400']); + }); + + it('gives every group a unique key even when a channel appears more than once', () => { + const groups = buildSearchResultGroups([ + message('500', 'general'), + message('400', 'random'), + message('300', 'general'), + ]); + expect(new Set(groups.map((group) => group.key)).size).toBe(3); + }); + + it('indexes each message to the group it renders in', () => { + const groups = buildSearchResultGroups([ + message('500', 'general'), + message('400', 'random'), + message('300', 'general'), + ]); + const groupsByMessageId = buildSearchResultGroupsByMessageId(groups); + expect(groupsByMessageId.get('500')).toBe(groups[0]); + expect(groupsByMessageId.get('400')).toBe(groups[1]); + expect(groupsByMessageId.get('300')).toBe(groups[2]); + }); + + it('counts distinct channels rather than groups', () => { + const groups = buildSearchResultGroups([ + message('500', 'general'), + message('400', 'random'), + message('300', 'general'), + ]); + expect(countSearchResultChannels(groups)).toBe(2); + expect(countSearchResultChannels(buildSearchResultGroups([]))).toBe(0); + }); +}); diff --git a/fluxer_app/src/features/channel/components/SearchResultGrouping.ts b/fluxer_app/src/features/channel/components/SearchResultGrouping.ts new file mode 100644 index 000000000..4884c4e9b --- /dev/null +++ b/fluxer_app/src/features/channel/components/SearchResultGrouping.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {Message} from '@app/features/messaging/models/MessagingMessage'; + +export interface SearchResultGroup { + key: string; + channelId: string; + messages: Array; +} + +export const buildSearchResultGroups = (messages: Array): Array => { + const groups: Array = []; + let currentGroup: SearchResultGroup | null = null; + for (const message of messages) { + if (!currentGroup || currentGroup.channelId !== message.channelId) { + currentGroup = {key: `${message.channelId}-${message.id}`, channelId: message.channelId, messages: []}; + groups.push(currentGroup); + } + currentGroup.messages.push(message); + } + return groups; +}; + +export const buildSearchResultGroupsByMessageId = ( + groups: Array, +): Map => { + const groupsByMessageId = new Map(); + for (const group of groups) { + for (const message of group.messages) { + groupsByMessageId.set(message.id, group); + } + } + return groupsByMessageId; +}; + +export const countSearchResultChannels = (groups: Array): number => { + return new Set(groups.map((group) => group.channelId)).size; +}; diff --git a/fluxer_app/src/features/channel/components/bottomsheets/ChannelSearchBottomSheet.tsx b/fluxer_app/src/features/channel/components/bottomsheets/ChannelSearchBottomSheet.tsx index 09681e053..a640a848a 100644 --- a/fluxer_app/src/features/channel/components/bottomsheets/ChannelSearchBottomSheet.tsx +++ b/fluxer_app/src/features/channel/components/bottomsheets/ChannelSearchBottomSheet.tsx @@ -8,6 +8,11 @@ import {Message as MessageComponent} from '@app/features/channel/components/Chan 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'; @@ -216,22 +221,17 @@ export const ChannelSearchBottomSheet: React.FC = () => new Map(successChannels.map((searchChannel) => [searchChannel.id, searchChannel])), [successChannels], ); - const messagesByChannel = React.useMemo(() => { - const grouped = new Map>(); - for (const message of successResults) { - if (!grouped.has(message.channelId)) { - grouped.set(message.channelId, []); - } - grouped.get(message.channelId)!.push(message); - } - return grouped; - }, [successResults]); + 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 channelMessages = messagesByChannel.get(message.channelId); - if (!channelMessages) { + const resultGroup = resultGroupsByMessageId.get(message.id); + if (!resultGroup) { return false; } const messageChannel = searchChannelsById.get(message.channelId) ?? Channels.getChannel(message.channelId); @@ -240,14 +240,14 @@ export const ChannelSearchBottomSheet: React.FC = } const groupKey = getCollapsedMessageGroupKey({ channel: messageChannel, - messages: channelMessages, + messages: resultGroup.messages, messageId: message.id, treatSpam: true, }); return groupKey != null && revealedGroupKeys.has(groupKey); }, }), - [messagesByChannel, revealedGroupKeys, searchChannelsById, spammerOverrideVersion], + [resultGroupsByMessageId, revealedGroupKeys, searchChannelsById, spammerOverrideVersion], ); const handleCollapsedGroupRevealChange = useCallback((groupKey: string, revealed: boolean) => { setRevealedGroupKeys((current) => { @@ -598,7 +598,7 @@ export const ChannelSearchBottomSheet: React.FC = ); } const totalPages = Math.max(1, Math.ceil(total / hitsPerPage)); - const hasMultipleChannels = messagesByChannel.size > 1; + const hasMultipleChannels = countSearchResultChannels(resultGroups) > 1; return ( <> = data-message-selection-root="true" data-flx="channel.channel-search-bottom-sheet.render-content.results-scroller" > - {Array.from(messagesByChannel.entries()).map(([channelId, messages]) => { - const messageChannel = searchChannelsById.get(channelId) ?? Channels.getChannel(channelId); + {resultGroups.map((resultGroup) => { + const messageChannel = + searchChannelsById.get(resultGroup.channelId) ?? Channels.getChannel(resultGroup.channelId); if (!messageChannel) { return null; } @@ -648,7 +649,7 @@ export const ChannelSearchBottomSheet: React.FC = ); return ( - + {hasMultipleChannels && (
= )}