Synchronous delete call link

This commit is contained in:
ayumi-signal 2024-10-09 09:35:24 -07:00 committed by GitHub
parent e60df56500
commit 42cc5e0013
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 443 additions and 135 deletions

View file

@ -1633,6 +1633,10 @@
"messageformat": "Signal Call", "messageformat": "Signal Call",
"description": "Default title for Signal call links." "description": "Default title for Signal call links."
}, },
"icu:calling__call-link-delete-failed": {
"messageformat": "Can't delete link. Check your connection and try again.",
"description": "Default title for Signal call links."
},
"icu:calling__join-request-denied": { "icu:calling__join-request-denied": {
"messageformat": "Your request to join this call has been denied.", "messageformat": "Your request to join this call has been denied.",
"description": "Error message when a request to join a call link call was rejected by a call admin." "description": "Error message when a request to join a call link call was rejected by a call admin."
@ -7298,7 +7302,11 @@
"description": "Calls Tab > Confirm Clear Call History Dialog > Title" "description": "Calls Tab > Confirm Clear Call History Dialog > Title"
}, },
"icu:CallsTab__ConfirmClearCallHistory__Body": { "icu:CallsTab__ConfirmClearCallHistory__Body": {
"messageformat": "This will permanently delete all call history", "messageformat": "This will permanently delete all call history.",
"description": "Calls Tab > Confirm Clear Call History Dialog > Body Text"
},
"icu:CallsTab__ConfirmClearCallHistory__Body--call-links": {
"messageformat": "This will permanently delete all call history. Call links youve created will no longer work for people who have them. ",
"description": "Calls Tab > Confirm Clear Call History Dialog > Body Text" "description": "Calls Tab > Confirm Clear Call History Dialog > Body Text"
}, },
"icu:CallsTab__ConfirmClearCallHistory__ConfirmButton": { "icu:CallsTab__ConfirmClearCallHistory__ConfirmButton": {
@ -7309,6 +7317,14 @@
"messageformat": "Call history cleared", "messageformat": "Call history cleared",
"description": "Calls Tab > Clear Call History > Toast" "description": "Calls Tab > Clear Call History > Toast"
}, },
"icu:CallsTab__ClearCallHistoryError--call-links": {
"messageformat": "Not all call links could be deleted. Check your connection and try again.",
"description": "Calls Tab > Clear Call History > Error modal when we failed to delete call links."
},
"icu:CallsTab__ClearCallHistoryError": {
"messageformat": "Not all call history could be cleared. Check your connection and try again.",
"description": "Calls Tab > Clear Call History > Generic error modal"
},
"icu:CallsTab__EmptyStateText--with-icon-2": { "icu:CallsTab__EmptyStateText--with-icon-2": {
"messageformat": "Click <newCallButtonIcon></newCallButtonIcon> to start a new voice or video call.", "messageformat": "Click <newCallButtonIcon></newCallButtonIcon> to start a new voice or video call.",
"description": "Calls Tab > When no call is selected > Empty state > Call to action text" "description": "Calls Tab > When no call is selected > Empty state > Call to action text"

View file

@ -49,6 +49,7 @@ type CallsTabProps = Readonly<{
getCallLink: (id: string) => CallLinkType | undefined; getCallLink: (id: string) => CallLinkType | undefined;
getConversation: (id: string) => ConversationType | void; getConversation: (id: string) => ConversationType | void;
hangUpActiveCall: (reason: string) => void; hangUpActiveCall: (reason: string) => void;
hasAnyAdminCallLinks: boolean;
hasFailedStorySends: boolean; hasFailedStorySends: boolean;
hasPendingUpdate: boolean; hasPendingUpdate: boolean;
i18n: LocalizerType; i18n: LocalizerType;
@ -105,6 +106,7 @@ export function CallsTab({
getCallLink, getCallLink,
getConversation, getConversation,
hangUpActiveCall, hangUpActiveCall,
hasAnyAdminCallLinks,
hasFailedStorySends, hasFailedStorySends,
hasPendingUpdate, hasPendingUpdate,
i18n, i18n,
@ -370,7 +372,9 @@ export function CallsTab({
}, },
]} ]}
> >
{i18n('icu:CallsTab__ConfirmClearCallHistory__Body')} {hasAnyAdminCallLinks
? i18n('icu:CallsTab__ConfirmClearCallHistory__Body--call-links')
: i18n('icu:CallsTab__ConfirmClearCallHistory__Body')}
</ConfirmationDialog> </ConfirmationDialog>
)} )}
</> </>

View file

