From 3cf38b1b4026ece3f597a0b0bf9ca71c0657f117 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Sat, 21 Mar 2026 07:15:42 +1000 Subject: [PATCH] Collapse items into multi-day sets, handling start/end incomplete days --- .../conversation/CollapseSet.dom.tsx | 2 +- ts/components/conversation/Timeline.dom.tsx | 14 +- .../conversation/TimelineItem.dom.tsx | 2 +- ts/state/smart/Timeline.preload.tsx | 368 +---- ts/state/smart/TimelineItem.preload.tsx | 2 +- ts/test-node/util/CollapseSet_test.std.ts | 1385 +++++++++++++++++ ts/util/CollapseSet.std.ts | 588 +++++++ 7 files changed, 2007 insertions(+), 354 deletions(-) create mode 100644 ts/test-node/util/CollapseSet_test.std.ts create mode 100644 ts/util/CollapseSet.std.ts diff --git a/ts/components/conversation/CollapseSet.dom.tsx b/ts/components/conversation/CollapseSet.dom.tsx index 25d5629a76..88ee5f4cf9 100644 --- a/ts/components/conversation/CollapseSet.dom.tsx +++ b/ts/components/conversation/CollapseSet.dom.tsx @@ -18,7 +18,7 @@ import type { WidthBreakpoint } from '../_util.std.js'; import type { CollapsedMessage, CollapseSet, -} from '../../state/smart/Timeline.preload.js'; +} from '../../util/CollapseSet.std.js'; import type { RenderItemProps } from '../../state/smart/TimelineItem.preload.js'; import type { LocalizerType } from '../../types/I18N.std.js'; import type { TargetedMessageType } from '../../state/selectors/conversations.dom.js'; diff --git a/ts/components/conversation/Timeline.dom.tsx b/ts/components/conversation/Timeline.dom.tsx index 4b1d68a58f..269c367a6b 100644 --- a/ts/components/conversation/Timeline.dom.tsx +++ b/ts/components/conversation/Timeline.dom.tsx @@ -44,7 +44,7 @@ import { } from '../../hooks/useScrollLock.dom.js'; import { MessageInteractivity } from './Message.dom.js'; import type { RenderItemProps } from '../../state/smart/TimelineItem.preload.js'; -import type { CollapseSet } from '../../state/smart/Timeline.preload.js'; +import type { CollapseSet } from '../../util/CollapseSet.std.js'; const { first, get, isNumber, last, throttle } = lodash; @@ -442,7 +442,17 @@ export class Timeline extends React.Component< maxRowIndex >= 0 && rowIndex >= maxRowIndex - LOAD_NEWER_THRESHOLD ) { - loadNewerMessages(id, newestBottomVisibleMessageId); + let targetMessageId = newestBottomVisibleMessageId; + const newestItem = items.find( + item => item.id === newestBottomVisibleMessageId + ); + if (newestItem && newestItem.type !== 'none') { + const lastItem = last(newestItem.messages); + strictAssert(lastItem, 'lastItem in newestItem.messages array'); + targetMessageId = lastItem.id; + } + + loadNewerMessages(id, targetMessageId); } } diff --git a/ts/components/conversation/TimelineItem.dom.tsx b/ts/components/conversation/TimelineItem.dom.tsx index bffce4172b..84a204ce4e 100644 --- a/ts/components/conversation/TimelineItem.dom.tsx +++ b/ts/components/conversation/TimelineItem.dom.tsx @@ -72,7 +72,7 @@ import type { MessageInteractivity } from './Message.dom.js'; import type { PinMessageData } from '../../model-types.js'; import type { AciString } from '../../types/ServiceId.std.js'; import type { RenderItemProps } from '../../state/smart/TimelineItem.preload.js'; -import type { CollapseSet } from '../../state/smart/Timeline.preload.js'; +import type { CollapseSet } from '../../util/CollapseSet.std.js'; import { CollapseSetViewer } from './CollapseSet.dom.js'; import type { TargetedMessageType } from '../../state/selectors/conversations.dom.js'; diff --git a/ts/state/smart/Timeline.preload.tsx b/ts/state/smart/Timeline.preload.tsx index 98d56130d9..a1d064b6b7 100644 --- a/ts/state/smart/Timeline.preload.tsx +++ b/ts/state/smart/Timeline.preload.tsx @@ -3,7 +3,7 @@ import React, { memo, useCallback } from 'react'; import { useSelector } from 'react-redux'; -import { isEqual, last } from 'lodash'; +import { isEqual } from 'lodash'; import { Timeline } from '../../components/conversation/Timeline.dom.js'; import { useCallingActions } from '../ducks/calling.preload.js'; @@ -29,55 +29,19 @@ import { getCallSelector, isInFullScreenCall as getIsInFullScreenCall, } from '../selectors/calling.std.js'; -import type { CallStateType } from '../selectors/calling.std.js'; import { getMidnight } from '../../types/NotificationProfile.std.js'; -import { strictAssert } from '../../util/assert.std.js'; -import { missingCaseError } from '../../util/missingCaseError.std.js'; import type { PropsType as SmartContactSpoofingReviewDialogPropsType } from './ContactSpoofingReviewDialog.preload.js'; import type { RenderItemProps } from './TimelineItem.preload.js'; -import type { DurationInSeconds } from '../../util/durations/duration-in-seconds.std.js'; -import type { MessageType } from '../ducks/conversations.preload.js'; -import { SeenStatus } from '../../MessageSeenStatus.std.js'; import { getCallHistorySelector } from '../selectors/callHistory.std.js'; -import type { CallHistorySelectorType } from '../selectors/callHistory.std.js'; -import { CallMode } from '../../types/CallDisposition.std.js'; import { getCallIdFromEra } from '../../util/callDisposition.preload.js'; +import { mapItemsIntoCollapseSets } from '../../util/CollapseSet.std.js'; +import type { CollapseSet } from '../../util/CollapseSet.std.js'; type ExternalProps = { id: string; }; -export type CollapsedMessage = { - id: string; - isUnseen: boolean; - // A single group-v2-change message can have more than one change in it - extraItems?: number; -}; - -export type CollapseSet = - | { - type: 'none'; - id: string; - messages: undefined; - } - | { - type: 'group-updates'; - id: string; - messages: Array; - } - | { - type: 'timer-changes'; - id: string; - messages: Array; - endingState: DurationInSeconds | undefined; - } - | { - type: 'call-events'; - id: string; - messages: Array; - }; - function renderItem(props: RenderItemProps): React.JSX.Element { return ( @@ -97,87 +61,6 @@ function renderTypingBubble(conversationId: string): React.JSX.Element { return ; } -function canCollapseForGroupSet(type: MessageType['type']): boolean { - if ( - type === 'group-v2-change' || - type === 'keychange' || - type === 'profile-change' - ) { - return true; - } - - return false; -} - -function canCollapseForTimerSet(message: MessageType): boolean { - if (message.type === 'timer-notification') { - return true; - } - - // Found some examples of messages with type = 'incoming' and an expirationTimerUpdate - if (message.expirationTimerUpdate) { - return true; - } - - return false; -} - -function canCollapseForCallSet( - message: MessageType, - options: { - activeCall: CallStateType | undefined; - callHistorySelector: CallHistorySelectorType; - callSelector: (conversationId: string) => CallStateType | undefined; - } -): boolean { - if (message.type !== 'call-history') { - return false; - } - - const { callId, conversationId } = message; - if (!callId) { - return true; - } - - const callHistory = options.callHistorySelector(callId); - if (!callHistory) { - return true; - } - - // If a direct call is currently ongoing, we don't want to group it - if (callHistory.mode === CallMode.Direct) { - const isActiveCall = options.activeCall?.conversationId === conversationId; - - return !isActiveCall; - } - - const conversationCall = options.callSelector(conversationId); - if (!conversationCall) { - return true; - } - - strictAssert( - conversationCall?.callMode === CallMode.Group, - 'canCollapseForCallSet: Call was expected to be a group call' - ); - - const conversationCallId = - conversationCall?.peekInfo?.eraId != null && - getCallIdFromEra(conversationCall.peekInfo.eraId); - - const deviceCount = conversationCall?.peekInfo?.deviceCount ?? 0; - - // Don't group if current call in the converasation, or there are devices in the call - if ( - callHistory.mode === CallMode.Group && - (callId === conversationCallId || deviceCount > 0) - ) { - return false; - } - - return true; -} - export const SmartTimeline = memo(function SmartTimeline({ id, }: ExternalProps) { @@ -251,238 +134,24 @@ export const SmartTimeline = memo(function SmartTimeline({ const previousCollapseSet = React.useRef | undefined>( undefined ); + const midnightToday = getMidnight(Date.now()); const { collapseSets, updatedOldestLastSeenIndex, updatedScrollToIndex } = React.useMemo(() => { - let resultSets: Array = []; - let resultUnseenIndex = oldestUnseenIndex; - let resultScrollToIndex = scrollToIndex; + const result = mapItemsIntoCollapseSets({ + activeCall, + allowMultidayDaySets: true, + callHistorySelector, + callSelector, + getCallIdFromEra, + items, + messages, + midnightToday, + oldestUnseenIndex, + scrollToIndex, + }); - const max = items.length; - - for (let i = 0; i < max; i += 1) { - const previousId = items[i - 1]; - const lastCollapseSet = last(resultSets); - - const currentId = items[i]; - strictAssert(currentId, 'no item at index i'); - - const currentMessage = messages[currentId]; - const previousMessage = previousId ? messages[previousId] : undefined; - - const changeLength = currentMessage?.groupV2Change?.details.length; - const extraItems = - changeLength && changeLength > 1 ? changeLength - 1 : undefined; - - const DEFAULT_SET: CollapseSet = - currentMessage && - canCollapseForGroupSet(currentMessage.type) && - extraItems && - extraItems > 0 - ? { - // A group-v2-change message with more than one inner change detail can be - // a set all by itself! - type: 'group-updates', - id: currentId, - messages: [ - { - id: currentId, - isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, - extraItems, - }, - ], - } - : { - type: 'none' as const, - id: currentId, - messages: undefined, - }; - - // scrollToIndex needs to be translated to collapseSets. - if (i === scrollToIndex) { - resultScrollToIndex = resultSets.length; - } - - // Start a new group if we just crossed the last seen indicator - if (i === oldestUnseenIndex) { - resultSets.push(DEFAULT_SET); - resultUnseenIndex = resultSets.length - 1; - continue; - } - - // Start a new set if we just started looping - if (!previousId) { - resultSets.push(DEFAULT_SET); - continue; - } - - // Start a new set if we can't find message details - if (!currentMessage || !previousMessage) { - resultSets.push(DEFAULT_SET); - continue; - } - - // Start a new set if previous message and current message are on different days - const currentDay = getMidnight( - currentMessage.received_at_ms || currentMessage.timestamp - ); - const previousDay = getMidnight( - previousMessage.received_at_ms || previousMessage.timestamp - ); - if (currentDay !== previousDay) { - resultSets.push(DEFAULT_SET); - continue; - } - - strictAssert( - lastCollapseSet, - 'collapseSets: expect lastCollapseSet to be defined' - ); - - // Add to current set if previous and current messages are both group updates - if ( - canCollapseForGroupSet(currentMessage.type) && - canCollapseForGroupSet(previousMessage.type) - ) { - strictAssert( - lastCollapseSet.type !== 'timer-changes' && - lastCollapseSet.type !== 'call-events', - 'Should never have two matching group items, but be in a timer or call set' - ); - - if (lastCollapseSet.type === 'group-updates') { - lastCollapseSet.messages.push({ - id: currentId, - isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, - extraItems, - }); - } else if (lastCollapseSet.type === 'none') { - resultSets.pop(); - resultSets.push({ - type: 'group-updates', - id: previousId, - messages: [ - { - id: previousId, - isUnseen: previousMessage.seenStatus === SeenStatus.Unseen, - }, - { - id: currentId, - isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, - extraItems, - }, - ], - }); - } else { - throw missingCaseError(lastCollapseSet); - } - - if (i === scrollToIndex) { - resultScrollToIndex = resultSets.length - 1; - } - - continue; - } - - // Add to current set if previous and current messages are both timer updates - if ( - canCollapseForTimerSet(currentMessage) && - canCollapseForTimerSet(previousMessage) - ) { - strictAssert( - lastCollapseSet.type !== 'group-updates' && - lastCollapseSet.type !== 'call-events', - 'Should never have two matching timer items, but be in a group or call set' - ); - - if (lastCollapseSet.type === 'timer-changes') { - lastCollapseSet.messages.push({ - id: currentId, - isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, - }); - lastCollapseSet.endingState = - currentMessage.expirationTimerUpdate?.expireTimer; - } else if (lastCollapseSet.type === 'none') { - resultSets.pop(); - resultSets.push({ - type: 'timer-changes', - id: previousId, - endingState: currentMessage.expirationTimerUpdate?.expireTimer, - messages: [ - { - id: previousId, - isUnseen: previousMessage.seenStatus === SeenStatus.Unseen, - }, - { - id: currentId, - isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, - }, - ], - }); - } else { - throw missingCaseError(lastCollapseSet); - } - - if (i === scrollToIndex) { - resultScrollToIndex = resultSets.length - 1; - } - - continue; - } - - // Add to current set if previous and current messages are both call events - if ( - canCollapseForCallSet(currentMessage, { - activeCall, - callHistorySelector, - callSelector, - }) && - canCollapseForCallSet(previousMessage, { - activeCall, - callHistorySelector, - callSelector, - }) - ) { - strictAssert( - lastCollapseSet.type !== 'group-updates' && - lastCollapseSet.type !== 'timer-changes', - 'Should never have two matching timer items, but be in a group or timer set' - ); - - if (lastCollapseSet.type === 'call-events') { - lastCollapseSet.messages.push({ - id: currentId, - isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, - }); - } else if (lastCollapseSet.type === 'none') { - resultSets.pop(); - resultSets.push({ - type: 'call-events', - id: previousId, - messages: [ - { - id: previousId, - isUnseen: previousMessage.seenStatus === SeenStatus.Unseen, - }, - { - id: currentId, - isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, - }, - ], - }); - } else { - throw missingCaseError(lastCollapseSet); - } - - if (i === scrollToIndex) { - resultScrollToIndex = resultSets.length - 1; - } - - continue; - } - - // Finally, just add a new empty set if no situations above triggered - resultSets.push(DEFAULT_SET); - } + const { resultUnseenIndex, resultScrollToIndex } = result; + let { resultSets } = result; // Params messages changes a lot, items less often, call selectors possibly a lot. // But we need to massage items based on the values from these params. So, if we @@ -507,6 +176,7 @@ export const SmartTimeline = memo(function SmartTimeline({ callSelector, items, messages, + midnightToday, oldestUnseenIndex, scrollToIndex, ]); diff --git a/ts/state/smart/TimelineItem.preload.tsx b/ts/state/smart/TimelineItem.preload.tsx index 62074f255c..44a4583775 100644 --- a/ts/state/smart/TimelineItem.preload.tsx +++ b/ts/state/smart/TimelineItem.preload.tsx @@ -45,7 +45,7 @@ import { MessageInteractivity } from '../../components/conversation/Message.dom. import { useNavActions } from '../ducks/nav.std.js'; import { DataReader } from '../../sql/Client.preload.js'; import { isInternalFeaturesEnabled } from '../../util/isInternalFeaturesEnabled.dom.js'; -import type { CollapseSet } from './Timeline.preload.js'; +import type { CollapseSet } from '../../util/CollapseSet.std.js'; export type RenderItemProps = Omit; diff --git a/ts/test-node/util/CollapseSet_test.std.ts b/ts/test-node/util/CollapseSet_test.std.ts new file mode 100644 index 0000000000..f323a29889 --- /dev/null +++ b/ts/test-node/util/CollapseSet_test.std.ts @@ -0,0 +1,1385 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { v4 as generateUuid } from 'uuid'; + +import { getMidnight } from '../../types/NotificationProfile.std.js'; +import { mapItemsIntoCollapseSets } from '../../util/CollapseSet.std.js'; +import { generateAci } from '../../types/ServiceId.std.js'; +import { ReadStatus } from '../../messages/MessageReadStatus.std.js'; +import { SeenStatus } from '../../MessageSeenStatus.std.js'; +import { DurationInSeconds } from '../../util/durations/duration-in-seconds.std.js'; +import { + CallDirection, + CallMode, + CallType, + DirectCallStatus, +} from '../../types/CallDisposition.std.js'; +import { DAY } from '../../util/durations/constants.std.js'; + +import type { CallHistoryDetails } from '../../types/CallDisposition.std.js'; +import type { + MessageLookupType, + MessageType, +} from '../../state/ducks/conversations.preload.js'; +import type { CollapseSet } from '../../util/CollapseSet.std.js'; + +describe('util/CollapseSets', () => { + describe('mapItemsIntoCollapseSets', () => { + const conversationId = generateUuid(); + const now = Date.now(); + const yesterday = now - DAY; + const defaultParams = { + activeCall: undefined, + allowMultidayDaySets: true, + callHistorySelector: () => undefined, + callSelector: () => undefined, + getCallIdFromEra: (eraId: string) => eraId, + items: [], + messages: {}, + midnightToday: getMidnight(now), + oldestUnseenIndex: null, + scrollToIndex: null, + }; + + function getDefaultMessage(id: string, timestamp = yesterday): MessageType { + return { + attachments: [], + conversationId, + id, + received_at: timestamp, + sent_at: timestamp, + source: 'source', + sourceServiceId: generateAci(), + timestamp, + type: 'incoming' as const, + readStatus: ReadStatus.Read, + }; + } + + it('returns all "none" for normal messages', () => { + const items = ['id0', 'id1', 'id2']; + const messages: MessageLookupType = { + id0: getDefaultMessage('id0'), + id1: getDefaultMessage('id1'), + id2: getDefaultMessage('id2'), + }; + + const expectedSets: Array = [ + { + id: 'id0', + type: 'none', + messages: undefined, + }, + { + id: 'id1', + type: 'none', + messages: undefined, + }, + { + id: 'id2', + type: 'none', + messages: undefined, + }, + ]; + + const { resultSets, resultScrollToIndex, resultUnseenIndex } = + mapItemsIntoCollapseSets({ + ...defaultParams, + items, + messages, + }); + + assert.deepEqual(resultSets, expectedSets); + assert.isNull(resultScrollToIndex); + assert.isNull(resultUnseenIndex); + }); + it('returns single set for all group update items', () => { + const items = ['id0', 'id1', 'id2']; + const messages: MessageLookupType = { + id0: { + ...getDefaultMessage('id0'), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'create' }], + }, + }, + id1: { + ...getDefaultMessage('id1'), + type: 'group-v2-change', + groupV2Change: { + details: [ + { type: 'member-add', aci: generateAci() }, + { type: 'member-add', aci: generateAci() }, + ], + }, + }, + id2: { + ...getDefaultMessage('id2'), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + seenStatus: SeenStatus.Unseen, + }, + }; + const scrollToIndex = 2; + + const expectedSets: Array = [ + { + id: 'id0', + type: 'group-updates', + messages: [ + { + id: 'id0', + isUnseen: false, + extraItems: undefined, + }, + { + id: 'id1', + isUnseen: false, + extraItems: 1, + }, + { + id: 'id2', + isUnseen: true, + extraItems: undefined, + }, + ], + }, + ]; + const expectedScrollToIndex = 0; + + const { resultSets, resultScrollToIndex, resultUnseenIndex } = + mapItemsIntoCollapseSets({ + ...defaultParams, + items, + messages, + scrollToIndex, + }); + + assert.deepEqual(resultSets, expectedSets); + assert.strictEqual( + resultScrollToIndex, + expectedScrollToIndex, + 'resultScrollToIndex' + ); + assert.isNull(resultUnseenIndex); + }); + + it('returns single set for all timer change items', () => { + const items = ['id0', 'id1', 'id2']; + const messages: MessageLookupType = { + id0: { + ...getDefaultMessage('id0'), + type: 'incoming', + expirationTimerUpdate: { + expireTimer: DurationInSeconds.fromHours(5), + }, + }, + id1: { + ...getDefaultMessage('id1'), + type: 'timer-notification', + expirationTimerUpdate: { + expireTimer: undefined, + }, + seenStatus: SeenStatus.Unseen, + }, + id2: { + ...getDefaultMessage('id2'), + type: 'outgoing', + expirationTimerUpdate: { + expireTimer: DurationInSeconds.fromSeconds(30), + }, + seenStatus: SeenStatus.Unseen, + }, + }; + const expectedSets: Array = [ + { + id: 'id0', + type: 'timer-changes', + endingState: DurationInSeconds.fromSeconds(30), + messages: [ + { + id: 'id0', + isUnseen: false, + }, + { + id: 'id1', + isUnseen: true, + }, + { + id: 'id2', + isUnseen: true, + }, + ], + }, + ]; + + const { resultSets, resultScrollToIndex, resultUnseenIndex } = + mapItemsIntoCollapseSets({ ...defaultParams, items, messages }); + + assert.deepEqual(resultSets, expectedSets); + assert.isNull(resultScrollToIndex); + assert.isNull(resultUnseenIndex); + }); + it('returns single set for all call event items', () => { + const items = ['id0', 'id1', 'id2']; + const messages: MessageLookupType = { + id0: { + ...getDefaultMessage('id0'), + type: 'call-history', + callId: 'id0', + }, + id1: { + ...getDefaultMessage('id1'), + type: 'call-history', + callId: 'id1', + }, + id2: { + ...getDefaultMessage('id2'), + type: 'call-history', + callId: 'id2', + seenStatus: SeenStatus.Unseen, + }, + }; + const callHistorySelector = (callId: string): CallHistoryDetails => { + if (callId === 'id0') { + return { + callId: 'id0', + peerId: generateUuid(), + ringerId: generateAci(), + startedById: generateAci(), + mode: CallMode.Direct, + type: CallType.Audio, + direction: CallDirection.Incoming, + timestamp: now, + endedTimestamp: now, + status: DirectCallStatus.Accepted, + }; + } + if (callId === 'id1') { + return { + callId: 'id1', + peerId: generateUuid(), + ringerId: generateAci(), + startedById: generateAci(), + mode: CallMode.Direct, + type: CallType.Audio, + direction: CallDirection.Incoming, + timestamp: now, + endedTimestamp: now, + status: DirectCallStatus.Missed, + }; + } + if (callId === 'id2') { + return { + callId: 'id2', + peerId: generateUuid(), + ringerId: generateAci(), + startedById: generateAci(), + mode: CallMode.Direct, + type: CallType.Audio, + direction: CallDirection.Incoming, + timestamp: now, + endedTimestamp: now, + status: DirectCallStatus.Missed, + }; + } + throw new Error(`${callId} is not known!`); + }; + + const expectedSets: Array = [ + { + id: 'id0', + type: 'call-events', + messages: [ + { + id: 'id0', + isUnseen: false, + }, + { + id: 'id1', + isUnseen: false, + }, + { + id: 'id2', + isUnseen: true, + }, + ], + }, + ]; + + const { resultSets, resultScrollToIndex, resultUnseenIndex } = + mapItemsIntoCollapseSets({ + ...defaultParams, + items, + messages, + callHistorySelector, + }); + + assert.deepEqual(resultSets, expectedSets); + assert.isNull(resultScrollToIndex); + assert.isNull(resultUnseenIndex); + }); + + it('returns a combination of sets for combination of items', () => { + const items = [ + 'id0', + 'id1', + 'id2', + 'id3', + 'id4', + 'id5', + 'id6', + 'id7', + 'id8', + ]; + const messages: MessageLookupType = { + id0: { + ...getDefaultMessage('id0'), + type: 'call-history', + callId: 'id0', + }, + id1: { + ...getDefaultMessage('id1'), + type: 'call-history', + callId: 'id1', + }, + id2: getDefaultMessage('id2'), + id3: { + ...getDefaultMessage('id3'), + type: 'group-v2-change', + groupV2Change: { + details: [ + { type: 'member-add', aci: generateAci() }, + { type: 'member-add', aci: generateAci() }, + ], + }, + }, + id4: { + ...getDefaultMessage('id4'), + type: 'incoming', + expirationTimerUpdate: { + expireTimer: DurationInSeconds.fromHours(5), + }, + }, + id5: { + ...getDefaultMessage('id5'), + type: 'timer-notification', + expirationTimerUpdate: { + expireTimer: undefined, + }, + }, + id6: { + ...getDefaultMessage('id4'), + type: 'call-history', + callId: 'id4', + }, + id7: getDefaultMessage('id7'), + id8: getDefaultMessage('id8'), + }; + const callHistorySelector = (callId: string): CallHistoryDetails => { + if (callId === 'id0') { + return { + callId: 'id0', + peerId: generateUuid(), + ringerId: generateAci(), + startedById: generateAci(), + mode: CallMode.Direct, + type: CallType.Audio, + direction: CallDirection.Incoming, + timestamp: now, + endedTimestamp: now, + status: DirectCallStatus.Accepted, + }; + } + if (callId === 'id1') { + return { + callId: 'id1', + peerId: generateUuid(), + ringerId: generateAci(), + startedById: generateAci(), + mode: CallMode.Direct, + type: CallType.Audio, + direction: CallDirection.Incoming, + timestamp: now, + endedTimestamp: now, + status: DirectCallStatus.Missed, + }; + } + if (callId === 'id4') { + return { + callId: 'id4', + peerId: generateUuid(), + ringerId: generateAci(), + startedById: generateAci(), + mode: CallMode.Direct, + type: CallType.Audio, + direction: CallDirection.Incoming, + timestamp: now, + endedTimestamp: now, + status: DirectCallStatus.Missed, + }; + } + throw new Error(`${callId} is not known!`); + }; + const scrollToIndex = 5; + const oldestUnseenIndex = 7; + + const expectedSets: Array = [ + { + id: 'id0', + type: 'call-events', + messages: [ + { + id: 'id0', + isUnseen: false, + }, + { + id: 'id1', + isUnseen: false, + }, + ], + }, + { + id: 'id2', + type: 'none', + messages: undefined, + }, + { + id: 'id3', + type: 'group-updates', + messages: [ + { + id: 'id3', + isUnseen: false, + extraItems: 1, + }, + ], + }, + { + id: 'id4', + type: 'timer-changes', + endingState: undefined, + messages: [ + { + id: 'id4', + isUnseen: false, + }, + { + id: 'id5', + isUnseen: false, + }, + ], + }, + { + id: 'id6', + type: 'none', + messages: undefined, + }, + { + id: 'id7', + type: 'none', + messages: undefined, + }, + { + id: 'id8', + type: 'none', + messages: undefined, + }, + ]; + const expectedScrollToIndex = 3; + const expectedUnseenIndex = 5; + + const { resultSets, resultScrollToIndex, resultUnseenIndex } = + mapItemsIntoCollapseSets({ + ...defaultParams, + items, + messages, + callHistorySelector, + scrollToIndex, + oldestUnseenIndex, + }); + + assert.deepEqual(resultSets, expectedSets); + assert.strictEqual( + resultScrollToIndex, + expectedScrollToIndex, + 'resultScrollToIndex' + ); + assert.strictEqual( + resultUnseenIndex, + expectedUnseenIndex, + 'resultUnseenIndex' + ); + }); + + it('splits sets across the lastSeenIndex', () => { + const items = ['id0', 'id1', 'id2', 'id3']; + const messages: MessageLookupType = { + id0: { + ...getDefaultMessage('id0'), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'create' }], + }, + }, + id1: { + ...getDefaultMessage('id1'), + type: 'group-v2-change', + groupV2Change: { + details: [ + { type: 'member-add', aci: generateAci() }, + { type: 'member-add', aci: generateAci() }, + ], + }, + }, + id2: { + ...getDefaultMessage('id2'), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id3: { + ...getDefaultMessage('id3'), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + }; + const oldestUnseenIndex = 2; + + const expectedSets: Array = [ + { + id: 'id0', + type: 'group-updates', + messages: [ + { + id: 'id0', + isUnseen: false, + extraItems: undefined, + }, + { + id: 'id1', + isUnseen: false, + extraItems: 1, + }, + ], + }, + { + id: 'id2', + type: 'group-updates', + messages: [ + { + id: 'id2', + isUnseen: false, + extraItems: undefined, + }, + { + id: 'id3', + isUnseen: false, + extraItems: undefined, + }, + ], + }, + ]; + const expectedLastSeenIndex = 1; + + const { resultSets, resultScrollToIndex, resultUnseenIndex } = + mapItemsIntoCollapseSets({ + ...defaultParams, + items, + oldestUnseenIndex, + messages, + }); + + assert.deepEqual(resultSets, expectedSets); + assert.isNull(resultScrollToIndex); + assert.strictEqual( + resultUnseenIndex, + expectedLastSeenIndex, + 'resultUnseenIndex' + ); + }); + + it('splits timer events and updates endingState properly', () => { + const items = ['id0', 'id1', 'id2', 'id3', 'id4']; + const messages: MessageLookupType = { + id0: { + ...getDefaultMessage('id0', now - DAY * 2), + type: 'timer-notification', + expirationTimerUpdate: { + expireTimer: DurationInSeconds.fromHours(1), + }, + }, + id1: { + ...getDefaultMessage('id1', now - DAY * 2), + type: 'timer-notification', + expirationTimerUpdate: { + expireTimer: DurationInSeconds.fromHours(2), + }, + }, + id2: { + ...getDefaultMessage('id2', yesterday), + type: 'timer-notification', + expirationTimerUpdate: { + expireTimer: DurationInSeconds.fromHours(3), + }, + }, + id3: { + ...getDefaultMessage('id3', yesterday), + type: 'timer-notification', + expirationTimerUpdate: { + expireTimer: DurationInSeconds.fromHours(4), + }, + }, + id4: getDefaultMessage('id4', yesterday), + }; + + const expectedSets: Array = [ + { + id: 'id0', + type: 'timer-changes', + endingState: DurationInSeconds.fromHours(2), + messages: [ + { + id: 'id0', + isUnseen: false, + }, + { + id: 'id1', + isUnseen: false, + }, + ], + }, + { + id: 'id2', + type: 'timer-changes', + endingState: DurationInSeconds.fromHours(4), + messages: [ + { + id: 'id2', + isUnseen: false, + }, + { + id: 'id3', + isUnseen: false, + }, + ], + }, + { id: 'id4', type: 'none', messages: undefined }, + ]; + + const { resultSets, resultScrollToIndex, resultUnseenIndex } = + mapItemsIntoCollapseSets({ + ...defaultParams, + items, + messages, + }); + + assert.deepEqual(resultSets, expectedSets); + assert.isNull(resultScrollToIndex); + assert.isNull(resultUnseenIndex); + }); + + it('generates multiday sets, but not if start/end are incomplete days', () => { + const items = [ + 'id0', // Today - 4 + 'id1', + 'id2', + 'id3', // Today - 3 + 'id4', + 'id5', + 'id6', // Today - 2 + 'id7', + 'id8', // Yesterday + 'id9', + 'id10', + ]; + const messages: MessageLookupType = { + id0: { + ...getDefaultMessage('id0', now - DAY * 4), + }, + id1: { + ...getDefaultMessage('id1', now - DAY * 4), + type: 'group-v2-change', + groupV2Change: { + details: [ + { type: 'member-add', aci: generateAci() }, + { type: 'member-add', aci: generateAci() }, + ], + }, + }, + id2: { + ...getDefaultMessage('id2', now - DAY * 4), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id3: { + ...getDefaultMessage('id3', now - DAY * 3), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id4: { + ...getDefaultMessage('id4', now - DAY * 3), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id5: { + ...getDefaultMessage('id5', now - DAY * 3), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id6: { + ...getDefaultMessage('id6', now - DAY * 2), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id7: { + ...getDefaultMessage('id7', now - DAY * 2), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id8: { + ...getDefaultMessage('id8', yesterday), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id9: { + ...getDefaultMessage('id9', yesterday), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id10: { + ...getDefaultMessage('id10', yesterday), + }, + }; + + const expectedSets: Array = [ + { + id: 'id0', + type: 'none', + messages: undefined, + }, + { + id: 'id1', + type: 'group-updates', + messages: [ + { + id: 'id1', + isUnseen: false, + extraItems: 1, + }, + { + id: 'id2', + isUnseen: false, + extraItems: undefined, + }, + ], + }, + { + id: 'id3', + type: 'group-updates', + messages: [ + { + id: 'id3', + isUnseen: false, + extraItems: undefined, + }, + { + id: 'id4', + isUnseen: false, + extraItems: undefined, + }, + { + id: 'id5', + isUnseen: false, + extraItems: undefined, + }, + { + id: 'id6', + isUnseen: false, + extraItems: undefined, + }, + { + id: 'id7', + isUnseen: false, + extraItems: undefined, + }, + ], + }, + { + id: 'id8', + type: 'group-updates', + messages: [ + { + id: 'id8', + isUnseen: false, + extraItems: undefined, + }, + { + id: 'id9', + isUnseen: false, + extraItems: undefined, + }, + ], + }, + { + id: 'id10', + type: 'none', + messages: undefined, + }, + ]; + const { resultSets, resultScrollToIndex, resultUnseenIndex } = + mapItemsIntoCollapseSets({ + ...defaultParams, + items, + messages, + }); + + assert.deepEqual(resultSets, expectedSets); + assert.isNull(resultScrollToIndex); + assert.isNull(resultUnseenIndex); + }); + + it('handles multiday edge cases: single-item days, etc.', () => { + const items = [ + 'id0', // Today - 6 + 'id1', // Today - 5 + 'id2', // Today - 4 + 'id3', // Today - 3 + 'id4', // Today - 2 + 'id5', // Yesterday + ]; + const messages: MessageLookupType = { + id0: { + ...getDefaultMessage('id0', now - DAY * 6), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id1: getDefaultMessage('id1', now - DAY * 5), + id2: { + ...getDefaultMessage('id2', now - DAY * 4), + type: 'group-v2-change', + groupV2Change: { + details: [ + { type: 'member-add', aci: generateAci() }, + { type: 'member-add', aci: generateAci() }, + ], + }, + }, + id3: getDefaultMessage('id3', now - DAY * 3), + id4: { + ...getDefaultMessage('id4', now - DAY * 2), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id5: { + ...getDefaultMessage('id5', yesterday), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + }; + + const expectedSets: Array = [ + { + id: 'id0', + type: 'none', + messages: undefined, + }, + { + id: 'id1', + type: 'none', + messages: undefined, + }, + { + id: 'id2', + type: 'group-updates', + messages: [ + { + id: 'id2', + isUnseen: false, + extraItems: 1, + }, + ], + }, + { + id: 'id3', + type: 'none', + messages: undefined, + }, + { + id: 'id4', + type: 'group-updates', + messages: [ + { + id: 'id4', + isUnseen: false, + extraItems: undefined, + }, + { + id: 'id5', + isUnseen: false, + extraItems: undefined, + }, + ], + }, + ]; + const { resultSets, resultScrollToIndex, resultUnseenIndex } = + mapItemsIntoCollapseSets({ + ...defaultParams, + items, + messages, + }); + + assert.deepEqual(resultSets, expectedSets); + assert.isNull(resultScrollToIndex); + assert.isNull(resultUnseenIndex); + }); + + it('splits failed multiday sets into none sets if needed', () => { + const items = [ + 'id0', // Today - 6 + 'id1', // Today - 5 + 'id2', + 'id3', // Today - 4 + 'id4', // Today - 3 + 'id5', + 'id6', // Today - 2 + 'id7', // Yesterday + 'id8', + ]; + const messages: MessageLookupType = { + id0: { + ...getDefaultMessage('id0', now - DAY * 6), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id1: { + ...getDefaultMessage('id1', now - DAY * 5), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id2: getDefaultMessage('id2', now - DAY * 5), + id3: { + ...getDefaultMessage('id3', now - DAY * 4), + type: 'group-v2-change', + groupV2Change: { + details: [ + { type: 'member-add', aci: generateAci() }, + { type: 'member-add', aci: generateAci() }, + ], + }, + }, + id4: { + ...getDefaultMessage('id4', now - DAY * 3), + type: 'group-v2-change', + groupV2Change: { + details: [ + { type: 'member-add', aci: generateAci() }, + { type: 'member-add', aci: generateAci() }, + ], + }, + }, + id5: getDefaultMessage('id5', now - DAY * 3), + id6: { + ...getDefaultMessage('id6', now - DAY * 2), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id7: { + ...getDefaultMessage('id7', yesterday), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id8: { + ...getDefaultMessage('id8', yesterday), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + }; + const oldestUnseenIndex = 8; + + const expectedSets: Array = [ + { + id: 'id0', + type: 'none', + messages: undefined, + }, + { + id: 'id1', + type: 'none', + messages: undefined, + }, + { + id: 'id2', + type: 'none', + messages: undefined, + }, + { + id: 'id3', + type: 'group-updates', + messages: [ + { + id: 'id3', + isUnseen: false, + extraItems: 1, + }, + ], + }, + { + id: 'id4', + type: 'group-updates', + messages: [ + { + id: 'id4', + isUnseen: false, + extraItems: 1, + }, + ], + }, + { + id: 'id5', + type: 'none', + messages: undefined, + }, + { + id: 'id6', + type: 'none', + messages: undefined, + }, + { + id: 'id7', + type: 'none', + messages: undefined, + }, + { + id: 'id8', + type: 'none', + messages: undefined, + }, + ]; + const expectedUnseenIndex = 8; + + const { resultSets, resultScrollToIndex, resultUnseenIndex } = + mapItemsIntoCollapseSets({ + ...defaultParams, + items, + messages, + oldestUnseenIndex, + }); + + assert.deepEqual(resultSets, expectedSets); + assert.isNull(resultScrollToIndex); + assert.strictEqual( + resultUnseenIndex, + expectedUnseenIndex, + 'resultUnseenIndex' + ); + }); + + it('today is never included in a multiday set', () => { + const items = [ + 'id0', // Yesterday + 'id1', + 'id2', // Today + 'id3', + ]; + const messages: MessageLookupType = { + id0: { + ...getDefaultMessage('id0', yesterday), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id1: { + ...getDefaultMessage('id1', yesterday), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id2: { + ...getDefaultMessage('id2', now), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id3: { + ...getDefaultMessage('id3', now), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + }; + + const expectedSets: Array = [ + { + id: 'id0', + type: 'group-updates', + messages: [ + { + id: 'id0', + isUnseen: false, + extraItems: undefined, + }, + { + id: 'id1', + isUnseen: false, + extraItems: undefined, + }, + ], + }, + { + id: 'id2', + type: 'group-updates', + messages: [ + { + id: 'id2', + isUnseen: false, + extraItems: undefined, + }, + { + id: 'id3', + isUnseen: false, + extraItems: undefined, + }, + ], + }, + ]; + + const { resultSets, resultScrollToIndex, resultUnseenIndex } = + mapItemsIntoCollapseSets({ + ...defaultParams, + items, + messages, + }); + + assert.deepEqual(resultSets, expectedSets); + assert.isNull(resultScrollToIndex); + assert.isNull(resultUnseenIndex); + }); + + it('if allowMultidaySets=false, generates a set for each day', () => { + const items = [ + 'id0', // Today - 4 + 'id1', + 'id2', + 'id3', // Today - 3 + 'id4', + 'id5', + 'id6', // Today - 2 + 'id7', + 'id8', // Yesterday + 'id9', + 'id10', + ]; + const messages: MessageLookupType = { + id0: { + ...getDefaultMessage('id0', now - DAY * 4), + }, + id1: { + ...getDefaultMessage('id1', now - DAY * 4), + type: 'group-v2-change', + groupV2Change: { + details: [ + { type: 'member-add', aci: generateAci() }, + { type: 'member-add', aci: generateAci() }, + ], + }, + }, + id2: { + ...getDefaultMessage('id2', now - DAY * 4), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id3: { + ...getDefaultMessage('id3', now - DAY * 3), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id4: { + ...getDefaultMessage('id4', now - DAY * 3), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id5: { + ...getDefaultMessage('id5', now - DAY * 3), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id6: { + ...getDefaultMessage('id6', now - DAY * 2), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id7: { + ...getDefaultMessage('id7', now - DAY * 2), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id8: { + ...getDefaultMessage('id8', yesterday), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id9: { + ...getDefaultMessage('id9', yesterday), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }, + id10: { + ...getDefaultMessage('id10', yesterday), + }, + }; + + const expectedSets: Array = [ + { + id: 'id0', + type: 'none', + messages: undefined, + }, + { + id: 'id1', + type: 'group-updates', + messages: [ + { + id: 'id1', + isUnseen: false, + extraItems: 1, + }, + { + id: 'id2', + isUnseen: false, + extraItems: undefined, + }, + ], + }, + { + id: 'id3', + type: 'group-updates', + messages: [ + { + id: 'id3', + isUnseen: false, + extraItems: undefined, + }, + { + id: 'id4', + isUnseen: false, + extraItems: undefined, + }, + { + id: 'id5', + isUnseen: false, + extraItems: undefined, + }, + ], + }, + { + id: 'id6', + type: 'group-updates', + messages: [ + { + id: 'id6', + isUnseen: false, + extraItems: undefined, + }, + { + id: 'id7', + isUnseen: false, + extraItems: undefined, + }, + ], + }, + { + id: 'id8', + type: 'group-updates', + messages: [ + { + id: 'id8', + isUnseen: false, + extraItems: undefined, + }, + { + id: 'id9', + isUnseen: false, + extraItems: undefined, + }, + ], + }, + { + id: 'id10', + type: 'none', + messages: undefined, + }, + ]; + const { resultSets, resultScrollToIndex, resultUnseenIndex } = + mapItemsIntoCollapseSets({ + ...defaultParams, + allowMultidayDaySets: false, + items, + messages, + }); + + assert.deepEqual(resultSets, expectedSets); + assert.isNull(resultScrollToIndex); + assert.isNull(resultUnseenIndex); + }); + }); +}); diff --git a/ts/util/CollapseSet.std.ts b/ts/util/CollapseSet.std.ts new file mode 100644 index 0000000000..cb299d3da4 --- /dev/null +++ b/ts/util/CollapseSet.std.ts @@ -0,0 +1,588 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { last } from 'lodash'; + +import { CallMode } from '../types/CallDisposition.std.js'; +import { strictAssert } from './assert.std.js'; +import { getMidnight } from '../types/NotificationProfile.std.js'; +import { SeenStatus } from '../MessageSeenStatus.std.js'; +import { missingCaseError } from './missingCaseError.std.js'; + +import type { + MessageLookupType, + MessageType, +} from '../state/ducks/conversations.preload.js'; +import type { + CallSelectorType, + CallStateType, +} from '../state/selectors/calling.std.js'; +import type { DurationInSeconds } from './durations/duration-in-seconds.std.js'; +import type { CallHistorySelectorType } from '../state/selectors/callHistory.std.js'; + +export type CollapsedMessage = { + id: string; + isUnseen: boolean; + // A single group-v2-change message can have more than one change in it + extraItems?: number; +}; + +export type CollapseSet = + | { + type: 'none'; + id: string; + messages: undefined; + } + | { + type: 'group-updates'; + id: string; + messages: Array; + } + | { + type: 'timer-changes'; + id: string; + messages: Array; + endingState: DurationInSeconds | undefined; + } + | { + type: 'call-events'; + id: string; + messages: Array; + }; + +export function canCollapseForGroupSet(type: MessageType['type']): boolean { + if ( + type === 'group-v2-change' || + type === 'keychange' || + type === 'profile-change' + ) { + return true; + } + + return false; +} + +export function canCollapseForTimerSet(message: MessageType): boolean { + if (message.type === 'timer-notification') { + return true; + } + + // Found some examples of messages with type = 'incoming' and an expirationTimerUpdate + if (message.expirationTimerUpdate) { + return true; + } + + return false; +} + +export function canCollapseForCallSet( + message: MessageType, + options: { + activeCall: CallStateType | undefined; + callHistorySelector: CallHistorySelectorType; + callSelector: (conversationId: string) => CallStateType | undefined; + getCallIdFromEra: (eraId: string) => string; + } +): boolean { + if (message.type !== 'call-history') { + return false; + } + + const { callId, conversationId } = message; + if (!callId) { + return true; + } + + const callHistory = options.callHistorySelector(callId); + if (!callHistory) { + return true; + } + + // If a direct call is currently ongoing, we don't want to group it + if (callHistory.mode === CallMode.Direct) { + const isActiveCall = options.activeCall?.conversationId === conversationId; + + return !isActiveCall; + } + + const conversationCall = options.callSelector(conversationId); + if (!conversationCall) { + return true; + } + + strictAssert( + conversationCall?.callMode === CallMode.Group, + 'canCollapseForCallSet: Call was expected to be a group call' + ); + + const conversationCallId = + conversationCall?.peekInfo?.eraId != null && + options.getCallIdFromEra(conversationCall.peekInfo.eraId); + + const deviceCount = conversationCall?.peekInfo?.deviceCount ?? 0; + + // Don't group if current call in the converasation, or there are devices in the call + if ( + callHistory.mode === CallMode.Group && + (callId === conversationCallId || deviceCount > 0) + ) { + return false; + } + + return true; +} + +export function mapItemsIntoCollapseSets({ + activeCall, + allowMultidayDaySets, + callHistorySelector, + callSelector, + getCallIdFromEra, + items, + messages, + midnightToday, + oldestUnseenIndex, + scrollToIndex, +}: { + activeCall: CallStateType | undefined; + allowMultidayDaySets: boolean; + callHistorySelector: CallHistorySelectorType; + callSelector: CallSelectorType; + getCallIdFromEra: (eraId: string) => string; + items: ReadonlyArray; + messages: MessageLookupType; + midnightToday: number; + oldestUnseenIndex: number | null; + scrollToIndex: number | null; +}): { + resultSets: Array; + resultUnseenIndex: number | null; + resultScrollToIndex: number | null; +} { + const resultSets: Array = []; + let resultUnseenIndex = oldestUnseenIndex; + let resultScrollToIndex = scrollToIndex; + + // Everything in the current day is the current collapseSet type other than 'none' + let haveCompleteDay = true; + // Yesterday the entire day was captured by lastCollapseSet + let havePreviousCompleteDay = false; + let currentDayFirstId: string | undefined; + + const max = items.length; + + for (let i = 0; i < max; i += 1) { + const previousId = items[i - 1]; + const lastCollapseSet = last(resultSets); + + const currentId = items[i]; + strictAssert(currentId, 'no item at index i'); + if (!currentDayFirstId) { + currentDayFirstId = currentId; + } + + const currentMessage = messages[currentId]; + const previousMessage = previousId ? messages[previousId] : undefined; + + const changeLength = currentMessage?.groupV2Change?.details.length; + const extraItems = + changeLength && changeLength > 1 ? changeLength - 1 : undefined; + + const DEFAULT_SET: CollapseSet = + currentMessage && + canCollapseForGroupSet(currentMessage.type) && + extraItems && + extraItems > 0 + ? { + // A group-v2-change message with more than one inner change detail can be + // a set all by itself! + type: 'group-updates', + id: currentId, + messages: [ + { + id: currentId, + isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, + extraItems, + }, + ], + } + : { + type: 'none' as const, + id: currentId, + messages: undefined, + }; + + // These values need to be translated to the world of collapseSets + // Note: these values will need to be updated if, in the loop iteration these are + // set, something other than a push happens below. These both expect the length + // of resultSets to go up by one. + if (i === scrollToIndex) { + resultScrollToIndex = resultSets.length; + } + if (i === oldestUnseenIndex) { + resultUnseenIndex = resultSets.length; + } + + // Start a new set if we just started looping, or couldn't find target messages + if (!currentMessage || !previousId || !previousMessage) { + resultSets.push(DEFAULT_SET); + continue; + } + + const currentDay = getMidnight( + currentMessage.received_at_ms || currentMessage.timestamp + ); + const previousDay = getMidnight( + previousMessage.received_at_ms || previousMessage.timestamp + ); + const atDateBoundary = currentDay !== previousDay; + const isToday = currentDay === midnightToday; + if (atDateBoundary) { + havePreviousCompleteDay = haveCompleteDay; + haveCompleteDay = true; + currentDayFirstId = currentId; + } + const canContinueSet = + !atDateBoundary || + (allowMultidayDaySets && !isToday && havePreviousCompleteDay); + + // Start a new set if we just crossed the last seen indicator + if (i === oldestUnseenIndex) { + haveCompleteDay &&= atDateBoundary; + + if (allowMultidayDaySets) { + // If we've just terminated a multiday set, we need to split it; everything from + // the current day needs to be in its own set. + const didSplit = maybeSplitLastCollapseSet({ + atDateBoundary, + currentDayFirstId, + haveCompleteDay, + havePreviousCompleteDay, + lastCollapseSet, + messages, + resultSets, + }); + + if (didSplit) { + if (i === scrollToIndex) { + resultScrollToIndex = resultSets.length; + } + if (i === oldestUnseenIndex) { + resultUnseenIndex = resultSets.length; + } + } + } + + resultSets.push(DEFAULT_SET); + continue; + } + + strictAssert( + lastCollapseSet, + 'collapseSets: expect lastCollapseSet to be defined' + ); + + // Add to current set if previous and current messages are both group updates + if ( + canContinueSet && + canCollapseForGroupSet(currentMessage.type) && + canCollapseForGroupSet(previousMessage.type) + ) { + strictAssert( + lastCollapseSet.type !== 'timer-changes' && + lastCollapseSet.type !== 'call-events', + 'Should never have two matching group items, but be in a timer or call set' + ); + + if (lastCollapseSet.type === 'group-updates') { + lastCollapseSet.messages.push({ + id: currentId, + isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, + extraItems, + }); + } else if (lastCollapseSet.type === 'none') { + resultSets.pop(); + resultSets.push({ + type: 'group-updates', + id: previousId, + messages: [ + { + id: previousId, + isUnseen: previousMessage.seenStatus === SeenStatus.Unseen, + extraItems: undefined, + }, + { + id: currentId, + isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, + extraItems, + }, + ], + }); + } else { + throw missingCaseError(lastCollapseSet); + } + + if (i === scrollToIndex) { + resultScrollToIndex = resultSets.length - 1; + } + if (i === oldestUnseenIndex) { + resultUnseenIndex = resultSets.length - 1; + } + + continue; + } + + // Add to current set if previous and current messages are both timer updates + if ( + canContinueSet && + canCollapseForTimerSet(currentMessage) && + canCollapseForTimerSet(previousMessage) + ) { + strictAssert( + lastCollapseSet.type !== 'group-updates' && + lastCollapseSet.type !== 'call-events', + 'Should never have two matching timer items, but be in a group or call set' + ); + + if (lastCollapseSet.type === 'timer-changes') { + lastCollapseSet.messages.push({ + id: currentId, + isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, + }); + lastCollapseSet.endingState = + currentMessage.expirationTimerUpdate?.expireTimer; + } else if (lastCollapseSet.type === 'none') { + resultSets.pop(); + resultSets.push({ + type: 'timer-changes', + id: previousId, + endingState: currentMessage.expirationTimerUpdate?.expireTimer, + messages: [ + { + id: previousId, + isUnseen: previousMessage.seenStatus === SeenStatus.Unseen, + }, + { + id: currentId, + isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, + }, + ], + }); + } else { + throw missingCaseError(lastCollapseSet); + } + + if (i === scrollToIndex) { + resultScrollToIndex = resultSets.length - 1; + } + if (i === oldestUnseenIndex) { + resultUnseenIndex = resultSets.length - 1; + } + + continue; + } + + // Add to current set if previous and current messages are both call events + if ( + canContinueSet && + canCollapseForCallSet(currentMessage, { + activeCall, + callHistorySelector, + callSelector, + getCallIdFromEra, + }) && + canCollapseForCallSet(previousMessage, { + activeCall, + callHistorySelector, + callSelector, + getCallIdFromEra, + }) + ) { + strictAssert( + lastCollapseSet.type !== 'group-updates' && + lastCollapseSet.type !== 'timer-changes', + 'Should never have two matching timer items, but be in a group or timer set' + ); + + if (lastCollapseSet.type === 'call-events') { + lastCollapseSet.messages.push({ + id: currentId, + isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, + }); + } else if (lastCollapseSet.type === 'none') { + resultSets.pop(); + resultSets.push({ + type: 'call-events', + id: previousId, + messages: [ + { + id: previousId, + isUnseen: previousMessage.seenStatus === SeenStatus.Unseen, + }, + { + id: currentId, + isUnseen: currentMessage.seenStatus === SeenStatus.Unseen, + }, + ], + }); + } else { + throw missingCaseError(lastCollapseSet); + } + + if (i === scrollToIndex) { + resultScrollToIndex = resultSets.length - 1; + } + if (i === oldestUnseenIndex) { + resultUnseenIndex = resultSets.length - 1; + } + + continue; + } + + haveCompleteDay &&= atDateBoundary; + + if (allowMultidayDaySets) { + // If we've just terminated a multiday set, we need to split it; everything from + // the current day needs to be in its own set. + const didSplit = maybeSplitLastCollapseSet({ + atDateBoundary, + currentDayFirstId, + haveCompleteDay, + havePreviousCompleteDay, + lastCollapseSet, + messages, + resultSets, + }); + + if (didSplit) { + if (i === scrollToIndex) { + resultScrollToIndex = resultSets.length; + } + if (i === oldestUnseenIndex) { + resultUnseenIndex = resultSets.length; + } + } + } + + // Finally, just add a new empty set if no situations above triggered + resultSets.push(DEFAULT_SET); + } + return { resultSets, resultUnseenIndex, resultScrollToIndex }; +} + +// In the case where an existing multiday set extends into the current day, and we then +// discover that the set is ending in the middle of the current day, we need to split +// lastCollapseSet into everything before today, and everything from today. +export function maybeSplitLastCollapseSet({ + atDateBoundary, + currentDayFirstId, + haveCompleteDay, + havePreviousCompleteDay, + lastCollapseSet, + messages, + resultSets, +}: { + atDateBoundary: boolean; + currentDayFirstId: string | undefined; + haveCompleteDay: boolean; + havePreviousCompleteDay: boolean; + lastCollapseSet: CollapseSet | undefined; + messages: MessageLookupType; + resultSets: Array; +}): boolean { + if (!lastCollapseSet) { + return false; + } + + if (lastCollapseSet.type === 'none') { + return false; + } + + if (atDateBoundary) { + return false; + } + + if (haveCompleteDay || !havePreviousCompleteDay) { + return false; + } + + const currentDayStartingIndex = lastCollapseSet.messages.findIndex( + message => message.id === currentDayFirstId + ); + if (currentDayStartingIndex < 1) { + return false; + } + + const previousDayMessages = lastCollapseSet.messages.slice( + 0, + currentDayStartingIndex + ); + const currentDayMessages = lastCollapseSet.messages.slice( + currentDayStartingIndex + ); + + const firstPreviousMessage = previousDayMessages[0]; + strictAssert( + firstPreviousMessage, + 'No message in previousDayMessages at index 0' + ); + if ( + previousDayMessages.length > 1 || + (firstPreviousMessage.extraItems ?? 0) > 0 + ) { + // eslint-disable-next-line no-param-reassign + lastCollapseSet.messages = previousDayMessages; + + if (lastCollapseSet.type === 'timer-changes') { + const lastMessage = last(lastCollapseSet.messages); + strictAssert( + lastMessage, + 'We know lastMessage exists; we previously looked it up' + ); + // eslint-disable-next-line no-param-reassign + lastCollapseSet.endingState = + messages[lastMessage.id]?.expirationTimerUpdate?.expireTimer; + } + } else { + resultSets.pop(); + resultSets.push({ + type: 'none', + id: firstPreviousMessage.id, + messages: undefined, + }); + } + + const firstCurrentMessage = currentDayMessages[0]; + strictAssert( + firstCurrentMessage, + 'No message in currentDayMessages at index 0' + ); + if ( + currentDayMessages.length > 1 || + (firstCurrentMessage.extraItems ?? 0) > 0 + ) { + const currentDaySet: CollapseSet = { + ...lastCollapseSet, + id: firstCurrentMessage.id, + messages: currentDayMessages, + }; + if (currentDaySet.type === 'timer-changes') { + const lastMessage = last(currentDayMessages); + strictAssert( + lastMessage, + 'We know lastMessage exists; we previously looked it up' + ); + currentDaySet.endingState = + messages[lastMessage.id]?.expirationTimerUpdate?.expireTimer; + } + resultSets.push(currentDaySet); + } else { + resultSets.push({ + type: 'none', + id: firstCurrentMessage.id, + messages: undefined, + }); + } + + return true; +}