From 167b2f4f1cf19d8a5949ae6f77ad14732433335a Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Tue, 27 Feb 2024 11:01:25 -0500 Subject: [PATCH] Improve timeline rendering performance --- ts/messages/MessageSendState.ts | 112 +++++++++++++-- ts/models/messages.ts | 15 +- ts/state/ducks/badges.ts | 5 +- ts/state/selectors/conversations.ts | 31 ++-- ts/state/selectors/message.ts | 64 ++++----- ts/state/selectors/timeline.ts | 33 +++-- ts/state/smart/Timeline.tsx | 16 +-- ts/state/smart/TimelineItem.tsx | 14 +- ts/state/smart/TypingBubble.tsx | 5 +- .../messages/MessageSendState_test.ts | 134 +++++++++++++++++- ts/test-mock/benchmarks/group_send_bench.ts | 6 +- 11 files changed, 329 insertions(+), 106 deletions(-) diff --git a/ts/messages/MessageSendState.ts b/ts/messages/MessageSendState.ts index 09f5bc99a6..ec30747c2e 100644 --- a/ts/messages/MessageSendState.ts +++ b/ts/messages/MessageSendState.ts @@ -1,6 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import memoizee from 'memoizee'; import { makeEnumParser } from '../util/enum'; /** @@ -153,22 +154,111 @@ const STATE_TRANSITIONS: Record = { export type SendStateByConversationId = Record; +/** Test all of sendStateByConversationId for predicate */ export const someSendStatus = ( - sendStateByConversationId: undefined | Readonly, + sendStateByConversationId: SendStateByConversationId, predicate: (value: SendStatus) => boolean -): boolean => - Object.values(sendStateByConversationId || {}).some(sendState => - predicate(sendState.status) - ); +): boolean => { + return [ + ...summarizeMessageSendStatuses(sendStateByConversationId).statuses, + ].some(predicate); +}; + +/** Test sendStateByConversationId, excluding ourConversationId, for predicate */ +export const someRecipientSendStatus = ( + sendStateByConversationId: SendStateByConversationId, + ourConversationId: string | undefined, + predicate: (value: SendStatus) => boolean +): boolean => { + return getStatusesIgnoringOurConversationId( + sendStateByConversationId, + ourConversationId + ).some(predicate); +}; export const isMessageJustForMe = ( - sendStateByConversationId: undefined | Readonly, + sendStateByConversationId: SendStateByConversationId, ourConversationId: string | undefined ): boolean => { - const conversationIds = Object.keys(sendStateByConversationId || {}); - return Boolean( - ourConversationId && - conversationIds.length === 1 && - conversationIds[0] === ourConversationId + const { length } = summarizeMessageSendStatuses(sendStateByConversationId); + + return ( + ourConversationId !== undefined && + length === 1 && + Object.hasOwn(sendStateByConversationId, ourConversationId) ); }; + +export const getHighestSuccessfulRecipientStatus = ( + sendStateByConversationId: SendStateByConversationId, + ourConversationId: string | undefined +): SendStatus => { + return getStatusesIgnoringOurConversationId( + sendStateByConversationId, + ourConversationId + ).reduce( + (result: SendStatus, status) => maxStatus(result, status), + SendStatus.Pending + ); +}; + +const getStatusesIgnoringOurConversationId = ( + sendStateByConversationId: SendStateByConversationId, + ourConversationId: string | undefined +): Array => { + const { statuses, statusesWithOnlyOneConversationId } = + summarizeMessageSendStatuses(sendStateByConversationId); + + const statusesIgnoringOurConversationId = []; + + for (const status of statuses) { + if ( + ourConversationId && + statusesWithOnlyOneConversationId.get(status) === ourConversationId + ) { + // ignore this status; it only applies to us + } else { + statusesIgnoringOurConversationId.push(status); + } + } + + return statusesIgnoringOurConversationId; +}; + +// Looping through each value in sendStateByConversationId can be quite slow, especially +// if sendStateByConversationId is large (e.g. in a large group) and if it is actually a +// proxy (e.g. being called via useProxySelector) -- that's why we memoize it here. +const summarizeMessageSendStatuses = memoizee( + ( + sendStateByConversationId: SendStateByConversationId + ): { + statuses: Set; + statusesWithOnlyOneConversationId: Map; + length: number; + } => { + const statuses: Set = new Set(); + + // We keep track of statuses with only one conversationId associated with it + // so that we can ignore a status if it is only for ourConversationId, as needed + const statusesWithOnlyOneConversationId: Map = + new Map(); + + const entries = Object.entries(sendStateByConversationId); + + for (const [conversationId, { status }] of entries) { + if (!statuses.has(status)) { + statuses.add(status); + statusesWithOnlyOneConversationId.set(status, conversationId); + } else { + statusesWithOnlyOneConversationId.delete(status); + } + } + + return { + statuses, + statusesWithOnlyOneConversationId, + length: entries.length, + }; + }, + { max: 100 } +); diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 111cc6653c..493334b79d 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -2,13 +2,11 @@ // SPDX-License-Identifier: AGPL-3.0-only import { - isEmpty, isNumber, isObject, mapValues, maxBy, noop, - omit, partition, pick, union, @@ -58,7 +56,7 @@ import { SendStatus, isSent, sendStateReducer, - someSendStatus, + someRecipientSendStatus, } from '../messages/MessageSendState'; import { migrateLegacyReadStatus } from '../messages/migrateLegacyReadStatus'; import { migrateLegacySendAttributes } from '../messages/migrateLegacySendAttributes'; @@ -824,11 +822,14 @@ export class MessageModel extends window.Backbone.Model { public hasSuccessfulDelivery(): boolean { const sendStateByConversationId = this.get('sendStateByConversationId'); - const withoutMe = omit( - sendStateByConversationId, - window.ConversationController.getOurConversationIdOrThrow() + const ourConversationId = + window.ConversationController.getOurConversationIdOrThrow(); + + return someRecipientSendStatus( + sendStateByConversationId ?? {}, + ourConversationId, + isSent ); - return isEmpty(withoutMe) || someSendStatus(withoutMe, isSent); } /** diff --git a/ts/state/ducks/badges.ts b/ts/state/ducks/badges.ts index db4156d088..8093e381fe 100644 --- a/ts/state/ducks/badges.ts +++ b/ts/state/ducks/badges.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { ThunkAction } from 'redux-thunk'; -import { mapValues } from 'lodash'; +import { isEqual, mapValues } from 'lodash'; import type { ReadonlyDeep } from 'type-fest'; import type { StateType as RootStateType } from '../reducer'; import type { BadgeType, BadgeImageType } from '../../badges/types'; @@ -147,6 +147,9 @@ export function reducer( } }); + if (isEqual(state.byId, newById)) { + return state; + } return { ...state, byId: newById, diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index b2a3f68a9c..5ea109277d 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -904,7 +904,7 @@ export const getConversationByServiceIdSelector = createSelector( getOwn(conversationsByServiceId, serviceId) ); -const getCachedConversationMemberColorsSelector = createSelector( +export const getCachedConversationMemberColorsSelector = createSelector( getConversationSelector, getUserConversationId, ( @@ -958,23 +958,30 @@ export const getContactNameColorSelector = createSelector( conversationId: string, contactId: string | undefined ): ContactNameColorType => { - if (!contactId) { - log.warn('No color generated for missing contactId'); - return ContactNameColors[0]; - } - const contactNameColors = conversationMemberColorsSelector(conversationId); - const color = contactNameColors.get(contactId); - if (!color) { - log.warn(`No color generated for contact ${contactId}`); - return ContactNameColors[0]; - } - return color; + return getContactNameColor(contactNameColors, contactId); }; } ); +export const getContactNameColor = ( + contactNameColors: Map, + contactId: string | undefined +): string => { + if (!contactId) { + log.warn('No color generated for missing contactId'); + return ContactNameColors[0]; + } + + const color = contactNameColors.get(contactId); + if (!color) { + log.warn(`No color generated for contact ${contactId}`); + return ContactNameColors[0]; + } + return color; +}; + export function _conversationMessagesSelector( conversation: ConversationMessageType ): TimelinePropsType { diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 91ea9a8acb..39cff5ae4f 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -1,7 +1,7 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { groupBy, isEmpty, isNumber, isObject, map, omit } from 'lodash'; +import { groupBy, isEmpty, isNumber, isObject, map } from 'lodash'; import { createSelector } from 'reselect'; import filesize from 'filesize'; import getDirection from 'direction'; @@ -59,7 +59,7 @@ import { getMentionsRegex } from '../../types/Message'; import { SignalService as Proto } from '../../protobuf'; import type { AttachmentType } from '../../types/Attachment'; import { isVoiceMessage, canBeDownloaded } from '../../types/Attachment'; -import type { DefaultConversationColorType } from '../../types/Colors'; +import { type DefaultConversationColorType } from '../../types/Colors'; import { ReadStatus } from '../../messages/MessageReadStatus'; import type { CallingNotificationType } from '../../util/callingNotification'; @@ -74,12 +74,13 @@ import { canEditMessage } from '../../util/canEditMessage'; import { getAccountSelector } from './accounts'; import { getDefaultConversationColor } from './items'; import { - getContactNameColorSelector, getConversationSelector, getSelectedMessageIds, getTargetedMessage, isMissingRequiredProfileSharing, getMessages, + getCachedConversationMemberColorsSelector, + getContactNameColor, } from './conversations'; import { getIntl, @@ -97,19 +98,17 @@ import type { import type { AccountSelectorType } from './accounts'; import type { CallSelectorType, CallStateType } from './calling'; -import type { - GetConversationByIdType, - ContactNameColorSelectorType, -} from './conversations'; +import type { GetConversationByIdType } from './conversations'; import { SendStatus, isDelivered, isFailed, - isMessageJustForMe, isRead, isSent, isViewed, - maxStatus, + isMessageJustForMe, + someRecipientSendStatus, + getHighestSuccessfulRecipientStatus, someSendStatus, } from '../../messages/MessageSendState'; import * as log from '../../logging/log'; @@ -179,7 +178,7 @@ export type GetPropsForBubbleOptions = Readonly<{ callHistorySelector: CallHistorySelectorType; activeCall?: CallStateType; accountSelector: AccountSelectorType; - contactNameColorSelector: ContactNameColorSelectorType; + contactNameColors: Map; defaultConversationColor: DefaultConversationColorType; }>; @@ -581,7 +580,7 @@ export type GetPropsForMessageOptions = Pick< | 'selectedMessageIds' | 'regionCode' | 'accountSelector' - | 'contactNameColorSelector' + | 'contactNameColors' | 'defaultConversationColor' >; @@ -679,7 +678,7 @@ export const getPropsForMessage = ( targetedMessageId, targetedMessageCounter, selectedMessageIds, - contactNameColorSelector, + contactNameColors, defaultConversationColor, } = options; @@ -708,7 +707,7 @@ export const getPropsForMessage = ( ourNumber, ourAci, }); - const contactNameColor = contactNameColorSelector(conversationId, authorId); + const contactNameColor = getContactNameColor(contactNameColors, authorId); const { conversationColor, customColor } = getConversationColorAttributes( conversation, @@ -786,7 +785,7 @@ export const getMessagePropsSelector = createSelector( getUserNumber, getRegionCode, getAccountSelector, - getContactNameColorSelector, + getCachedConversationMemberColorsSelector, getTargetedMessage, getSelectedMessageIds, getDefaultConversationColor, @@ -798,15 +797,18 @@ export const getMessagePropsSelector = createSelector( ourNumber, regionCode, accountSelector, - contactNameColorSelector, + cachedConversationMemberColorsSelector, targetedMessage, selectedMessageIds, defaultConversationColor ) => (message: MessageWithUIFieldsType) => { + const contactNameColors = cachedConversationMemberColorsSelector( + message.conversationId + ); return getPropsForMessage(message, { accountSelector, - contactNameColorSelector, + contactNameColors, conversationSelector, ourConversationId, ourNumber, @@ -1646,14 +1648,9 @@ export function getMessagePropStatus( return sent ? 'viewed' : 'sending'; } - const sendStates = Object.values( + const highestSuccessfulStatus = getHighestSuccessfulRecipientStatus( + sendStateByConversationId, ourConversationId - ? omit(sendStateByConversationId, ourConversationId) - : sendStateByConversationId - ); - const highestSuccessfulStatus = sendStates.reduce( - (result: SendStatus, { status }) => maxStatus(result, status), - SendStatus.Pending ); if ( @@ -1758,8 +1755,8 @@ function canReplyOrReact( MessageWithUIFieldsType, | 'canReplyToStory' | 'deletedForEveryone' - | 'sendStateByConversationId' | 'payment' + | 'sendStateByConversationId' | 'type' >, ourConversationId: string | undefined, @@ -1800,11 +1797,10 @@ function canReplyOrReact( if (isOutgoing(message)) { return ( - isMessageJustForMe(sendStateByConversationId, ourConversationId) || - someSendStatus( - ourConversationId - ? omit(sendStateByConversationId, ourConversationId) - : sendStateByConversationId, + isMessageJustForMe(sendStateByConversationId ?? {}, ourConversationId) || + someRecipientSendStatus( + sendStateByConversationId ?? {}, + ourConversationId, isSent ) ); @@ -1884,7 +1880,7 @@ export function canDeleteForEveryone( // Is it too old to delete? (we relax that requirement in Note to Self) (isMoreRecentThan(message.sent_at, DAY) || isMe) && // Is it sent to anyone? - someSendStatus(message.sendStateByConversationId, isSent) + someSendStatus(message.sendStateByConversationId ?? {}, isSent) ); } @@ -1971,7 +1967,7 @@ const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError'; export const getMessageDetails = createSelector( getAccountSelector, - getContactNameColorSelector, + getCachedConversationMemberColorsSelector, getConversationSelector, getIntl, getRegionCode, @@ -1984,7 +1980,7 @@ export const getMessageDetails = createSelector( getDefaultConversationColor, ( accountSelector, - contactNameColorSelector, + cachedConversationMemberColorsSelector, conversationSelector, i18n, regionCode, @@ -2122,7 +2118,9 @@ export const getMessageDetails = createSelector( errors, message: getPropsForMessage(message, { accountSelector, - contactNameColorSelector, + contactNameColors: cachedConversationMemberColorsSelector( + message.conversationId + ), conversationSelector, ourAci, ourPni, diff --git a/ts/state/selectors/timeline.ts b/ts/state/selectors/timeline.ts index b45768a1ec..8a66077627 100644 --- a/ts/state/selectors/timeline.ts +++ b/ts/state/selectors/timeline.ts @@ -1,15 +1,16 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { useSelector } from 'react-redux'; import type { TimelineItemType } from '../../components/conversation/TimelineItem'; import type { StateType } from '../reducer'; import { - getContactNameColorSelector, getConversationSelector, getTargetedMessage, - getMessages, getSelectedMessageIds, + getMessages, + getCachedConversationMemberColorsSelector, } from './conversations'; import { getAccountSelector } from './accounts'; import { @@ -23,18 +24,20 @@ import { getDefaultConversationColor } from './items'; import { getActiveCall, getCallSelector } from './calling'; import { getPropsForBubble } from './message'; import { getCallHistorySelector } from './callHistory'; +import { useProxySelector } from '../../hooks/useProxySelector'; -export const getTimelineItem = ( +const getTimelineItem = ( state: StateType, - id?: string + messageId: string | undefined, + contactNameColors: Map ): TimelineItemType | undefined => { - if (id === undefined) { + if (messageId === undefined) { return undefined; } const messageLookup = getMessages(state); - const message = messageLookup[id]; + const message = messageLookup[messageId]; if (!message) { return undefined; } @@ -50,7 +53,6 @@ export const getTimelineItem = ( const callHistorySelector = getCallHistorySelector(state); const activeCall = getActiveCall(state); const accountSelector = getAccountSelector(state); - const contactNameColorSelector = getContactNameColorSelector(state); const selectedMessageIds = getSelectedMessageIds(state); const defaultConversationColor = getDefaultConversationColor(state); @@ -63,7 +65,7 @@ export const getTimelineItem = ( regionCode, targetedMessageId: targetedMessage?.id, targetedMessageCounter: targetedMessage?.counter, - contactNameColorSelector, + contactNameColors, callSelector, callHistorySelector, activeCall, @@ -72,3 +74,18 @@ export const getTimelineItem = ( defaultConversationColor, }); }; + +export const useTimelineItem = ( + messageId: string | undefined, + conversationId: string +): TimelineItemType | undefined => { + // Generating contact name colors can take a while in large groups. We don't want to do + // this inside of useProxySelector, since the proxied state invalidates the memoization + // from createSelector. So we do the expensive part outside of useProxySelector, taking + // advantage of reselect's global cache. + const contactNameColors = useSelector( + getCachedConversationMemberColorsSelector + )(conversationId); + + return useProxySelector(getTimelineItem, messageId, contactNameColors); +}; diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index 734f21b377..c07b7896a4 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import { isEmpty, pick } from 'lodash'; -import type { RefObject } from 'react'; import React from 'react'; import { connect } from 'react-redux'; @@ -25,7 +24,7 @@ import { } from '../selectors/conversations'; import { selectAudioPlayerActive } from '../selectors/audioPlayer'; -import { SmartTimelineItem } from './TimelineItem'; +import { SmartTimelineItem, type SmartTimelineItemProps } from './TimelineItem'; import { SmartCollidingAvatars } from './CollidingAvatars'; import type { PropsType as SmartCollidingAvatarsPropsType } from './CollidingAvatars'; import { SmartContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog'; @@ -40,8 +39,6 @@ import { getCollisionsFromMemberships, } from '../../util/groupMemberNameCollisions'; import { ContactSpoofingType } from '../../util/contactSpoofing'; -import type { UnreadIndicatorPlacement } from '../../util/timelineUtil'; -import type { WidthBreakpoint } from '../../components/_util'; import { getPreferredBadgeSelector } from '../selectors/badges'; import { SmartMiniPlayer } from './MiniPlayer'; @@ -58,16 +55,7 @@ function renderItem({ nextMessageId, previousMessageId, unreadIndicatorPlacement, -}: { - containerElementRef: RefObject; - containerWidthBreakpoint: WidthBreakpoint; - conversationId: string; - isOldestTimelineItem: boolean; - messageId: string; - nextMessageId: undefined | string; - previousMessageId: undefined | string; - unreadIndicatorPlacement: undefined | UnreadIndicatorPlacement; -}): JSX.Element { +}: SmartTimelineItemProps): JSX.Element { return ( ; containerWidthBreakpoint: WidthBreakpoint; conversationId: string; @@ -55,8 +54,7 @@ function renderContact(contactId: string): JSX.Element { function renderUniversalTimerNotification(): JSX.Element { return ; } - -export function SmartTimelineItem(props: ExternalProps): JSX.Element { +export function SmartTimelineItem(props: SmartTimelineItemProps): JSX.Element { const { containerElementRef, containerWidthBreakpoint, @@ -73,10 +71,10 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element { const interactionMode = useSelector(getInteractionMode); const theme = useSelector(getTheme); const platform = useSelector(getPlatform); - const item = useProxySelector(getTimelineItem, messageId); - const previousItem = useProxySelector(getTimelineItem, previousMessageId); - const nextItem = useProxySelector(getTimelineItem, nextMessageId); + const item = useTimelineItem(messageId, conversationId); + const previousItem = useTimelineItem(previousMessageId, conversationId); + const nextItem = useTimelineItem(nextMessageId, conversationId); const targetedMessage = useSelector(getTargetedMessage); const isTargeted = Boolean( targetedMessage && messageId === targetedMessage.id diff --git a/ts/state/smart/TypingBubble.tsx b/ts/state/smart/TypingBubble.tsx index 15714d620c..10595db2b0 100644 --- a/ts/state/smart/TypingBubble.tsx +++ b/ts/state/smart/TypingBubble.tsx @@ -7,9 +7,8 @@ import { useSelector } from 'react-redux'; import { TypingBubble } from '../../components/conversation/TypingBubble'; import { useGlobalModalActions } from '../ducks/globalModals'; -import { useProxySelector } from '../../hooks/useProxySelector'; import { getIntl, getTheme } from '../selectors/user'; -import { getTimelineItem } from '../selectors/timeline'; +import { useTimelineItem } from '../selectors/timeline'; import { getConversationSelector, getConversationMessagesSelector, @@ -37,7 +36,7 @@ export function SmartTypingBubble({ conversationId ); const lastMessageId = last(conversationMessages.items); - const lastItem = useProxySelector(getTimelineItem, lastMessageId); + const lastItem = useTimelineItem(lastMessageId, conversationId); let lastItemAuthorId: string | undefined; let lastItemTimestamp: number | undefined; if (lastItem?.data) { diff --git a/ts/test-both/messages/MessageSendState_test.ts b/ts/test-both/messages/MessageSendState_test.ts index adfcc44d7d..e0d8eeca11 100644 --- a/ts/test-both/messages/MessageSendState_test.ts +++ b/ts/test-both/messages/MessageSendState_test.ts @@ -13,6 +13,7 @@ import type { import { SendActionType, SendStatus, + getHighestSuccessfulRecipientStatus, isDelivered, isFailed, isMessageJustForMe, @@ -21,6 +22,7 @@ import { isViewed, maxStatus, sendStateReducer, + someRecipientSendStatus, someSendStatus, } from '../../messages/MessageSendState'; @@ -123,29 +125,37 @@ describe('message send state utilities', () => { }); }); - describe('someSendStatus', () => { + describe('someRecipientSendStatus', () => { + const ourConversationId = uuid(); it('returns false if there are no send states', () => { const alwaysTrue = () => true; - assert.isFalse(someSendStatus(undefined, alwaysTrue)); - assert.isFalse(someSendStatus({}, alwaysTrue)); + assert.isFalse( + someRecipientSendStatus({}, ourConversationId, alwaysTrue) + ); + assert.isFalse(someRecipientSendStatus({}, undefined, alwaysTrue)); }); - it('returns false if no send states match', () => { + it('returns false if no send states match, excluding our own', () => { const sendStateByConversationId: SendStateByConversationId = { abc: { status: SendStatus.Sent, updatedAt: Date.now(), }, def: { + status: SendStatus.Delivered, + updatedAt: Date.now(), + }, + [ourConversationId]: { status: SendStatus.Read, updatedAt: Date.now(), }, }; assert.isFalse( - someSendStatus( + someRecipientSendStatus( sendStateByConversationId, - (status: SendStatus) => status === SendStatus.Delivered + ourConversationId, + (status: SendStatus) => status === SendStatus.Read ) ); }); @@ -160,6 +170,67 @@ describe('message send state utilities', () => { status: SendStatus.Read, updatedAt: Date.now(), }, + [ourConversationId]: { + status: SendStatus.Read, + updatedAt: Date.now(), + }, + }; + + assert.isTrue( + someRecipientSendStatus( + sendStateByConversationId, + ourConversationId, + (status: SendStatus) => status === SendStatus.Read + ) + ); + }); + }); + + describe('someSendStatus', () => { + const ourConversationId = uuid(); + it('returns false if there are no send states', () => { + const alwaysTrue = () => true; + assert.isFalse(someSendStatus({}, alwaysTrue)); + }); + + it('returns false if no send states match', () => { + const sendStateByConversationId: SendStateByConversationId = { + abc: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + def: { + status: SendStatus.Read, + updatedAt: Date.now(), + }, + [ourConversationId]: { + status: SendStatus.Delivered, + updatedAt: Date.now(), + }, + }; + + assert.isFalse( + someSendStatus( + sendStateByConversationId, + (status: SendStatus) => status === SendStatus.Viewed + ) + ); + }); + + it("returns true if at least one send state matches, even if it's ours", () => { + const sendStateByConversationId: SendStateByConversationId = { + abc: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + [ourConversationId]: { + status: SendStatus.Read, + updatedAt: Date.now(), + }, + def: { + status: SendStatus.Delivered, + updatedAt: Date.now(), + }, }; assert.isTrue( @@ -171,11 +242,44 @@ describe('message send state utilities', () => { }); }); + describe('getHighestSuccessfulRecipientStatus', () => { + const ourConversationId = uuid(); + it('returns pending if the conversation has an empty send state', () => { + assert.equal( + getHighestSuccessfulRecipientStatus({}, ourConversationId), + SendStatus.Pending + ); + }); + + it('returns highest status, excluding our conversation', () => { + const sendStateByConversationId: SendStateByConversationId = { + abc: { + status: SendStatus.Sent, + updatedAt: Date.now(), + }, + [ourConversationId]: { + status: SendStatus.Read, + updatedAt: Date.now(), + }, + def: { + status: SendStatus.Delivered, + updatedAt: Date.now(), + }, + }; + assert.equal( + getHighestSuccessfulRecipientStatus( + sendStateByConversationId, + ourConversationId + ), + SendStatus.Delivered + ); + }); + }); + describe('isMessageJustForMe', () => { const ourConversationId = uuid(); it('returns false if the conversation has an empty send state', () => { - assert.isFalse(isMessageJustForMe(undefined, ourConversationId)); assert.isFalse(isMessageJustForMe({}, ourConversationId)); }); @@ -195,6 +299,22 @@ describe('message send state utilities', () => { ourConversationId ) ); + + assert.isFalse( + isMessageJustForMe( + { + [uuid()]: { + status: SendStatus.Pending, + updatedAt: 123, + }, + [ourConversationId]: { + status: SendStatus.Sent, + updatedAt: 123, + }, + }, + ourConversationId + ) + ); // This is an invalid state, but we still want to test the behavior. assert.isFalse( isMessageJustForMe( diff --git a/ts/test-mock/benchmarks/group_send_bench.ts b/ts/test-mock/benchmarks/group_send_bench.ts index 8e372378bb..658d207e5a 100644 --- a/ts/test-mock/benchmarks/group_send_bench.ts +++ b/ts/test-mock/benchmarks/group_send_bench.ts @@ -183,8 +183,10 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise => { debug('waiting for timing from the app'); const { timestamp, delta } = await app.waitForMessageSend(); - // Sleep to allow any receipts from previous rounds to be processed - await sleep(1000); + if (GROUP_DELIVERY_RECEIPTS > 1) { + // Sleep to allow any receipts from previous rounds to be processed + await sleep(1000); + } debug('sending delivery receipts'); receiptsFromPreviousMessage = await Promise.all(