@ -10,7 +10,7 @@ import { Button, ButtonVariant } from './Button';
export type PropsType = { export type PropsType = {
buttonVariant?: ButtonVariant; buttonVariant?: ButtonVariant;
description?: string; description?: string;
title?: string; title?: string | null;
onClose: () => void; onClose: () => void;
i18n: LocalizerType; i18n: LocalizerType;
@ -40,7 +40,7 @@ export function ErrorModal(props: PropsType): JSX.Element {
modalName="ErrorModal" modalName="ErrorModal"
i18n={i18n} i18n={i18n}
onClose={onClose} onClose={onClose}
title={title || i18n('icu:ErrorModal--title')} title={title == null ? undefined : i18n('icu:ErrorModal--title')}
modalFooter={footer} modalFooter={footer}
> >
<div className="module-error-modal__description"> <div className="module-error-modal__description">

View file

@ -53,12 +53,16 @@ export type PropsType = {
renderEditNicknameAndNoteModal: () => JSX.Element; renderEditNicknameAndNoteModal: () => JSX.Element;
// ErrorModal // ErrorModal
errorModalProps: errorModalProps:
| { buttonVariant?: ButtonVariant; description?: string; title?: string } | {
buttonVariant?: ButtonVariant;
description?: string;
title?: string | null;
}
| undefined; | undefined;
renderErrorModal: (opts: { renderErrorModal: (opts: {
buttonVariant?: ButtonVariant; buttonVariant?: ButtonVariant;
description?: string; description?: string;
title?: string; title?: string | null;
}) => JSX.Element; }) => JSX.Element;
// DeleteMessageModal // DeleteMessageModal
deleteMessagesProps: DeleteMessagesPropsType | undefined; deleteMessagesProps: DeleteMessagesPropsType | undefined;

View file

@ -9,8 +9,6 @@ import {
type JobManagerJobResultType, type JobManagerJobResultType,
type JobManagerJobType, type JobManagerJobType,
} from './JobManager'; } from './JobManager';
import { calling } from '../services/calling';
import { callLinkFromRecord } from '../util/callLinksRingrtc';
// Type for adding a new job // Type for adding a new job
export type NewCallLinkDeleteJobType = { export type NewCallLinkDeleteJobType = {
@ -28,7 +26,7 @@ export type CallLinkDeleteJobType = CoreCallLinkDeleteJobType &
const MAX_CONCURRENT_JOBS = 5; const MAX_CONCURRENT_JOBS = 5;
const DEFAULT_RETRY_CONFIG = { const DEFAULT_RETRY_CONFIG = {
maxAttempts: Infinity, maxAttempts: 10,
backoffConfig: { backoffConfig: {
// 1 min, 5 min, 25 min, (max) 1 day // 1 min, 5 min, 25 min, (max) 1 day
multiplier: 5, multiplier: 5,
@ -37,19 +35,24 @@ const DEFAULT_RETRY_CONFIG = {
}, },
}; };
type CallLinkDeleteManagerParamsType = type CallLinkFinalizeDeleteManagerParamsType =
JobManagerParamsType<CoreCallLinkDeleteJobType>; JobManagerParamsType<CoreCallLinkDeleteJobType>;
function getJobId(job: CoreCallLinkDeleteJobType): string { function getJobId(job: CoreCallLinkDeleteJobType): string {
return job.roomId; return job.roomId;
} }
export class CallLinkDeleteManager extends JobManager<CoreCallLinkDeleteJobType> { // The purpose of this job is to finalize local DB delete of call links and
// associated call history, after we confirm storage sync.
// It does *not* delete the call link from the server -- this should be done
// synchronously and prior to running this job, so we can show confirmation
// or error to the user.
export class CallLinkFinalizeDeleteManager extends JobManager<CoreCallLinkDeleteJobType> {
jobs: Map<string, CallLinkDeleteJobType> = new Map(); jobs: Map<string, CallLinkDeleteJobType> = new Map();
private static _instance: CallLinkDeleteManager | undefined; private static _instance: CallLinkFinalizeDeleteManager | undefined;
override logPrefix = 'CallLinkDeleteManager'; override logPrefix = 'CallLinkFinalizeDeleteManager';
static defaultParams: CallLinkDeleteManagerParamsType = { static defaultParams: CallLinkFinalizeDeleteManagerParamsType = {
markAllJobsInactive: () => Promise.resolve(), markAllJobsInactive: () => Promise.resolve(),
getNextJobs, getNextJobs,
saveJob, saveJob,
@ -61,7 +64,7 @@ export class CallLinkDeleteManager extends JobManager<CoreCallLinkDeleteJobType>
maxConcurrentJobs: MAX_CONCURRENT_JOBS, maxConcurrentJobs: MAX_CONCURRENT_JOBS,
}; };
constructor(params: CallLinkDeleteManagerParamsType) { constructor(params: CallLinkFinalizeDeleteManagerParamsType) {
super({ super({
...params, ...params,
getNextJobs: ({ limit, timestamp }) => getNextJobs: ({ limit, timestamp }) =>
@ -103,40 +106,43 @@ export class CallLinkDeleteManager extends JobManager<CoreCallLinkDeleteJobType>
roomIds.forEach(roomId => this.addJob({ roomId }, options)); roomIds.forEach(roomId => this.addJob({ roomId }, options));
} }
static get instance(): CallLinkDeleteManager { static get instance(): CallLinkFinalizeDeleteManager {
if (!CallLinkDeleteManager._instance) { if (!CallLinkFinalizeDeleteManager._instance) {
CallLinkDeleteManager._instance = new CallLinkDeleteManager( CallLinkFinalizeDeleteManager._instance =
CallLinkDeleteManager.defaultParams new CallLinkFinalizeDeleteManager(
CallLinkFinalizeDeleteManager.defaultParams
); );
} }
return CallLinkDeleteManager._instance; return CallLinkFinalizeDeleteManager._instance;
} }
static async start(): Promise<void> { static async start(): Promise<void> {
await CallLinkDeleteManager.instance.enqueueAllDeletedCallLinks(); await CallLinkFinalizeDeleteManager.instance.enqueueAllDeletedCallLinks();
await CallLinkDeleteManager.instance.start(); await CallLinkFinalizeDeleteManager.instance.start();
} }
static async stop(): Promise<void> { static async stop(): Promise<void> {
return CallLinkDeleteManager._instance?.stop(); return CallLinkFinalizeDeleteManager._instance?.stop();
} }
static async addJob( static async addJob(
newJob: CoreCallLinkDeleteJobType, newJob: CoreCallLinkDeleteJobType,
options?: { delay: number } options?: { delay: number }
): Promise<void> { ): Promise<void> {
return CallLinkDeleteManager.instance.addJob(newJob, options); return CallLinkFinalizeDeleteManager.instance.addJob(newJob, options);
} }
static async enqueueAllDeletedCallLinks(options?: { static async enqueueAllDeletedCallLinks(options?: {
delay: number; delay: number;
}): Promise<void> { }): Promise<void> {
return CallLinkDeleteManager.instance.enqueueAllDeletedCallLinks(options); return CallLinkFinalizeDeleteManager.instance.enqueueAllDeletedCallLinks(
options
);
} }
} }
async function getNextJobs( async function getNextJobs(
this: CallLinkDeleteManager, this: CallLinkFinalizeDeleteManager,
{ {
limit, limit,
timestamp, timestamp,
@ -162,7 +168,7 @@ async function getNextJobs(
} }
async function saveJob( async function saveJob(
this: CallLinkDeleteManager, this: CallLinkFinalizeDeleteManager,
job: CallLinkDeleteJobType job: CallLinkDeleteJobType
): Promise<void> { ): Promise<void> {
const { roomId } = job; const { roomId } = job;
@ -170,14 +176,10 @@ async function saveJob(
} }
async function removeJob( async function removeJob(
this: CallLinkDeleteManager, this: CallLinkFinalizeDeleteManager,
job: CallLinkDeleteJobType job: CallLinkDeleteJobType
): Promise<void> { ): Promise<void> {
const logId = `CallLinkDeleteJobType/removeJob/${getJobId(job)}`; this.jobs.delete(job.roomId);
const { roomId } = job;
await DataWriter.finalizeDeleteCallLink(job.roomId);
log.info(`${logId}: Finalized local delete`);
this.jobs.delete(roomId);
} }
async function runJob( async function runJob(
@ -191,10 +193,8 @@ async function runJob(
log.warn(`${logId}: Call link gone from DB`); log.warn(`${logId}: Call link gone from DB`);
return { status: 'finished' }; return { status: 'finished' };
} }
if (callLinkRecord.adminKey == null) { if (callLinkRecord.deleted !== 1) {
log.error( log.error(`${logId}: Call link not marked deleted. Giving up.`);
`${logId}: No admin key available, deletion on server not possible. Giving up.`
);
return { status: 'finished' }; return { status: 'finished' };
} }
@ -204,10 +204,7 @@ async function runJob(
return { status: 'retry' }; return { status: 'retry' };
} }
// Delete link on calling server. May 200 or 404 and both are OK. await DataWriter.finalizeDeleteCallLink(job.roomId);
// Errs if call link is active or network is unavailable. log.info(`${logId}: Finalized local delete`);
const callLink = callLinkFromRecord(callLinkRecord);
await calling.deleteCallLink(callLink);
log.info(`${logId}: Deleted call link on server`);
return { status: 'finished' }; return { status: 'finished' };
} }

View file

@ -97,10 +97,7 @@ export class CallLinkRefreshJobQueue extends JobQueue<CallLinkRefreshJobData> {
`${logId}: Call link not found on server and deleteLocallyIfMissingOnCallingServer; deleting local call link` `${logId}: Call link not found on server and deleteLocallyIfMissingOnCallingServer; deleting local call link`
); );
// This will leave a storage service record, and it's up to primary to delete it // This will leave a storage service record, and it's up to primary to delete it
await DataWriter.beginDeleteCallLink(roomId, { await DataWriter.deleteCallLinkAndHistory(roomId);
storageNeedsSync: false,
});
await DataWriter.finalizeDeleteCallLink(roomId);
window.reduxActions.calling.handleCallLinkDelete({ roomId }); window.reduxActions.calling.handleCallLinkDelete({ roomId });
} else { } else {
log.info(`${logId}: Call link not found on server, ignoring`); log.info(`${logId}: Call link not found on server, ignoring`);

View file

@ -3,7 +3,7 @@
import type { WebAPIType } from '../textsecure/WebAPI'; import type { WebAPIType } from '../textsecure/WebAPI';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
import { CallLinkDeleteManager } from './CallLinkDeleteManager'; import { CallLinkFinalizeDeleteManager } from './CallLinkFinalizeDeleteManager';
import { callLinkRefreshJobQueue } from './callLinkRefreshJobQueue'; import { callLinkRefreshJobQueue } from './callLinkRefreshJobQueue';
import { conversationJobQueue } from './conversationJobQueue'; import { conversationJobQueue } from './conversationJobQueue';
@ -43,7 +43,7 @@ export function initializeAllJobQueues({
drop(removeStorageKeyJobQueue.streamJobs()); drop(removeStorageKeyJobQueue.streamJobs());
drop(reportSpamJobQueue.streamJobs()); drop(reportSpamJobQueue.streamJobs());
drop(callLinkRefreshJobQueue.streamJobs()); drop(callLinkRefreshJobQueue.streamJobs());
drop(CallLinkDeleteManager.start()); drop(CallLinkFinalizeDeleteManager.start());
} }
export async function shutdownAllJobQueues(): Promise<void> { export async function shutdownAllJobQueues(): Promise<void> {
@ -57,6 +57,6 @@ export async function shutdownAllJobQueues(): Promise<void> {
viewOnceOpenJobQueue.shutdown(), viewOnceOpenJobQueue.shutdown(),
removeStorageKeyJobQueue.shutdown(), removeStorageKeyJobQueue.shutdown(),
reportSpamJobQueue.shutdown(), reportSpamJobQueue.shutdown(),
CallLinkDeleteManager.stop(), CallLinkFinalizeDeleteManager.stop(),
]); ]);
} }

View file

@ -2075,12 +2075,8 @@ export async function mergeCallLinkRecord(
// Another device deleted the link and uploaded to storage, and we learned about it // Another device deleted the link and uploaded to storage, and we learned about it
log.info(`${logId}: Discovered deleted call link, deleting locally`); log.info(`${logId}: Discovered deleted call link, deleting locally`);
details.push('deleting locally'); details.push('deleting locally');
await DataWriter.beginDeleteCallLink(roomId, {
storageNeedsSync: false,
deletedAt,
});
// No need to delete via RingRTC as we assume the originating device did that already // No need to delete via RingRTC as we assume the originating device did that already
await DataWriter.finalizeDeleteCallLink(roomId); await DataWriter.deleteCallLinkAndHistory(roomId);
window.reduxActions.calling.handleCallLinkDelete({ roomId }); window.reduxActions.calling.handleCallLinkDelete({ roomId });
} else if (!deletedAt && localCallLinkDbRecord.deleted === 1) { } else if (!deletedAt && localCallLinkDbRecord.deleted === 1) {
// Not deleted in storage, but we've marked it as deleted locally. // Not deleted in storage, but we've marked it as deleted locally.

View file

@ -44,7 +44,6 @@ import type {
import type { SyncTaskType } from '../util/syncTasks'; import type { SyncTaskType } from '../util/syncTasks';
import type { AttachmentBackupJobType } from '../types/AttachmentBackup'; import type { AttachmentBackupJobType } from '../types/AttachmentBackup';
import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue'; import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import type { DeleteCallLinkOptions } from './server/callLinks';
export type ReadableDB = Database & { __readable_db: never }; export type ReadableDB = Database & { __readable_db: never };
export type WritableDB = ReadableDB & { __writable_db: never }; export type WritableDB = ReadableDB & { __writable_db: never };
@ -584,6 +583,7 @@ type ReadableInterface = {
getAllCallLinks: () => ReadonlyArray<CallLinkType>; getAllCallLinks: () => ReadonlyArray<CallLinkType>;
getCallLinkByRoomId: (roomId: string) => CallLinkType | undefined; getCallLinkByRoomId: (roomId: string) => CallLinkType | undefined;
getCallLinkRecordByRoomId: (roomId: string) => CallLinkRecord | undefined; getCallLinkRecordByRoomId: (roomId: string) => CallLinkRecord | undefined;
getAllAdminCallLinks(): ReadonlyArray<CallLinkType>;
getAllCallLinkRecordsWithAdminKey(): ReadonlyArray<CallLinkRecord>; getAllCallLinkRecordsWithAdminKey(): ReadonlyArray<CallLinkRecord>;
getAllMarkedDeletedCallLinkRoomIds(): ReadonlyArray<string>; getAllMarkedDeletedCallLinkRoomIds(): ReadonlyArray<string>;
getMessagesBetween: ( getMessagesBetween: (
@ -813,8 +813,10 @@ type WritableInterface = {
roomId: string, roomId: string,
callLinkState: CallLinkStateType callLinkState: CallLinkStateType
): CallLinkType; ): CallLinkType;
beginDeleteAllCallLinks(): void; beginDeleteAllCallLinks(): boolean;
beginDeleteCallLink(roomId: string, options: DeleteCallLinkOptions): void; beginDeleteCallLink(roomId: string): boolean;
deleteCallHistoryByRoomId(roomid: string): void;
deleteCallLinkAndHistory(roomId: string): void;
finalizeDeleteCallLink(roomId: string): void; finalizeDeleteCallLink(roomId: string): void;
_removeAllCallLinks(): void; _removeAllCallLinks(): void;
deleteCallLinkFromSync(roomId: string): void; deleteCallLinkFromSync(roomId: string): void;

View file

@ -180,6 +180,9 @@ import {
updateCallLinkAdminKeyByRoomId, updateCallLinkAdminKeyByRoomId,
updateCallLinkState, updateCallLinkState,
beginDeleteAllCallLinks, beginDeleteAllCallLinks,
deleteCallHistoryByRoomId,
deleteCallLinkAndHistory,
getAllAdminCallLinks,
getAllCallLinkRecordsWithAdminKey, getAllCallLinkRecordsWithAdminKey,
getAllMarkedDeletedCallLinkRoomIds, getAllMarkedDeletedCallLinkRoomIds,
finalizeDeleteCallLink, finalizeDeleteCallLink,
@ -313,6 +316,7 @@ export const DataReader: ServerReadableInterface = {
getAllCallLinks, getAllCallLinks,
getCallLinkByRoomId, getCallLinkByRoomId,
getCallLinkRecordByRoomId, getCallLinkRecordByRoomId,
getAllAdminCallLinks,
getAllCallLinkRecordsWithAdminKey, getAllCallLinkRecordsWithAdminKey,
getAllMarkedDeletedCallLinkRoomIds, getAllMarkedDeletedCallLinkRoomIds,
getMessagesBetween, getMessagesBetween,
@ -451,6 +455,8 @@ export const DataWriter: ServerWritableInterface = {
updateCallLinkState, updateCallLinkState,
beginDeleteAllCallLinks, beginDeleteAllCallLinks,
beginDeleteCallLink, beginDeleteCallLink,
deleteCallHistoryByRoomId,
deleteCallLinkAndHistory,
finalizeDeleteCallLink, finalizeDeleteCallLink,
_removeAllCallLinks, _removeAllCallLinks,
deleteCallLinkFromSync, deleteCallLinkFromSync,
@ -3543,6 +3549,14 @@ function _removeAllCallHistory(db: WritableDB): void {
db.prepare(query).run(params); db.prepare(query).run(params);
} }
/**
* Deletes call history by marking it deleted. Tombstoning is needed in case sync messages
* come in around the same time, to prevent reappearance of deleted call history.
* Limitation: History for admin call links is skipped. Admin call links need to be
* deleted on the calling server first, before we can clear local history.
*
* @returns ReadonlyArray<string>: message ids of call history messages
*/
function clearCallHistory( function clearCallHistory(
db: WritableDB, db: WritableDB,
target: CallLogEventTarget target: CallLogEventTarget
@ -3555,17 +3569,33 @@ function clearCallHistory(
} }
const { timestamp } = callHistory; const { timestamp } = callHistory;
// Admin call links are deleted separately after server confirmation
const [selectAdminCallLinksQuery, selectAdminCallLinksParams] = sql`
SELECT roomId
FROM callLinks
WHERE callLinks.adminKey IS NOT NULL;
`;
const adminCallLinkIds: ReadonlyArray<string> = db
.prepare(selectAdminCallLinksQuery)
.pluck()
.all(selectAdminCallLinksParams);
const adminCallLinkIdsFragment = sqlJoin(adminCallLinkIds);
const [selectCallsQuery, selectCallsParams] = sql` const [selectCallsQuery, selectCallsParams] = sql`
SELECT callsHistory.callId SELECT callsHistory.callId
FROM callsHistory FROM callsHistory
WHERE WHERE
(
-- Prior calls -- Prior calls
(callsHistory.timestamp <= ${timestamp}) (callsHistory.timestamp <= ${timestamp})
-- Unused call links -- Unused call links
OR ( OR (
callsHistory.mode IS ${CALL_MODE_ADHOC} AND callsHistory.mode IS ${CALL_MODE_ADHOC} AND
callsHistory.status IS ${CALL_STATUS_PENDING} callsHistory.status IS ${CALL_STATUS_PENDING}
); )
) AND
callsHistory.peerId NOT IN (${adminCallLinkIdsFragment});
`; `;
const deletedCallIds: ReadonlyArray<string> = db const deletedCallIds: ReadonlyArray<string> = db

View file

@ -0,0 +1,28 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from '@signalapp/better-sqlite3';
import type { LoggerType } from '../../types/Logging';
export const version = 1230;
export function updateToSchemaVersion1230(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 1230) {
return;
}
db.transaction(() => {
db.exec(`
DROP INDEX IF EXISTS callLinks_adminKey;
CREATE INDEX callLinks_adminKey
ON callLinks (adminKey);
`);
db.pragma('user_version = 1230');
})();
logger.info('updateToSchemaVersion1230: success!');
}

View file

@ -98,10 +98,11 @@ import { updateToSchemaVersion1180 } from './1180-add-attachment-download-source
import { updateToSchemaVersion1190 } from './1190-call-links-storage'; import { updateToSchemaVersion1190 } from './1190-call-links-storage';
import { updateToSchemaVersion1200 } from './1200-attachment-download-source-index'; import { updateToSchemaVersion1200 } from './1200-attachment-download-source-index';
import { updateToSchemaVersion1210 } from './1210-call-history-started-id'; import { updateToSchemaVersion1210 } from './1210-call-history-started-id';
import { updateToSchemaVersion1220 } from './1220-blob-sessions';
import { import {
updateToSchemaVersion1220, updateToSchemaVersion1230,
version as MAX_VERSION, version as MAX_VERSION,
} from './1220-blob-sessions'; } from './1230-call-links-admin-key-index';
function updateToSchemaVersion1( function updateToSchemaVersion1(
currentVersion: number, currentVersion: number,
@ -2069,6 +2070,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion1200, updateToSchemaVersion1200,
updateToSchemaVersion1210, updateToSchemaVersion1210,
updateToSchemaVersion1220, updateToSchemaVersion1220,
updateToSchemaVersion1230,
]; ];
export class DBVersionFromFutureError extends Error { export class DBVersionFromFutureError extends Error {

View file

@ -20,7 +20,7 @@ import type { ReadableDB, WritableDB } from '../Interface';
import { prepare } from '../Server'; import { prepare } from '../Server';
import { sql } from '../util'; import { sql } from '../util';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import { CallStatusValue } from '../../types/CallDisposition'; import { CallStatusValue, DirectCallStatus } from '../../types/CallDisposition';
import { parseStrict, parseUnknown } from '../../util/schemas'; import { parseStrict, parseUnknown } from '../../util/schemas';
export function callLinkExists(db: ReadableDB, roomId: string): boolean { export function callLinkExists(db: ReadableDB, roomId: string): boolean {
@ -190,7 +190,10 @@ function assertRoomIdMatchesRootKey(roomId: string, rootKey: string): void {
); );
} }
function deleteCallHistoryByRoomId(db: WritableDB, roomId: string) { export function deleteCallHistoryByRoomId(
db: WritableDB,
roomId: string
): void {
const [ const [
markCallHistoryDeleteByPeerIdQuery, markCallHistoryDeleteByPeerIdQuery,
markCallHistoryDeleteByPeerIdParams, markCallHistoryDeleteByPeerIdParams,
@ -222,17 +225,14 @@ export function deleteCallLinkFromSync(db: WritableDB, roomId: string): void {
})(); })();
} }
export type DeleteCallLinkOptions = { /**
storageNeedsSync: boolean; * Deletes a non-admin call link from the local database, or if it's an admin call link,
deletedAt?: number; * then marks it for deletion and storage sync.
}; *
* @returns boolean: True if storage sync is needed; False if not
export function beginDeleteCallLink( */
db: WritableDB, export function beginDeleteCallLink(db: WritableDB, roomId: string): boolean {
roomId: string, return db.transaction(() => {
options: DeleteCallLinkOptions
): void {
db.transaction(() => {
// If adminKey is null, then we should delete the call link // If adminKey is null, then we should delete the call link
const [deleteNonAdminCallLinksQuery, deleteNonAdminCallLinksParams] = sql` const [deleteNonAdminCallLinksQuery, deleteNonAdminCallLinksParams] = sql`
DELETE FROM callLinks DELETE FROM callLinks
@ -244,10 +244,13 @@ export function beginDeleteCallLink(
.prepare(deleteNonAdminCallLinksQuery) .prepare(deleteNonAdminCallLinksQuery)
.run(deleteNonAdminCallLinksParams); .run(deleteNonAdminCallLinksParams);
// Skip this query if the call is already deleted // If we successfully deleted the call link, then it was a non-admin call link
if (result.changes === 0) { // and we're done
const { storageNeedsSync } = options; if (result.changes !== 0) {
const deletedAt = options.deletedAt ?? new Date().getTime(); return false;
}
const deletedAt = new Date().getTime();
// If the admin key is not null, we should mark it for deletion // If the admin key is not null, we should mark it for deletion
const [markAdminCallLinksDeletedQuery, markAdminCallLinksDeletedParams] = const [markAdminCallLinksDeletedQuery, markAdminCallLinksDeletedParams] =
@ -256,23 +259,47 @@ export function beginDeleteCallLink(
SET SET
deleted = 1, deleted = 1,
deletedAt = ${deletedAt}, deletedAt = ${deletedAt},
storageNeedsSync = ${storageNeedsSync ? 1 : 0} storageNeedsSync = 1
WHERE adminKey IS NOT NULL WHERE adminKey IS NOT NULL
AND deleted IS NOT 1
AND roomId = ${roomId}; AND roomId = ${roomId};
`; `;
db.prepare(markAdminCallLinksDeletedQuery).run( const deleteAdminLinkResult = db
markAdminCallLinksDeletedParams .prepare(markAdminCallLinksDeletedQuery)
); .run(markAdminCallLinksDeletedParams);
} return deleteAdminLinkResult.changes > 0;
deleteCallHistoryByRoomId(db, roomId);
})(); })();
} }
export function beginDeleteAllCallLinks(db: WritableDB): void { export function deleteCallLinkAndHistory(db: WritableDB, roomId: string): void {
const deletedAt = new Date().getTime();
db.transaction(() => { db.transaction(() => {
const [deleteCallLinkQuery, deleteCallLinkParams] = sql`
DELETE FROM callLinks
WHERE roomId = ${roomId};
`;
db.prepare(deleteCallLinkQuery).run(deleteCallLinkParams);
const [deleteCallHistoryQuery, clearCallHistoryParams] = sql`
UPDATE callsHistory
SET
status = ${DirectCallStatus.Deleted},
timestamp = ${Date.now()}
WHERE peerId = ${roomId};
`;
db.prepare(deleteCallHistoryQuery).run(clearCallHistoryParams);
})();
}
/**
* Deletes all non-admin call link from the local database, and marks all admin call links
* for deletion and storage sync.
*
* @returns boolean: True if storage sync is needed; False if not
*/
export function beginDeleteAllCallLinks(db: WritableDB): boolean {
const deletedAt = new Date().getTime();
return db.transaction(() => {
const [markAdminCallLinksDeletedQuery, markAdminCallLinksDeletedParams] = const [markAdminCallLinksDeletedQuery, markAdminCallLinksDeletedParams] =
sql` sql`
UPDATE callLinks UPDATE callLinks
@ -280,12 +307,13 @@ export function beginDeleteAllCallLinks(db: WritableDB): void {
deleted = 1, deleted = 1,
deletedAt = ${deletedAt}, deletedAt = ${deletedAt},
storageNeedsSync = 1 storageNeedsSync = 1
WHERE adminKey IS NOT NULL; WHERE adminKey IS NOT NULL
AND deleted IS NOT 1;
`; `;
db.prepare(markAdminCallLinksDeletedQuery).run( const markAdminCallLinksDeletedResult = db
markAdminCallLinksDeletedParams .prepare(markAdminCallLinksDeletedQuery)
); .run(markAdminCallLinksDeletedParams);
const [deleteNonAdminCallLinksQuery] = sql` const [deleteNonAdminCallLinksQuery] = sql`
DELETE FROM callLinks DELETE FROM callLinks
@ -293,6 +321,9 @@ export function beginDeleteAllCallLinks(db: WritableDB): void {
`; `;
db.prepare(deleteNonAdminCallLinksQuery).run(); db.prepare(deleteNonAdminCallLinksQuery).run();
// If admin call links were marked deleted, then storage will need sync
return markAdminCallLinksDeletedResult.changes > 0;
})(); })();
} }
@ -311,6 +342,14 @@ export function getAllCallLinkRecordsWithAdminKey(
.map((item: unknown) => parseUnknown(callLinkRecordSchema, item)); .map((item: unknown) => parseUnknown(callLinkRecordSchema, item));
} }
export function getAllAdminCallLinks(
db: ReadableDB
): ReadonlyArray<CallLinkType> {
return getAllCallLinkRecordsWithAdminKey(db).map((record: CallLinkRecord) =>
callLinkFromRecord(record)
);
}
export function getAllMarkedDeletedCallLinkRoomIds( export function getAllMarkedDeletedCallLinkRoomIds(
db: ReadableDB db: ReadableDB
): ReadonlyArray<string> { ): ReadonlyArray<string> {

View file

@ -15,7 +15,10 @@ import type { ToastActionType } from './toast';
import { showToast } from './toast'; import { showToast } from './toast';
import { DataReader, DataWriter } from '../../sql/Client'; import { DataReader, DataWriter } from '../../sql/Client';
import { ToastType } from '../../types/Toast'; import { ToastType } from '../../types/Toast';
import type { CallHistoryDetails } from '../../types/CallDisposition'; import {
ClearCallHistoryResult,
type CallHistoryDetails,
} from '../../types/CallDisposition';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import * as Errors from '../../types/errors'; import * as Errors from '../../types/errors';
import { drop } from '../../util/drop'; import { drop } from '../../util/drop';
@ -29,6 +32,11 @@ import {
loadCallHistory, loadCallHistory,
} from '../../services/callHistoryLoader'; } from '../../services/callHistoryLoader';
import { makeLookup } from '../../util/makeLookup'; import { makeLookup } from '../../util/makeLookup';
import { missingCaseError } from '../../util/missingCaseError';
import { getIntl } from '../selectors/user';
import { ButtonVariant } from '../../components/Button';
import type { ShowErrorModalActionType } from './globalModals';
import { SHOW_ERROR_MODAL } from './globalModals';
export type CallHistoryState = ReadonlyDeep<{ export type CallHistoryState = ReadonlyDeep<{
// This informs the app that underlying call history data has changed. // This informs the app that underlying call history data has changed.
@ -191,14 +199,42 @@ function clearAllCallHistory(): ThunkAction<
void, void,
RootStateType, RootStateType,
unknown, unknown,
CallHistoryReset | ToastActionType CallHistoryReset | ToastActionType | ShowErrorModalActionType
> { > {
return async (dispatch, getState) => { return async (dispatch, getState) => {
try { try {
const latestCall = getCallHistoryLatestCall(getState()); const latestCall = getCallHistoryLatestCall(getState());
if (latestCall != null) { if (latestCall == null) {
await clearCallHistoryDataAndSync(latestCall); return;
}
const result = await clearCallHistoryDataAndSync(latestCall);
if (result === ClearCallHistoryResult.Success) {
dispatch(showToast({ toastType: ToastType.CallHistoryCleared })); dispatch(showToast({ toastType: ToastType.CallHistoryCleared }));
} else if (result === ClearCallHistoryResult.Error) {
const i18n = getIntl(getState());
dispatch({
type: SHOW_ERROR_MODAL,
payload: {
title: null,
description: i18n('icu:CallsTab__ClearCallHistoryError'),
buttonVariant: ButtonVariant.Primary,
},
});
} else if (result === ClearCallHistoryResult.ErrorDeletingCallLinks) {
const i18n = getIntl(getState());
dispatch({
type: SHOW_ERROR_MODAL,
payload: {
title: null,
description: i18n(
'icu:CallsTab__ClearCallHistoryError--call-links'
),
buttonVariant: ButtonVariant.Primary,
},
});
} else {
throw missingCaseError(result);
} }
} catch (error) { } catch (error) {
log.error('Error clearing call history', Errors.toLogFormat(error)); log.error('Error clearing call history', Errors.toLogFormat(error));

View file

@ -36,10 +36,11 @@ import type {
PresentedSource, PresentedSource,
PresentableSource, PresentableSource,
} from '../../types/Calling'; } from '../../types/Calling';
import type { import {
CallLinkRestrictions, isCallLinkAdmin,
CallLinkStateType, type CallLinkRestrictions,
CallLinkType, type CallLinkStateType,
type CallLinkType,
} from '../../types/CallLink'; } from '../../types/CallLink';
import { import {
CALLING_REACTIONS_LIFETIME, CALLING_REACTIONS_LIFETIME,
@ -110,7 +111,7 @@ import {
getPresentingSource, getPresentingSource,
} from '../selectors/calling'; } from '../selectors/calling';
import { storageServiceUploadJob } from '../../services/storage'; import { storageServiceUploadJob } from '../../services/storage';
import { CallLinkDeleteManager } from '../../jobs/CallLinkDeleteManager'; import { CallLinkFinalizeDeleteManager } from '../../jobs/CallLinkFinalizeDeleteManager';
import { callLinkRefreshJobQueue } from '../../jobs/callLinkRefreshJobQueue'; import { callLinkRefreshJobQueue } from '../../jobs/callLinkRefreshJobQueue';
// State // State
@ -2125,13 +2126,50 @@ function createCallLink(
function deleteCallLink( function deleteCallLink(
roomId: string roomId: string
): ThunkAction<void, RootStateType, unknown, HandleCallLinkDeleteActionType> { ): ThunkAction<
return async dispatch => { void,
await DataWriter.beginDeleteCallLink(roomId, { storageNeedsSync: true }); RootStateType,
unknown,
HandleCallLinkDeleteActionType | ShowErrorModalActionType
> {
return async (dispatch, getState) => {
const callLink = await DataReader.getCallLinkByRoomId(roomId);
if (!callLink) {
return;
}
const isStorageSyncNeeded = await DataWriter.beginDeleteCallLink(roomId);
if (isStorageSyncNeeded) {
storageServiceUploadJob({ reason: 'deleteCallLink' }); storageServiceUploadJob({ reason: 'deleteCallLink' });
// Wait for storage service sync before finalizing delete }
drop(CallLinkDeleteManager.addJob({ roomId }, { delay: 10000 })); try {
if (isCallLinkAdmin(callLink)) {
// This throws if call link is active or network is unavailable.
await calling.deleteCallLink(callLink);
// Wait for storage service sync before finalizing delete.
drop(
CallLinkFinalizeDeleteManager.addJob(
{ roomId: callLink.roomId },
{ delay: 10000 }
)
);
}
await DataWriter.deleteCallHistoryByRoomId(callLink.roomId);
dispatch(handleCallLinkDelete({ roomId })); dispatch(handleCallLinkDelete({ roomId }));
} catch (error) {
log.warn('clearCallHistory: Failed to delete call link', error);
const i18n = getIntl(getState());
dispatch({
type: SHOW_ERROR_MODAL,
payload: {
title: null,
description: i18n('icu:calling__call-link-delete-failed'),
buttonVariant: ButtonVariant.Primary,
},
});
}
}; };
} }
@ -3985,5 +4023,14 @@ export function reducer(
}; };
} }
if (action.type === HANDLE_CALL_LINK_DELETE) {
const { roomId } = action.payload;
return {
...state,
callLinks: omit(state.callLinks, roomId),
};
}
return state; return state;
} }

View file

@ -103,7 +103,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{
errorModalProps?: { errorModalProps?: {
buttonVariant?: ButtonVariant; buttonVariant?: ButtonVariant;
description?: string; description?: string;
title?: string; title?: string | null;
}; };
forwardMessagesProps?: ForwardMessagesPropsType; forwardMessagesProps?: ForwardMessagesPropsType;
gv2MigrationProps?: MigrateToGV2PropsType; gv2MigrationProps?: MigrateToGV2PropsType;
@ -338,7 +338,7 @@ export type ShowErrorModalActionType = ReadonlyDeep<{
payload: { payload: {
buttonVariant?: ButtonVariant; buttonVariant?: ButtonVariant;
description?: string; description?: string;
title?: string; title?: string | null;
}; };
}>; }>;

View file

@ -16,7 +16,7 @@ import type {
import { getIncomingCall as getIncomingCallHelper } from '../ducks/callingHelpers'; import { getIncomingCall as getIncomingCallHelper } from '../ducks/callingHelpers';
import type { PresentedSource } from '../../types/Calling'; import type { PresentedSource } from '../../types/Calling';
import { CallMode } from '../../types/CallDisposition'; import { CallMode } from '../../types/CallDisposition';
import type { CallLinkType } from '../../types/CallLink'; import { isCallLinkAdmin, type CallLinkType } from '../../types/CallLink';
import { getUserACI } from './user'; import { getUserACI } from './user';
import { getOwn } from '../../util/getOwn'; import { getOwn } from '../../util/getOwn';
import type { AciString } from '../../types/ServiceId'; import type { AciString } from '../../types/ServiceId';
@ -96,6 +96,11 @@ export const getAllCallLinks = createSelector(
(lookup): Array<CallLinkType> => Object.values(lookup) (lookup): Array<CallLinkType> => Object.values(lookup)
); );
export const getHasAnyAdminCallLinks = createSelector(
getAllCallLinks,
(callLinks): boolean => callLinks.some(callLink => isCallLinkAdmin(callLink))
);
export type CallSelectorType = ( export type CallSelectorType = (
conversationId: string conversationId: string
) => CallStateType | undefined; ) => CallStateType | undefined;

View file

@ -32,6 +32,7 @@ import {
getAllCallLinks, getAllCallLinks,
getCallSelector, getCallSelector,
getCallLinkSelector, getCallLinkSelector,
getHasAnyAdminCallLinks,
} from '../selectors/calling'; } from '../selectors/calling';
import { useCallHistoryActions } from '../ducks/callHistory'; import { useCallHistoryActions } from '../ducks/callHistory';
import { getCallHistoryEdition } from '../selectors/callHistory'; import { getCallHistoryEdition } from '../selectors/callHistory';
@ -151,6 +152,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
const getAdhocCall = useSelector(getAdhocCallSelector); const getAdhocCall = useSelector(getAdhocCallSelector);
const getCall = useSelector(getCallSelector); const getCall = useSelector(getCallSelector);
const getCallLink = useSelector(getCallLinkSelector); const getCallLink = useSelector(getCallLinkSelector);
const hasAnyAdminCallLinks = useSelector(getHasAnyAdminCallLinks);
const activeCall = useSelector(getActiveCallState); const activeCall = useSelector(getActiveCallState);
const callHistoryEdition = useSelector(getCallHistoryEdition); const callHistoryEdition = useSelector(getCallHistoryEdition);
@ -242,6 +244,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
callHistoryEdition={callHistoryEdition} callHistoryEdition={callHistoryEdition}
canCreateCallLinks={canCreateCallLinks} canCreateCallLinks={canCreateCallLinks}
hangUpActiveCall={hangUpActiveCall} hangUpActiveCall={hangUpActiveCall}
hasAnyAdminCallLinks={hasAnyAdminCallLinks}
hasFailedStorySends={hasFailedStorySends} hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate} hasPendingUpdate={hasPendingUpdate}
i18n={i18n} i18n={i18n}

View file

@ -174,7 +174,7 @@ export const SmartGlobalModalContainer = memo(
}: { }: {
buttonVariant?: ButtonVariant; buttonVariant?: ButtonVariant;
description?: string; description?: string;
title?: string; title?: string | null;
}) => ( }) => (
<ErrorModal <ErrorModal
buttonVariant={buttonVariant} buttonVariant={buttonVariant}

View file

@ -16,6 +16,7 @@ import { generateAci } from '../../../types/ServiceId';
import { import {
getCallsByConversation, getCallsByConversation,
getCallSelector, getCallSelector,
getHasAnyAdminCallLinks,
getIncomingCall, getIncomingCall,
isInCall, isInCall,
} from '../../../state/selectors/calling'; } from '../../../state/selectors/calling';
@ -25,6 +26,10 @@ import type {
GroupCallStateType, GroupCallStateType,
} from '../../../state/ducks/calling'; } from '../../../state/ducks/calling';
import { getEmptyState } from '../../../state/ducks/calling'; import { getEmptyState } from '../../../state/ducks/calling';
import {
FAKE_CALL_LINK,
FAKE_CALL_LINK_WITH_ADMIN_KEY,
} from '../../../test-both/helpers/fakeCallLink';
const OUR_ACI = generateAci(); const OUR_ACI = generateAci();
const ACI_1 = generateAci(); const ACI_1 = generateAci();
@ -118,6 +123,20 @@ describe('state/selectors/calling', () => {
}, },
}; };
const stateWithCallLink: CallingStateType = {
...getEmptyState(),
callLinks: {
[FAKE_CALL_LINK.roomId]: FAKE_CALL_LINK,
},
};
const stateWithAdminCallLink: CallingStateType = {
...getEmptyState(),
callLinks: {
[FAKE_CALL_LINK_WITH_ADMIN_KEY.roomId]: FAKE_CALL_LINK_WITH_ADMIN_KEY,
},
};
describe('getCallsByConversation', () => { describe('getCallsByConversation', () => {
it('returns state.calling.callsByConversation', () => { it('returns state.calling.callsByConversation', () => {
assert.deepEqual(getCallsByConversation(getEmptyRootState()), {}); assert.deepEqual(getCallsByConversation(getEmptyRootState()), {});
@ -217,4 +236,22 @@ describe('state/selectors/calling', () => {
assert.isTrue(isInCall(getCallingState(stateWithActiveDirectCall))); assert.isTrue(isInCall(getCallingState(stateWithActiveDirectCall)));
}); });
}); });
describe('getHasAnyAdminCallLinks', () => {
it('returns true with admin call links', () => {
assert.isTrue(
getHasAnyAdminCallLinks(getCallingState(stateWithAdminCallLink))
);
});
it('returns false with only non-admin call links', () => {
assert.isFalse(
getHasAnyAdminCallLinks(getCallingState(stateWithCallLink))
);
});
it('returns false without any call links', () => {
assert.isFalse(getHasAnyAdminCallLinks(getEmptyRootState()));
});
});
}); });

View file

@ -99,6 +99,7 @@ import {
} from '../types/CallDisposition'; } from '../types/CallDisposition';
import { import {
getBytesForPeerId, getBytesForPeerId,
getCallIdForProto,
getProtoForCallHistory, getProtoForCallHistory,
} from '../util/callDisposition'; } from '../util/callDisposition';
import { MAX_MESSAGE_COUNT } from '../util/deleteForMe.types'; import { MAX_MESSAGE_COUNT } from '../util/deleteForMe.types';
@ -1612,7 +1613,7 @@ export default class MessageSender {
type: Proto.SyncMessage.CallLogEvent.Type.CLEAR, type: Proto.SyncMessage.CallLogEvent.Type.CLEAR,
timestamp: Long.fromNumber(latestCall.timestamp), timestamp: Long.fromNumber(latestCall.timestamp),
peerId: getBytesForPeerId(latestCall), peerId: getBytesForPeerId(latestCall),
callId: Long.fromString(latestCall.callId), callId: getCallIdForProto(latestCall),
}); });
const syncMessage = MessageSender.createSyncMessage(); const syncMessage = MessageSender.createSyncMessage();

View file

@ -186,6 +186,12 @@ export type CallHistoryPagination = Readonly<{
limit: number; limit: number;
}>; }>;
export enum ClearCallHistoryResult {
Success = 'Success',
Error = 'Error',
ErrorDeletingCallLinks = 'ErrorDeletingCallLinks',
}
const ringerIdSchema = z.union([aciSchema, z.string(), z.null()]); const ringerIdSchema = z.union([aciSchema, z.string(), z.null()]);
const callModeSchema = z.nativeEnum(CallMode); const callModeSchema = z.nativeEnum(CallMode);

View file

@ -35,6 +35,7 @@ import {
CallStatusValue, CallStatusValue,
callLogEventNormalizeSchema, callLogEventNormalizeSchema,
CallLogEvent, CallLogEvent,
ClearCallHistoryResult,
} from '../types/CallDisposition'; } from '../types/CallDisposition';
import type { AciString } from '../types/ServiceId'; import type { AciString } from '../types/ServiceId';
import { isAciString } from './isAciString'; import { isAciString } from './isAciString';
@ -67,8 +68,9 @@ import type { ConversationModel } from '../models/conversations';
import { drop } from './drop'; import { drop } from './drop';
import { sendCallLinkUpdateSync } from './sendCallLinkUpdateSync'; import { sendCallLinkUpdateSync } from './sendCallLinkUpdateSync';
import { storageServiceUploadJob } from '../services/storage'; import { storageServiceUploadJob } from '../services/storage';
import { CallLinkDeleteManager } from '../jobs/CallLinkDeleteManager'; import { CallLinkFinalizeDeleteManager } from '../jobs/CallLinkFinalizeDeleteManager';
import { parsePartial, parseStrict } from './schemas'; import { parsePartial, parseStrict } from './schemas';
import { calling } from '../services/calling';
// utils // utils
// ----- // -----
@ -347,6 +349,23 @@ export function getBytesForPeerId(callHistory: CallHistoryDetails): Uint8Array {
return peerId; return peerId;
} }
export function getCallIdForProto(
callHistory: CallHistoryDetails
): Long.Long | undefined {
try {
return Long.fromString(callHistory.callId);
} catch (error) {
// When CallHistory is a placeholder record for call links, then the history item's
// callId is invalid. We will ignore it and only send the timestamp.
if (callHistory.mode === CallMode.Adhoc) {
return undefined;
}
// For other calls, we expect a valid callId.
throw error;
}
}
export function getProtoForCallHistory( export function getProtoForCallHistory(
callHistory: CallHistoryDetails callHistory: CallHistoryDetails
): Proto.SyncMessage.ICallEvent | null { ): Proto.SyncMessage.ICallEvent | null {
@ -361,7 +380,7 @@ export function getProtoForCallHistory(
return new Proto.SyncMessage.CallEvent({ return new Proto.SyncMessage.CallEvent({
peerId: getBytesForPeerId(callHistory), peerId: getBytesForPeerId(callHistory),
callId: Long.fromString(callHistory.callId), callId: getCallIdForProto(callHistory),
type: typeToProto[callHistory.type], type: typeToProto[callHistory.type],
direction: directionToProto[callHistory.direction], direction: directionToProto[callHistory.direction],
event, event,
@ -1343,24 +1362,63 @@ export function updateDeletedMessages(messageIds: ReadonlyArray<string>): void {
export async function clearCallHistoryDataAndSync( export async function clearCallHistoryDataAndSync(
latestCall: CallHistoryDetails latestCall: CallHistoryDetails
): Promise<void> { ): Promise<ClearCallHistoryResult> {
try { try {
log.info( log.info(
`clearCallHistory: Clearing call history before (${latestCall.callId}, ${latestCall.timestamp})` `clearCallHistory: Clearing call history before (${latestCall.callId}, ${latestCall.timestamp})`
); );
// This skips call history for admin call links.
const messageIds = await DataWriter.clearCallHistory(latestCall); const messageIds = await DataWriter.clearCallHistory(latestCall);
await DataWriter.beginDeleteAllCallLinks(); const isStorageSyncNeeded = await DataWriter.beginDeleteAllCallLinks();
if (isStorageSyncNeeded) {
storageServiceUploadJob({ reason: 'clearCallHistoryDataAndSync' }); storageServiceUploadJob({ reason: 'clearCallHistoryDataAndSync' });
// Wait for storage sync before finalizing delete }
drop(CallLinkDeleteManager.enqueueAllDeletedCallLinks({ delay: 10000 }));
updateDeletedMessages(messageIds); updateDeletedMessages(messageIds);
log.info('clearCallHistory: Queueing sync message'); log.info('clearCallHistory: Queueing sync message');
await singleProtoJobQueue.add( await singleProtoJobQueue.add(
MessageSender.getClearCallHistoryMessage(latestCall) MessageSender.getClearCallHistoryMessage(latestCall)
); );
const adminCallLinks = await DataReader.getAllAdminCallLinks();
const callLinkCount = adminCallLinks.length;
if (callLinkCount > 0) {
log.info(`clearCallHistory: Deleting ${callLinkCount} admin call links`);
let successCount = 0;
let failCount = 0;
for (const callLink of adminCallLinks) {
try {
// This throws if call link is active or network is unavailable.
// eslint-disable-next-line no-await-in-loop
await calling.deleteCallLink(callLink);
// eslint-disable-next-line no-await-in-loop
await DataWriter.deleteCallHistoryByRoomId(callLink.roomId);
// Wait for storage service sync before finalizing delete.
drop(
CallLinkFinalizeDeleteManager.addJob(
{ roomId: callLink.roomId },
{ delay: 10000 }
)
);
successCount += 1;
} catch (error) {
log.warn('clearCallHistory: Failed to delete admin call link', error);
failCount += 1;
}
}
log.info(
`clearCallHistory: Deleted admin call links, success=${successCount} failed=${failCount}`
);
if (failCount > 0) {
return ClearCallHistoryResult.ErrorDeletingCallLinks;
}
}
} catch (error) { } catch (error) {
log.error('clearCallHistory: Failed to clear call history', error); log.error('clearCallHistory: Failed to clear call history', error);
return ClearCallHistoryResult.Error;
} }
return ClearCallHistoryResult.Success;
} }
export async function markAllCallHistoryReadAndSync( export async function markAllCallHistoryReadAndSync(