mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-04-02 00:07:56 +01:00
Collapse items into multi-day sets, handling start/end incomplete days
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<CollapsedMessage>;
|
||||
}
|
||||
| {
|
||||
type: 'timer-changes';
|
||||
id: string;
|
||||
messages: Array<CollapsedMessage>;
|
||||
endingState: DurationInSeconds | undefined;
|
||||
}
|
||||
| {
|
||||
type: 'call-events';
|
||||
id: string;
|
||||
messages: Array<CollapsedMessage>;
|
||||
};
|
||||
|
||||
function renderItem(props: RenderItemProps): React.JSX.Element {
|
||||
return (
|
||||
<SmartTimelineItem key={props.item.id} {...props} renderItem={renderItem} />
|
||||
@@ -97,87 +61,6 @@ function renderTypingBubble(conversationId: string): React.JSX.Element {
|
||||
return <SmartTypingBubble conversationId={conversationId} />;
|
||||
}
|
||||
|
||||
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<Array<CollapseSet> | undefined>(
|
||||
undefined
|
||||
);
|
||||
const midnightToday = getMidnight(Date.now());
|
||||
const { collapseSets, updatedOldestLastSeenIndex, updatedScrollToIndex } =
|
||||
React.useMemo(() => {
|
||||
let resultSets: Array<CollapseSet> = [];
|
||||
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,
|
||||
]);
|
||||
|
||||
@@ -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<SmartTimelineItemProps, 'renderItem'>;
|
||||
|
||||
|
||||
1385
ts/test-node/util/CollapseSet_test.std.ts
Normal file
1385
ts/test-node/util/CollapseSet_test.std.ts
Normal file
File diff suppressed because it is too large
Load Diff
588
ts/util/CollapseSet.std.ts
Normal file
588
ts/util/CollapseSet.std.ts
Normal file
@@ -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<CollapsedMessage>;
|
||||
}
|
||||
| {
|
||||
type: 'timer-changes';
|
||||
id: string;
|
||||
messages: Array<CollapsedMessage>;
|
||||
endingState: DurationInSeconds | undefined;
|
||||
}
|
||||
| {
|
||||
type: 'call-events';
|
||||
id: string;
|
||||
messages: Array<CollapsedMessage>;
|
||||
};
|
||||
|
||||
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<string>;
|
||||
messages: MessageLookupType;
|
||||
midnightToday: number;
|
||||
oldestUnseenIndex: number | null;
|
||||
scrollToIndex: number | null;
|
||||
}): {
|
||||
resultSets: Array<CollapseSet>;
|
||||
resultUnseenIndex: number | null;
|
||||
resultScrollToIndex: number | null;
|
||||
} {
|
||||
const resultSets: Array<CollapseSet> = [];
|
||||
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<CollapseSet>;
|
||||
}): 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;
|
||||
}
|
||||
Reference in New Issue
Block a user