diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 2b9e17d8c..f98ae496c 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -1633,6 +1633,10 @@
"messageformat": "Signal Call",
"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": {
"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."
@@ -7298,7 +7302,11 @@
"description": "Calls Tab > Confirm Clear Call History Dialog > Title"
},
"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 you’ve created will no longer work for people who have them. ",
"description": "Calls Tab > Confirm Clear Call History Dialog > Body Text"
},
"icu:CallsTab__ConfirmClearCallHistory__ConfirmButton": {
@@ -7309,6 +7317,14 @@
"messageformat": "Call history cleared",
"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": {
"messageformat": "Click to start a new voice or video call.",
"description": "Calls Tab > When no call is selected > Empty state > Call to action text"
diff --git a/ts/components/CallsTab.tsx b/ts/components/CallsTab.tsx
index 152637389..59e3cf778 100644
--- a/ts/components/CallsTab.tsx
+++ b/ts/components/CallsTab.tsx
@@ -49,6 +49,7 @@ type CallsTabProps = Readonly<{
getCallLink: (id: string) => CallLinkType | undefined;
getConversation: (id: string) => ConversationType | void;
hangUpActiveCall: (reason: string) => void;
+ hasAnyAdminCallLinks: boolean;
hasFailedStorySends: boolean;
hasPendingUpdate: boolean;
i18n: LocalizerType;
@@ -105,6 +106,7 @@ export function CallsTab({
getCallLink,
getConversation,
hangUpActiveCall,
+ hasAnyAdminCallLinks,
hasFailedStorySends,
hasPendingUpdate,
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')}
)}
>
diff --git a/ts/components/ErrorModal.tsx b/ts/components/ErrorModal.tsx
index c24243d9e..cd964508c 100644
--- a/ts/components/ErrorModal.tsx
+++ b/ts/components/ErrorModal.tsx
@@ -10,7 +10,7 @@ import { Button, ButtonVariant } from './Button';
export type PropsType = {
buttonVariant?: ButtonVariant;
description?: string;
- title?: string;
+ title?: string | null;
onClose: () => void;
i18n: LocalizerType;
@@ -40,7 +40,7 @@ export function ErrorModal(props: PropsType): JSX.Element {
modalName="ErrorModal"
i18n={i18n}
onClose={onClose}
- title={title || i18n('icu:ErrorModal--title')}
+ title={title == null ? undefined : i18n('icu:ErrorModal--title')}
modalFooter={footer}
>
diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx
index 0f5a3b235..b7f14bb1e 100644
--- a/ts/components/GlobalModalContainer.tsx
+++ b/ts/components/GlobalModalContainer.tsx
@@ -53,12 +53,16 @@ export type PropsType = {
renderEditNicknameAndNoteModal: () => JSX.Element;
// ErrorModal
errorModalProps:
- | { buttonVariant?: ButtonVariant; description?: string; title?: string }
+ | {
+ buttonVariant?: ButtonVariant;
+ description?: string;
+ title?: string | null;
+ }
| undefined;
renderErrorModal: (opts: {
buttonVariant?: ButtonVariant;
description?: string;
- title?: string;
+ title?: string | null;
}) => JSX.Element;
// DeleteMessageModal
deleteMessagesProps: DeleteMessagesPropsType | undefined;
diff --git a/ts/jobs/CallLinkDeleteManager.ts b/ts/jobs/CallLinkFinalizeDeleteManager.ts
similarity index 72%
rename from ts/jobs/CallLinkDeleteManager.ts
rename to ts/jobs/CallLinkFinalizeDeleteManager.ts
index f567581eb..9dd5f1608 100644
--- a/ts/jobs/CallLinkDeleteManager.ts
+++ b/ts/jobs/CallLinkFinalizeDeleteManager.ts
@@ -9,8 +9,6 @@ import {
type JobManagerJobResultType,
type JobManagerJobType,
} from './JobManager';
-import { calling } from '../services/calling';
-import { callLinkFromRecord } from '../util/callLinksRingrtc';
// Type for adding a new job
export type NewCallLinkDeleteJobType = {
@@ -28,7 +26,7 @@ export type CallLinkDeleteJobType = CoreCallLinkDeleteJobType &
const MAX_CONCURRENT_JOBS = 5;
const DEFAULT_RETRY_CONFIG = {
- maxAttempts: Infinity,
+ maxAttempts: 10,
backoffConfig: {
// 1 min, 5 min, 25 min, (max) 1 day
multiplier: 5,
@@ -37,19 +35,24 @@ const DEFAULT_RETRY_CONFIG = {
},
};
-type CallLinkDeleteManagerParamsType =
+type CallLinkFinalizeDeleteManagerParamsType =
JobManagerParamsType;
function getJobId(job: CoreCallLinkDeleteJobType): string {
return job.roomId;
}
-export class CallLinkDeleteManager extends JobManager {
+// 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 {
jobs: Map = new Map();
- private static _instance: CallLinkDeleteManager | undefined;
- override logPrefix = 'CallLinkDeleteManager';
+ private static _instance: CallLinkFinalizeDeleteManager | undefined;
+ override logPrefix = 'CallLinkFinalizeDeleteManager';
- static defaultParams: CallLinkDeleteManagerParamsType = {
+ static defaultParams: CallLinkFinalizeDeleteManagerParamsType = {
markAllJobsInactive: () => Promise.resolve(),
getNextJobs,
saveJob,
@@ -61,7 +64,7 @@ export class CallLinkDeleteManager extends JobManager
maxConcurrentJobs: MAX_CONCURRENT_JOBS,
};
- constructor(params: CallLinkDeleteManagerParamsType) {
+ constructor(params: CallLinkFinalizeDeleteManagerParamsType) {
super({
...params,
getNextJobs: ({ limit, timestamp }) =>
@@ -103,40 +106,43 @@ export class CallLinkDeleteManager extends JobManager
roomIds.forEach(roomId => this.addJob({ roomId }, options));
}
- static get instance(): CallLinkDeleteManager {
- if (!CallLinkDeleteManager._instance) {
- CallLinkDeleteManager._instance = new CallLinkDeleteManager(
- CallLinkDeleteManager.defaultParams
- );
+ static get instance(): CallLinkFinalizeDeleteManager {
+ if (!CallLinkFinalizeDeleteManager._instance) {
+ CallLinkFinalizeDeleteManager._instance =
+ new CallLinkFinalizeDeleteManager(
+ CallLinkFinalizeDeleteManager.defaultParams
+ );
}
- return CallLinkDeleteManager._instance;
+ return CallLinkFinalizeDeleteManager._instance;
}
static async start(): Promise {
- await CallLinkDeleteManager.instance.enqueueAllDeletedCallLinks();
- await CallLinkDeleteManager.instance.start();
+ await CallLinkFinalizeDeleteManager.instance.enqueueAllDeletedCallLinks();
+ await CallLinkFinalizeDeleteManager.instance.start();
}
static async stop(): Promise {
- return CallLinkDeleteManager._instance?.stop();
+ return CallLinkFinalizeDeleteManager._instance?.stop();
}
static async addJob(
newJob: CoreCallLinkDeleteJobType,
options?: { delay: number }
): Promise {
- return CallLinkDeleteManager.instance.addJob(newJob, options);
+ return CallLinkFinalizeDeleteManager.instance.addJob(newJob, options);
}
static async enqueueAllDeletedCallLinks(options?: {
delay: number;
}): Promise {
- return CallLinkDeleteManager.instance.enqueueAllDeletedCallLinks(options);
+ return CallLinkFinalizeDeleteManager.instance.enqueueAllDeletedCallLinks(
+ options
+ );
}
}
async function getNextJobs(
- this: CallLinkDeleteManager,
+ this: CallLinkFinalizeDeleteManager,
{
limit,
timestamp,
@@ -162,7 +168,7 @@ async function getNextJobs(
}
async function saveJob(
- this: CallLinkDeleteManager,
+ this: CallLinkFinalizeDeleteManager,
job: CallLinkDeleteJobType
): Promise {
const { roomId } = job;
@@ -170,14 +176,10 @@ async function saveJob(
}
async function removeJob(
- this: CallLinkDeleteManager,
+ this: CallLinkFinalizeDeleteManager,
job: CallLinkDeleteJobType
): Promise {
- const logId = `CallLinkDeleteJobType/removeJob/${getJobId(job)}`;
- const { roomId } = job;
- await DataWriter.finalizeDeleteCallLink(job.roomId);
- log.info(`${logId}: Finalized local delete`);
- this.jobs.delete(roomId);
+ this.jobs.delete(job.roomId);
}
async function runJob(
@@ -191,10 +193,8 @@ async function runJob(
log.warn(`${logId}: Call link gone from DB`);
return { status: 'finished' };
}
- if (callLinkRecord.adminKey == null) {
- log.error(
- `${logId}: No admin key available, deletion on server not possible. Giving up.`
- );
+ if (callLinkRecord.deleted !== 1) {
+ log.error(`${logId}: Call link not marked deleted. Giving up.`);
return { status: 'finished' };
}
@@ -204,10 +204,7 @@ async function runJob(
return { status: 'retry' };
}
- // Delete link on calling server. May 200 or 404 and both are OK.
- // Errs if call link is active or network is unavailable.
- const callLink = callLinkFromRecord(callLinkRecord);
- await calling.deleteCallLink(callLink);
- log.info(`${logId}: Deleted call link on server`);
+ await DataWriter.finalizeDeleteCallLink(job.roomId);
+ log.info(`${logId}: Finalized local delete`);
return { status: 'finished' };
}
diff --git a/ts/jobs/callLinkRefreshJobQueue.ts b/ts/jobs/callLinkRefreshJobQueue.ts
index 00ab01792..be76c891e 100644
--- a/ts/jobs/callLinkRefreshJobQueue.ts
+++ b/ts/jobs/callLinkRefreshJobQueue.ts
@@ -97,10 +97,7 @@ export class CallLinkRefreshJobQueue extends JobQueue {
`${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
- await DataWriter.beginDeleteCallLink(roomId, {
- storageNeedsSync: false,
- });
- await DataWriter.finalizeDeleteCallLink(roomId);
+ await DataWriter.deleteCallLinkAndHistory(roomId);
window.reduxActions.calling.handleCallLinkDelete({ roomId });
} else {
log.info(`${logId}: Call link not found on server, ignoring`);
diff --git a/ts/jobs/initializeAllJobQueues.ts b/ts/jobs/initializeAllJobQueues.ts
index e1a9396b3..5e56741eb 100644
--- a/ts/jobs/initializeAllJobQueues.ts
+++ b/ts/jobs/initializeAllJobQueues.ts
@@ -3,7 +3,7 @@
import type { WebAPIType } from '../textsecure/WebAPI';
import { drop } from '../util/drop';
-import { CallLinkDeleteManager } from './CallLinkDeleteManager';
+import { CallLinkFinalizeDeleteManager } from './CallLinkFinalizeDeleteManager';
import { callLinkRefreshJobQueue } from './callLinkRefreshJobQueue';
import { conversationJobQueue } from './conversationJobQueue';
@@ -43,7 +43,7 @@ export function initializeAllJobQueues({
drop(removeStorageKeyJobQueue.streamJobs());
drop(reportSpamJobQueue.streamJobs());
drop(callLinkRefreshJobQueue.streamJobs());
- drop(CallLinkDeleteManager.start());
+ drop(CallLinkFinalizeDeleteManager.start());
}
export async function shutdownAllJobQueues(): Promise {
@@ -57,6 +57,6 @@ export async function shutdownAllJobQueues(): Promise {
viewOnceOpenJobQueue.shutdown(),
removeStorageKeyJobQueue.shutdown(),
reportSpamJobQueue.shutdown(),
- CallLinkDeleteManager.stop(),
+ CallLinkFinalizeDeleteManager.stop(),
]);
}
diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts
index 6a48dda24..4fc4fa7a6 100644
--- a/ts/services/storageRecordOps.ts
+++ b/ts/services/storageRecordOps.ts
@@ -2075,12 +2075,8 @@ export async function mergeCallLinkRecord(
// Another device deleted the link and uploaded to storage, and we learned about it
log.info(`${logId}: Discovered deleted call link, 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
- await DataWriter.finalizeDeleteCallLink(roomId);
+ await DataWriter.deleteCallLinkAndHistory(roomId);
window.reduxActions.calling.handleCallLinkDelete({ roomId });
} else if (!deletedAt && localCallLinkDbRecord.deleted === 1) {
// Not deleted in storage, but we've marked it as deleted locally.
diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts
index ad69d1fe6..2a5e8a403 100644
--- a/ts/sql/Interface.ts
+++ b/ts/sql/Interface.ts
@@ -44,7 +44,6 @@ import type {
import type { SyncTaskType } from '../util/syncTasks';
import type { AttachmentBackupJobType } from '../types/AttachmentBackup';
import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue';
-import type { DeleteCallLinkOptions } from './server/callLinks';
export type ReadableDB = Database & { __readable_db: never };
export type WritableDB = ReadableDB & { __writable_db: never };
@@ -584,6 +583,7 @@ type ReadableInterface = {
getAllCallLinks: () => ReadonlyArray;
getCallLinkByRoomId: (roomId: string) => CallLinkType | undefined;
getCallLinkRecordByRoomId: (roomId: string) => CallLinkRecord | undefined;
+ getAllAdminCallLinks(): ReadonlyArray;
getAllCallLinkRecordsWithAdminKey(): ReadonlyArray;
getAllMarkedDeletedCallLinkRoomIds(): ReadonlyArray;
getMessagesBetween: (
@@ -813,8 +813,10 @@ type WritableInterface = {
roomId: string,
callLinkState: CallLinkStateType
): CallLinkType;
- beginDeleteAllCallLinks(): void;
- beginDeleteCallLink(roomId: string, options: DeleteCallLinkOptions): void;
+ beginDeleteAllCallLinks(): boolean;
+ beginDeleteCallLink(roomId: string): boolean;
+ deleteCallHistoryByRoomId(roomid: string): void;
+ deleteCallLinkAndHistory(roomId: string): void;
finalizeDeleteCallLink(roomId: string): void;
_removeAllCallLinks(): void;
deleteCallLinkFromSync(roomId: string): void;
diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts
index 16d29d499..03733ca3f 100644
--- a/ts/sql/Server.ts
+++ b/ts/sql/Server.ts
@@ -180,6 +180,9 @@ import {
updateCallLinkAdminKeyByRoomId,
updateCallLinkState,
beginDeleteAllCallLinks,
+ deleteCallHistoryByRoomId,
+ deleteCallLinkAndHistory,
+ getAllAdminCallLinks,
getAllCallLinkRecordsWithAdminKey,
getAllMarkedDeletedCallLinkRoomIds,
finalizeDeleteCallLink,
@@ -313,6 +316,7 @@ export const DataReader: ServerReadableInterface = {
getAllCallLinks,
getCallLinkByRoomId,
getCallLinkRecordByRoomId,
+ getAllAdminCallLinks,
getAllCallLinkRecordsWithAdminKey,
getAllMarkedDeletedCallLinkRoomIds,
getMessagesBetween,
@@ -451,6 +455,8 @@ export const DataWriter: ServerWritableInterface = {
updateCallLinkState,
beginDeleteAllCallLinks,
beginDeleteCallLink,
+ deleteCallHistoryByRoomId,
+ deleteCallLinkAndHistory,
finalizeDeleteCallLink,
_removeAllCallLinks,
deleteCallLinkFromSync,
@@ -3543,6 +3549,14 @@ function _removeAllCallHistory(db: WritableDB): void {
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: message ids of call history messages
+ */
function clearCallHistory(
db: WritableDB,
target: CallLogEventTarget
@@ -3555,17 +3569,33 @@ function clearCallHistory(
}
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 = db
+ .prepare(selectAdminCallLinksQuery)
+ .pluck()
+ .all(selectAdminCallLinksParams);
+ const adminCallLinkIdsFragment = sqlJoin(adminCallLinkIds);
+
const [selectCallsQuery, selectCallsParams] = 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}
- );
+ (
+ -- Prior calls
+ (callsHistory.timestamp <= ${timestamp})
+ -- Unused call links
+ OR (
+ callsHistory.mode IS ${CALL_MODE_ADHOC} AND
+ callsHistory.status IS ${CALL_STATUS_PENDING}
+ )
+ ) AND
+ callsHistory.peerId NOT IN (${adminCallLinkIdsFragment});
`;
const deletedCallIds: ReadonlyArray = db
diff --git a/ts/sql/migrations/1230-call-links-admin-key-index.ts b/ts/sql/migrations/1230-call-links-admin-key-index.ts
new file mode 100644
index 000000000..3dc883901
--- /dev/null
+++ b/ts/sql/migrations/1230-call-links-admin-key-index.ts
@@ -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!');
+}
diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts
index 8c5f95ac8..8bee9e34f 100644
--- a/ts/sql/migrations/index.ts
+++ b/ts/sql/migrations/index.ts
@@ -98,10 +98,11 @@ import { updateToSchemaVersion1180 } from './1180-add-attachment-download-source
import { updateToSchemaVersion1190 } from './1190-call-links-storage';
import { updateToSchemaVersion1200 } from './1200-attachment-download-source-index';
import { updateToSchemaVersion1210 } from './1210-call-history-started-id';
+import { updateToSchemaVersion1220 } from './1220-blob-sessions';
import {
- updateToSchemaVersion1220,
+ updateToSchemaVersion1230,
version as MAX_VERSION,
-} from './1220-blob-sessions';
+} from './1230-call-links-admin-key-index';
function updateToSchemaVersion1(
currentVersion: number,
@@ -2069,6 +2070,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion1200,
updateToSchemaVersion1210,
updateToSchemaVersion1220,
+ updateToSchemaVersion1230,
];
export class DBVersionFromFutureError extends Error {
diff --git a/ts/sql/server/callLinks.ts b/ts/sql/server/callLinks.ts
index cb7d1e68e..f9917df15 100644
--- a/ts/sql/server/callLinks.ts
+++ b/ts/sql/server/callLinks.ts
@@ -20,7 +20,7 @@ import type { ReadableDB, WritableDB } from '../Interface';
import { prepare } from '../Server';
import { sql } from '../util';
import { strictAssert } from '../../util/assert';
-import { CallStatusValue } from '../../types/CallDisposition';
+import { CallStatusValue, DirectCallStatus } from '../../types/CallDisposition';
import { parseStrict, parseUnknown } from '../../util/schemas';
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 [
markCallHistoryDeleteByPeerIdQuery,
markCallHistoryDeleteByPeerIdParams,
@@ -222,17 +225,14 @@ export function deleteCallLinkFromSync(db: WritableDB, roomId: string): void {
})();
}
-export type DeleteCallLinkOptions = {
- storageNeedsSync: boolean;
- deletedAt?: number;
-};
-
-export function beginDeleteCallLink(
- db: WritableDB,
- roomId: string,
- options: DeleteCallLinkOptions
-): void {
- db.transaction(() => {
+/**
+ * Deletes a non-admin call link from the local database, or if it's an admin call link,
+ * then marks it for deletion and storage sync.
+ *
+ * @returns boolean: True if storage sync is needed; False if not
+ */
+export function beginDeleteCallLink(db: WritableDB, roomId: string): boolean {
+ return db.transaction(() => {
// If adminKey is null, then we should delete the call link
const [deleteNonAdminCallLinksQuery, deleteNonAdminCallLinksParams] = sql`
DELETE FROM callLinks
@@ -244,35 +244,62 @@ export function beginDeleteCallLink(
.prepare(deleteNonAdminCallLinksQuery)
.run(deleteNonAdminCallLinksParams);
- // Skip this query if the call is already deleted
- if (result.changes === 0) {
- const { storageNeedsSync } = options;
- const deletedAt = options.deletedAt ?? new Date().getTime();
-
- // If the admin key is not null, we should mark it for deletion
- const [markAdminCallLinksDeletedQuery, markAdminCallLinksDeletedParams] =
- sql`
- UPDATE callLinks
- SET
- deleted = 1,
- deletedAt = ${deletedAt},
- storageNeedsSync = ${storageNeedsSync ? 1 : 0}
- WHERE adminKey IS NOT NULL
- AND roomId = ${roomId};
- `;
-
- db.prepare(markAdminCallLinksDeletedQuery).run(
- markAdminCallLinksDeletedParams
- );
+ // If we successfully deleted the call link, then it was a non-admin call link
+ // and we're done
+ if (result.changes !== 0) {
+ return false;
}
- deleteCallHistoryByRoomId(db, roomId);
+ const deletedAt = new Date().getTime();
+
+ // If the admin key is not null, we should mark it for deletion
+ const [markAdminCallLinksDeletedQuery, markAdminCallLinksDeletedParams] =
+ sql`
+ UPDATE callLinks
+ SET
+ deleted = 1,
+ deletedAt = ${deletedAt},
+ storageNeedsSync = 1
+ WHERE adminKey IS NOT NULL
+ AND deleted IS NOT 1
+ AND roomId = ${roomId};
+ `;
+
+ const deleteAdminLinkResult = db
+ .prepare(markAdminCallLinksDeletedQuery)
+ .run(markAdminCallLinksDeletedParams);
+ return deleteAdminLinkResult.changes > 0;
})();
}
-export function beginDeleteAllCallLinks(db: WritableDB): void {
- const deletedAt = new Date().getTime();
+export function deleteCallLinkAndHistory(db: WritableDB, roomId: string): void {
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] =
sql`
UPDATE callLinks
@@ -280,12 +307,13 @@ export function beginDeleteAllCallLinks(db: WritableDB): void {
deleted = 1,
deletedAt = ${deletedAt},
storageNeedsSync = 1
- WHERE adminKey IS NOT NULL;
+ WHERE adminKey IS NOT NULL
+ AND deleted IS NOT 1;
`;
- db.prepare(markAdminCallLinksDeletedQuery).run(
- markAdminCallLinksDeletedParams
- );
+ const markAdminCallLinksDeletedResult = db
+ .prepare(markAdminCallLinksDeletedQuery)
+ .run(markAdminCallLinksDeletedParams);
const [deleteNonAdminCallLinksQuery] = sql`
DELETE FROM callLinks
@@ -293,6 +321,9 @@ export function beginDeleteAllCallLinks(db: WritableDB): void {
`;
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));
}
+export function getAllAdminCallLinks(
+ db: ReadableDB
+): ReadonlyArray {
+ return getAllCallLinkRecordsWithAdminKey(db).map((record: CallLinkRecord) =>
+ callLinkFromRecord(record)
+ );
+}
+
export function getAllMarkedDeletedCallLinkRoomIds(
db: ReadableDB
): ReadonlyArray {
diff --git a/ts/state/ducks/callHistory.ts b/ts/state/ducks/callHistory.ts
index 032d00cd5..8cb159047 100644
--- a/ts/state/ducks/callHistory.ts
+++ b/ts/state/ducks/callHistory.ts
@@ -15,7 +15,10 @@ import type { ToastActionType } from './toast';
import { showToast } from './toast';
import { DataReader, DataWriter } from '../../sql/Client';
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 Errors from '../../types/errors';
import { drop } from '../../util/drop';
@@ -29,6 +32,11 @@ import {
loadCallHistory,
} from '../../services/callHistoryLoader';
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<{
// This informs the app that underlying call history data has changed.
@@ -191,14 +199,42 @@ function clearAllCallHistory(): ThunkAction<
void,
RootStateType,
unknown,
- CallHistoryReset | ToastActionType
+ CallHistoryReset | ToastActionType | ShowErrorModalActionType
> {
return async (dispatch, getState) => {
try {
const latestCall = getCallHistoryLatestCall(getState());
- if (latestCall != null) {
- await clearCallHistoryDataAndSync(latestCall);
+ if (latestCall == null) {
+ return;
+ }
+
+ const result = await clearCallHistoryDataAndSync(latestCall);
+ if (result === ClearCallHistoryResult.Success) {
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) {
log.error('Error clearing call history', Errors.toLogFormat(error));
diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts
index 9b560a407..97163afbe 100644
--- a/ts/state/ducks/calling.ts
+++ b/ts/state/ducks/calling.ts
@@ -36,10 +36,11 @@ import type {
PresentedSource,
PresentableSource,
} from '../../types/Calling';
-import type {
- CallLinkRestrictions,
- CallLinkStateType,
- CallLinkType,
+import {
+ isCallLinkAdmin,
+ type CallLinkRestrictions,
+ type CallLinkStateType,
+ type CallLinkType,
} from '../../types/CallLink';
import {
CALLING_REACTIONS_LIFETIME,
@@ -110,7 +111,7 @@ import {
getPresentingSource,
} from '../selectors/calling';
import { storageServiceUploadJob } from '../../services/storage';
-import { CallLinkDeleteManager } from '../../jobs/CallLinkDeleteManager';
+import { CallLinkFinalizeDeleteManager } from '../../jobs/CallLinkFinalizeDeleteManager';
import { callLinkRefreshJobQueue } from '../../jobs/callLinkRefreshJobQueue';
// State
@@ -2125,13 +2126,50 @@ function createCallLink(
function deleteCallLink(
roomId: string
-): ThunkAction {
- return async dispatch => {
- await DataWriter.beginDeleteCallLink(roomId, { storageNeedsSync: true });
- storageServiceUploadJob({ reason: 'deleteCallLink' });
- // Wait for storage service sync before finalizing delete
- drop(CallLinkDeleteManager.addJob({ roomId }, { delay: 10000 }));
- dispatch(handleCallLinkDelete({ roomId }));
+): ThunkAction<
+ void,
+ 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' });
+ }
+ 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 }));
+ } 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;
}
diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts
index a9b75a544..a5b4e1f10 100644
--- a/ts/state/ducks/globalModals.ts
+++ b/ts/state/ducks/globalModals.ts
@@ -103,7 +103,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{
errorModalProps?: {
buttonVariant?: ButtonVariant;
description?: string;
- title?: string;
+ title?: string | null;
};
forwardMessagesProps?: ForwardMessagesPropsType;
gv2MigrationProps?: MigrateToGV2PropsType;
@@ -338,7 +338,7 @@ export type ShowErrorModalActionType = ReadonlyDeep<{
payload: {
buttonVariant?: ButtonVariant;
description?: string;
- title?: string;
+ title?: string | null;
};
}>;
diff --git a/ts/state/selectors/calling.ts b/ts/state/selectors/calling.ts
index e14a6bf3f..ccdb6b465 100644
--- a/ts/state/selectors/calling.ts
+++ b/ts/state/selectors/calling.ts
@@ -16,7 +16,7 @@ import type {
import { getIncomingCall as getIncomingCallHelper } from '../ducks/callingHelpers';
import type { PresentedSource } from '../../types/Calling';
import { CallMode } from '../../types/CallDisposition';
-import type { CallLinkType } from '../../types/CallLink';
+import { isCallLinkAdmin, type CallLinkType } from '../../types/CallLink';
import { getUserACI } from './user';
import { getOwn } from '../../util/getOwn';
import type { AciString } from '../../types/ServiceId';
@@ -96,6 +96,11 @@ export const getAllCallLinks = createSelector(
(lookup): Array => Object.values(lookup)
);
+export const getHasAnyAdminCallLinks = createSelector(
+ getAllCallLinks,
+ (callLinks): boolean => callLinks.some(callLink => isCallLinkAdmin(callLink))
+);
+
export type CallSelectorType = (
conversationId: string
) => CallStateType | undefined;
diff --git a/ts/state/smart/CallsTab.tsx b/ts/state/smart/CallsTab.tsx
index db1f06759..fd36c9872 100644
--- a/ts/state/smart/CallsTab.tsx
+++ b/ts/state/smart/CallsTab.tsx
@@ -32,6 +32,7 @@ import {
getAllCallLinks,
getCallSelector,
getCallLinkSelector,
+ getHasAnyAdminCallLinks,
} from '../selectors/calling';
import { useCallHistoryActions } from '../ducks/callHistory';
import { getCallHistoryEdition } from '../selectors/callHistory';
@@ -151,6 +152,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
const getAdhocCall = useSelector(getAdhocCallSelector);
const getCall = useSelector(getCallSelector);
const getCallLink = useSelector(getCallLinkSelector);
+ const hasAnyAdminCallLinks = useSelector(getHasAnyAdminCallLinks);
const activeCall = useSelector(getActiveCallState);
const callHistoryEdition = useSelector(getCallHistoryEdition);
@@ -242,6 +244,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
callHistoryEdition={callHistoryEdition}
canCreateCallLinks={canCreateCallLinks}
hangUpActiveCall={hangUpActiveCall}
+ hasAnyAdminCallLinks={hasAnyAdminCallLinks}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
i18n={i18n}
diff --git a/ts/state/smart/GlobalModalContainer.tsx b/ts/state/smart/GlobalModalContainer.tsx
index c660c67d1..8f802ca52 100644
--- a/ts/state/smart/GlobalModalContainer.tsx
+++ b/ts/state/smart/GlobalModalContainer.tsx
@@ -174,7 +174,7 @@ export const SmartGlobalModalContainer = memo(
}: {
buttonVariant?: ButtonVariant;
description?: string;
- title?: string;
+ title?: string | null;
}) => (
{
},
};
+ 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', () => {
it('returns state.calling.callsByConversation', () => {
assert.deepEqual(getCallsByConversation(getEmptyRootState()), {});
@@ -217,4 +236,22 @@ describe('state/selectors/calling', () => {
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()));
+ });
+ });
});
diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts
index bfb51c97d..11c858caa 100644
--- a/ts/textsecure/SendMessage.ts
+++ b/ts/textsecure/SendMessage.ts
@@ -99,6 +99,7 @@ import {
} from '../types/CallDisposition';
import {
getBytesForPeerId,
+ getCallIdForProto,
getProtoForCallHistory,
} from '../util/callDisposition';
import { MAX_MESSAGE_COUNT } from '../util/deleteForMe.types';
@@ -1612,7 +1613,7 @@ export default class MessageSender {
type: Proto.SyncMessage.CallLogEvent.Type.CLEAR,
timestamp: Long.fromNumber(latestCall.timestamp),
peerId: getBytesForPeerId(latestCall),
- callId: Long.fromString(latestCall.callId),
+ callId: getCallIdForProto(latestCall),
});
const syncMessage = MessageSender.createSyncMessage();
diff --git a/ts/types/CallDisposition.ts b/ts/types/CallDisposition.ts
index f3d328505..f96b4fbe5 100644
--- a/ts/types/CallDisposition.ts
+++ b/ts/types/CallDisposition.ts
@@ -186,6 +186,12 @@ export type CallHistoryPagination = Readonly<{
limit: number;
}>;
+export enum ClearCallHistoryResult {
+ Success = 'Success',
+ Error = 'Error',
+ ErrorDeletingCallLinks = 'ErrorDeletingCallLinks',
+}
+
const ringerIdSchema = z.union([aciSchema, z.string(), z.null()]);
const callModeSchema = z.nativeEnum(CallMode);
diff --git a/ts/util/callDisposition.ts b/ts/util/callDisposition.ts
index 6bd7633b1..51fc43541 100644
--- a/ts/util/callDisposition.ts
+++ b/ts/util/callDisposition.ts
@@ -35,6 +35,7 @@ import {
CallStatusValue,
callLogEventNormalizeSchema,
CallLogEvent,
+ ClearCallHistoryResult,
} from '../types/CallDisposition';
import type { AciString } from '../types/ServiceId';
import { isAciString } from './isAciString';
@@ -67,8 +68,9 @@ import type { ConversationModel } from '../models/conversations';
import { drop } from './drop';
import { sendCallLinkUpdateSync } from './sendCallLinkUpdateSync';
import { storageServiceUploadJob } from '../services/storage';
-import { CallLinkDeleteManager } from '../jobs/CallLinkDeleteManager';
+import { CallLinkFinalizeDeleteManager } from '../jobs/CallLinkFinalizeDeleteManager';
import { parsePartial, parseStrict } from './schemas';
+import { calling } from '../services/calling';
// utils
// -----
@@ -347,6 +349,23 @@ export function getBytesForPeerId(callHistory: CallHistoryDetails): Uint8Array {
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(
callHistory: CallHistoryDetails
): Proto.SyncMessage.ICallEvent | null {
@@ -361,7 +380,7 @@ export function getProtoForCallHistory(
return new Proto.SyncMessage.CallEvent({
peerId: getBytesForPeerId(callHistory),
- callId: Long.fromString(callHistory.callId),
+ callId: getCallIdForProto(callHistory),
type: typeToProto[callHistory.type],
direction: directionToProto[callHistory.direction],
event,
@@ -1343,24 +1362,63 @@ export function updateDeletedMessages(messageIds: ReadonlyArray): void {
export async function clearCallHistoryDataAndSync(
latestCall: CallHistoryDetails
-): Promise {
+): Promise {
try {
log.info(
`clearCallHistory: Clearing call history before (${latestCall.callId}, ${latestCall.timestamp})`
);
+ // This skips call history for admin call links.
const messageIds = await DataWriter.clearCallHistory(latestCall);
- await DataWriter.beginDeleteAllCallLinks();
- storageServiceUploadJob({ reason: 'clearCallHistoryDataAndSync' });
- // Wait for storage sync before finalizing delete
- drop(CallLinkDeleteManager.enqueueAllDeletedCallLinks({ delay: 10000 }));
+ const isStorageSyncNeeded = await DataWriter.beginDeleteAllCallLinks();
+ if (isStorageSyncNeeded) {
+ storageServiceUploadJob({ reason: 'clearCallHistoryDataAndSync' });
+ }
updateDeletedMessages(messageIds);
log.info('clearCallHistory: Queueing sync message');
await singleProtoJobQueue.add(
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) {
log.error('clearCallHistory: Failed to clear call history', error);
+ return ClearCallHistoryResult.Error;
}
+
+ return ClearCallHistoryResult.Success;
}
export async function markAllCallHistoryReadAndSync(