diff --git a/ts/test-node/util/CollapseSet_test.std.ts b/ts/test-node/util/CollapseSet_test.std.ts index ec789beee3..5a376ac159 100644 --- a/ts/test-node/util/CollapseSet_test.std.ts +++ b/ts/test-node/util/CollapseSet_test.std.ts @@ -5,7 +5,10 @@ import { assert } from 'chai'; import { v4 as generateUuid } from 'uuid'; import { getMidnight } from '../../types/NotificationProfile.std.ts'; -import { mapItemsIntoCollapseSets } from '../../util/CollapseSet.std.ts'; +import { + mapItemsIntoCollapseSets, + MAX_COLLAPSE_SET_SIZE, +} from '../../util/CollapseSet.std.ts'; import { generateAci } from '../../types/ServiceId.std.ts'; import { ReadStatus } from '../../messages/MessageReadStatus.std.ts'; import { SeenStatus } from '../../MessageSeenStatus.std.ts'; @@ -24,6 +27,7 @@ import type { MessageType, } from '../../state/ducks/conversations.preload.ts'; import type { CollapseSet } from '../../util/CollapseSet.std.ts'; +import type { MessageAttributesType } from '../../model-types.d.ts'; describe('util/CollapseSets', () => { describe('mapItemsIntoCollapseSets', () => { @@ -169,6 +173,346 @@ describe('util/CollapseSets', () => { ); assert.isNull(resultUnseenIndex); }); + it('returns single set for all non-groupv2 items included in group sets', () => { + const groupMessage = { + ...getDefaultMessage('unused'), + type: 'group-v2-change' as const, + groupV2Change: { + details: [{ type: 'create' as const }], + }, + }; + // The best test is if these are all right next to group messages; otherwise + // it's only testing whether they group against their neighbors... + const itemsToMixIn = [ + // The first set is included + { + ...getDefaultMessage('unused'), + type: 'profile-change' as const, + profileChange: { + type: 'name' as const, + oldName: 'Someone', + newName: 'Sometwo', + }, + changedId: generateAci(), + }, + { + ...getDefaultMessage('unused'), + type: 'poll-terminate' as const, + pollTerminateNotification: { + question: 'What is the best?', + pollTimestamp: yesterday, + }, + changedId: generateAci(), + }, + { + ...getDefaultMessage('unused'), + type: 'keychange' as const, + key_changed: generateAci(), + }, + { + ...getDefaultMessage('unused'), + type: 'change-number-notification' as const, + changedId: generateAci(), + }, + { + ...getDefaultMessage('unused'), + type: 'pinned-message-notification' as const, + pinMessage: { + targetAuthorAci: generateAci(), + targetSentTimestamp: yesterday, + }, + }, + // From here on, they should not be included + { + ...getDefaultMessage('unused'), + type: 'group-v2-change' as const, + groupV2Change: { + details: [{ type: 'terminated' as const }], + }, + }, + { + ...getDefaultMessage('unused'), + type: 'chat-session-refreshed' as const, + }, + { + ...getDefaultMessage('unused'), + type: 'conversation-merge' as const, + }, + { + ...getDefaultMessage('unused'), + type: 'delivery-issue' as const, + }, + { + ...getDefaultMessage('unused'), + type: 'group-v1-migration' as const, + }, + { + ...getDefaultMessage('unused'), + type: 'group' as const, + }, + { + ...getDefaultMessage('unused'), + type: 'joined-signal-notification' as const, + }, + { + ...getDefaultMessage('unused'), + type: 'phone-number-discovery' as const, + }, + { + ...getDefaultMessage('unused'), + type: 'universal-timer-notification' as const, + }, + { + ...getDefaultMessage('unused'), + type: 'contact-removed-notification' as const, + }, + { + ...getDefaultMessage('unused'), + type: 'title-transition-notification' as const, + }, + { + ...getDefaultMessage('unused'), + type: 'verified-change' as const, + }, + { + ...getDefaultMessage('unused'), + type: 'message-request-response-event' as const, + }, + ]; + const items = []; + const messages: Record = {}; + let i = 0; + + for (const item of itemsToMixIn) { + const firstId = `id${i}`; + items.push(firstId); + messages[firstId] = { + ...groupMessage, + id: firstId, + }; + + i += 1; + + const secondId = `id${i}`; + items.push(secondId); + messages[secondId] = { + ...item, + id: secondId, + }; + + i += 1; + } + + const expectedSets: Array = [ + { + id: 'id0', + type: 'group-updates', + messages: [ + { + id: 'id0', + isUnseen: false, + extraItems: undefined, + }, + { + id: 'id1', + isUnseen: false, + extraItems: undefined, + atDateBoundary: false, + }, + { + id: 'id2', + isUnseen: false, + extraItems: undefined, + atDateBoundary: false, + }, + { + id: 'id3', + isUnseen: false, + extraItems: undefined, + atDateBoundary: false, + }, + { + id: 'id4', + isUnseen: false, + extraItems: undefined, + atDateBoundary: false, + }, + { + id: 'id5', + isUnseen: false, + extraItems: undefined, + atDateBoundary: false, + }, + { + id: 'id6', + isUnseen: false, + extraItems: undefined, + atDateBoundary: false, + }, + { + id: 'id7', + isUnseen: false, + extraItems: undefined, + atDateBoundary: false, + }, + { + id: 'id8', + isUnseen: false, + extraItems: undefined, + atDateBoundary: false, + }, + { + id: 'id9', + isUnseen: false, + extraItems: undefined, + atDateBoundary: false, + }, + { + id: 'id10', + isUnseen: false, + extraItems: undefined, + atDateBoundary: false, + }, + ], + }, + { + id: 'id11', + type: 'none', + messages: undefined, + }, + { + id: 'id12', + type: 'none', + messages: undefined, + }, + { + id: 'id13', + type: 'none', + messages: undefined, + }, + { + id: 'id14', + type: 'none', + messages: undefined, + }, + { + id: 'id15', + type: 'none', + messages: undefined, + }, + { + id: 'id16', + type: 'none', + messages: undefined, + }, + { + id: 'id17', + type: 'none', + messages: undefined, + }, + { + id: 'id18', + type: 'none', + messages: undefined, + }, + { + id: 'id19', + type: 'none', + messages: undefined, + }, + { + id: 'id20', + type: 'none', + messages: undefined, + }, + { + id: 'id21', + type: 'none', + messages: undefined, + }, + { + id: 'id22', + type: 'none', + messages: undefined, + }, + { + id: 'id23', + type: 'none', + messages: undefined, + }, + { + id: 'id24', + type: 'none', + messages: undefined, + }, + { + id: 'id25', + type: 'none', + messages: undefined, + }, + { + id: 'id26', + type: 'none', + messages: undefined, + }, + { + id: 'id27', + type: 'none', + messages: undefined, + }, + { + id: 'id28', + type: 'none', + messages: undefined, + }, + { + id: 'id29', + type: 'none', + messages: undefined, + }, + { + id: 'id30', + type: 'none', + messages: undefined, + }, + { + id: 'id31', + type: 'none', + messages: undefined, + }, + { + id: 'id32', + type: 'none', + messages: undefined, + }, + { + id: 'id33', + type: 'none', + messages: undefined, + }, + { + id: 'id34', + type: 'none', + messages: undefined, + }, + { + id: 'id35', + 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 timer change items', () => { const items = ['id0', 'id1', 'id2']; @@ -634,20 +978,20 @@ describe('util/CollapseSets', () => { }, }, id2: { - ...getDefaultMessage('id2', yesterday), + ...getDefaultMessage('id2'), type: 'timer-notification', expirationTimerUpdate: { expireTimer: DurationInSeconds.fromHours(3), }, }, id3: { - ...getDefaultMessage('id3', yesterday), + ...getDefaultMessage('id3'), type: 'timer-notification', expirationTimerUpdate: { expireTimer: DurationInSeconds.fromHours(4), }, }, - id4: getDefaultMessage('id4', yesterday), + id4: getDefaultMessage('id4'), }; const expectedSets: Array = [ @@ -770,21 +1114,21 @@ describe('util/CollapseSets', () => { }, }, id8: { - ...getDefaultMessage('id8', yesterday), + ...getDefaultMessage('id8'), type: 'group-v2-change', groupV2Change: { details: [{ type: 'group-link-reset' }], }, }, id9: { - ...getDefaultMessage('id9', yesterday), + ...getDefaultMessage('id9'), type: 'group-v2-change', groupV2Change: { details: [{ type: 'group-link-reset' }], }, }, id10: { - ...getDefaultMessage('id10', yesterday), + ...getDefaultMessage('id10'), }, }; @@ -919,7 +1263,7 @@ describe('util/CollapseSets', () => { }, }, id5: { - ...getDefaultMessage('id5', yesterday), + ...getDefaultMessage('id5'), type: 'group-v2-change', groupV2Change: { details: [{ type: 'group-link-reset' }], @@ -1041,14 +1385,14 @@ describe('util/CollapseSets', () => { }, }, id7: { - ...getDefaultMessage('id7', yesterday), + ...getDefaultMessage('id7'), type: 'group-v2-change', groupV2Change: { details: [{ type: 'group-link-reset' }], }, }, id8: { - ...getDefaultMessage('id8', yesterday), + ...getDefaultMessage('id8'), type: 'group-v2-change', groupV2Change: { details: [{ type: 'group-link-reset' }], @@ -1145,14 +1489,14 @@ describe('util/CollapseSets', () => { ]; const messages: MessageLookupType = { id0: { - ...getDefaultMessage('id0', yesterday), + ...getDefaultMessage('id0'), type: 'group-v2-change', groupV2Change: { details: [{ type: 'group-link-reset' }], }, }, id1: { - ...getDefaultMessage('id1', yesterday), + ...getDefaultMessage('id1'), type: 'group-v2-change', groupV2Change: { details: [{ type: 'group-link-reset' }], @@ -1223,6 +1567,53 @@ describe('util/CollapseSets', () => { assert.isNull(resultUnseenIndex); }); + it('limits collapse set size based on MAX_COLLAPSE_SET_SIZE', () => { + const items = []; + const messages: Record = {}; + + const max = MAX_COLLAPSE_SET_SIZE * 3 + 1; + for (let i = 0; i < max; i += 1) { + const id = `id${i}`; + items.push(id); + messages[id] = { + ...getDefaultMessage(id), + type: 'group-v2-change', + groupV2Change: { + details: [{ type: 'group-link-reset' }], + }, + }; + } + + const { resultSets, resultScrollToIndex, resultUnseenIndex } = + mapItemsIntoCollapseSets({ + ...defaultParams, + items, + messages, + }); + + assert.strictEqual(resultSets.length, 4); + + assert.strictEqual( + resultSets[0]?.messages?.length, + MAX_COLLAPSE_SET_SIZE, + 'first set' + ); + assert.strictEqual( + resultSets[1]?.messages?.length, + MAX_COLLAPSE_SET_SIZE, + 'second set' + ); + assert.strictEqual( + resultSets[2]?.messages?.length, + MAX_COLLAPSE_SET_SIZE, + 'third set' + ); + assert.strictEqual(resultSets[3]?.type, 'none', 'fourth set'); + + assert.isNull(resultScrollToIndex); + assert.isNull(resultUnseenIndex); + }); + it('if allowMultidaySets=false, generates a set for each day', () => { const items = [ 'id0', // Today - 4 @@ -1294,21 +1685,21 @@ describe('util/CollapseSets', () => { }, }, id8: { - ...getDefaultMessage('id8', yesterday), + ...getDefaultMessage('id8'), type: 'group-v2-change', groupV2Change: { details: [{ type: 'group-link-reset' }], }, }, id9: { - ...getDefaultMessage('id9', yesterday), + ...getDefaultMessage('id9'), type: 'group-v2-change', groupV2Change: { details: [{ type: 'group-link-reset' }], }, }, id10: { - ...getDefaultMessage('id10', yesterday), + ...getDefaultMessage('id10'), }, }; diff --git a/ts/util/CollapseSet.std.ts b/ts/util/CollapseSet.std.ts index e195349f96..062acdbfe1 100644 --- a/ts/util/CollapseSet.std.ts +++ b/ts/util/CollapseSet.std.ts @@ -20,6 +20,8 @@ import type { import type { DurationInSeconds } from './durations/duration-in-seconds.std.ts'; import type { CallHistorySelectorType } from '../state/selectors/callHistory.std.ts'; +export const MAX_COLLAPSE_SET_SIZE = 50; + export type CollapsedMessage = { id: string; isUnseen: boolean; @@ -51,11 +53,22 @@ export type CollapseSet = messages: Array; }; -export function canCollapseForGroupSet(type: MessageType['type']): boolean { +export function canCollapseForGroupSet(message: MessageType): boolean { + const { type, groupV2Change } = message; + if ( - type === 'group-v2-change' || type === 'keychange' || - type === 'profile-change' + type === 'profile-change' || + type === 'poll-terminate' || + type === 'change-number-notification' || + type === 'pinned-message-notification' + ) { + return true; + } + + if ( + type === 'group-v2-change' && + groupV2Change?.details[0]?.type !== 'terminated' ) { return true; } @@ -185,7 +198,7 @@ export function mapItemsIntoCollapseSets({ const DEFAULT_SET: CollapseSet = currentMessage && - canCollapseForGroupSet(currentMessage.type) && + canCollapseForGroupSet(currentMessage) && extraItems && extraItems > 0 ? { @@ -237,9 +250,6 @@ export function mapItemsIntoCollapseSets({ haveCompleteDay = true; currentDayFirstId = currentId; } - const canContinueSet = - !atDateBoundary || - (allowMultidaySets && !isToday && havePreviousCompleteDay); // Start a new set if we just crossed the last seen indicator if (i === oldestUnseenIndex) { @@ -277,11 +287,17 @@ export function mapItemsIntoCollapseSets({ 'collapseSets: expect lastCollapseSet to be defined' ); + const canContinueSet = + (lastCollapseSet.type === 'none' || + lastCollapseSet.messages.length < MAX_COLLAPSE_SET_SIZE) && + (!atDateBoundary || + (allowMultidaySets && !isToday && havePreviousCompleteDay)); + // Add to current set if previous and current messages are both group updates if ( canContinueSet && - canCollapseForGroupSet(currentMessage.type) && - canCollapseForGroupSet(previousMessage.type) + canCollapseForGroupSet(currentMessage) && + canCollapseForGroupSet(previousMessage) ) { strictAssert( lastCollapseSet.type !== 'timer-changes' &&