diff --git a/ts/components/conversation/CallingNotification.tsx b/ts/components/conversation/CallingNotification.tsx index 8df5abb1c7..bf368064ab 100644 --- a/ts/components/conversation/CallingNotification.tsx +++ b/ts/components/conversation/CallingNotification.tsx @@ -47,6 +47,9 @@ export type PropsType = CallingNotificationType & export const CallingNotification: React.FC = React.memo( function CallingNotificationInner(props) { const { i18n } = props; + if (props.callHistory == null) { + return null; + } const { type, direction, status, timestamp } = props.callHistory; const icon = getCallingIcon(type, direction, status); return ( @@ -96,6 +99,10 @@ function renderCallingNotificationButton( let disabledTooltipText: undefined | string; let onClick: () => void; + if (props.callHistory == null) { + return null; + } + switch (props.callHistory.mode) { case CallMode.Direct: { const { direction, type } = props.callHistory; @@ -149,10 +156,6 @@ function renderCallingNotificationButton( } break; } - case CallMode.None: { - log.error('renderCallingNotificationButton: Call mode cant be none'); - return null; - } default: log.error(missingCaseError(props.callHistory.mode)); return null; diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 69e92a2fbb..7af9bdaee1 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -721,9 +721,15 @@ export class MessageModel extends window.Backbone.Model { conversationSelector: getConversationSelector(state), }); if (callingNotification) { - return { - text: getCallingNotificationText(callingNotification, window.i18n), - }; + const text = getCallingNotificationText( + callingNotification, + window.i18n + ); + if (text != null) { + return { + text, + }; + } } log.error("This call history message doesn't have valid call history"); diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 2e79c85faa..418626165d 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -416,7 +416,7 @@ export class CallingClass { const callMode = getConversationCallMode(conversation); switch (callMode) { - case CallMode.None: + case null: log.error('Conversation does not support calls, new call not allowed.'); return; case CallMode.Direct: { diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index ef83b0db56..2772223d2c 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -1953,7 +1953,6 @@ function saveMessageSync( sourceServiceId, sourceDevice, storyId, - callId, type, readStatus, expireTimer, @@ -2011,7 +2010,6 @@ function saveMessageSync( sourceServiceId: sourceServiceId || null, sourceDevice: sourceDevice || null, storyId: storyId || null, - callId: callId || null, type: type || null, readStatus: readStatus ?? null, seenStatus: seenStatus ?? SeenStatus.NotApplicable, @@ -2044,7 +2042,6 @@ function saveMessageSync( sourceServiceId = $sourceServiceId, sourceDevice = $sourceDevice, storyId = $storyId, - callId = $callId, type = $type, readStatus = $readStatus, seenStatus = $seenStatus @@ -2090,7 +2087,6 @@ function saveMessageSync( sourceServiceId, sourceDevice, storyId, - callId, type, readStatus, seenStatus @@ -2117,7 +2113,6 @@ function saveMessageSync( $sourceServiceId, $sourceDevice, $storyId, - $callId, $type, $readStatus, $seenStatus diff --git a/ts/sql/migrations/87-calls-history-table.ts b/ts/sql/migrations/87-calls-history-table.ts deleted file mode 100644 index 27e159aef7..0000000000 --- a/ts/sql/migrations/87-calls-history-table.ts +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright 2023 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import type { Database } from '@signalapp/better-sqlite3'; - -import { callIdFromEra } from '@signalapp/ringrtc'; -import Long from 'long'; - -import type { LoggerType } from '../../types/Logging'; -import { sql } from '../util'; -import { getOurUuid } from './41-uuid-keys'; -import type { CallHistoryDetails } from '../../types/CallDisposition'; -import { - DirectCallStatus, - CallDirection, - CallType, - GroupCallStatus, - callHistoryDetailsSchema, -} from '../../types/CallDisposition'; -import { CallMode } from '../../types/Calling'; - -export default function updateToSchemaVersion87( - currentVersion: number, - db: Database, - logger: LoggerType -): void { - if (currentVersion >= 87) { - return; - } - - db.transaction(() => { - const ourUuid = getOurUuid(db); - - const [createTable] = sql` - DROP TABLE IF EXISTS callsHistory; - - CREATE TABLE callsHistory ( - callId TEXT PRIMARY KEY, - peerId TEXT NOT NULL, -- conversation id (legacy) | uuid | groupId | roomId - ringerId TEXT DEFAULT NULL, -- ringer uuid - mode TEXT NOT NULL, -- enum "Direct" | "Group" - type TEXT NOT NULL, -- enum "Audio" | "Video" | "Group" - direction TEXT NOT NULL, -- enum "Incoming" | "Outgoing - -- Direct: enum "Pending" | "Missed" | "Accepted" | "Deleted" - -- Group: enum "GenericGroupCall" | "OutgoingRing" | "Ringing" | "Joined" | "Missed" | "Declined" | "Accepted" | "Deleted" - status TEXT NOT NULL, - timestamp INTEGER NOT NULL, - UNIQUE (callId, peerId) ON CONFLICT FAIL - ); - - CREATE INDEX callsHistory_order on callsHistory (timestamp DESC); - CREATE INDEX callsHistory_byConversation ON callsHistory (peerId); - -- For 'getCallHistoryGroupData': - -- This index should target the subqueries for 'possible_parent' and 'possible_children' - CREATE INDEX callsHistory_callAndGroupInfo_optimize on callsHistory ( - direction, - peerId, - timestamp DESC, - status - ); - `; - - db.exec(createTable); - - const [selectQuery] = sql` - SELECT * FROM messages - WHERE type = 'call-history' - AND callId IS NOT NULL; -- Older messages don't have callId - `; - - const rows = db.prepare(selectQuery).all(); - - const uniqueConstraint = new Set(); - - for (const row of rows) { - const json = JSON.parse(row.json); - const details = json.callHistoryDetails; - - const { conversationId: peerId } = row; - const { callMode } = details; - - let callId: string; - let type: CallType; - let direction: CallDirection; - let status: GroupCallStatus | DirectCallStatus; - let timestamp: number; - let ringerId: string | null = null; - - if (details.callMode === CallMode.Direct) { - callId = details.callId; - type = details.wasVideoCall ? CallType.Video : CallType.Audio; - direction = details.wasIncoming - ? CallDirection.Incoming - : CallDirection.Outgoing; - if (details.acceptedTime != null) { - status = DirectCallStatus.Accepted; - } else { - status = details.wasDeclined - ? DirectCallStatus.Declined - : DirectCallStatus.Missed; - } - timestamp = details.endedTime ?? details.acceptedTime ?? null; - } else if (details.callMode === CallMode.Group) { - callId = Long.fromValue(callIdFromEra(details.eraId)).toString(); - type = CallType.Group; - direction = - details.creatorUuid === ourUuid - ? CallDirection.Outgoing - : CallDirection.Incoming; - status = GroupCallStatus.GenericGroupCall; - timestamp = details.startedTime; - ringerId = details.creatorUuid; - } else { - logger.error( - `updateToSchemaVersion87: unknown callMode: ${details.callMode}` - ); - continue; - } - - if (callId == null) { - logger.error( - "updateToSchemaVersion87: callId doesn't exist, too old, skipping" - ); - continue; - } - - const callHistory: CallHistoryDetails = { - callId, - peerId, - ringerId, - mode: callMode, - type, - direction, - status, - timestamp, - }; - - const result = callHistoryDetailsSchema.safeParse(callHistory); - - if (!result.success) { - logger.error( - `updateToSchemaVersion87: invalid callHistoryDetails (error: ${JSON.stringify( - result.error.format() - )}, input: ${JSON.stringify(json)}, output: ${JSON.stringify( - callHistory - )}))` - ); - continue; - } - - // We need to ensure a call with the same callId and peerId doesn't get - // inserted twice because of the unique constraint on the table. - const uniqueKey = `${callId} -> ${peerId}`; - if (uniqueConstraint.has(uniqueKey)) { - logger.error( - `updateToSchemaVersion87: duplicate callId/peerId pair (${uniqueKey})` - ); - continue; - } - uniqueConstraint.add(uniqueKey); - - const [insertQuery, insertParams] = sql` - INSERT INTO callsHistory ( - callId, - peerId, - ringerId, - mode, - type, - direction, - status, - timestamp - ) VALUES ( - ${callHistory.callId}, - ${callHistory.peerId}, - ${callHistory.ringerId}, - ${callHistory.mode}, - ${callHistory.type}, - ${callHistory.direction}, - ${callHistory.status}, - ${callHistory.timestamp} - ) - `; - - db.prepare(insertQuery).run(insertParams); - - const [updateQuery, updateParams] = sql` - UPDATE messages - SET json = JSON_PATCH(json, ${JSON.stringify({ - callHistoryDetails: null, // delete - callId, - })}) - WHERE id = ${row.id} - `; - - db.prepare(updateQuery).run(updateParams); - } - - const [updateMessagesTable] = sql` - DROP INDEX IF EXISTS messages_call; - - ALTER TABLE messages - DROP COLUMN callId; - ALTER TABLE messages - ADD COLUMN callId TEXT; - ALTER TABLE messages - DROP COLUMN callMode; - `; - - db.exec(updateMessagesTable); - - db.pragma('user_version = 87'); - })(); - - logger.info('updateToSchemaVersion87: success!'); -} diff --git a/ts/sql/migrations/89-call-history.ts b/ts/sql/migrations/89-call-history.ts new file mode 100644 index 0000000000..76d2a434a1 --- /dev/null +++ b/ts/sql/migrations/89-call-history.ts @@ -0,0 +1,369 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from '@signalapp/better-sqlite3'; +import { callIdFromEra } from '@signalapp/ringrtc'; +import Long from 'long'; +import { v4 as generateUuid } from 'uuid'; +import type { SetOptional } from 'type-fest'; +import type { LoggerType } from '../../types/Logging'; +import { jsonToObject, sql } from '../util'; +import { getOurUuid } from './41-uuid-keys'; +import type { CallHistoryDetails } from '../../types/CallDisposition'; +import { + DirectCallStatus, + CallDirection, + CallType, + GroupCallStatus, + callHistoryDetailsSchema, +} from '../../types/CallDisposition'; +import { CallMode } from '../../types/Calling'; +import type { MessageType, ConversationType } from '../Interface'; +import { strictAssert } from '../../util/assert'; +import { missingCaseError } from '../../util/missingCaseError'; +import type { ServiceIdString } from '../../types/ServiceId'; +import { isAciString } from '../../types/ServiceId'; + +// Legacy type for calls that never had a call id +type DirectCallHistoryDetailsType = { + callId?: string; + callMode: CallMode.Direct; + wasIncoming: boolean; + wasVideoCall: boolean; + wasDeclined: boolean; + acceptedTime?: number; + endedTime?: number; +}; +type GroupCallHistoryDetailsType = { + callMode: CallMode.Group; + creatorUuid: string; + eraId: string; + startedTime: number; +}; +export type CallHistoryDetailsType = + | DirectCallHistoryDetailsType + | GroupCallHistoryDetailsType; + +export type CallHistoryDetailsFromDiskType = + // old messages weren't set with a callMode + | SetOptional + | SetOptional; + +export type MessageWithCallHistoryDetails = MessageType & { + callHistoryDetails: CallHistoryDetailsFromDiskType; +}; + +function upcastCallHistoryDetailsFromDiskType( + callDetails: CallHistoryDetailsFromDiskType +): CallHistoryDetailsType { + if (callDetails.callMode === CallMode.Direct) { + return callDetails as DirectCallHistoryDetailsType; + } + if (callDetails.callMode === CallMode.Group) { + return callDetails as GroupCallHistoryDetailsType; + } + // Some very old calls don't have a callMode, so we need to infer it from the + // other fields. This is a best effort attempt to make sure we at least have + // enough data to form the call history entry correctly. + if ( + Object.hasOwn(callDetails, 'wasIncoming') && + Object.hasOwn(callDetails, 'wasVideoCall') + ) { + return { + callMode: CallMode.Direct, + ...callDetails, + } as DirectCallHistoryDetailsType; + } + if ( + Object.hasOwn(callDetails, 'eraId') && + Object.hasOwn(callDetails, 'startedTime') + ) { + return { + callMode: CallMode.Group, + ...callDetails, + } as GroupCallHistoryDetailsType; + } + throw new Error('Could not determine call mode'); +} + +function getPeerIdFromConversation( + conversation: Pick +): string { + if (conversation.type === 'private') { + strictAssert( + isAciString(conversation.serviceId), + 'ACI must exist for direct chat' + ); + return conversation.serviceId; + } + strictAssert( + conversation.groupId != null, + 'groupId must exist for group chat' + ); + return conversation.groupId; +} + +function convertLegacyCallDetails( + ourUuid: string | undefined, + peerId: string, + message: MessageType, + partialDetails: CallHistoryDetailsFromDiskType +): CallHistoryDetails { + const details = upcastCallHistoryDetailsFromDiskType(partialDetails); + const { callMode: mode } = details; + + let callId: string; + let type: CallType; + let direction: CallDirection; + let status: GroupCallStatus | DirectCallStatus; + let timestamp: number; + let ringerId: string | null = null; + + strictAssert(mode != null, 'mode must exist'); + + if (mode === CallMode.Direct) { + // We don't have a callId for older calls, generating a uuid instead + callId = details.callId ?? generateUuid(); + type = details.wasVideoCall ? CallType.Video : CallType.Audio; + direction = details.wasIncoming + ? CallDirection.Incoming + : CallDirection.Outgoing; + if (details.acceptedTime != null) { + status = DirectCallStatus.Accepted; + } else { + status = details.wasDeclined + ? DirectCallStatus.Declined + : DirectCallStatus.Missed; + } + timestamp = details.acceptedTime ?? details.endedTime ?? message.timestamp; + } else if (mode === CallMode.Group) { + callId = Long.fromValue(callIdFromEra(details.eraId)).toString(); + type = CallType.Group; + direction = + details.creatorUuid === ourUuid + ? CallDirection.Outgoing + : CallDirection.Incoming; + status = GroupCallStatus.GenericGroupCall; + timestamp = details.startedTime; + ringerId = details.creatorUuid; + } else { + throw missingCaseError(mode); + } + + const callHistory: CallHistoryDetails = { + callId, + peerId, + ringerId, + mode, + type, + direction, + status, + timestamp, + }; + + return callHistoryDetailsSchema.parse(callHistory); +} + +export default function updateToSchemaVersion89( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 89) { + return; + } + + db.transaction(() => { + const ourUuid = getOurUuid(db); + + const [createTable] = sql` + -- This table may have already existed from migration 87 + CREATE TABLE IF NOT EXISTS callsHistory ( + callId TEXT PRIMARY KEY, + peerId TEXT NOT NULL, -- conversation id (legacy) | uuid | groupId | roomId + ringerId TEXT DEFAULT NULL, -- ringer uuid + mode TEXT NOT NULL, -- enum "Direct" | "Group" + type TEXT NOT NULL, -- enum "Audio" | "Video" | "Group" + direction TEXT NOT NULL, -- enum "Incoming" | "Outgoing + -- Direct: enum "Pending" | "Missed" | "Accepted" | "Deleted" + -- Group: enum "GenericGroupCall" | "OutgoingRing" | "Ringing" | "Joined" | "Missed" | "Declined" | "Accepted" | "Deleted" + status TEXT NOT NULL, + timestamp INTEGER NOT NULL, + UNIQUE (callId, peerId) ON CONFLICT FAIL + ); + + -- Update peerId to be uuid or groupId + UPDATE callsHistory + SET peerId = ( + SELECT + CASE + WHEN conversations.type = 'private' THEN conversations.serviceId + WHEN conversations.type = 'group' THEN conversations.groupId + END + FROM conversations + WHERE callsHistory.peerId IS conversations.id + AND callsHistory.peerId IS NOT conversations.serviceId + ) + WHERE EXISTS ( + SELECT 1 + FROM conversations + WHERE callsHistory.peerId IS conversations.id + AND callsHistory.peerId IS NOT conversations.serviceId + ); + + CREATE INDEX IF NOT EXISTS callsHistory_order on callsHistory (timestamp DESC); + CREATE INDEX IF NOT EXISTS callsHistory_byConversation ON callsHistory (peerId); + -- For 'getCallHistoryGroupData': + -- This index should target the subqueries for 'possible_parent' and 'possible_children' + CREATE INDEX IF NOT EXISTS callsHistory_callAndGroupInfo_optimize on callsHistory ( + direction, + peerId, + timestamp DESC, + status + ); + `; + + db.exec(createTable); + + const [selectQuery] = sql` + SELECT + messages.json AS messageJson, + conversations.type AS conversationType, + conversations.serviceId AS conversationServiceId, + conversations.groupId AS conversationGroupId + FROM messages + LEFT JOIN conversations ON conversations.id = messages.conversationId + WHERE messages.type = 'call-history' + -- Some of these messages were already migrated + AND messages.json->'callHistoryDetails' IS NOT NULL + -- Sort from oldest to newest, so that newer messages can overwrite older + ORDER BY messages.received_at ASC, messages.sent_at ASC; + `; + + // Must match query above + type CallHistoryRow = { + messageJson: string; + conversationType: ConversationType['type']; + conversationServiceId: ServiceIdString | undefined; + conversationGroupId: string | undefined; + }; + + const rows: Array = db.prepare(selectQuery).all(); + + for (const row of rows) { + const { + messageJson, + conversationType, + conversationServiceId, + conversationGroupId, + } = row; + const message = jsonToObject(messageJson); + const details = message.callHistoryDetails; + + const peerId = getPeerIdFromConversation({ + type: conversationType, + serviceId: conversationServiceId, + groupId: conversationGroupId, + }); + + const callHistory = convertLegacyCallDetails( + ourUuid, + peerId, + message, + details + ); + + const [insertQuery, insertParams] = sql` + -- Using 'OR REPLACE' because in some earlier versions of call history + -- we had a bug where we would insert duplicate call history entries + -- for the same callId and peerId. + -- We're assuming here that the latest call history entry is the most + -- accurate. + INSERT OR REPLACE INTO callsHistory ( + callId, + peerId, + ringerId, + mode, + type, + direction, + status, + timestamp + ) VALUES ( + ${callHistory.callId}, + ${callHistory.peerId}, + ${callHistory.ringerId}, + ${callHistory.mode}, + ${callHistory.type}, + ${callHistory.direction}, + ${callHistory.status}, + ${callHistory.timestamp} + ) + `; + + db.prepare(insertQuery).run(insertParams); + + const messageId = message.id; + strictAssert(messageId != null, 'message.id must exist'); + + const [updateQuery, updateParams] = sql` + UPDATE messages + SET json = JSON_PATCH(json, ${JSON.stringify({ + callHistoryDetails: null, // delete + callId: callHistory.callId, + })}) + WHERE id = ${messageId} + `; + + db.prepare(updateQuery).run(updateParams); + } + + const [dropIndex] = sql` + DROP INDEX IF EXISTS messages_call; + `; + db.exec(dropIndex); + + try { + const [dropColumnQuery] = sql` + ALTER TABLE messages + DROP COLUMN callMode; + `; + db.exec(dropColumnQuery); + } catch (error) { + if (!error.message.includes('no such column: "callMode"')) { + throw error; + } + } + + try { + const [dropColumnQuery] = sql` + ALTER TABLE messages + DROP COLUMN callId; + `; + db.exec(dropColumnQuery); + } catch (error) { + if (!error.message.includes('no such column: "callId"')) { + throw error; + } + } + + const [optimizeMessages] = sql` + ALTER TABLE messages + ADD COLUMN callId TEXT + GENERATED ALWAYS AS ( + json_extract(json, '$.callId') + ); + -- Optimize getCallHistoryMessageByCallId + CREATE INDEX messages_call ON messages + (conversationId, type, callId); + + CREATE INDEX messages_callHistory_readStatus ON messages + (type, readStatus) + WHERE type IS 'call-history'; + `; + db.exec(optimizeMessages); + + db.pragma('user_version = 89'); + })(); + + logger.info('updateToSchemaVersion89: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 48d0da4969..07cf3f6ea6 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -62,8 +62,8 @@ import updateToSchemaVersion83 from './83-mentions'; import updateToSchemaVersion84 from './84-all-mentions'; import updateToSchemaVersion85 from './85-add-kyber-keys'; import updateToSchemaVersion86 from './86-story-replies-index'; -import updateToSchemaVersion87 from './87-calls-history-table'; import updateToSchemaVersion88 from './88-service-ids'; +import updateToSchemaVersion89 from './89-call-history'; function updateToSchemaVersion1( currentVersion: number, @@ -1996,8 +1996,9 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion84, updateToSchemaVersion85, updateToSchemaVersion86, - updateToSchemaVersion87, + (_v: number, _i: Database, _l: LoggerType): void => undefined, // version 87 was dropped updateToSchemaVersion88, + updateToSchemaVersion89, ]; export function updateSchema(db: Database, logger: LoggerType): void { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 7ff58d39e5..d653f6d035 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -512,14 +512,14 @@ export type ConversationsStateType = Readonly<{ export const getConversationCallMode = ( conversation: ConversationType -): CallMode => { +): CallMode | null => { if ( conversation.left || conversation.isBlocked || conversation.isMe || !conversation.acceptedMessageRequest ) { - return CallMode.None; + return null; } if (conversation.type === 'direct') { @@ -530,7 +530,7 @@ export const getConversationCallMode = ( return CallMode.Group; } - return CallMode.None; + return null; }; // Actions diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 8432bc343c..11490e908b 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -1317,6 +1317,14 @@ export type GetPropsForCallHistoryOptions = Pick< | 'ourConversationId' >; +const emptyCallNotification = { + callHistory: null, + callCreator: null, + callExternalState: CallExternalState.Ended, + maxDevices: Infinity, + deviceCount: 0, +}; + export function getPropsForCallHistory( message: MessageWithUIFieldsType, { @@ -1328,17 +1336,15 @@ export function getPropsForCallHistory( }: GetPropsForCallHistoryOptions ): CallingNotificationType { const { callId } = message; - if (callId == null && 'callHistoryDetails' in message) { - log.error( - 'getPropsForCallHistory: Found callHistoryDetails, but no callId' - ); + if (callId == null) { + log.error('getPropsForCallHistory: Missing callId'); + return emptyCallNotification; } - strictAssert(callId != null, 'getPropsForCallHistory: Missing callId'); const callHistory = callHistorySelector(callId); - strictAssert( - callHistory != null, - 'getPropsForCallHistory: Missing callHistory' - ); + if (callHistory == null) { + log.error('getPropsForCallHistory: Missing callHistory'); + return emptyCallNotification; + } const conversation = conversationSelector(callHistory.peerId); strictAssert( diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index 7f3ddb6d8d..bb04b3b22f 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -54,7 +54,7 @@ const getOutgoingCallButtonStyle = ( const conversationCallMode = getConversationCallMode(conversation); switch (conversationCallMode) { - case CallMode.None: + case null: return OutgoingCallButtonStyle.None; case CallMode.Direct: return OutgoingCallButtonStyle.Both; diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 498e0a2fac..f2b615f5b4 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -136,53 +136,53 @@ describe('both/state/ducks/conversations', () => { const fakeConversation: ConversationType = getDefaultConversation(); const fakeGroup: ConversationType = getDefaultGroup(); - it("returns CallMode.None if you've left the conversation", () => { + it("returns null if you've left the conversation", () => { assert.strictEqual( getConversationCallMode({ ...fakeConversation, left: true, }), - CallMode.None + null ); }); - it("returns CallMode.None if you've blocked the other person", () => { + it("returns null if you've blocked the other person", () => { assert.strictEqual( getConversationCallMode({ ...fakeConversation, isBlocked: true, }), - CallMode.None + null ); }); - it("returns CallMode.None if you haven't accepted message requests", () => { + it("returns null if you haven't accepted message requests", () => { assert.strictEqual( getConversationCallMode({ ...fakeConversation, acceptedMessageRequest: false, }), - CallMode.None + null ); }); - it('returns CallMode.None if the conversation is Note to Self', () => { + it('returns null if the conversation is Note to Self', () => { assert.strictEqual( getConversationCallMode({ ...fakeConversation, isMe: true, }), - CallMode.None + null ); }); - it('returns CallMode.None for v1 groups', () => { + it('returns null for v1 groups', () => { assert.strictEqual( getConversationCallMode({ ...fakeGroup, groupVersion: 1, }), - CallMode.None + null ); assert.strictEqual( @@ -190,7 +190,7 @@ describe('both/state/ducks/conversations', () => { ...fakeGroup, groupVersion: undefined, }), - CallMode.None + null ); }); diff --git a/ts/test-node/sql/migration_88_test.ts b/ts/test-node/sql/migration_88_test.ts index 1284dd1702..b0e0a07db6 100644 --- a/ts/test-node/sql/migration_88_test.ts +++ b/ts/test-node/sql/migration_88_test.ts @@ -21,7 +21,7 @@ describe('SQL/updateToSchemaVersion88', () => { beforeEach(() => { db = new SQL(':memory:'); - updateToVersion(db, 87); + updateToVersion(db, 86); insertData(db, 'items', [ { diff --git a/ts/test-node/sql/migration_89_test.ts b/ts/test-node/sql/migration_89_test.ts new file mode 100644 index 0000000000..10dab08b66 --- /dev/null +++ b/ts/test-node/sql/migration_89_test.ts @@ -0,0 +1,404 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import type { Database } from '@signalapp/better-sqlite3'; +import SQL from '@signalapp/better-sqlite3'; +import { v4 as generateGuid } from 'uuid'; + +import { jsonToObject, sql } from '../../sql/util'; +import { CallMode } from '../../types/Calling'; +import type { CallHistoryDetails } from '../../types/CallDisposition'; +import { + CallDirection, + CallType, + DirectCallStatus, + GroupCallStatus, + callHistoryDetailsSchema, +} from '../../types/CallDisposition'; +import type { + CallHistoryDetailsFromDiskType, + MessageWithCallHistoryDetails, +} from '../../sql/migrations/89-call-history'; +import { getCallIdFromEra } from '../../util/callDisposition'; +import { isValidUuid } from '../../util/isValidUuid'; +import { updateToVersion } from './helpers'; +import type { MessageType } from '../../sql/Interface'; + +describe('SQL/updateToSchemaVersion89', () => { + let db: Database; + + beforeEach(() => { + db = new SQL(':memory:'); + updateToVersion(db, 88); + }); + + afterEach(() => { + db.close(); + }); + + function getDirectCallHistoryDetails(options: { + callId: string | null; + noCallMode?: boolean; + wasDeclined?: boolean; + }): CallHistoryDetailsFromDiskType { + return { + callId: options.callId ?? undefined, + callMode: options.noCallMode ? undefined : CallMode.Direct, + wasDeclined: options.wasDeclined ?? false, + wasIncoming: false, + wasVideoCall: false, + acceptedTime: undefined, + endedTime: undefined, + }; + } + + function getGroupCallHistoryDetails(options: { + eraId: string; + noCallMode?: boolean; + }): CallHistoryDetailsFromDiskType { + return { + eraId: options.eraId, + callMode: options.noCallMode ? undefined : CallMode.Group, + creatorUuid: generateGuid(), + startedTime: Date.now(), + }; + } + + function createCallHistoryMessage(options: { + messageId: string; + conversationId: string; + callHistoryDetails: CallHistoryDetailsFromDiskType; + }): MessageWithCallHistoryDetails { + const message: MessageWithCallHistoryDetails = { + id: options.messageId, + type: 'call-history', + conversationId: options.conversationId, + sent_at: Date.now() - 10, + received_at: Date.now() - 10, + timestamp: Date.now() - 10, + callHistoryDetails: options.callHistoryDetails, + }; + + const json = JSON.stringify(message); + + const [query, params] = sql` + INSERT INTO messages + (id, conversationId, type, json) + VALUES + ( + ${message.id}, + ${message.conversationId}, + ${message.type}, + ${json} + ) + `; + + db.prepare(query).run(params); + + return message; + } + + function createConversation(type: 'private' | 'group') { + const id = generateGuid(); + const serviceId = type === 'private' ? generateGuid() : null; + const groupId = type === 'group' ? generateGuid() : null; + + const [query, params] = sql` + INSERT INTO conversations + (id, type, serviceId, groupId) + VALUES + (${id}, ${type}, ${serviceId}, ${groupId}); + `; + + db.prepare(query).run(params); + + return { id, serviceId, groupId }; + } + + function getAllCallHistory() { + const [selectHistoryQuery] = sql` + SELECT * FROM callsHistory; + `; + return db + .prepare(selectHistoryQuery) + .all() + .map(row => { + return callHistoryDetailsSchema.parse(row); + }); + } + + it('pulls out call history messages into the new table', () => { + updateToVersion(db, 88); + + const conversation1 = createConversation('private'); + const conversation2 = createConversation('group'); + + const callId1 = '123'; + const eraId2 = 'abc'; + + createCallHistoryMessage({ + messageId: generateGuid(), + conversationId: conversation1.id, + callHistoryDetails: getDirectCallHistoryDetails({ + callId: callId1, + }), + }); + + createCallHistoryMessage({ + messageId: generateGuid(), + conversationId: conversation2.id, + callHistoryDetails: getGroupCallHistoryDetails({ + eraId: eraId2, + }), + }); + + updateToVersion(db, 89); + + const callHistory = getAllCallHistory(); + + assert.strictEqual(callHistory.length, 2); + assert.strictEqual(callHistory[0].callId, callId1); + assert.strictEqual(callHistory[1].callId, getCallIdFromEra(eraId2)); + }); + + it('migrates older messages without a callId', () => { + updateToVersion(db, 88); + + const conversation = createConversation('private'); + createCallHistoryMessage({ + messageId: generateGuid(), + conversationId: conversation.id, + callHistoryDetails: getDirectCallHistoryDetails({ + callId: null, // no id + }), + }); + + updateToVersion(db, 89); + + const callHistory = getAllCallHistory(); + + assert.strictEqual(callHistory.length, 1); + assert.isTrue(isValidUuid(callHistory[0].callId)); + }); + + it('migrates older messages without a callMode', () => { + updateToVersion(db, 88); + + const conversation1 = createConversation('private'); + const conversation2 = createConversation('group'); + createCallHistoryMessage({ + messageId: generateGuid(), + conversationId: conversation1.id, + callHistoryDetails: getDirectCallHistoryDetails({ + callId: null, // no id + noCallMode: true, + }), + }); + createCallHistoryMessage({ + messageId: generateGuid(), + conversationId: conversation2.id, + callHistoryDetails: getGroupCallHistoryDetails({ + eraId: 'abc', + noCallMode: true, + }), + }); + + updateToVersion(db, 89); + + const callHistory = getAllCallHistory(); + + assert.strictEqual(callHistory.length, 2); + assert.strictEqual(callHistory[0].mode, CallMode.Direct); + assert.strictEqual(callHistory[1].mode, CallMode.Group); + }); + + it('handles unique constraint violations', () => { + updateToVersion(db, 88); + + const conversation = createConversation('private'); + createCallHistoryMessage({ + messageId: generateGuid(), + conversationId: conversation.id, // same conversation + callHistoryDetails: getDirectCallHistoryDetails({ + callId: '123', // same callId + }), + }); + createCallHistoryMessage({ + messageId: generateGuid(), + conversationId: conversation.id, // same conversation + callHistoryDetails: getDirectCallHistoryDetails({ + callId: '123', // same callId + }), + }); + + updateToVersion(db, 89); + + const callHistory = getAllCallHistory(); + assert.strictEqual(callHistory.length, 1); + }); + + it('normalizes peerId to conversation.serviceId or conversation.groupId', () => { + updateToVersion(db, 88); + + const conversation1 = createConversation('private'); + const conversation2 = createConversation('group'); + createCallHistoryMessage({ + messageId: generateGuid(), + conversationId: conversation1.id, + callHistoryDetails: getDirectCallHistoryDetails({ + callId: '123', + }), + }); + createCallHistoryMessage({ + messageId: generateGuid(), + conversationId: conversation2.id, + callHistoryDetails: getGroupCallHistoryDetails({ + eraId: 'abc', + }), + }); + + updateToVersion(db, 89); + + const callHistory = getAllCallHistory(); + assert.strictEqual(callHistory.length, 2); + assert.strictEqual(callHistory[0].peerId, conversation1.serviceId); + assert.strictEqual(callHistory[1].peerId, conversation2.groupId); + }); + + describe('clients with schema version 87', () => { + function createCallHistoryTable() { + const [query] = sql` + CREATE TABLE callsHistory ( + callId TEXT PRIMARY KEY, + peerId TEXT NOT NULL, -- conversation id (legacy) | uuid | groupId | roomId + ringerId TEXT DEFAULT NULL, -- ringer uuid + mode TEXT NOT NULL, -- enum "Direct" | "Group" + type TEXT NOT NULL, -- enum "Audio" | "Video" | "Group" + direction TEXT NOT NULL, -- enum "Incoming" | "Outgoing + -- Direct: enum "Pending" | "Missed" | "Accepted" | "Deleted" + -- Group: enum "GenericGroupCall" | "OutgoingRing" | "Ringing" | "Joined" | "Missed" | "Declined" | "Accepted" | "Deleted" + status TEXT NOT NULL, + timestamp INTEGER NOT NULL, + UNIQUE (callId, peerId) ON CONFLICT FAIL + ); + `; + db.exec(query); + } + + function insertCallHistory(callHistory: CallHistoryDetails) { + const [query, params] = sql` + INSERT INTO callsHistory ( + callId, + peerId, + ringerId, + mode, + type, + direction, + status, + timestamp + ) VALUES ( + ${callHistory.callId}, + ${callHistory.peerId}, + ${callHistory.ringerId}, + ${callHistory.mode}, + ${callHistory.type}, + ${callHistory.direction}, + ${callHistory.status}, + ${callHistory.timestamp} + ); + `; + db.prepare(query).run(params); + } + + function getMessages() { + const [query] = sql` + SELECT json FROM messages; + `; + return db + .prepare(query) + .all() + .map(row => { + return jsonToObject(row.json); + }); + } + + it('migrates existing peerId to conversation.serviceId or conversation.groupId', () => { + updateToVersion(db, 88); + + createCallHistoryTable(); + + const conversation1 = createConversation('private'); + const conversation2 = createConversation('group'); + + insertCallHistory({ + callId: '123', + peerId: conversation1.id, + ringerId: null, + mode: CallMode.Direct, + type: CallType.Audio, + direction: CallDirection.Incoming, + status: DirectCallStatus.Accepted, + timestamp: Date.now(), + }); + insertCallHistory({ + callId: 'abc', + peerId: conversation2.id, + ringerId: null, + mode: CallMode.Group, + type: CallType.Group, + direction: CallDirection.Incoming, + status: GroupCallStatus.Accepted, + timestamp: Date.now(), + }); + + updateToVersion(db, 89); + + const callHistory = getAllCallHistory(); + assert.strictEqual(callHistory.length, 2); + assert.strictEqual(callHistory[0].peerId, conversation1.serviceId); + assert.strictEqual(callHistory[1].peerId, conversation2.groupId); + }); + + it('migrates duplicate call history where the first was already migrated', () => { + updateToVersion(db, 88); + + createCallHistoryTable(); + + const conversation = createConversation('private'); + + insertCallHistory({ + callId: '123', + peerId: conversation.id, + ringerId: null, + mode: CallMode.Direct, + type: CallType.Audio, + direction: CallDirection.Incoming, + status: DirectCallStatus.Pending, + timestamp: Date.now() - 1000, + }); + + createCallHistoryMessage({ + messageId: generateGuid(), + conversationId: conversation.id, + callHistoryDetails: getDirectCallHistoryDetails({ + callId: '123', + wasDeclined: true, + }), + }); + + updateToVersion(db, 89); + + const callHistory = getAllCallHistory(); + + assert.strictEqual(callHistory.length, 1); + assert.strictEqual(callHistory[0].status, DirectCallStatus.Declined); + + const messages = getMessages(); + assert.strictEqual(messages.length, 1); + assert.strictEqual(messages[0].type, 'call-history'); + assert.strictEqual(messages[0].callId, '123'); + assert.notProperty(messages[0], 'callHistoryDetails'); + }); + }); +}); diff --git a/ts/test-node/sql/migrations_test.ts b/ts/test-node/sql/migrations_test.ts index f244ed4cd7..9478b7d3d0 100644 --- a/ts/test-node/sql/migrations_test.ts +++ b/ts/test-node/sql/migrations_test.ts @@ -17,9 +17,6 @@ import { objectToJSON, sql, sqlJoin } from '../../sql/util'; import { BodyRange } from '../../types/BodyRange'; import type { AciString } from '../../types/ServiceId'; import { generateAci } from '../../types/ServiceId'; -import { callHistoryDetailsSchema } from '../../types/CallDisposition'; -import { CallMode } from '../../types/Calling'; -import type { MessageAttributesType } from '../../model-types.d'; import { updateToVersion } from './helpers'; const OUR_UUID = generateGuid(); @@ -3565,162 +3562,4 @@ describe('SQL migrations test', () => { ); }); }); - - describe('updateToSchemaVersion87', () => { - it('pulls out call history messages into the new table', () => { - updateToVersion(db, 86); - - const message1Id = generateGuid(); - const message2Id = generateGuid(); - const conversationId = generateGuid(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- using old types - const message1: MessageAttributesType & { callHistoryDetails: any } = { - id: message1Id, - type: 'call-history', - conversationId, - sent_at: Date.now() - 10, - received_at: Date.now() - 10, - timestamp: Date.now() - 10, - callHistoryDetails: { - callId: '123', - callMode: CallMode.Direct, - wasDeclined: false, - wasDeleted: false, - wasIncoming: false, - wasVideoCall: false, - acceptedTime: Date.now(), - endedTime: undefined, - }, - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- using old types - const message2: MessageAttributesType & { callHistoryDetails: any } = { - id: message2Id, - type: 'call-history', - conversationId, - sent_at: Date.now(), - received_at: Date.now(), - timestamp: Date.now(), - callHistoryDetails: { - callMode: CallMode.Group, - creatorUuid: generateGuid(), - eraId: (0x123).toString(16), - startedTime: Date.now(), - }, - }; - - const [insertQuery, insertParams] = sql` - INSERT INTO messages ( - id, - conversationId, - type, - json - ) - VALUES - ( - ${message1Id}, - ${conversationId}, - ${message1.type}, - ${JSON.stringify(message1)} - ), - ( - ${message2Id}, - ${conversationId}, - ${message2.type}, - ${JSON.stringify(message2)} - ); - `; - - db.prepare(insertQuery).run(insertParams); - - updateToVersion(db, 87); - - const [selectHistoryQuery] = sql` - SELECT * FROM callsHistory; - `; - - const rows = db.prepare(selectHistoryQuery).all(); - - for (const row of rows) { - callHistoryDetailsSchema.parse(row); - } - }); - - it('handles unique constraint violations', () => { - updateToVersion(db, 86); - - const message1Id = generateGuid(); - const message2Id = generateGuid(); - const conversationId = generateGuid(); - const callHistoryDetails = { - callId: '123', - callMode: CallMode.Direct, - wasDeclined: false, - wasDeleted: false, - wasIncoming: false, - wasVideoCall: false, - acceptedTime: Date.now(), - endedTime: undefined, - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- using old types - const message1: MessageAttributesType & { callHistoryDetails: any } = { - id: message1Id, - type: 'call-history', - conversationId, - sent_at: Date.now() - 10, - received_at: Date.now() - 10, - timestamp: Date.now() - 10, - callHistoryDetails, - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- using old types - const message2: MessageAttributesType & { callHistoryDetails: any } = { - id: message2Id, - type: 'call-history', - conversationId, - sent_at: Date.now(), - received_at: Date.now(), - timestamp: Date.now(), - callHistoryDetails, - }; - - const [insertQuery, insertParams] = sql` - INSERT INTO messages ( - id, - conversationId, - type, - json - ) - VALUES - ( - ${message1Id}, - ${conversationId}, - ${message1.type}, - ${JSON.stringify(message1)} - ), - ( - ${message2Id}, - ${conversationId}, - ${message2.type}, - ${JSON.stringify(message2)} - ); - `; - - db.prepare(insertQuery).run(insertParams); - - updateToVersion(db, 87); - - const [selectHistoryQuery] = sql` - SELECT * FROM callsHistory; - `; - - const rows = db.prepare(selectHistoryQuery).all(); - for (const row of rows) { - callHistoryDetailsSchema.parse(row); - } - assert.strictEqual(rows.length, 1); - }); - }); }); diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index bd1cd2adba..dd680b732c 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -7,7 +7,6 @@ import type { ServiceIdString } from './ServiceId'; // These are strings (1) for the database (2) for Storybook. export enum CallMode { - None = 'None', Direct = 'Direct', Group = 'Group', } diff --git a/ts/util/callDisposition.ts b/ts/util/callDisposition.ts index 32b6a57cc2..e507ed58f1 100644 --- a/ts/util/callDisposition.ts +++ b/ts/util/callDisposition.ts @@ -500,8 +500,6 @@ export function transitionCallHistory( event, direction ); - } else if (mode === CallMode.None) { - throw new TypeError('Call mode must not be none'); } else { throw missingCaseError(mode); } diff --git a/ts/util/callingNotification.ts b/ts/util/callingNotification.ts index eff67cced2..4d979b60ec 100644 --- a/ts/util/callingNotification.ts +++ b/ts/util/callingNotification.ts @@ -22,7 +22,8 @@ export enum CallExternalState { } export type CallingNotificationType = Readonly<{ - callHistory: CallHistoryDetails; + // In some older calls, we don't have a call id, this hardens against that. + callHistory: CallHistoryDetails | null; callCreator: ConversationType | null; callExternalState: CallExternalState; deviceCount: number; @@ -110,8 +111,12 @@ function getGroupCallNotificationText( export function getCallingNotificationText( callingNotification: CallingNotificationType, i18n: LocalizerType -): string { +): string | null { const { callHistory, callCreator, callExternalState } = callingNotification; + if (callHistory == null) { + return null; + } + if (callHistory.mode === CallMode.Direct) { return getDirectCallNotificationText( callHistory.direction, @@ -123,11 +128,6 @@ export function getCallingNotificationText( if (callHistory.mode === CallMode.Group) { return getGroupCallNotificationText(callExternalState, callCreator, i18n); } - if (callHistory.mode === CallMode.None) { - throw new Error( - 'getCallingNotificationText: Cannot render call history details with mode = None' - ); - } throw missingCaseError(callHistory.mode); }