From d96b765d00cc058622e211de5584253081560688 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Wed, 26 Jun 2024 11:42:31 -0500 Subject: [PATCH] Update CallLogEvent to latest spec Co-authored-by: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> --- protos/SignalService.proto | 17 ++- ts/sql/Interface.ts | 12 +- ts/sql/Server.ts | 160 ++++++++++++++++++------- ts/state/ducks/callHistory.ts | 39 +++++- ts/state/ducks/conversations.ts | 9 +- ts/state/selectors/callHistory.ts | 31 ++++- ts/textsecure/MessageReceiver.ts | 32 +---- ts/textsecure/SendMessage.ts | 15 ++- ts/textsecure/messageReceiverEvents.ts | 10 +- ts/types/CallDisposition.ts | 21 ++++ ts/util/callDisposition.ts | 121 +++++++++++++------ ts/util/onCallLogEventSync.ts | 41 +++++-- 12 files changed, 366 insertions(+), 142 deletions(-) diff --git a/protos/SignalService.proto b/protos/SignalService.proto index dd3837ab21..73894ada6c 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -604,7 +604,12 @@ message SyncMessage { DELETE = 3; } + /* Data identifying a conversation. The service ID for 1:1, the group ID for + * group, or the room ID for an ad-hoc call. See also + * `CallLogEvent/peerId`. */ optional bytes peerId = 1; + /* An identifier for a call. Generated directly for 1:1, or derived from + * the era ID for group and ad-hoc calls. See also `CallLogEvent/callId`. */ optional uint64 callId = 2; optional uint64 timestamp = 3; optional Type type = 4; @@ -627,10 +632,18 @@ message SyncMessage { enum Type { CLEAR = 0; MARKED_AS_READ = 1; + MARKED_AS_READ_IN_CONVERSATION = 2; } optional Type type = 1; optional uint64 timestamp = 2; + /* Data identifying a conversation. The service ID for 1:1, the group ID for + * group, or the room ID for an ad-hoc call. See also + * `CallEvent/peerId`. */ + optional bytes peerId = 3; + /* An identifier for a call. Generated directly for 1:1, or derived from + * the era ID for group and ad-hoc calls. See also `CallEvent/callId`. */ + optional uint64 callId = 4; } message DeleteForMe { @@ -641,7 +654,7 @@ message SyncMessage { string threadE164 = 3; } } - + message AddressableMessage { oneof author { string authorServiceId = 1; @@ -682,7 +695,7 @@ message SyncMessage { repeated LocalOnlyConversationDelete localOnlyConversationDeletes = 3; repeated AttachmentDelete attachmentDeletes = 4; } - + optional Sent sent = 1; optional Contacts contacts = 2; reserved /* groups */ 3; diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 0f7c1974ab..6533d232b6 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -28,6 +28,7 @@ import type { CallHistoryFilter, CallHistoryGroup, CallHistoryPagination, + CallLogEventTarget, } from '../types/CallDisposition'; import type { CallLinkStateType, CallLinkType } from '../types/CallLink'; import type { AttachmentDownloadJobType } from '../types/AttachmentDownload'; @@ -666,14 +667,17 @@ export type DataInterface = { conversationId: string; }): Promise; getAllCallHistory: () => Promise>; + clearCallHistory: ( + target: CallLogEventTarget + ) => Promise>; markCallHistoryDeleted: (callId: string) => Promise; - clearCallHistory: (beforeTimestamp: number) => Promise>; cleanupCallHistoryMessages: () => Promise; getCallHistoryUnreadCount(): Promise; markCallHistoryRead(callId: string): Promise; - markAllCallHistoryRead( - beforeTimestamp: number - ): Promise>; + markAllCallHistoryRead(target: CallLogEventTarget): Promise; + markAllCallHistoryReadInConversation( + target: CallLogEventTarget + ): Promise; getCallHistoryMessageByCallId(options: { conversationId: string; callId: string; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index d02ce288c5..c2e341939d 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -157,6 +157,7 @@ import type { CallHistoryFilter, CallHistoryGroup, CallHistoryPagination, + CallLogEventTarget, } from '../types/CallDisposition'; import { DirectCallStatus, @@ -166,6 +167,7 @@ import { CallDirection, GroupCallStatus, CallType, + CallStatusValue, } from '../types/CallDisposition'; import { callLinkExists, @@ -352,6 +354,7 @@ const dataInterface: ServerInterface = { getCallHistoryUnreadCount, markCallHistoryRead, markAllCallHistoryRead, + markAllCallHistoryReadInConversation, getCallHistoryMessageByCallId, getCallHistory, getCallHistoryGroupsCount, @@ -3631,34 +3634,56 @@ async function getAllCallHistory(): Promise> { } async function clearCallHistory( - beforeTimestamp: number -): Promise> { + target: CallLogEventTarget +): Promise> { const db = await getWritableInstance(); return db.transaction(() => { - const whereMessages = sqlFragment` - WHERE messages.type IS 'call-history' - AND messages.sent_at <= ${beforeTimestamp}; + const timestamp = getTimestampForCallLogEventTarget(db, target); + + const [selectCallIdsQuery, selectCallIdsParams] = sql` + SELECT callsHistory.callId + FROM callsHistory + WHERE + -- Prior calls + (callsHistory.timestamp <= ${timestamp}) + -- Unused call links + OR ( + callsHistory.mode IS ${CALL_MODE_ADHOC} AND + callsHistory.status IS ${CALL_STATUS_PENDING} + ); `; - const [selectMessagesQuery, selectMessagesParams] = sql` - SELECT id FROM messages ${whereMessages} - `; - const [clearMessagesQuery, clearMessagesParams] = sql` - DELETE FROM messages ${whereMessages} - `; + const callIds = db + .prepare(selectCallIdsQuery) + .pluck() + .all(selectCallIdsParams); + + let deletedMessageIds: ReadonlyArray = []; + + batchMultiVarQuery(db, callIds, (ids: ReadonlyArray): void => { + const [deleteMessagesQuery, deleteMessagesParams] = sql` + DELETE FROM messages + WHERE messages.type IS 'call-history' + AND messages.callId IN (${sqlJoin(ids)}) + RETURNING id; + `; + + const batchDeletedMessageIds = db + .prepare(deleteMessagesQuery) + .pluck() + .all(deleteMessagesParams); + + deletedMessageIds = deletedMessageIds.concat(batchDeletedMessageIds); + }); + const [clearCallsHistoryQuery, clearCallsHistoryParams] = sql` UPDATE callsHistory SET status = ${DirectCallStatus.Deleted}, timestamp = ${Date.now()} - WHERE callsHistory.timestamp <= ${beforeTimestamp}; + WHERE callsHistory.timestamp <= ${timestamp}; `; - const messageIds = db - .prepare(selectMessagesQuery) - .pluck() - .all(selectMessagesParams); - db.prepare(clearMessagesQuery).run(clearMessagesParams); try { db.prepare(clearCallsHistoryQuery).run(clearCallsHistoryParams); } catch (error) { @@ -3666,7 +3691,7 @@ async function clearCallHistory( throw error; } - return messageIds; + return deletedMessageIds; })(); } @@ -3743,8 +3768,9 @@ async function getCallHistory( const SEEN_STATUS_UNSEEN = sqlConstant(SeenStatus.Unseen); const SEEN_STATUS_SEEN = sqlConstant(SeenStatus.Seen); -const CALL_STATUS_MISSED = sqlConstant(DirectCallStatus.Missed); -const CALL_STATUS_DELETED = sqlConstant(DirectCallStatus.Deleted); +const CALL_STATUS_MISSED = sqlConstant(CallStatusValue.Missed); +const CALL_STATUS_DELETED = sqlConstant(CallStatusValue.Deleted); +const CALL_STATUS_PENDING = sqlConstant(CallStatusValue.Pending); const CALL_STATUS_INCOMING = sqlConstant(CallDirection.Incoming); const CALL_MODE_ADHOC = sqlConstant(CallMode.Adhoc); const FOUR_HOURS_IN_MS = sqlConstant(4 * 60 * 60 * 1000); @@ -3781,44 +3807,88 @@ async function markCallHistoryRead(callId: string): Promise { db.prepare(query).run(params); } -async function markAllCallHistoryRead( - beforeTimestamp: number -): Promise> { - const db = await getWritableInstance(); - - return db.transaction(() => { - const where = sqlFragment` - WHERE messages.type IS 'call-history' - AND messages.seenStatus IS ${SEEN_STATUS_UNSEEN} - AND messages.sent_at <= ${beforeTimestamp}; - `; +function getTimestampForCallLogEventTarget( + db: Database, + target: CallLogEventTarget +): number { + let { timestamp } = target; + if (target.peerId != null && target.callId != null) { const [selectQuery, selectParams] = sql` - SELECT DISTINCT conversationId - FROM messages - ${where}; + SELECT callsHistory.timestamp + FROM callsHistory + WHERE callsHistory.callId IS ${target.callId} + AND callsHistory.peerId IS ${target.peerId} `; + const value = db.prepare(selectQuery).pluck().get(selectParams); - const conversationIds = db.prepare(selectQuery).pluck().all(selectParams); + if (value != null) { + timestamp = value; + } else { + log.warn( + 'getTimestampForCallLogEventTarget: Target call not found', + target.callId + ); + } + } + return timestamp; +} + +async function markAllCallHistoryReadWithPredicate( + target: CallLogEventTarget, + inConversation: boolean +) { + const db = await getWritableInstance(); + db.transaction(() => { const jsonPatch = JSON.stringify({ seenStatus: SeenStatus.Seen, }); - const [updateQuery, updateParams] = sql` - UPDATE messages - SET - seenStatus = ${SEEN_STATUS_SEEN}, - json = json_patch(json, ${jsonPatch}) - ${where}; + const timestamp = getTimestampForCallLogEventTarget(db, target); + + const predicate = inConversation + ? sqlFragment`callsHistory.peerId IS ${target.peerId}` + : sqlFragment`TRUE`; + + const [selectQuery, selectParams] = sql` + SELECT callsHistory.callId + FROM callsHistory + WHERE ${predicate} + AND callsHistory.timestamp <= ${timestamp} `; - db.prepare(updateQuery).run(updateParams); + const callIds = db.prepare(selectQuery).pluck().all(selectParams); - return conversationIds; + batchMultiVarQuery(db, callIds, ids => { + const idList = sqlJoin(ids.map(id => sqlFragment`${id}`)); + + const [updateQuery, updateParams] = sql` + UPDATE messages + SET + seenStatus = ${SEEN_STATUS_SEEN}, + json = json_patch(json, ${jsonPatch}) + WHERE callId IN (${idList}); + `; + + db.prepare(updateQuery).run(updateParams); + }); })(); } +async function markAllCallHistoryRead( + target: CallLogEventTarget +): Promise { + await markAllCallHistoryReadWithPredicate(target, false); +} + +async function markAllCallHistoryReadInConversation( + target: CallLogEventTarget +): Promise { + strictAssert(target.peerId, 'peerId is required'); + await markAllCallHistoryReadWithPredicate(target, true); +} + function getCallHistoryGroupDataSync( db: Database, isCount: boolean, @@ -4932,7 +5002,7 @@ async function saveAttachmentBackupJob( attempts, data, lastAttemptTimestamp, - mediaName, + mediaName, receivedAt, retryAfter, type @@ -5003,7 +5073,7 @@ function removeAttachmentBackupJobSync( ): void { const [query, params] = sql` DELETE FROM attachment_backup_jobs - WHERE + WHERE mediaName = ${job.mediaName}; `; diff --git a/ts/state/ducks/callHistory.ts b/ts/state/ducks/callHistory.ts index 52d58d96e2..ec94983844 100644 --- a/ts/state/ducks/callHistory.ts +++ b/ts/state/ducks/callHistory.ts @@ -18,6 +18,10 @@ import type { CallHistoryDetails } from '../../types/CallDisposition'; import * as log from '../../logging/log'; import * as Errors from '../../types/errors'; import { drop } from '../../util/drop'; +import { + getCallHistoryLatestCall, + getCallHistorySelector, +} from '../selectors/callHistory'; export type CallHistoryState = ReadonlyDeep<{ // This informs the app that underlying call history data has changed. @@ -103,15 +107,35 @@ function markCallHistoryRead( }; } +export function markCallHistoryReadInConversation( + callId: string +): ThunkAction { + return async (dispatch, getState) => { + const callHistorySelector = getCallHistorySelector(getState()); + const callHistory = callHistorySelector(callId); + if (callHistory == null) { + return; + } + try { + await markAllCallHistoryReadAndSync(callHistory, true); + } finally { + dispatch(updateCallHistoryUnreadCount()); + } + }; +} + function markCallsTabViewed(): ThunkAction< void, RootStateType, unknown, CallHistoryUpdateUnread > { - return async dispatch => { - await markAllCallHistoryReadAndSync(); - dispatch(updateCallHistoryUnreadCount()); + return async (dispatch, getState) => { + const latestCall = getCallHistoryLatestCall(getState()); + if (latestCall != null) { + await markAllCallHistoryReadAndSync(latestCall, false); + dispatch(updateCallHistoryUnreadCount()); + } }; } @@ -143,10 +167,13 @@ function clearAllCallHistory(): ThunkAction< unknown, CallHistoryReset | ToastActionType > { - return async dispatch => { + return async (dispatch, getState) => { try { - await clearCallHistoryDataAndSync(); - dispatch(showToast({ toastType: ToastType.CallHistoryCleared })); + const latestCall = getCallHistoryLatestCall(getState()); + if (latestCall != null) { + await clearCallHistoryDataAndSync(latestCall); + dispatch(showToast({ toastType: ToastType.CallHistoryCleared })); + } } catch (error) { log.error('Error clearing call history', Errors.toLogFormat(error)); } finally { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 7d46407ee1..4ac683ad00 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -195,6 +195,7 @@ import { } from '../../util/deleteForMe'; import { MAX_MESSAGE_COUNT } from '../../util/deleteForMe.types'; import { isEnabled } from '../../RemoteConfig'; +import { markCallHistoryReadInConversation } from './callHistory'; import type { CapabilitiesType } from '../../textsecure/WebAPI'; // State @@ -1358,7 +1359,7 @@ function markMessageRead( conversationId: string, messageId: string ): ThunkAction { - return async (_dispatch, getState) => { + return async (dispatch, getState) => { const conversation = window.ConversationController.get(conversationId); if (!conversation) { throw new Error('markMessageRead: Conversation not found!'); @@ -1382,6 +1383,12 @@ function markMessageRead( newestSentAt: message.get('sent_at'), sendReadReceipts: true, }); + + if (message.get('type') === 'call-history') { + const callId = message.get('callId'); + strictAssert(callId, 'callId not found'); + dispatch(markCallHistoryReadInConversation(callId)); + } }; } diff --git a/ts/state/selectors/callHistory.ts b/ts/state/selectors/callHistory.ts index fdff49c8b2..6d921f23e4 100644 --- a/ts/state/selectors/callHistory.ts +++ b/ts/state/selectors/callHistory.ts @@ -4,7 +4,11 @@ import { createSelector } from 'reselect'; import type { CallHistoryState } from '../ducks/callHistory'; import type { StateType } from '../reducer'; -import type { CallHistoryDetails } from '../../types/CallDisposition'; +import { + AdhocCallStatus, + CallType, + type CallHistoryDetails, +} from '../../types/CallDisposition'; import { getOwn } from '../../util/getOwn'; const getCallHistory = (state: StateType): CallHistoryState => @@ -36,3 +40,28 @@ export const getCallHistoryUnreadCount = createSelector( return callHistory.unreadCount; } ); + +export const getCallHistoryLatestCall = createSelector( + getCallHistory, + callHistory => { + let latestCall = null; + + for (const callId of Object.keys(callHistory.callHistoryByCallId)) { + const call = callHistory.callHistoryByCallId[callId]; + + // Skip unused call links + if ( + call.type === CallType.Adhoc && + call.status === AdhocCallStatus.Pending + ) { + continue; + } + + if (latestCall == null || call.timestamp > latestCall.timestamp) { + latestCall = call; + } + } + + return latestCall; + } +); diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 5d3c034975..4231ee2949 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -152,9 +152,11 @@ import { chunk } from '../util/iterables'; import { inspectUnknownFieldTags } from '../util/inspectProtobufs'; import { incrementMessageCounter } from '../util/incrementMessageCounter'; import { filterAndClean } from '../types/BodyRange'; -import { getCallEventForProto } from '../util/callDisposition'; +import { + getCallEventForProto, + getCallLogEventForProto, +} from '../util/callDisposition'; import { checkOurPniIdentityKey } from '../util/checkOurPniIdentityKey'; -import { CallLogEvent } from '../types/CallDisposition'; import { CallLinkUpdateSyncType } from '../types/CallLink'; import { bytesToUuid } from '../util/uuidToBytes'; @@ -3614,32 +3616,10 @@ export default class MessageReceiver const { receivedAtCounter } = envelope; - let event: CallLogEvent; - if (callLogEvent.type == null) { - throw new Error('MessageReceiver.handleCallLogEvent: type was null'); - } else if ( - callLogEvent.type === Proto.SyncMessage.CallLogEvent.Type.CLEAR - ) { - event = CallLogEvent.Clear; - } else if ( - callLogEvent.type === Proto.SyncMessage.CallLogEvent.Type.MARKED_AS_READ - ) { - event = CallLogEvent.MarkedAsRead; - } else { - throw new Error( - `MessageReceiver.handleCallLogEvent: unknown type ${callLogEvent.type}` - ); - } - - if (callLogEvent.timestamp == null) { - throw new Error('MessageReceiver.handleCallLogEvent: timestamp was null'); - } - const timestamp = callLogEvent.timestamp.toNumber(); - + const callLogEventDetails = getCallLogEventForProto(callLogEvent); const callLogEventSync = new CallLogEventSyncEvent( { - event, - timestamp, + callLogEventDetails, receivedAtCounter, }, this.removeFromCache.bind(this, envelope) diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index a3cfb46c24..1cc0fb898a 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -89,13 +89,16 @@ import type { MessageToDelete, } from './messageReceiverEvents'; import { getConversationFromTarget } from '../util/deleteForMe'; -import type { CallDetails } from '../types/CallDisposition'; +import type { CallDetails, CallHistoryDetails } from '../types/CallDisposition'; import { AdhocCallStatus, DirectCallStatus, GroupCallStatus, } from '../types/CallDisposition'; -import { getProtoForCallHistory } from '../util/callDisposition'; +import { + getBytesForPeerId, + getProtoForCallHistory, +} from '../util/callDisposition'; import { CallMode } from '../types/Calling'; import { MAX_MESSAGE_COUNT } from '../util/deleteForMe.types'; @@ -1589,11 +1592,15 @@ export default class MessageSender { }; } - static getClearCallHistoryMessage(timestamp: number): SingleProtoJobData { + static getClearCallHistoryMessage( + latestCall: CallHistoryDetails + ): SingleProtoJobData { const ourAci = window.textsecure.storage.user.getCheckedAci(); const callLogEvent = new Proto.SyncMessage.CallLogEvent({ type: Proto.SyncMessage.CallLogEvent.Type.CLEAR, - timestamp: Long.fromNumber(timestamp), + timestamp: Long.fromNumber(latestCall.timestamp), + peerId: getBytesForPeerId(latestCall), + callId: Long.fromString(latestCall.callId), }); const syncMessage = MessageSender.createSyncMessage(); diff --git a/ts/textsecure/messageReceiverEvents.ts b/ts/textsecure/messageReceiverEvents.ts index 4f54e4b342..a86e1b0516 100644 --- a/ts/textsecure/messageReceiverEvents.ts +++ b/ts/textsecure/messageReceiverEvents.ts @@ -18,7 +18,10 @@ import type { ProcessedSent, } from './Types.d'; import type { ContactDetailsWithAvatar } from './ContactsParser'; -import type { CallEventDetails, CallLogEvent } from '../types/CallDisposition'; +import type { + CallEventDetails, + CallLogEventDetails, +} from '../types/CallDisposition'; import type { CallLinkUpdateSyncType } from '../types/CallLink'; import { isAciString } from '../util/isAciString'; @@ -559,14 +562,13 @@ export class DeleteForMeSyncEvent extends ConfirmableEvent { } export type CallLogEventSyncEventData = Readonly<{ - event: CallLogEvent; - timestamp: number; + callLogEventDetails: CallLogEventDetails; receivedAtCounter: number; }>; export class CallLogEventSyncEvent extends ConfirmableEvent { constructor( - public readonly callLogEvent: CallLogEventSyncEventData, + public readonly data: CallLogEventSyncEventData, confirm: ConfirmCallback ) { super('callLogEventSync', confirm); diff --git a/ts/types/CallDisposition.ts b/ts/types/CallDisposition.ts index a3e35864b8..a4f96b9ff9 100644 --- a/ts/types/CallDisposition.ts +++ b/ts/types/CallDisposition.ts @@ -26,6 +26,7 @@ export enum CallDirection { export enum CallLogEvent { Clear = 'Clear', MarkedAsRead = 'MarkedAsRead', + MarkedAsReadInConversation = 'MarkedAsReadInConversation', } export enum LocalCallEvent { @@ -97,6 +98,19 @@ export type CallDetails = Readonly<{ timestamp: number; }>; +export type CallLogEventTarget = Readonly<{ + timestamp: number; + callId: string | null; + peerId: AciString | string | null; +}>; + +export type CallLogEventDetails = Readonly<{ + type: CallLogEvent; + timestamp: number; + peerId: AciString | string | null; + callId: string | null; +}>; + export type CallEventDetails = CallDetails & Readonly<{ event: CallEvent; @@ -221,6 +235,13 @@ export const callEventNormalizeSchema = z.object({ event: z.nativeEnum(Proto.SyncMessage.CallEvent.Event), }); +export const callLogEventNormalizeSchema = z.object({ + type: z.nativeEnum(Proto.SyncMessage.CallLogEvent.Type), + timestamp: longToNumberSchema, + peerId: peerIdInBytesSchema.optional(), + callId: longToStringSchema.optional(), +}); + export function isSameCallHistoryGroup( a: CallHistoryGroup, b: CallHistoryGroup diff --git a/ts/util/callDisposition.ts b/ts/util/callDisposition.ts index 30ba96394e..ed043d6a16 100644 --- a/ts/util/callDisposition.ts +++ b/ts/util/callDisposition.ts @@ -44,6 +44,7 @@ import type { CallEventDetails, CallHistoryDetails, CallHistoryGroup, + CallLogEventDetails, CallStatus, GroupCallMeta, } from '../types/CallDisposition'; @@ -60,6 +61,8 @@ import { callDetailsSchema, AdhocCallStatus, CallStatusValue, + callLogEventNormalizeSchema, + CallLogEvent, } from '../types/CallDisposition'; import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationModel } from '../models/conversations'; @@ -248,6 +251,34 @@ export function getCallEventForProto( }); } +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.ICallLogEvent +): CallLogEventDetails { + const callLogEvent = callLogEventNormalizeSchema.parse(callLogEventProto); + + const type = callLogEventFromProto[callLogEvent.type]; + if (type == null) { + throw new TypeError(`Unknown call log event ${callLogEvent.type}`); + } + + return { + type, + timestamp: callLogEvent.timestamp, + peerId: callLogEvent.peerId ?? null, + callId: callLogEvent.callId ?? null, + }; +} + const directionToProto = { [CallDirection.Incoming]: Proto.SyncMessage.CallEvent.Direction.INCOMING, [CallDirection.Outgoing]: Proto.SyncMessage.CallEvent.Direction.OUTGOING, @@ -280,6 +311,17 @@ function shouldSyncStatus(callStatus: CallStatus) { return statusToProto[callStatus] != null; } +export function getBytesForPeerId(callHistory: CallHistoryDetails): Uint8Array { + let peerId = + callHistory.mode === CallMode.Adhoc + ? Bytes.fromBase64(callHistory.peerId) + : uuidToBytes(callHistory.peerId); + if (peerId.length === 0) { + peerId = Bytes.fromBase64(callHistory.peerId); + } + return peerId; +} + export function getProtoForCallHistory( callHistory: CallHistoryDetails ): Proto.SyncMessage.ICallEvent | null { @@ -292,16 +334,8 @@ export function getProtoForCallHistory( )}` ); - let peerId = - callHistory.mode === CallMode.Adhoc - ? Bytes.fromBase64(callHistory.peerId) - : uuidToBytes(callHistory.peerId); - if (peerId.length === 0) { - peerId = Bytes.fromBase64(callHistory.peerId); - } - return new Proto.SyncMessage.CallEvent({ - peerId, + peerId: getBytesForPeerId(callHistory), callId: Long.fromString(callHistory.callId), type: typeToProto[callHistory.type], direction: directionToProto[callHistory.direction], @@ -1191,50 +1225,63 @@ export async function updateCallHistoryFromLocalEvent( await updateRemoteCallHistory(updatedCallHistory); } -export async function clearCallHistoryDataAndSync(): Promise { +export function updateDeletedMessages(messageIds: ReadonlyArray): void { + messageIds.forEach(messageId => { + const message = window.MessageCache.__DEPRECATED$getById(messageId); + const conversation = message?.getConversation(); + if (message == null || conversation == null) { + return; + } + window.reduxActions.conversations.messageDeleted( + messageId, + message.get('conversationId') + ); + conversation.debouncedUpdateLastMessage(); + window.MessageCache.__DEPRECATED$unregister(messageId); + }); +} + +export async function clearCallHistoryDataAndSync( + latestCall: CallHistoryDetails +): Promise { try { - const timestamp = Date.now(); - - log.info(`clearCallHistory: Clearing call history before ${timestamp}`); - const messageIds = await window.Signal.Data.clearCallHistory(timestamp); - - messageIds.forEach(messageId => { - const message = window.MessageCache.__DEPRECATED$getById(messageId); - const conversation = message?.getConversation(); - if (message == null || conversation == null) { - return; - } - window.reduxActions.conversations.messageDeleted( - messageId, - message.get('conversationId') - ); - conversation.debouncedUpdateLastMessage(); - window.MessageCache.__DEPRECATED$unregister(messageId); - }); - + log.info( + `clearCallHistory: Clearing call history before (${latestCall.callId}, ${latestCall.timestamp})` + ); + const messageIds = await window.Signal.Data.clearCallHistory(latestCall); + updateDeletedMessages(messageIds); log.info('clearCallHistory: Queueing sync message'); await singleProtoJobQueue.add( - MessageSender.getClearCallHistoryMessage(timestamp) + MessageSender.getClearCallHistoryMessage(latestCall) ); } catch (error) { log.error('clearCallHistory: Failed to clear call history', error); } } -export async function markAllCallHistoryReadAndSync(): Promise { +export async function markAllCallHistoryReadAndSync( + latestCall: CallHistoryDetails, + inConversation: boolean +): Promise { try { - const timestamp = Date.now(); - log.info( - `markAllCallHistoryReadAndSync: Marking call history read before ${timestamp}` + `markAllCallHistoryReadAndSync: Marking call history read before (${latestCall.callId}, ${latestCall.timestamp})` ); - await window.Signal.Data.markAllCallHistoryRead(timestamp); + if (inConversation) { + await window.Signal.Data.markAllCallHistoryReadInConversation(latestCall); + } else { + await window.Signal.Data.markAllCallHistoryRead(latestCall); + } const ourAci = window.textsecure.storage.user.getCheckedAci(); const callLogEvent = new Proto.SyncMessage.CallLogEvent({ - type: Proto.SyncMessage.CallLogEvent.Type.MARKED_AS_READ, - timestamp: Long.fromNumber(timestamp), + type: inConversation + ? Proto.SyncMessage.CallLogEvent.Type.MARKED_AS_READ_IN_CONVERSATION + : Proto.SyncMessage.CallLogEvent.Type.MARKED_AS_READ, + timestamp: Long.fromNumber(latestCall.timestamp), + peerId: getBytesForPeerId(latestCall), + callId: Long.fromString(latestCall.callId), }); const syncMessage = MessageSender.createSyncMessage(); diff --git a/ts/util/onCallLogEventSync.ts b/ts/util/onCallLogEventSync.ts index c15783fe7a..e50f8e0e3a 100644 --- a/ts/util/onCallLogEventSync.ts +++ b/ts/util/onCallLogEventSync.ts @@ -3,39 +3,56 @@ import type { CallLogEventSyncEvent } from '../textsecure/messageReceiverEvents'; import * as log from '../logging/log'; +import type { CallLogEventTarget } from '../types/CallDisposition'; import { CallLogEvent } from '../types/CallDisposition'; import { missingCaseError } from './missingCaseError'; +import { strictAssert } from './assert'; +import { updateDeletedMessages } from './callDisposition'; export async function onCallLogEventSync( syncEvent: CallLogEventSyncEvent ): Promise { - const { callLogEvent, confirm } = syncEvent; - const { event, timestamp } = callLogEvent; + const { data, confirm } = syncEvent; + const { type, peerId, callId, timestamp } = data.callLogEventDetails; + + const target: CallLogEventTarget = { + peerId, + callId, + timestamp, + }; log.info( - `onCallLogEventSync: Processing event (Event: ${event}, Timestamp: ${timestamp})` + `onCallLogEventSync: Processing event (Event: ${type}, CallId: ${callId}, Timestamp: ${timestamp})` ); - if (event === CallLogEvent.Clear) { - log.info(`onCallLogEventSync: Clearing call history before ${timestamp}`); + if (type === CallLogEvent.Clear) { + log.info('onCallLogEventSync: Clearing call history'); try { - await window.Signal.Data.clearCallHistory(timestamp); + const messageIds = await window.Signal.Data.clearCallHistory(target); + updateDeletedMessages(messageIds); } finally { // We want to reset the call history even if the clear fails. window.reduxActions.callHistory.resetCallHistory(); } confirm(); - } else if (event === CallLogEvent.MarkedAsRead) { - log.info( - `onCallLogEventSync: Marking call history read before ${timestamp}` - ); + } else if (type === CallLogEvent.MarkedAsRead) { + log.info('onCallLogEventSync: Marking call history read'); try { - await window.Signal.Data.markAllCallHistoryRead(timestamp); + await window.Signal.Data.markAllCallHistoryRead(target); + } finally { + window.reduxActions.callHistory.updateCallHistoryUnreadCount(); + } + confirm(); + } else if (type === CallLogEvent.MarkedAsReadInConversation) { + log.info('onCallLogEventSync: Marking call history read in conversation'); + try { + strictAssert(peerId, 'Missing peerId'); + await window.Signal.Data.markAllCallHistoryReadInConversation(target); } finally { window.reduxActions.callHistory.updateCallHistoryUnreadCount(); } confirm(); } else { - throw missingCaseError(event); + throw missingCaseError(type); } }