diff --git a/package.json b/package.json index 46a36b9f2a..39fb70465e 100644 --- a/package.json +++ b/package.json @@ -157,7 +157,7 @@ "@react-spring/web": "10.0.3", "@signalapp/lame": "workspace:*", "@signalapp/minimask": "1.0.1", - "@signalapp/mock-server": "22.2.0", + "@signalapp/mock-server": "23.0.0", "@signalapp/parchment-cjs": "3.0.1", "@signalapp/quill-cjs": "2.1.2", "@storybook/addon-a11y": "8.4.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff7497edf3..1fb8eb26a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,8 +147,8 @@ importers: specifier: 1.0.1 version: 1.0.1 '@signalapp/mock-server': - specifier: 22.2.0 - version: 22.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + specifier: 23.0.0 + version: 23.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@signalapp/parchment-cjs': specifier: 3.0.1 version: 3.0.1 @@ -4199,8 +4199,8 @@ packages: '@signalapp/minimask@1.0.1': resolution: {integrity: sha512-QAwo0joA60urTNbW9RIz6vLKQjy+jdVtH7cvY0wD9PVooD46MAjE40MLssp4xUJrph91n2XvtJ3pbEUDrmT2AA==, tarball: https://registry.npmjs.org/@signalapp/minimask/-/minimask-1.0.1.tgz} - '@signalapp/mock-server@22.2.0': - resolution: {integrity: sha512-NGLdxx2iUY6VxZbsISryLXlxXu545UEYRo0W/Ixpi+Ufisw+++fUTLH4gqr8/q69Ft6eGMN6rjCvJ2nkZBSUqw==, tarball: https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-22.2.0.tgz} + '@signalapp/mock-server@23.0.0': + resolution: {integrity: sha512-w+Olk/o6kO0DuerE+PNGFSUp1bzsr6S49nvMv7a9H97WVNMqwqz89fX7I8FYUtQ7XWtNtXWewriIOWEmWSPH9g==, tarball: https://registry.npmjs.org/@signalapp/mock-server/-/mock-server-23.0.0.tgz} '@signalapp/parchment-cjs@3.0.1': resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==, tarball: https://registry.npmjs.org/@signalapp/parchment-cjs/-/parchment-cjs-3.0.1.tgz} @@ -14569,7 +14569,7 @@ snapshots: '@signalapp/minimask@1.0.1': {} - '@signalapp/mock-server@22.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + '@signalapp/mock-server@23.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)': dependencies: '@indutny/parallel-prettier': 3.0.0(prettier@3.8.3) '@indutny/protopiler': 4.0.0 diff --git a/ts/models/conversations.preload.ts b/ts/models/conversations.preload.ts index c7074e2f7e..4703a5d40d 100644 --- a/ts/models/conversations.preload.ts +++ b/ts/models/conversations.preload.ts @@ -5087,7 +5087,7 @@ export class ConversationModel { ): Promise { await markConversationRead(this.attributes, readMessage, options); this.throttledUpdateUnread(); - window.reduxActions.callHistory.updateCallHistoryUnreadCount(); + window.reduxActions.callHistory.updateCallHistoryUnreadCount([]); } async #updateUnread(): Promise { diff --git a/ts/services/calling.preload.ts b/ts/services/calling.preload.ts index 12f566eb34..68e0d63072 100644 --- a/ts/services/calling.preload.ts +++ b/ts/services/calling.preload.ts @@ -1255,6 +1255,31 @@ class CallingClass { return call instanceof GroupCall ? call : undefined; } + public getActiveCallIds(): Set { + const callIds = new Set(); + + for (const call of Object.values(this.#callsLookup)) { + if (call instanceof Call) { + callIds.add(call.callId.toString()); + } else if (call instanceof GroupCall) { + const callId = call.getCallId(); + if (callId != null) { + callIds.add(callId.toString()); + } + } else { + throw missingCaseError(call); + } + } + return callIds; + } + + public isCallActive(conversationId: string): boolean { + return ( + this.#getDirectCall(conversationId) != null || + this.#getGroupCall(conversationId) != null + ); + } + #getGroupCallMembers(conversationId: string) { return getMembershipList(conversationId).map( member => @@ -3172,19 +3197,21 @@ class CallingClass { Proto.CallMessage.Offer.Type.OFFER_VIDEO_CALL; const peerId = getPeerIdFromConversation(conversation.attributes); - const callDetails = getCallDetailsFromEndedDirectCall( - callId.toString(), + const eventTimestamp = envelope.timestamp; + const callDetails = getCallDetailsFromEndedDirectCall({ + callId, peerId, - remoteUserId, // Incoming call + ringerId: remoteUserId, // Incoming call wasVideoCall, - envelope.timestamp - ); + eventTimestamp, + }); const localCallEvent = LocalCallEvent.Missed; - const callEvent = getCallEventDetails( + const callEvent = getCallEventDetails({ callDetails, - localCallEvent, - 'CallingClass.handleCallingMessage' - ); + event: localCallEvent, + eventSource: 'CallingClass.handleCallingMessage', + eventTimestamp, + }); const { receivedAtCounter, receivedAtDate: receivedAtMS, @@ -3459,9 +3486,14 @@ class CallingClass { const localEventFromRing = getLocalCallEventFromRingUpdate(update); if (localEventFromRing != null) { const callId = getCallIdFromRing(ringId); - const callDetails = getCallDetailsFromGroupCallMeta(groupId, { - callId, - ringerId: ringerAci, + const eventTimestamp = Date.now(); + const callDetails = getCallDetailsFromGroupCallMeta({ + peerId: groupId, + groupCallMeta: { + callId, + ringerId: ringerAci, + }, + eventTimestamp, }); let localEventForCall; if (localEventFromRing === LocalCallEvent.Missed) { @@ -3471,11 +3503,12 @@ class CallingClass { ? LocalCallEvent.Ringing : LocalCallEvent.Started; } - const callEvent = getCallEventDetails( + const callEvent = getCallEventDetails({ callDetails, - localEventForCall, - 'CallingClass.handleGroupCallRingUpdate' - ); + event: localEventForCall, + eventSource: 'CallingClass.handleGroupCallRingUpdate', + eventTimestamp, + }); await updateCallHistoryFromLocalEvent({ callEvent, }); @@ -3567,12 +3600,18 @@ class CallingClass { const localCallEvent = LocalCallEvent.Missed; const peerId = getPeerIdFromConversation(conversation.attributes); - const callDetails = getCallDetailsFromDirectCall(peerId, call); - const callEvent = getCallEventDetails( + const eventTimestamp = Date.now(); + const callDetails = getCallDetailsFromDirectCall({ + peerId, + call, + eventTimestamp, + }); + const callEvent = getCallEventDetails({ callDetails, - localCallEvent, - 'CallingClass.handleIncomingCall' - ); + event: localCallEvent, + eventSource: 'CallingClass.handleIncomingCall', + eventTimestamp, + }); await updateCallHistoryFromLocalEvent({ callEvent, }); @@ -3604,7 +3643,7 @@ class CallingClass { } async #handleRejectedIncomingCallRequest( - callIdValue: CallId, + callId: CallId, remoteUserId: UserId, callRejectReason: CallRejectReason, ageInSeconds: number, @@ -3629,7 +3668,6 @@ class CallingClass { const callEndedReason = this.#convertRingRtcCallRejectReason(callRejectReason); - const callId = callIdValue.toString(); const peerId = getPeerIdFromConversation(conversation.attributes); // This is extra defensive, just in case RingRTC passes us a bad value. (It probably @@ -3638,22 +3676,23 @@ class CallingClass { isNormalNumber(ageInSeconds) && ageInSeconds >= 0 ? ageInSeconds * durations.SECOND : 0; - const timestamp = Date.now() - ageInMilliseconds; + const eventTimestamp = Date.now() - ageInMilliseconds; - const callDetails = getCallDetailsFromEndedDirectCall( + const callDetails = getCallDetailsFromEndedDirectCall({ callId, peerId, - remoteUserId, + ringerId: remoteUserId, wasVideoCall, - timestamp - ); + eventTimestamp, + }); // We classify all of the call rejection events as 'Missed'. - const callEvent = getCallEventDetails( + const callEvent = getCallEventDetails({ callDetails, - LocalCallEvent.Missed, - 'CallingClass.handleRejectedIncomingCallRequest' - ); + event: LocalCallEvent.Missed, + eventSource: 'CallingClass.handleRejectedIncomingCallRequest', + eventTimestamp, + }); if (callRejectReason === CallRejectReason.ReceivedOfferWhileActive) { // This is a special case where we won't update our local call, because we have @@ -3731,12 +3770,18 @@ class CallingClass { ); if (localCallEvent != null) { const peerId = getPeerIdFromConversation(conversation.attributes); - const callDetails = getCallDetailsFromDirectCall(peerId, call); - const callEvent = getCallEventDetails( + const eventTimestamp = Date.now(); + const callDetails = getCallDetailsFromDirectCall({ + peerId, + call, + eventTimestamp, + }); + const callEvent = getCallEventDetails({ callDetails, - localCallEvent, - 'call.handleStateChanged' - ); + event: localCallEvent, + eventSource: 'call.handleStateChanged', + eventTimestamp, + }); await updateCallHistoryFromLocalEvent({ callEvent, }); @@ -4104,12 +4149,18 @@ class CallingClass { return; } - const callDetails = getCallDetailsForAdhocCall(roomId, callId); - const callEvent = getCallEventDetails( + const eventTimestamp = Date.now(); + const callDetails = getCallDetailsForAdhocCall({ + peerId: roomId, + callId, + eventTimestamp, + }); + const callEvent = getCallEventDetails({ callDetails, - localCallEvent, - 'CallingClass.updateCallHistoryForAdhocCall' - ); + event: localCallEvent, + eventSource: 'CallingClass.updateCallHistoryForAdhocCall', + eventTimestamp: callDetails.timestamp, + }); await updateAdhocCallHistory(callEvent); } catch (error) { log.error( @@ -4146,15 +4197,18 @@ class CallingClass { ); const peerId = getPeerIdFromConversation(conversation.attributes); - const callDetails = getCallDetailsFromGroupCallMeta( + const eventTimestamp = Date.now(); + const callDetails = getCallDetailsFromGroupCallMeta({ peerId, - groupCallMeta - ); - const callEvent = getCallEventDetails( + groupCallMeta, + eventTimestamp, + }); + const callEvent = getCallEventDetails({ callDetails, - localCallEvent, - 'CallingClass.updateCallHistoryForGroupCallOnLocalChanged' - ); + event: localCallEvent, + eventSource: 'CallingClass.updateCallHistoryForGroupCallOnLocalChanged', + eventTimestamp, + }); await updateCallHistoryFromLocalEvent({ callEvent, }); @@ -4202,16 +4256,19 @@ class CallingClass { groupCallMeta ); if (localCallEvent != null) { + const eventTimestamp = Date.now(); const peerId = getPeerIdFromConversation(conversation.attributes); - const callDetails = getCallDetailsFromGroupCallMeta( + const callDetails = getCallDetailsFromGroupCallMeta({ peerId, - groupCallMeta - ); - const callEvent = getCallEventDetails( + groupCallMeta, + eventTimestamp, + }); + const callEvent = getCallEventDetails({ callDetails, - localCallEvent, - 'CallingClass.updateCallHistoryForGroupCallOnPeek' - ); + event: localCallEvent, + eventSource: 'CallingClass.updateCallHistoryForGroupCallOnPeek', + eventTimestamp, + }); await updateCallHistoryFromLocalEvent({ callEvent, }); diff --git a/ts/sql/Interface.std.ts b/ts/sql/Interface.std.ts index c57a72679f..25662b4f33 100644 --- a/ts/sql/Interface.std.ts +++ b/ts/sql/Interface.std.ts @@ -984,6 +984,7 @@ type ReadableInterface = { conversationId: string; }) => MessageType | undefined; getAllCallHistory: () => ReadonlyArray; + getCallHistoryUnreadCallConversationIds: () => ReadonlyArray; getCallHistoryUnreadCount: () => number; getCallHistoryMessageByCallId: (options: { conversationId: string; @@ -1231,6 +1232,11 @@ type WritableInterface = { readAt?: number; storyId?: string; }) => GetUnreadByConversationAndMarkReadResultType; + getUnreadCallMessagesAndMarkRead: (options: { + conversationId: string; + readMessageReceivedAt: number; + activeCallIds: Set; + }) => GetUnreadByConversationAndMarkReadResultType; getUnreadEditedMessagesAndMarkRead: (options: { conversationId: string; readMessageReceivedAt: number; @@ -1275,8 +1281,16 @@ type WritableInterface = { markCallHistoryDeleted: (callId: string) => void; cleanupCallHistoryMessages: () => void; markCallHistoryRead: (callId: string) => void; - markAllCallHistoryRead: (target: CallLogEventTarget) => number; - markAllCallHistoryReadInConversation: (target: CallLogEventTarget) => number; + markAllCallHistoryRead: ( + target: CallLogEventTarget, + readAt: number, + activeCallIds: Set + ) => number; + markAllCallHistoryReadInConversation: ( + target: CallLogEventTarget, + readAt: number, + activeCallIds: Set + ) => number; saveCallHistory: (callHistory: CallHistoryDetails) => void; markCallHistoryMissed: (callIds: ReadonlyArray) => void; getRecentStaleRingsAndMarkOlderMissed: () => ReadonlyArray; diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts index 2e449da72d..12f67c6fc9 100644 --- a/ts/sql/Server.node.ts +++ b/ts/sql/Server.node.ts @@ -486,6 +486,7 @@ export const DataReader: ServerReadableInterface = { getConversationMessageStats, getLastConversationMessage, getAllCallHistory, + getCallHistoryUnreadCallConversationIds, getCallHistoryUnreadCount, getCallHistoryMessageByCallId, getCallHistory, @@ -646,6 +647,8 @@ export const DataWriter: ServerWritableInterface = { updateAllConversationColors, removeAllProfileKeyCredentials, getUnreadByConversationAndMarkRead, + getUnreadCallMessagesAndMarkRead, + getUnreadEditedMessagesAndMarkRead, getUnreadReactionsAndMarkRead, getUnreadPollVotesAndMarkRead, @@ -664,7 +667,6 @@ export const DataWriter: ServerWritableInterface = { _removeAllReactions, _removeAllMessages, _removeMessage: removeMessage, - getUnreadEditedMessagesAndMarkRead, clearCallHistory, _removeAllCallHistory, markCallHistoryDeleted, @@ -3620,7 +3622,8 @@ function getUnreadByConversationAndMarkRead( } ): GetUnreadByConversationAndMarkReadResultType { return db.transaction(() => { - const expirationStartTimestamp = Math.min(now, readAt ?? Infinity); + const expirationStartTimestamp = + readAt != null ? Math.min(now, readAt) : now; const { predicate: storyReplyFilter, isFilteringOnStoryId } = _storyIdPredicateAndInfo(storyId, includeStoryReplies); @@ -3634,6 +3637,7 @@ function getUnreadByConversationAndMarkRead( conversationId = ${conversationId} AND ${storyReplyFilter} AND type IS NOT 'outgoing' AND + type IS NOT 'call-history' AND hasExpireTimer IS 1 AND received_at <= ${readMessageReceivedAt} `; @@ -4842,13 +4846,25 @@ const CALL_STATUS_INCOMING = sqlConstant(CallDirection.Incoming); const CALL_MODE_ADHOC = sqlConstant(CallMode.Adhoc); const FOUR_HOURS_IN_MS = sqlConstant(4 * 60 * 60 * 1000); +function getCallHistoryUnreadCallConversationIds( + db: ReadableDB +): ReadonlyArray { + const [query, params] = sql` + SELECT DISTINCT(messages.conversationId) FROM messages + INNER JOIN callsHistory ON callsHistory.callId = messages.callId + WHERE messages.type IS 'call-history' + AND messages.seenStatus IS ${SEEN_STATUS_UNSEEN} + AND callsHistory.direction IS ${CALL_STATUS_INCOMING} + `; + return db.prepare(query, { pluck: true }).all(params); +} + function getCallHistoryUnreadCount(db: ReadableDB): number { const [query, params] = sql` SELECT count(*) FROM messages INNER JOIN callsHistory ON callsHistory.callId = messages.callId WHERE messages.type IS 'call-history' AND messages.seenStatus IS ${SEEN_STATUS_UNSEEN} - AND callsHistory.status IS ${CALL_STATUS_MISSED} AND callsHistory.direction IS ${CALL_STATUS_INCOMING} `; const row = db @@ -5017,6 +5033,8 @@ function getMessageReceivedAtForCall( export function markAllCallHistoryRead( db: WritableDB, target: CallLogEventTarget, + readAt: number, + activeCallIds: Set, inConversation = false ): number { return db.transaction(() => { @@ -5089,15 +5107,32 @@ export function markAllCallHistoryRead( `; const result = db.prepare(updateQuery).run(updateParams); + + const [updateExpirationQuery, updateExpirationParams] = sql` + UPDATE messages + SET + expirationStartTimestamp = ${readAt} + WHERE messages.type IS 'call-history' + AND ${predicate} + AND messages.seenStatus IS ${SEEN_STATUS_SEEN} + AND messages.received_at <= ${receivedAt} + AND hasExpireTimer IS 1 + AND expirationStartTimestamp IS NULL + AND messages.callId NOT IN (${sqlJoin(Array.from(activeCallIds))}) + `; + db.prepare(updateExpirationQuery).run(updateExpirationParams); + return result.changes; })(); } function markAllCallHistoryReadInConversation( db: WritableDB, - target: CallLogEventTarget + target: CallLogEventTarget, + readAt: number, + activeCallIds: Set ): number { - return markAllCallHistoryRead(db, target, true); + return markAllCallHistoryRead(db, target, readAt, activeCallIds, true); } function getCallHistoryGroupData( @@ -9478,6 +9513,40 @@ function _getAllEditedMessages( .all({}); } +function getUnreadCallMessagesAndMarkRead( + db: WritableDB, + { + conversationId, + readMessageReceivedAt, + activeCallIds, + }: { + conversationId: string; + readMessageReceivedAt: number; + activeCallIds: Set; + } +): GetUnreadByConversationAndMarkReadResultType { + const [query, params] = sql` + UPDATE messages + SET + expirationStartTimestamp = ${readMessageReceivedAt} + WHERE type IS 'call-history' + AND messages.conversationId IS ${conversationId} + AND messages.seenStatus IS ${SEEN_STATUS_SEEN} + AND messages.received_at <= ${readMessageReceivedAt} + AND messages.hasExpireTimer IS 1 + AND messages.expirationStartTimestamp IS NULL + AND messages.callId NOT IN (${sqlJoin(Array.from(activeCallIds))}) + RETURNING * + `; + + const rows = db.prepare(query).all(params); + const messages = hydrateMessages(db, rows); + + return messages.map(message => { + return { ...message, originalReadStatus: undefined }; + }); +} + function getUnreadEditedMessagesAndMarkRead( db: WritableDB, { diff --git a/ts/state/ducks/callHistory.preload.ts b/ts/state/ducks/callHistory.preload.ts index 75a21d702b..a4d36b80cb 100644 --- a/ts/state/ducks/callHistory.preload.ts +++ b/ts/state/ducks/callHistory.preload.ts @@ -36,6 +36,7 @@ import { getIntl } from '../selectors/user.std.ts'; import type { ShowErrorModalActionType } from './globalModals.preload.ts'; import { SHOW_ERROR_MODAL } from './globalModals.preload.ts'; import type { ErrorModalDataProps } from '../../components/ErrorModal.dom.tsx'; +import { strictAssert } from '../../util/assert.std.ts'; const { debounce, omit } = lodash; @@ -114,13 +115,21 @@ const updateCallHistoryUnreadCountDebounced = debounce( 300 ); -function updateCallHistoryUnreadCount(): ThunkAction< - void, - RootStateType, - unknown, - CallHistoryUpdateUnread -> { +function updateConversationsUnreadCounts( + conversationIdsOrConversationPeerIds: ReadonlyArray +) { + for (const id of conversationIdsOrConversationPeerIds) { + const conversation = window.ConversationController.get(id); + strictAssert(conversation, `Missing conversation: ${id}`); + conversation.throttledUpdateUnread(); + } +} + +function updateCallHistoryUnreadCount( + conversationIdsOrConversationPeerIds: ReadonlyArray +): ThunkAction { return async dispatch => { + updateConversationsUnreadCounts(conversationIdsOrConversationPeerIds); await updateCallHistoryUnreadCountDebounced(dispatch); }; } @@ -132,16 +141,13 @@ function markCallHistoryRead( return async dispatch => { try { await DataWriter.markCallHistoryRead(callId); - window.ConversationController.get( - conversationId - )?.throttledUpdateUnread(); } catch (error) { log.error( 'markCallHistoryRead: Error marking call history read', Errors.toLogFormat(error) ); } finally { - dispatch(updateCallHistoryUnreadCount()); + dispatch(updateCallHistoryUnreadCount([conversationId])); } }; } @@ -158,7 +164,7 @@ export function markCallHistoryReadInConversation( try { await markAllCallHistoryReadAndSync(callHistory, true); } finally { - dispatch(updateCallHistoryUnreadCount()); + dispatch(updateCallHistoryUnreadCount([callHistory.peerId])); } }; } @@ -171,9 +177,12 @@ function markCallsTabViewed(): ThunkAction< > { return async (dispatch, getState) => { const latestCall = getCallHistoryLatestCall(getState()); + if (latestCall != null) { + const conversationIds = + await DataReader.getCallHistoryUnreadCallConversationIds(); await markAllCallHistoryReadAndSync(latestCall, false); - dispatch(updateCallHistoryUnreadCount()); + dispatch(updateCallHistoryUnreadCount(conversationIds)); } }; } @@ -207,13 +216,17 @@ function clearAllCallHistory(): ThunkAction< CallHistoryReset | ToastActionType | ShowErrorModalActionType > { return async (dispatch, getState) => { + let unreadConversationIds: ReadonlyArray = []; try { const latestCall = getCallHistoryLatestCall(getState()); if (latestCall == null) { return; } + unreadConversationIds = + await DataReader.getCallHistoryUnreadCallConversationIds(); const result = await clearCallHistoryDataAndSync(latestCall); + if (result === ClearCallHistoryResult.Success) { dispatch(showToast({ toastType: ToastType.CallHistoryCleared })); } else if (result === ClearCallHistoryResult.Error) { @@ -241,6 +254,9 @@ function clearAllCallHistory(): ThunkAction< } catch (error) { log.error('Error clearing call history', Errors.toLogFormat(error)); } finally { + // Ensure previously unread conversations are updated + updateConversationsUnreadCounts(unreadConversationIds); + // Just force a reload, even if the clear failed. dispatch(reloadCallHistory()); } diff --git a/ts/test-mock/bootstrap.node.ts b/ts/test-mock/bootstrap.node.ts index 8c2854e5a7..3a9a006492 100644 --- a/ts/test-mock/bootstrap.node.ts +++ b/ts/test-mock/bootstrap.node.ts @@ -970,7 +970,7 @@ export class Bootstrap { serverUrl: url, storageUrl: `${url}/storageService`, resourcesUrl: `${url}/updates2`, - sfuUrl: url, + sfuUrl: `${url}/callingService`, cdn: { '0': url, '2': url, diff --git a/ts/test-mock/calling/callHistory_expiration_test.node.ts b/ts/test-mock/calling/callHistory_expiration_test.node.ts new file mode 100644 index 0000000000..8ba662d104 --- /dev/null +++ b/ts/test-mock/calling/callHistory_expiration_test.node.ts @@ -0,0 +1,610 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import createDebug from 'debug'; +import type { Page } from 'playwright'; +import { expect } from 'playwright/test'; +import type { Group, PrimaryDevice } from '@signalapp/mock-server'; +import { + Proto, + StorageState, + EMPTY_DATA_MESSAGE, + SignalingProto, +} from '@signalapp/mock-server'; +import * as Bytes from '../../Bytes.std.ts'; +import { uuidToBytes } from '../../util/uuidToBytes.std.ts'; +import * as durations from '../../util/durations/index.std.ts'; +import { Bootstrap } from '../bootstrap.node.ts'; +import type { App } from '../bootstrap.node.ts'; +import { createGroup } from '../helpers.node.ts'; + +const debug = createDebug('mock:test:calling:callHistoryExpiration'); + +const EXPIRE_TIMER_SECONDS: 1 | 5 = 1; +const EXPIRE_TIMER_MS = EXPIRE_TIMER_SECONDS * 1000; +const WAIT_FOR_SLOW_EXPIRE_TIMER_MS = EXPIRE_TIMER_MS * 2; + +function delay(ms: number) { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} + +function $navTab(page: Page, name: string) { + return page.getByRole('tab', { name }); +} + +function $navTabUnreadBadge(page: Page, name: string) { + return $navTab(page, name).locator('.NavTabs__ItemIconLabel'); +} + +function $navSidebar(page: Page) { + return page.locator('.NavSidebar'); +} + +function $chatListItem(page: Page, id: string) { + return $navSidebar(page).getByTestId(id); +} + +function $callListItem(page: Page, title: string) { + return $navSidebar(page).locator('.CallsList__Item', { hasText: title }); +} + +function $unreadBadge(page: Page, id: string) { + return $chatListItem(page, id) + .locator( + '.module-conversation-list__item--contact-or-conversation__content' + ) + .locator( + '.module-conversation-list__item--contact-or-conversation__unread-indicator--unread-messages' + ); +} + +function $systemMessage(page: Page) { + return page.locator('.SystemMessage__contents'); +} + +function $incomingCall(page: Page) { + return $systemMessage(page).getByText('Incoming voice call'); +} + +function $missedCall(page: Page) { + return $systemMessage(page).getByText('Missed voice call'); +} + +function $groupCallEnded(page: Page) { + return $systemMessage(page).getByText('The video call has ended'); +} + +function $missedGroupCall(page: Page) { + return $systemMessage(page).getByText('Missed video call'); +} + +function $youStartedCall(page: Page) { + return $systemMessage(page).getByText('You started a video call'); +} + +describe('calling/callHistoryExpiration', function (this: Mocha.Suite) { + this.timeout(durations.MINUTE); + + let bootstrap: Bootstrap; + let app: App; + let contact: PrimaryDevice; + let group: Group; + + beforeEach(async () => { + bootstrap = new Bootstrap({ contactCount: 0 }); + await bootstrap.init(); + + const { server, phone } = bootstrap; + + contact = await server.createPrimaryDevice({ profileName: 'Alice' }); + + let state = StorageState.getEmpty(); + state = state.updateAccount({ + profileKey: phone.profileKey.serialize(), + givenName: phone.profileName, + }); + state = state.addContact(contact, { + identityKey: contact.publicKey.serialize(), + profileKey: contact.profileKey.serialize(), + whitelisted: true, + }); + state = state.pin(contact); + await phone.setStorageState(state); + + group = await createGroup(phone, [contact], 'Test Group'); + + app = await bootstrap.link(); + + const { desktop } = bootstrap; + const ourKey = await desktop.popSingleUseKey(); + await contact.addSingleUseKey(desktop, ourKey); + }); + + afterEach(async function (this: Mocha.Context) { + await bootstrap.maybeSaveLogs(this.currentTest, app); + await app.close(); + await bootstrap.teardown(); + }); + + async function setExpireTimer() { + const { desktop } = bootstrap; + + const key = await desktop.popSingleUseKey(); + await contact.addSingleUseKey(desktop, key); + + const timestamp = bootstrap.getTimestamp(); + const content: Proto.Content.Params = { + content: { + dataMessage: { + ...EMPTY_DATA_MESSAGE, + flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + expireTimer: EXPIRE_TIMER_SECONDS, + expireTimerVersion: 1, + timestamp: BigInt(timestamp), + }, + }, + pniSignatureMessage: null, + senderKeyDistributionMessage: null, + }; + await contact.sendRaw(desktop, content, { timestamp }); + } + + async function setGroupExpireTimer() { + const { desktop, phone } = bootstrap; + + await phone.modifyGroupDisappearingMessageTimer( + group, + EXPIRE_TIMER_SECONDS, + { + timestamp: bootstrap.getTimestamp(), + sendUpdateTo: [{ device: desktop }], + } + ); + } + + async function sendCallEventSync(params: { + callTimestamp: number; + wasAccepted: boolean; + }) { + const { desktop, phone } = bootstrap; + const content: Proto.Content.Params = { + content: { + syncMessage: { + content: { + callEvent: { + conversationId: uuidToBytes(contact.device.aci), + callId: BigInt(params.callTimestamp), + timestamp: BigInt(params.callTimestamp), + type: Proto.SyncMessage.CallEvent.Type.AUDIO_CALL, + direction: Proto.SyncMessage.CallEvent.Direction.INCOMING, + event: params.wasAccepted + ? Proto.SyncMessage.CallEvent.Event.ACCEPTED + : Proto.SyncMessage.CallEvent.Event.NOT_ACCEPTED, + }, + }, + read: null, + stickerPackOperation: null, + viewed: null, + padding: null, + }, + }, + pniSignatureMessage: null, + senderKeyDistributionMessage: null, + }; + await phone.sendRaw(desktop, content, { timestamp: params.callTimestamp }); + } + + async function sendGroupCallMessageOffer(params: { callTimestamp: number }) { + const { callTimestamp } = params; + const { desktop } = bootstrap; + + const content: Proto.Content.Params = { + content: { + callMessage: { + offer: { + id: BigInt(callTimestamp), + type: Proto.CallMessage.Offer.Type.OFFER_VIDEO_CALL, + opaque: null, + }, + answer: null, + iceUpdate: null, + busy: null, + hangup: null, + destinationDeviceId: null, + opaque: { + urgency: null, + data: SignalingProto.CallMessage.encode({ + ringIntention: { + type: SignalingProto.CallMessage.RingIntention.Type.RING, + groupId: Buffer.from(group.id, 'base64'), + ringId: BigInt(callTimestamp), + }, + groupCallMessage: { + groupId: Buffer.from(group.id, 'base64'), + }, + }), + }, + }, + }, + pniSignatureMessage: null, + senderKeyDistributionMessage: null, + }; + + await contact.sendRaw(desktop, content, { + timestamp: callTimestamp, + sealed: true, + group, + }); + } + + async function sendCallLogEventSync(params: { + callTimestamp: number; + groupCall?: boolean; + }) { + const { desktop, phone } = bootstrap; + const timestamp = Date.now(); + const conversationId = params.groupCall + ? Bytes.fromBase64(group.id) + : uuidToBytes(contact.device.aci); + const content: Proto.Content.Params = { + content: { + syncMessage: { + content: { + callLogEvent: { + type: Proto.SyncMessage.CallLogEvent.Type + .MARKED_AS_READ_IN_CONVERSATION, + timestamp: BigInt(params.callTimestamp), + conversationId, + callId: BigInt(params.callTimestamp), + }, + }, + read: null, + stickerPackOperation: null, + viewed: null, + padding: null, + }, + }, + pniSignatureMessage: null, + senderKeyDistributionMessage: null, + }; + await phone.sendRaw(desktop, content, { timestamp }); + } + + describe('1:1 calls', () => { + it('expire missed call: read by opening chat', async () => { + const page = await app.getWindow(); + const chatListItem = $chatListItem(page, contact.device.aci); + const unreadBadge = $unreadBadge(page, contact.device.aci); + + debug('waiting for chat list item'); + await expect(chatListItem).toBeVisible(); + + debug('setting expire timer via contact message'); + await setExpireTimer(); + await expect(unreadBadge).toContainText('1'); + await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible(); + await expect(chatListItem).toContainText(/Timer set to \d seconds?/); + + debug('sending incoming missed call event sync'); + const callTimestamp = bootstrap.getTimestamp(); + await sendCallEventSync({ callTimestamp, wasAccepted: false }); + await expect(unreadBadge).toContainText('1'); + await expect($navTabUnreadBadge(page, 'Calls')).toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1'); + await expect(chatListItem).toContainText('Missed voice call'); + + debug('wait to make sure message does not expire before its been read'); + await delay(WAIT_FOR_SLOW_EXPIRE_TIMER_MS); + await expect(unreadBadge).toContainText('1'); + await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1'); + await expect(chatListItem).toContainText('Missed voice call'); + + debug('checking the call doesnt need to be the latest message'); + await contact.sendText(bootstrap.desktop, 'Another message'); + await expect(unreadBadge).toContainText('2'); + await expect(chatListItem).toContainText('Another message'); + + debug('opening conversation to mark read'); + await chatListItem.click(); + await expect(unreadBadge).not.toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible(); + await expect($missedCall(page)).toBeVisible(); + + debug('waiting for message to expire'); + await delay(EXPIRE_TIMER_MS); + await expect($missedCall(page)).not.toBeVisible(); + }); + + it('missed call is expired by navigating to calls tab', async () => { + const page = await app.getWindow(); + const chatListItem = $chatListItem(page, contact.device.aci); + const unreadBadge = $unreadBadge(page, contact.device.aci); + + debug('waiting for chat list item'); + await expect(chatListItem).toBeVisible(); + + debug('setting expire timer via contact message'); + await setExpireTimer(); + await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible(); + await expect(chatListItem).toContainText(/Timer set to \d seconds?/); + + debug('sending incoming missed call event sync'); + const callTimestamp = bootstrap.getTimestamp(); + await sendCallEventSync({ callTimestamp, wasAccepted: false }); + await expect(unreadBadge).toContainText('1'); + await expect($navTabUnreadBadge(page, 'Calls')).toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1'); + await expect(chatListItem).toContainText('Missed voice call'); + + debug('navigating to calls tab'); + await $navTab(page, 'Calls').click(); + + debug('clicking on the call to mark it read'); + await $callListItem(page, 'Alice').click(); + await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible(); + + debug('navigating back to chats tab'); + await $navTab(page, 'Chats').click(); + await expect(chatListItem).toContainText('Missed voice call'); + + debug('waiting for missed call to expire'); + await delay(EXPIRE_TIMER_MS); + await expect(chatListItem).toContainText(/Timer set to \d seconds?/); + + debug('checking in conversation'); + await chatListItem.click(); + await expect($missedCall(page)).not.toBeVisible(); + }); + + it('expire missed call: read via CallLogEvent sync', async () => { + const page = await app.getWindow(); + const chatListItem = $chatListItem(page, contact.device.aci); + const unreadBadge = $unreadBadge(page, contact.device.aci); + + debug('waiting for chat list item'); + await expect(chatListItem).toBeVisible(); + + debug('setting expire timer via contact message'); + await setExpireTimer(); + await expect(unreadBadge).toContainText('1'); + await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible(); + await expect(chatListItem).toContainText(/Timer set to \d seconds?/); + + debug('sending incoming missed call event sync'); + const callTimestamp = bootstrap.getTimestamp(); + await sendCallEventSync({ callTimestamp, wasAccepted: false }); + + debug('waiting for missed call unread badge'); + await expect(unreadBadge).toContainText('1'); + await expect($navTabUnreadBadge(page, 'Calls')).toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1'); + await expect(chatListItem).toContainText('Missed voice call'); + + debug('marking calls read in conversation via sync'); + await sendCallLogEventSync({ callTimestamp }); + await expect(unreadBadge).toContainText('1'); + await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible(); + await expect(chatListItem).toContainText('Missed voice call'); + + debug('waiting for message to expire'); + await delay(EXPIRE_TIMER_MS); + await expect(chatListItem).toContainText(/Timer set to \d seconds?/); + + debug('checking in conversation'); + await chatListItem.click(); + await expect($missedCall(page)).not.toBeVisible(); + }); + + it('expire accepted call', async () => { + const page = await app.getWindow(); + const chatListItem = $chatListItem(page, contact.device.aci); + const unreadBadge = $unreadBadge(page, contact.device.aci); + + debug('waiting for chat list item'); + await expect(chatListItem).toBeVisible(); + + debug('setting expire timer via contact message'); + await setExpireTimer(); + await expect(unreadBadge).toContainText('1'); + await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible(); + await expect(chatListItem).toContainText(/Timer set to \d seconds?/); + + debug('sending incoming missed call event sync'); + const callTimestamp = bootstrap.getTimestamp(); + await sendCallEventSync({ callTimestamp, wasAccepted: false }); + await expect(unreadBadge).toContainText('1'); + await expect($navTabUnreadBadge(page, 'Calls')).toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1'); + await expect(chatListItem).toContainText('Missed voice call'); + + debug('marking call event accepted from sync'); + await sendCallEventSync({ callTimestamp, wasAccepted: true }); + await expect(unreadBadge).toContainText('1'); + await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible(); + await expect(chatListItem).toContainText(/Timer set to \d seconds?/); + + debug('waiting for message to expire'); + await delay(EXPIRE_TIMER_MS); + + debug('checking in conversation'); + await chatListItem.click(); + await expect($incomingCall(page)).not.toBeVisible(); + }); + }); + + describe('group calls', () => { + it('expire missed call: read by opening chat', async () => { + const page = await app.getWindow(); + const chatListItem = $chatListItem(page, group.id); + const unreadBadge = $unreadBadge(page, group.id); + + debug('waiting for group chat list item'); + await expect(chatListItem).toBeVisible(); + + debug('setting expire timer for group'); + await setGroupExpireTimer(); + await expect(unreadBadge).not.toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible(); + await expect(chatListItem).toContainText(/Timer set to \d seconds?/); + + debug('sending incoming missed group call via offer'); + const callTimestamp = bootstrap.getTimestamp(); + await sendGroupCallMessageOffer({ callTimestamp }); + await expect(unreadBadge).not.toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1'); + await expect(chatListItem).toContainText('The video call has ended'); + + debug('wait to make sure message does not expire before its been read'); + await delay(WAIT_FOR_SLOW_EXPIRE_TIMER_MS); + await expect(unreadBadge).not.toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1'); + await expect(chatListItem).toContainText('The video call has ended'); + + debug('opening group conversation'); + await chatListItem.click(); + await expect($groupCallEnded(page)).toBeVisible(); + await expect(unreadBadge).not.toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible(); + + debug('waiting for message to expire'); + await delay(EXPIRE_TIMER_MS); + + await expect($groupCallEnded(page)).not.toBeVisible(); + await expect(chatListItem).toContainText(/Timer set to \d seconds?/); + }); + + it('missed call is expired by navigating to calls tab', async () => { + const page = await app.getWindow(); + const chatListItem = $chatListItem(page, group.id); + const unreadBadge = $unreadBadge(page, group.id); + + debug('waiting for group chat list item'); + await expect(chatListItem).toBeVisible(); + + debug('setting expire timer via group state'); + await setGroupExpireTimer(); + await expect(unreadBadge).not.toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible(); + await expect(chatListItem).toContainText(/Timer set to \d seconds?/); + + debug('sending incoming missed group call via offer'); + const callTimestamp = bootstrap.getTimestamp(); + await sendGroupCallMessageOffer({ callTimestamp }); + await expect(unreadBadge).not.toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1'); + await expect(chatListItem).toContainText('The video call has ended'); + + debug('navigating to calls tab'); + await $navTab(page, 'Calls').click(); + + debug('clicking on the call to mark it read'); + await $callListItem(page, 'Test Group').click(); + await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible(); + + debug('navigating back to chats tab'); + await $navTab(page, 'Chats').click(); + await expect(unreadBadge).not.toBeVisible(); + await expect(chatListItem).toContainText('The video call has ended'); + + debug('waiting for missed call to expire'); + await delay(EXPIRE_TIMER_MS); + + debug('checking in conversation'); + await chatListItem.click(); + await expect($missedCall(page)).not.toBeVisible(); + }); + + it('expire missed call: read via CallLogEvent sync', async () => { + const page = await app.getWindow(); + const chatListItem = $chatListItem(page, group.id); + const unreadBadge = $unreadBadge(page, group.id); + + debug('waiting for group chat list item'); + await expect(chatListItem).toBeVisible(); + + debug('setting expire timer via group state'); + await setGroupExpireTimer(); + await expect(unreadBadge).not.toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible(); + await expect(chatListItem).toContainText(/Timer set to \d seconds?/); + + debug('creating incoming missed group call via ring notification'); + const callTimestamp = bootstrap.getTimestamp(); + await sendGroupCallMessageOffer({ callTimestamp }); + await expect(unreadBadge).not.toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).toContainText('1'); + await expect(chatListItem).toContainText('The video call has ended'); + + debug('marking calls read in conversation via sync'); + await sendCallLogEventSync({ callTimestamp, groupCall: true }); + await expect(unreadBadge).not.toBeVisible(); + await expect($navTabUnreadBadge(page, 'Calls')).not.toBeVisible(); + await expect(chatListItem).toContainText('The video call has ended'); + + debug('waiting for message to expire'); + await delay(EXPIRE_TIMER_MS); + await expect(chatListItem).toContainText(/Timer set to \d seconds?/); + + debug('opening group conversation to verify expired'); + await chatListItem.click(); + await expect($missedGroupCall(page)).not.toBeVisible(); + }); + + it('does not expire an ongoing group call', async () => { + const page = await app.getWindow(); + const chatListItem = $chatListItem(page, group.id); + + debug('waiting for group chat list item'); + await expect(chatListItem).toBeVisible(); + + debug('setting expire timer via group state'); + await setGroupExpireTimer(); + await expect(chatListItem).toContainText(/Timer set to \d seconds?/); + + debug('opening group conversation'); + await chatListItem.click(); + + debug('opening call screen'); + await page.getByRole('button', { name: 'Start a video call' }).click(); + + debug('accepting audio permissions'); + const audioPermissions = await app.waitForWindow(); + await audioPermissions + .getByRole('button', { name: 'Allow Access' }) + .click(); + + debug('accepting video permissions'); + const videoPermissions = await app.waitForWindow(); + await videoPermissions + .getByRole('button', { name: 'Allow Access' }) + .click(); + + debug('starting call'); + await page + .locator('.CallControls') + .getByRole('button', { name: 'Start' }) + .click(); + + debug('minimizing call'); + await page.getByRole('button', { name: 'Minimize call' }).click(); + await expect($youStartedCall(page)).toBeVisible(); + + debug('wait to make sure message does not expire before call is over'); + await delay(WAIT_FOR_SLOW_EXPIRE_TIMER_MS); + await expect($youStartedCall(page)).toBeVisible(); + + debug('leaving call'); + await page.getByRole('button', { name: 'Leave call' }).click(); + + debug('waiting for message to expire'); + await delay(EXPIRE_TIMER_MS); + await expect($youStartedCall(page)).not.toBeVisible(); + }); + }); +}); diff --git a/ts/test-mock/playwright.node.ts b/ts/test-mock/playwright.node.ts index 2ed35dfa85..809da69b58 100644 --- a/ts/test-mock/playwright.node.ts +++ b/ts/test-mock/playwright.node.ts @@ -197,6 +197,10 @@ export class App extends EventEmitter { return this.#app.firstWindow(); } + public async waitForWindow(): Promise { + return this.#app.waitForEvent('window'); + } + public async openSignalRoute(url: URL | string): Promise { const window = await this.getWindow(); await window.evaluate( diff --git a/ts/test-node/sql/migration_1100_test.node.ts b/ts/test-node/sql/migration_1100_test.node.ts index a6b9624c9e..19b212e448 100644 --- a/ts/test-node/sql/migration_1100_test.node.ts +++ b/ts/test-node/sql/migration_1100_test.node.ts @@ -23,7 +23,8 @@ describe('SQL/updateToSchemaVersion1100', () => { db = createDB(); // index updated in 1170 // columns updated in 1210 - updateToVersion(db, 1210); + // query now needs hasExpireTimer from 1530 + updateToVersion(db, 1530); }); afterEach(() => { @@ -88,8 +89,16 @@ describe('SQL/updateToSchemaVersion1100', () => { peerId: latestCallInConversation.peerId, }; + const readAt = target.timestamp + 1; + const start = performance.now(); - const changes = markAllCallHistoryRead(db, target, true); + const changes = markAllCallHistoryRead( + db, + target, + readAt, + new Set(), + true + ); const end = performance.now(); assert.equal(changes, Math.ceil(COUNT / CONVERSATIONS)); assert.isBelow(end - start, 50); diff --git a/ts/textsecure/MessageReceiver.preload.ts b/ts/textsecure/MessageReceiver.preload.ts index f40e325441..e23f15f568 100644 --- a/ts/textsecure/MessageReceiver.preload.ts +++ b/ts/textsecure/MessageReceiver.preload.ts @@ -3493,7 +3493,8 @@ export default class MessageReceiver const callEventDetails = getCallEventForProto( callEvent, - 'MessageReceiver.handleCallEvent' + 'MessageReceiver.handleCallEvent', + envelope.timestamp ); const callEventSync = new CallEventSyncEvent( @@ -3563,7 +3564,10 @@ export default class MessageReceiver const { receivedAtCounter } = envelope; - const callLogEventDetails = getCallLogEventForProto(callLogEvent); + const callLogEventDetails = getCallLogEventForProto( + callLogEvent, + envelope.timestamp + ); const callLogEventSync = new CallLogEventSyncEvent( { callLogEventDetails, diff --git a/ts/types/CallDisposition.std.ts b/ts/types/CallDisposition.std.ts index 52d92805a2..898b2c7eca 100644 --- a/ts/types/CallDisposition.std.ts +++ b/ts/types/CallDisposition.std.ts @@ -10,6 +10,7 @@ import * as Bytes from '../Bytes.std.ts'; import { UUID_BYTE_SIZE } from './Crypto.std.ts'; import { toNumber } from '../util/toNumber.std.ts'; +import { strictAssert } from '../util/assert.std.ts'; // These are strings (1) for the backup (2) for Storybook. export enum CallMode { @@ -36,6 +37,7 @@ export enum CallDirection { export enum CallLogEvent { Clear = 'Clear', + UNIMPLEMENTED_ClearInConversation = 'ClearInConversation', MarkedAsRead = 'MarkedAsRead', MarkedAsReadInConversation = 'MarkedAsReadInConversation', } @@ -143,16 +145,18 @@ export type CallLogEventTarget = Readonly< export type CallLogEventDetails = Readonly<{ type: CallLogEvent; - timestamp: number; + targetTimestamp: number; peerIdAsConversationId: AciString | string | null; peerIdAsRoomId: string | null; callId: string | null; + eventTimestamp: number; }>; export type CallEventDetails = CallDetails & Readonly<{ event: CallEvent; eventSource: string; + eventTimestamp: number; }>; export type CallHistoryDetails = CallDetails & @@ -233,6 +237,7 @@ export const callDetailsSchema = z.object({ export const callEventDetailsSchema = callDetailsSchema.extend({ event: callEventSchema, eventSource: z.string(), + eventTimestamp: z.number(), }) satisfies z.ZodType; export const callHistoryDetailsSchema = callDetailsSchema.extend({ @@ -256,7 +261,7 @@ export const callHistoryGroupSchema = z.object({ const conversationPeerIdInBytesSchema = z .instanceof(Uint8Array) - .transform(value => { + .transform((value): string | AciString => { // direct conversationId if (value.byteLength === UUID_BYTE_SIZE) { const uuid = bytesToUuid(value); @@ -301,13 +306,35 @@ export const callEventNormalizeSchema = z ]) ); +const CALL_LOG_EVENT_TYPE_MAP: Record< + Proto.SyncMessage.CallLogEvent.Type, + CallLogEvent +> = { + [Proto.SyncMessage.CallLogEvent.Type.CLEAR]: CallLogEvent.Clear, + [Proto.SyncMessage.CallLogEvent.Type.CLEAR_IN_CONVERSATION]: + CallLogEvent.UNIMPLEMENTED_ClearInConversation, + [Proto.SyncMessage.CallLogEvent.Type.MARKED_AS_READ]: + CallLogEvent.MarkedAsRead, + [Proto.SyncMessage.CallLogEvent.Type.MARKED_AS_READ_IN_CONVERSATION]: + CallLogEvent.MarkedAsReadInConversation, +}; + +const callLogEventTypeNormalizeSchema = z + .enum(Proto.SyncMessage.CallLogEvent.Type) + .transform(type => { + const result = CALL_LOG_EVENT_TYPE_MAP[type]; + strictAssert(result, `Missing CALL_LOG_EVENT_TYPE_MAP for ${type}`); + return result; + }); + export const callLogEventNormalizeSchema = z.object({ - type: z.nativeEnum(Proto.SyncMessage.CallLogEvent.Type), - timestamp: longToNumberSchema, - peerIdAsConversationId: conversationPeerIdInBytesSchema.optional(), - peerIdAsRoomId: roomIdInBytesSchema.optional(), - callId: longToStringSchema.optional(), -}); + type: callLogEventTypeNormalizeSchema, + targetTimestamp: longToNumberSchema, + peerIdAsConversationId: conversationPeerIdInBytesSchema.nullable(), + peerIdAsRoomId: roomIdInBytesSchema.nullable(), + callId: longToStringSchema.nullable(), + eventTimestamp: z.number(), +}) satisfies z.ZodType; export function isSameCallHistoryGroup( a: CallHistoryGroup, diff --git a/ts/util/Sound.std.ts b/ts/util/Sound.std.ts index 87b6e66aa0..b24f07cc17 100644 --- a/ts/util/Sound.std.ts +++ b/ts/util/Sound.std.ts @@ -1,6 +1,7 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { isTestOrMockEnvironment } from '../environment.std.ts'; import { createLogger } from '../logging/log.std.ts'; import { missingCaseError } from './missingCaseError.std.ts'; @@ -60,6 +61,11 @@ export class Sound { soundNode.connect(volumeNode); volumeNode.connect(this.#context.destination); + if (isTestOrMockEnvironment()) { + // Mute sounds in tests + volumeNode.gain.setValueAtTime(0, this.#context.currentTime); + } + soundNode.loop = this.#loop; soundNode.start(0, 0); diff --git a/ts/util/callDisposition.preload.ts b/ts/util/callDisposition.preload.ts index 40a20aaee1..0967c512be 100644 --- a/ts/util/callDisposition.preload.ts +++ b/ts/util/callDisposition.preload.ts @@ -34,7 +34,6 @@ import { AdhocCallStatus, CallStatusValue, callLogEventNormalizeSchema, - CallLogEvent, ClearCallHistoryResult, } from '../types/CallDisposition.std.ts'; import type { AciString } from '../types/ServiceId.std.ts'; @@ -70,6 +69,8 @@ import { calling } from '../services/calling.preload.ts'; import { cleanupMessages } from './cleanup.preload.ts'; import { MessageModel } from '../models/messages.preload.ts'; import { itemStorage } from '../textsecure/Storage.preload.ts'; +import { update as updateExpiringMessagesService } from '../services/expiringMessagesDeletion.preload.ts'; +import type { DurationInSeconds } from './durations/duration-in-seconds.std.ts'; const { isEqual } = lodash; @@ -202,7 +203,8 @@ export function convertJoinState(joinState: JoinState): GroupCallJoinState { export function getCallEventForProto( callEventProto: Proto.SyncMessage.CallEvent.Params, - eventSource: string + eventSource: string, + eventTimestamp: number ): CallEventDetails { const callEvent = parseLoose(callEventNormalizeSchema, callEventProto); const { callId, conversationId: peerId, timestamp } = callEvent; @@ -269,44 +271,29 @@ export function getCallEventForProto( timestamp, event, eventSource, + eventTimestamp, }); } -const callLogEventFromProto: Partial< - Record -> = { - [Proto.SyncMessage.CallLogEvent.Type.CLEAR]: CallLogEvent.Clear, - [Proto.SyncMessage.CallLogEvent.Type.MARKED_AS_READ]: - CallLogEvent.MarkedAsRead, - [Proto.SyncMessage.CallLogEvent.Type.MARKED_AS_READ_IN_CONVERSATION]: - CallLogEvent.MarkedAsReadInConversation, -}; - export function getCallLogEventForProto( - callLogEventProto: Proto.SyncMessage.CallLogEvent.Params + callLogEventProto: Proto.SyncMessage.CallLogEvent, + eventTimestamp: number ): CallLogEventDetails { // CallLogEvent peerId is ambiguous whether it's a conversationId (direct, or groupId) // or roomId so handle both cases - const { conversationId: peerIdBytes } = callLogEventProto; + const { + conversationId: peerIdBytes, + timestamp: targetTimestamp, + ...rest + } = callLogEventProto; - const callLogEvent = parseLoose(callLogEventNormalizeSchema, { - ...callLogEventProto, + return parseLoose(callLogEventNormalizeSchema, { + ...rest, + targetTimestamp, peerIdAsConversationId: peerIdBytes, peerIdAsRoomId: peerIdBytes, + eventTimestamp, }); - - const type = callLogEventFromProto[callLogEvent.type]; - if (type == null) { - throw new TypeError(`Unknown call log event ${callLogEvent.type}`); - } - - return { - type, - timestamp: callLogEvent.timestamp, - peerIdAsConversationId: callLogEvent.peerIdAsConversationId ?? null, - peerIdAsRoomId: callLogEvent.peerIdAsRoomId ?? null, - callId: callLogEvent.callId ?? null, - }; } const directionToProto = { @@ -533,70 +520,73 @@ function getCallDirectionFromRingerId( // Call Details // ------------ -export function getCallDetailsFromDirectCall( - peerId: AciString | string, - call: Call -): CallDetails { - const ringerId = call.isIncoming ? call.remoteUserId : null; +export function getCallDetailsFromDirectCall(params: { + peerId: AciString | string; + call: Call; + eventTimestamp: number; +}): CallDetails { + const ringerId = params.call.isIncoming ? params.call.remoteUserId : null; return parseStrict(callDetailsSchema, { - callId: (call.callId satisfies bigint).toString(), - peerId, + callId: params.call.callId.toString(), + peerId: params.peerId, ringerId, startedById: ringerId, mode: CallMode.Direct, - type: call.isVideoCall ? CallType.Video : CallType.Audio, - direction: call.isIncoming + type: params.call.isVideoCall ? CallType.Video : CallType.Audio, + direction: params.call.isIncoming ? CallDirection.Incoming : CallDirection.Outgoing, - timestamp: Date.now(), + timestamp: params.eventTimestamp, endedTimestamp: null, }); } -export function getCallDetailsFromEndedDirectCall( - callId: string, - peerId: AciString | string, - ringerId: AciString | string, - wasVideoCall: boolean, - timestamp: number -): CallDetails { +export function getCallDetailsFromEndedDirectCall(params: { + callId: bigint; + peerId: AciString | string; + ringerId: AciString | string; + wasVideoCall: boolean; + eventTimestamp: number; +}): CallDetails { return parseStrict(callDetailsSchema, { - callId, - peerId, - ringerId, - startedById: ringerId, + callId: params.callId.toString(), + peerId: params.peerId, + ringerId: params.ringerId, + startedById: params.ringerId, mode: CallMode.Direct, - type: wasVideoCall ? CallType.Video : CallType.Audio, - direction: getCallDirectionFromRingerId(ringerId), - timestamp, + type: params.wasVideoCall ? CallType.Video : CallType.Audio, + direction: getCallDirectionFromRingerId(params.ringerId), + timestamp: params.eventTimestamp, endedTimestamp: null, }); } -export function getCallDetailsFromGroupCallMeta( - peerId: AciString | string, - groupCallMeta: GroupCallMeta -): CallDetails { +export function getCallDetailsFromGroupCallMeta(params: { + peerId: AciString | string; + groupCallMeta: GroupCallMeta; + eventTimestamp: number; +}): CallDetails { return parseStrict(callDetailsSchema, { - callId: groupCallMeta.callId, - peerId, - ringerId: groupCallMeta.ringerId, - startedById: groupCallMeta.ringerId, + callId: params.groupCallMeta.callId, + peerId: params.peerId, + ringerId: params.groupCallMeta.ringerId, + startedById: params.groupCallMeta.ringerId, mode: CallMode.Group, type: CallType.Group, - direction: getCallDirectionFromRingerId(groupCallMeta.ringerId), - timestamp: Date.now(), + direction: getCallDirectionFromRingerId(params.groupCallMeta.ringerId), + timestamp: params.eventTimestamp, endedTimestamp: null, }); } -export function getCallDetailsForAdhocCall( - peerId: AciString | string, - callId: string -): CallDetails { +export function getCallDetailsForAdhocCall(params: { + peerId: AciString | string; + callId: string; + eventTimestamp: number; +}): CallDetails { return parseStrict(callDetailsSchema, { - callId, - peerId, + callId: params.callId, + peerId: params.peerId, ringerId: null, startedById: null, mode: CallMode.Adhoc, @@ -604,7 +594,7 @@ export function getCallDetailsForAdhocCall( // Direction is only outgoing when your action causes ringing for others. // As Adhoc calls do not support ringing, this is always incoming for now direction: CallDirection.Incoming, - timestamp: Date.now(), + timestamp: params.eventTimestamp, endedTimestamp: null, }); } @@ -612,15 +602,17 @@ export function getCallDetailsForAdhocCall( // Call Event Details // ------------------ -export function getCallEventDetails( - callDetails: CallDetails, - event: LocalCallEvent, - eventSource: string -): CallEventDetails { +export function getCallEventDetails(params: { + callDetails: CallDetails; + event: LocalCallEvent; + eventSource: string; + eventTimestamp: number; +}): CallEventDetails { return parseStrict(callEventDetailsSchema, { - ...callDetails, - event, - eventSource, + ...params.callDetails, + event: params.event, + eventSource: params.eventSource, + eventTimestamp: params.eventTimestamp, }); } @@ -1061,6 +1053,7 @@ async function updateLocalCallHistory({ receivedAtCounter, receivedAtMS, serverGuid, + eventTimestamp: callEvent.eventTimestamp, }); return updatedCallHistory; } @@ -1167,12 +1160,14 @@ async function saveCallHistory({ receivedAtCounter, receivedAtMS, serverGuid, + eventTimestamp, }: { callHistory: CallHistoryDetails; conversation: ConversationModel; receivedAtCounter: number | null; receivedAtMS: number | null; serverGuid: string | null; + eventTimestamp: number | null; }): Promise { log.info( 'saveCallHistory: Saving call history:', @@ -1220,6 +1215,8 @@ async function saveCallHistory({ let seenStatus: SeenStatus; + const isCallCurrentlyActive = calling.isCallActive(conversation.id); + const isUnseenDirectCall = callHistory.mode === CallMode.Direct && callHistory.direction === CallDirection.Incoming && @@ -1248,6 +1245,27 @@ async function saveCallHistory({ const { id: newId } = generateMessageId(counter); + let expireTimer: DurationInSeconds | undefined; + if (prevMessage != null) { + expireTimer = prevMessage.expireTimer; + } else { + expireTimer = conversation.get('expireTimer'); + } + + let expirationStartTimestamp: number | null | undefined = + prevMessage?.expirationStartTimestamp; + + if ( + expireTimer != null && + seenStatus === SeenStatus.Seen && + !isCallCurrentlyActive + ) { + expirationStartTimestamp ??= + eventTimestamp != null + ? Math.min(eventTimestamp, Date.now()) + : Date.now(); + } + const message = new MessageModel({ id: prevMessage?.id ?? newId, conversationId: conversation.id, @@ -1261,6 +1279,8 @@ async function saveCallHistory({ seenStatus, callId: callHistory.callId, serverGuid: prevMessage?.serverGuid ?? (serverGuid || undefined), + expireTimer, + expirationStartTimestamp, }); const id = await window.MessageCache.saveMessage(message, { @@ -1300,7 +1320,9 @@ async function saveCallHistory({ await DataWriter.updateConversation(conversation.attributes); } - window.reduxActions.callHistory.updateCallHistoryUnreadCount(); + window.reduxActions.callHistory.updateCallHistoryUnreadCount([ + conversation.id, + ]); return callHistory; } @@ -1489,17 +1511,31 @@ export async function markAllCallHistoryReadAndSync( log.info( `markAllCallHistoryReadAndSync: Marking call history read before (${latestCall.callId}, ${latestCall.timestamp})` ); + const readAt = Date.now(); + const activeCallIds = calling.getActiveCallIds(); let count: number; if (inConversation) { - count = await DataWriter.markAllCallHistoryReadInConversation(latestCall); + count = await DataWriter.markAllCallHistoryReadInConversation( + latestCall, + readAt, + activeCallIds + ); } else { - count = await DataWriter.markAllCallHistoryRead(latestCall); + count = await DataWriter.markAllCallHistoryRead( + latestCall, + readAt, + activeCallIds + ); } log.info( `markAllCallHistoryReadAndSync: Marked ${count} call history messages read` ); + if (count > 0) { + updateExpiringMessagesService(); + } + const ourAci = itemStorage.user.getCheckedAci(); const callLogEvent: Proto.SyncMessage.CallLogEvent.Params = { @@ -1591,6 +1627,7 @@ export async function updateLocalGroupCallHistoryTimestamp( receivedAtCounter: null, receivedAtMS: null, serverGuid: null, + eventTimestamp: null, }); return updatedCallHistory; diff --git a/ts/util/markConversationRead.preload.ts b/ts/util/markConversationRead.preload.ts index 62239deaf0..6aa81529fd 100644 --- a/ts/util/markConversationRead.preload.ts +++ b/ts/util/markConversationRead.preload.ts @@ -28,6 +28,7 @@ import { isAciString } from './isAciString.std.ts'; import type { MessageModel } from '../models/messages.preload.ts'; import { postSaveUpdates } from './cleanup.preload.ts'; import { itemStorage } from '../textsecure/Storage.preload.ts'; +import { calling } from '../services/calling.preload.ts'; const { isNumber, pick } = lodash; @@ -47,6 +48,7 @@ export async function markConversationRead( const [ unreadMessages, + unreadCallMessages, unreadEditedMessages, unreadReactions, unreadPollVotes, @@ -57,6 +59,11 @@ export async function markConversationRead( readAt: options.readAt, includeStoryReplies: !isGroup(conversationAttrs), }), + DataWriter.getUnreadCallMessagesAndMarkRead({ + conversationId, + readMessageReceivedAt: readMessage.received_at, + activeCallIds: calling.getActiveCallIds(), + }), DataWriter.getUnreadEditedMessagesAndMarkRead({ conversationId, readMessageReceivedAt: readMessage.received_at, @@ -80,12 +87,15 @@ export async function markConversationRead( receivedAt: readMessage.received_at, }, unreadMessages: unreadMessages.length, + unreadCallMessages: unreadCallMessages.length, + unreadEditedMessages: unreadEditedMessages.length, unreadReactions: unreadReactions.length, unreadPollVotes: unreadPollVotes.length, }); if ( !unreadMessages.length && + !unreadCallMessages.length && !unreadEditedMessages.length && !unreadReactions.length && !unreadPollVotes.length @@ -138,7 +148,11 @@ export async function markConversationRead( }); }); - const allUnreadMessages = [...unreadMessages, ...unreadEditedMessages]; + const allUnreadMessages = [ + ...unreadMessages, + ...unreadCallMessages, + ...unreadEditedMessages, + ]; const updatedMessages: Array = []; diff --git a/ts/util/onCallLogEventSync.preload.ts b/ts/util/onCallLogEventSync.preload.ts index a5a14ba724..d02e887816 100644 --- a/ts/util/onCallLogEventSync.preload.ts +++ b/ts/util/onCallLogEventSync.preload.ts @@ -3,12 +3,13 @@ import type { CallLogEventSyncEvent } from '../textsecure/messageReceiverEvents.std.ts'; import { createLogger } from '../logging/log.std.ts'; -import { DataWriter } from '../sql/Client.preload.ts'; +import { DataReader, DataWriter } from '../sql/Client.preload.ts'; import type { CallLogEventTarget } from '../types/CallDisposition.std.ts'; import { CallLogEvent } from '../types/CallDisposition.std.ts'; import { missingCaseError } from './missingCaseError.std.ts'; -import { strictAssert } from './assert.std.ts'; import { updateDeletedMessages } from './callDisposition.preload.ts'; +import { update as updateExpiringMessagesService } from '../services/expiringMessagesDeletion.preload.ts'; +import { calling } from '../services/calling.preload.ts'; const log = createLogger('onCallLogEventSync'); @@ -16,51 +17,85 @@ export async function onCallLogEventSync( syncEvent: CallLogEventSyncEvent ): Promise { const { data, confirm } = syncEvent; - const { type, peerIdAsConversationId, peerIdAsRoomId, callId, timestamp } = - data.callLogEventDetails; + const { + type, + peerIdAsConversationId, + peerIdAsRoomId, + callId, + targetTimestamp, + eventTimestamp, + } = data.callLogEventDetails; const target: CallLogEventTarget = { peerIdAsConversationId, peerIdAsRoomId, callId, - timestamp, + timestamp: targetTimestamp, }; log.info( - `Processing event (Event: ${type}, CallId: ${callId}, Timestamp: ${timestamp})` + `Processing event (Event: ${type}, CallId: ${callId}, Timestamp: ${targetTimestamp})` ); if (type === CallLogEvent.Clear) { log.info('Clearing call history'); + let unreadConversationIds: ReadonlyArray = []; try { + unreadConversationIds = + await DataReader.getCallHistoryUnreadCallConversationIds(); const messageIds = await DataWriter.clearCallHistory(target); updateDeletedMessages(messageIds); } finally { // We want to reset the call history even if the clear fails. window.reduxActions.callHistory.resetCallHistory(); + window.reduxActions.callHistory.updateCallHistoryUnreadCount( + unreadConversationIds + ); } confirm(); } else if (type === CallLogEvent.MarkedAsRead) { log.info('Marking call history read'); + + let unreadConversationIds: ReadonlyArray = []; try { - const count = await DataWriter.markAllCallHistoryRead(target); + unreadConversationIds = + await DataReader.getCallHistoryUnreadCallConversationIds(); + const count = await DataWriter.markAllCallHistoryRead( + target, + Math.min(Date.now(), eventTimestamp), + calling.getActiveCallIds() + ); log.info(`Marked ${count} call history messages read`); + if (count !== 0) { + updateExpiringMessagesService(); + } } finally { - window.reduxActions.callHistory.updateCallHistoryUnreadCount(); + window.reduxActions.callHistory.updateCallHistoryUnreadCount( + unreadConversationIds + ); } confirm(); } else if (type === CallLogEvent.MarkedAsReadInConversation) { log.info('Marking call history read in conversation'); try { - strictAssert(peerIdAsConversationId, 'Missing peerIdAsConversationId'); - strictAssert(peerIdAsRoomId, 'Missing peerIdAsRoomId'); - const count = - await DataWriter.markAllCallHistoryReadInConversation(target); + const count = await DataWriter.markAllCallHistoryReadInConversation( + target, + Math.min(Date.now(), eventTimestamp), + calling.getActiveCallIds() + ); log.info(`Marked ${count} call history messages read`); + if (count !== 0) { + updateExpiringMessagesService(); + } } finally { - window.reduxActions.callHistory.updateCallHistoryUnreadCount(); + window.reduxActions.callHistory.updateCallHistoryUnreadCount( + peerIdAsConversationId != null ? [peerIdAsConversationId] : [] + ); } confirm(); + } else if (type === CallLogEvent.UNIMPLEMENTED_ClearInConversation) { + log.warn('CallLogEvent.CLEAR_IN_CONVERSATION not supported'); + confirm(); } else { throw missingCaseError(type); }