From 00d6379baebb72822a6cc5ac3a28a6712020df0d Mon Sep 17 00:00:00 2001 From: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> Date: Mon, 1 Apr 2024 12:19:35 -0700 Subject: [PATCH] Call link call history --- _locales/en/messages.json | 4 + ts/background.ts | 6 + .../CallingAdhocCallInfo.stories.tsx | 2 + ts/components/CallingLobby.tsx | 2 +- ts/components/CallsList.tsx | 41 ++- ts/components/CallsTab.tsx | 7 + ts/services/LinkPreview.ts | 2 +- ts/services/callLinksLoader.ts | 18 ++ ts/services/calling.ts | 205 ++++++++++----- ts/sql/Interface.ts | 11 + ts/sql/Server.ts | 36 ++- ts/sql/migrations/1010-call-links-table.ts | 40 +++ ts/sql/migrations/index.ts | 6 +- ts/sql/server/callLinks.ts | 100 ++++++++ ts/state/ducks/calling.ts | 241 +++++++++++------- ts/state/getInitialState.ts | 8 +- ts/state/initializeRedux.ts | 4 + ts/state/selectors/calling.ts | 6 +- ts/state/smart/CallsTab.tsx | 6 +- ts/test-both/util/callLinks_test.ts | 39 +++ .../sql/getCallHistoryGroups_test.ts | 178 ++++++++++++- ts/test-electron/state/ducks/calling_test.ts | 9 +- ts/test-node/util/callDisposition_test.ts | 80 ++++++ ts/types/CallDisposition.ts | 9 +- ts/types/CallLink.ts | 61 ++++- ts/util/callDisposition.ts | 150 ++++++++++- ts/util/callLinks.ts | 55 +++- ts/util/callingNotification.ts | 1 - ts/windows/main/preload_test.ts | 1 + 29 files changed, 1124 insertions(+), 204 deletions(-) create mode 100644 ts/services/callLinksLoader.ts create mode 100644 ts/sql/migrations/1010-call-links-table.ts create mode 100644 ts/sql/server/callLinks.ts create mode 100644 ts/test-both/util/callLinks_test.ts create mode 100644 ts/test-node/util/callDisposition_test.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e4aa51f26d5a..9a5e5f7b310d 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -7408,6 +7408,10 @@ "messageformat": "Group call", "description": "Calls Tab > Calls List > Call Item > Call Status > When group call is in its default state" }, + "icu:CallsList__ItemCallInfo--CallLink": { + "messageformat": "Call link", + "description": "On the Calls Tab, the subtitle text for a Call Link entry." + }, "icu:CallsNewCall__EmptyState--noQuery": { "messageformat": "No recent conversations.", "description": "Calls Tab > New Call > Conversations List > When no results found > With no search query" diff --git a/ts/background.ts b/ts/background.ts index 400624bb36c7..8c166c63ebd3 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -187,6 +187,10 @@ import { getCallsHistoryUnreadCountForRedux, loadCallsHistory, } from './services/callHistoryLoader'; +import { + getCallLinksForRedux, + loadCallLinks, +} from './services/callLinksLoader'; import { getCallIdFromEra, updateLocalGroupCallHistoryTimestamp, @@ -1105,6 +1109,7 @@ export async function startApp(): Promise { loadStories(), loadDistributionLists(), loadCallsHistory(), + loadCallLinks(), window.textsecure.storage.protocol.hydrateCaches(), (async () => { mainWindowStats = await window.SignalContext.getMainWindowStats(); @@ -1155,6 +1160,7 @@ export async function startApp(): Promise { theme: ThemeType; }) { initializeRedux({ + callLinks: getCallLinksForRedux(), callsHistory: getCallsHistoryForRedux(), callsHistoryUnreadCount: getCallsHistoryUnreadCountForRedux(), initialBadgesState, diff --git a/ts/components/CallingAdhocCallInfo.stories.tsx b/ts/components/CallingAdhocCallInfo.stories.tsx index a7ea684cfca7..c5b545133c62 100644 --- a/ts/components/CallingAdhocCallInfo.stories.tsx +++ b/ts/components/CallingAdhocCallInfo.stories.tsx @@ -49,8 +49,10 @@ function getCallLink(overrideProps: Partial = {}): CallLinkType { return { roomId: 'abcd1234abcd1234abcd1234abcd1234abcd1234', rootKey: 'abcd-abcd-abcd-abcd-abcd-abcd-abcd-abcd', + adminKey: null, name: 'Axolotl Discuss', restrictions: CallLinkRestrictions.None, + revoked: false, expiration: Date.now() + 30 * 24 * 60 * 60 * 1000, ...overrideProps, }; diff --git a/ts/components/CallingLobby.tsx b/ts/components/CallingLobby.tsx index 980a9d78cb63..dbcafde81d14 100644 --- a/ts/components/CallingLobby.tsx +++ b/ts/components/CallingLobby.tsx @@ -206,7 +206,7 @@ export function CallingLobby({ callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.CallIsFull; } else if (isCallConnecting) { callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Loading; - } else if (peekedParticipants.length) { + } else if (peekedParticipants.length || callMode === CallMode.Adhoc) { callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Join; } else { callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Start; diff --git a/ts/components/CallsList.tsx b/ts/components/CallsList.tsx index e2782ba206f0..85fa8c74bd03 100644 --- a/ts/components/CallsList.tsx +++ b/ts/components/CallsList.tsx @@ -43,6 +43,10 @@ import { formatCallHistoryGroup } from '../util/callDisposition'; import { CallsNewCallButton } from './CallsNewCall'; import { Tooltip, TooltipPlacement } from './Tooltip'; import { Theme } from '../util/theme'; +import type { CallingConversationType } from '../types/Calling'; +import { CallMode } from '../types/Calling'; +import type { CallLinkType } from '../types/CallLink'; +import { callLinkToConversation } from '../util/callLinks'; function Timestamp({ i18n, @@ -112,6 +116,7 @@ type CallsListProps = Readonly<{ pagination: CallHistoryPagination ) => Promise>; callHistoryEdition: number; + getCallLink: (id: string) => CallLinkType | undefined; getConversation: (id: string) => ConversationType | void; i18n: LocalizerType; selectedCallHistoryGroup: CallHistoryGroup | null; @@ -121,6 +126,7 @@ type CallsListProps = Readonly<{ conversationId: string, selectedCallHistoryGroup: CallHistoryGroup ) => void; + startCallLinkLobbyByRoomId: (roomId: string) => void; }>; const CALL_LIST_ITEM_ROW_HEIGHT = 62; @@ -141,12 +147,14 @@ export function CallsList({ getCallHistoryGroupsCount, getCallHistoryGroups, callHistoryEdition, + getCallLink, getConversation, i18n, selectedCallHistoryGroup, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, onSelectCallHistoryGroup, + startCallLinkLobbyByRoomId, }: CallsListProps): JSX.Element { const infiniteLoaderRef = useRef(null); const listRef = useRef(null); @@ -301,7 +309,16 @@ export function CallsList({ const rowRenderer = useCallback( ({ key, index, style }: ListRowProps) => { const item = searchState.results?.items.at(index) ?? null; - const conversation = item != null ? getConversation(item.peerId) : null; + const isAdhoc = item?.mode === CallMode.Adhoc; + const callLink = isAdhoc ? getCallLink(item.peerId) : null; + + const conversation: CallingConversationType | null = + // eslint-disable-next-line no-nested-ternary + item + ? callLink + ? callLinkToConversation(callLink, i18n) + : getConversation(item.peerId) ?? null + : null; if ( searchState.state === 'pending' || @@ -337,6 +354,8 @@ export function CallsList({ let statusText; if (wasMissed) { statusText = i18n('icu:CallsList__ItemCallInfo--Missed'); + } else if (callLink) { + statusText = i18n('icu:CallsList__ItemCallInfo--CallLink'); } else if (item.type === CallType.Group) { statusText = i18n('icu:CallsList__ItemCallInfo--GroupCall'); } else if (item.direction === CallDirection.Outgoing) { @@ -363,7 +382,7 @@ export function CallsList({ { - if (item.type === CallType.Audio) { - onOutgoingAudioCallInConversation(conversation.id); - } else { - onOutgoingVideoCallInConversation(conversation.id); + if (callLink) { + startCallLinkLobbyByRoomId(callLink.roomId); + } else if (conversation) { + if (item.type === CallType.Audio) { + onOutgoingAudioCallInConversation(conversation.id); + } else { + onOutgoingVideoCallInConversation(conversation.id); + } } }} /> @@ -403,6 +426,10 @@ export function CallsList({ } onClick={() => { + if (callLink) { + return; + } + onSelectCallHistoryGroup(conversation.id, item); }} /> @@ -412,11 +439,13 @@ export function CallsList({ [ hasActiveCall, searchState, + getCallLink, getConversation, selectedCallHistoryGroup, onSelectCallHistoryGroup, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, + startCallLinkLobbyByRoomId, i18n, ] ); diff --git a/ts/components/CallsTab.tsx b/ts/components/CallsTab.tsx index 861606dab037..7b03b0025048 100644 --- a/ts/components/CallsTab.tsx +++ b/ts/components/CallsTab.tsx @@ -18,6 +18,7 @@ import { ContextMenu } from './ContextMenu'; import { ConfirmationDialog } from './ConfirmationDialog'; import type { UnreadStats } from '../util/countUnreadStats'; import type { WidthBreakpoint } from './_util'; +import type { CallLinkType } from '../types/CallLink'; enum CallsTabSidebarView { CallsListView, @@ -36,6 +37,7 @@ type CallsTabProps = Readonly<{ pagination: CallHistoryPagination ) => Promise>; callHistoryEdition: number; + getCallLink: (id: string) => CallLinkType | undefined; getConversation: (id: string) => ConversationType | void; hasFailedStorySends: boolean; hasPendingUpdate: boolean; @@ -56,6 +58,7 @@ type CallsTabProps = Readonly<{ }) => JSX.Element; regionCode: string | undefined; savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void; + startCallLinkLobbyByRoomId: (roomId: string) => void; }>; export function CallsTab({ @@ -65,6 +68,7 @@ export function CallsTab({ getCallHistoryGroupsCount, getCallHistoryGroups, callHistoryEdition, + getCallLink, getConversation, hasFailedStorySends, hasPendingUpdate, @@ -80,6 +84,7 @@ export function CallsTab({ renderToastManager, regionCode, savePreferredLeftPaneWidth, + startCallLinkLobbyByRoomId, }: CallsTabProps): JSX.Element { const [sidebarView, setSidebarView] = useState( CallsTabSidebarView.CallsListView @@ -230,6 +235,7 @@ export function CallsTab({ getCallHistoryGroupsCount={getCallHistoryGroupsCount} getCallHistoryGroups={getCallHistoryGroups} callHistoryEdition={callHistoryEdition} + getCallLink={getCallLink} getConversation={getConversation} i18n={i18n} selectedCallHistoryGroup={selected?.callHistoryGroup ?? null} @@ -240,6 +246,7 @@ export function CallsTab({ onOutgoingVideoCallInConversation={ handleOutgoingVideoCallInConversation } + startCallLinkLobbyByRoomId={startCallLinkLobbyByRoomId} /> )} {sidebarView === CallsTabSidebarView.NewCallView && ( diff --git a/ts/services/LinkPreview.ts b/ts/services/LinkPreview.ts index c179bafc5be3..4a2fc5ccd11c 100644 --- a/ts/services/LinkPreview.ts +++ b/ts/services/LinkPreview.ts @@ -586,7 +586,7 @@ async function getCallLinkPreview( const callLinkRootKey = CallLinkRootKey.parse(parsedUrl.args.key); const callLinkState = await calling.readCallLink({ callLinkRootKey }); - if (!callLinkState) { + if (!callLinkState || callLinkState.revoked) { return null; } diff --git a/ts/services/callLinksLoader.ts b/ts/services/callLinksLoader.ts new file mode 100644 index 000000000000..302561d9d37e --- /dev/null +++ b/ts/services/callLinksLoader.ts @@ -0,0 +1,18 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import dataInterface from '../sql/Client'; +import type { CallLinkType } from '../types/CallLink'; +import { strictAssert } from '../util/assert'; + +let callLinksData: ReadonlyArray; + +export async function loadCallLinks(): Promise { + await dataInterface.cleanupCallHistoryMessages(); + callLinksData = await dataInterface.getAllCallLinks(); +} + +export function getCallLinksForRedux(): ReadonlyArray { + strictAssert(callLinksData != null, 'callLinks has not been loaded'); + return callLinksData; +} diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 9003f7091d34..8ce0962d4848 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -124,6 +124,9 @@ import { getCallIdFromRing, getLocalCallEventFromRingUpdate, convertJoinState, + updateLocalAdhocCallHistory, + getCallIdFromEra, + getCallDetailsForAdhocCall, } from '../util/callDisposition'; import { isNormalNumber } from '../util/isNormalNumber'; import { LocalCallEvent } from '../types/CallDisposition'; @@ -181,6 +184,7 @@ type CallingReduxInterface = Pick< | 'setPresenting' | 'startCallingLobby' | 'startCallLinkLobby' + | 'startCallLinkLobbyByRoomId' | 'peekNotConnectedGroupCall' > & { areAnyCallsActiveOrRinging(): boolean; @@ -535,25 +539,25 @@ export class CallingClass { callLinkRootKey: CallLinkRootKey; }>): Promise { if (!this._sfuUrl) { - throw new Error('Missing SFU URL; not handling call link'); + throw new Error('readCallLink() missing SFU URL; not handling call link'); } const roomId = getRoomIdFromRootKey(callLinkRootKey); + const logId = `readCallLink(${roomId})`; const authCredentialPresentation = await getCallLinkAuthCredentialPresentation(callLinkRootKey); - log.info(`readCallLink: roomId ${roomId}`); const result = await RingRTC.readCallLink( this._sfuUrl, authCredentialPresentation.serialize(), callLinkRootKey ); if (!result.success) { - log.warn(`readCallLink: failed ${roomId}`); + log.warn(`${logId}: failed`); return; } - log.info('readCallLink: success', result); + log.info(`${logId}: success`); return result.value; } @@ -980,11 +984,20 @@ export class CallingClass { callMode: CallMode.Group | CallMode.Adhoc ): GroupCallObserver { let updateMessageState = GroupCallUpdateMessageState.SentNothing; + const updateCallHistoryOnLocalChanged = + callMode === CallMode.Group + ? this.updateCallHistoryForGroupCallOnLocalChanged + : this.updateCallHistoryForAdhocCall; + const updateCallHistoryOnPeek = + callMode === CallMode.Group + ? this.updateCallHistoryForGroupCallOnPeek + : this.updateCallHistoryForAdhocCall; return { onLocalDeviceStateChanged: groupCall => { const localDeviceState = groupCall.getLocalDeviceState(); const peekInfo = groupCall.getPeekInfo() ?? null; + const { eraId } = peekInfo ?? {}; log.info( 'GroupCall#onLocalDeviceStateChanged', @@ -992,43 +1005,14 @@ export class CallingClass { peekInfo != null ? formatPeekInfo(peekInfo) : '(No PeekInfo)' ); - const groupCallMeta = getGroupCallMeta(peekInfo); - - // TODO: Handle call history for adhoc calls - if (groupCallMeta != null && callMode === CallMode.Group) { - try { - const localCallEvent = getLocalCallEventFromJoinState( - convertJoinState(localDeviceState.joinState), - groupCallMeta - ); - - if (localCallEvent != null && peekInfo != null) { - const conversation = - window.ConversationController.get(conversationId); - strictAssert( - conversation != null, - 'GroupCall#onLocalDeviceStateChanged: Missing conversation' - ); - const peerId = getPeerIdFromConversation(conversation.attributes); - - const callDetails = getCallDetailsFromGroupCallMeta( - peerId, - groupCallMeta - ); - const callEvent = getCallEventDetails( - callDetails, - localCallEvent, - 'RingRTC.onLocalDeviceStateChanged' - ); - drop(updateCallHistoryFromLocalEvent(callEvent, null)); - } - } catch (error) { - log.error( - 'GroupCall#onLocalDeviceStateChanged: Error updating state', - Errors.toLogFormat(error) - ); - } - } + // For adhoc calls, conversationId will be a roomId + drop( + updateCallHistoryOnLocalChanged( + conversationId, + convertJoinState(localDeviceState.joinState), + peekInfo + ) + ); if (localDeviceState.connectionState === ConnectionState.NotConnected) { // NOTE: This assumes that only one call is active at a time. For example, if @@ -1040,14 +1024,11 @@ export class CallingClass { if ( updateMessageState === GroupCallUpdateMessageState.SentJoin && - peekInfo?.eraId != null + eraId ) { updateMessageState = GroupCallUpdateMessageState.SentLeft; if (callMode === CallMode.Group) { - void this.sendGroupCallUpdateMessage( - conversationId, - peekInfo?.eraId - ); + drop(this.sendGroupCallUpdateMessage(conversationId, eraId)); } } } else { @@ -1060,17 +1041,16 @@ export class CallingClass { this.videoCapturer.enableCaptureAndSend(groupCall); } + // Call enters the Joined state, once per call. + // This can also happen in onPeekChanged. if ( updateMessageState === GroupCallUpdateMessageState.SentNothing && localDeviceState.joinState === JoinState.Joined && - peekInfo?.eraId != null + eraId ) { updateMessageState = GroupCallUpdateMessageState.SentJoin; if (callMode === CallMode.Group) { - void this.sendGroupCallUpdateMessage( - conversationId, - peekInfo?.eraId - ); + drop(this.sendGroupCallUpdateMessage(conversationId, eraId)); } } } @@ -1128,6 +1108,7 @@ export class CallingClass { onPeekChanged: groupCall => { const localDeviceState = groupCall.getLocalDeviceState(); const peekInfo = groupCall.getPeekInfo() ?? null; + const { eraId } = peekInfo ?? {}; log.info( 'GroupCall#onPeekChanged', @@ -1135,25 +1116,29 @@ export class CallingClass { peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)' ); - if (callMode === CallMode.Group) { - const { eraId } = peekInfo ?? {}; - if ( - updateMessageState === GroupCallUpdateMessageState.SentNothing && - localDeviceState.connectionState !== ConnectionState.NotConnected && - localDeviceState.joinState === JoinState.Joined && - eraId - ) { - updateMessageState = GroupCallUpdateMessageState.SentJoin; - void this.sendGroupCallUpdateMessage(conversationId, eraId); - } + // Call enters the Joined state, once per call. + // This can also happen in onLocalDeviceStateChanged. + if ( + updateMessageState === GroupCallUpdateMessageState.SentNothing && + localDeviceState.connectionState !== ConnectionState.NotConnected && + localDeviceState.joinState === JoinState.Joined && + eraId + ) { + updateMessageState = GroupCallUpdateMessageState.SentJoin; - void this.updateCallHistoryForGroupCall( + if (callMode === CallMode.Group) { + drop(this.sendGroupCallUpdateMessage(conversationId, eraId)); + } + } + + // For adhoc calls, conversationId will be a roomId + drop( + updateCallHistoryOnPeek( conversationId, convertJoinState(localDeviceState.joinState), peekInfo - ); - } - // TODO: Call history for adhoc calls + ) + ); this.syncGroupCallToRedux(conversationId, groupCall, callMode); }, @@ -1381,11 +1366,12 @@ export class CallingClass { public formatCallLinkStateForRedux( callLinkState: CallLinkState ): CallLinkStateType { - const { name, restrictions, expiration } = callLinkState; + const { name, restrictions, expiration, revoked } = callLinkState; return { name, restrictions, expiration: expiration.getTime(), + revoked, }; } @@ -2641,7 +2627,84 @@ export class CallingClass { return true; } - public async updateCallHistoryForGroupCall( + public async updateCallHistoryForAdhocCall( + roomId: string, + joinState: GroupCallJoinState | null, + peekInfo: PeekInfo | null + ): Promise { + if (!peekInfo?.eraId) { + return; + } + const callId = getCallIdFromEra(peekInfo.eraId); + + try { + // We only log events confirmed joined. If admin approval is required, then + // the call begins in the Pending state and we don't want history for that. + if (joinState !== GroupCallJoinState.Joined) { + return; + } + + const callDetails = getCallDetailsForAdhocCall(roomId, callId); + const callEvent = getCallEventDetails( + callDetails, + LocalCallEvent.Accepted, + 'CallingClass.updateCallHistoryForGroupCallOnLocalChanged' + ); + await updateLocalAdhocCallHistory(callEvent); + } catch (error) { + log.error( + 'CallingClass.updateCallHistoryForGroupCallOnLocalChanged: Error updating state', + Errors.toLogFormat(error) + ); + } + } + + public async updateCallHistoryForGroupCallOnLocalChanged( + conversationId: string, + joinState: GroupCallJoinState | null, + peekInfo: PeekInfo | null + ): Promise { + const groupCallMeta = getGroupCallMeta(peekInfo); + if (!groupCallMeta) { + return; + } + + try { + const localCallEvent = getLocalCallEventFromJoinState( + joinState, + groupCallMeta + ); + + if (!localCallEvent) { + return; + } + + const conversation = window.ConversationController.get(conversationId); + strictAssert( + conversation != null, + 'CallingClass.updateCallHistoryForGroupCallOnLocalChanged: Missing conversation' + ); + const peerId = getPeerIdFromConversation(conversation.attributes); + + const callDetails = getCallDetailsFromGroupCallMeta( + peerId, + groupCallMeta + ); + const callEvent = getCallEventDetails( + callDetails, + localCallEvent, + 'CallingClass.updateCallHistoryForGroupCallOnLocalChanged' + ); + await updateCallHistoryFromLocalEvent(callEvent, null); + } catch (error) { + log.error( + 'CallingClass.updateCallHistoryForGroupCallOnLocalChanged: Error updating state', + Errors.toLogFormat(error) + ); + } + } + + public async updateCallHistoryForGroupCallOnPeek( conversationId: string, joinState: GroupCallJoinState | null, peekInfo: PeekInfo | null @@ -2658,7 +2721,9 @@ export class CallingClass { const conversation = window.ConversationController.get(conversationId); if (!conversation) { - log.error('maybeNotifyGroupCall(): could not find conversation'); + log.error( + 'updateCallHistoryForGroupCallOnPeek(): could not find conversation' + ); return; } @@ -2684,7 +2749,7 @@ export class CallingClass { const callEvent = getCallEventDetails( callDetails, localCallEvent, - 'CallingClass.updateCallHistoryForGroupCall' + 'CallingClass.updateCallHistoryForGroupCallOnPeek' ); await updateCallHistoryFromLocalEvent(callEvent, null); } diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 13fb15639133..8df6e17cae1c 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -30,6 +30,7 @@ import type { CallHistoryGroup, CallHistoryPagination, } from '../types/CallDisposition'; +import type { CallLinkType, CallLinkRestrictions } from '../types/CallLink'; export type AdjacentMessagesByConversationOptionsType = Readonly<{ conversationId: string; @@ -696,6 +697,16 @@ export type DataInterface = { getRecentStaleRingsAndMarkOlderMissed(): Promise< ReadonlyArray >; + callLinkExists(roomId: string): Promise; + getAllCallLinks: () => Promise>; + insertCallLink(callLink: CallLinkType): Promise; + updateCallLinkState( + roomId: string, + name: string, + restrictions: CallLinkRestrictions, + expiration: number | null, + revoked: boolean + ): Promise; migrateConversationMessages: ( obsoleteId: string, currentId: string diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 46ac73305b66..050db60d5308 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -166,6 +166,13 @@ import { GroupCallStatus, CallType, } from '../types/CallDisposition'; +import { + callLinkExists, + getAllCallLinks, + insertCallLink, + updateCallLinkState, +} from './server/callLinks'; +import { CallMode } from '../types/Calling'; type ConversationRow = Readonly<{ json: string; @@ -328,6 +335,10 @@ const dataInterface: ServerInterface = { hasGroupCallHistoryMessage, markCallHistoryMissed, getRecentStaleRingsAndMarkOlderMissed, + callLinkExists, + getAllCallLinks, + insertCallLink, + updateCallLinkState, migrateConversationMessages, getMessagesBetween, getNearbyMessageFromDeletedSet, @@ -439,7 +450,7 @@ type DatabaseQueryCache = Map>>; const statementCache = new WeakMap(); -function prepare | Record>( +export function prepare | Record>( db: Database, query: string, { pluck = false }: { pluck?: boolean } = {} @@ -732,7 +743,7 @@ async function removeIndexedDBFiles(): Promise { indexedDBPath = undefined; } -function getReadonlyInstance(): Database { +export function getReadonlyInstance(): Database { if (!globalReadonlyInstance) { throw new Error('getReadonlyInstance: globalReadonlyInstance not set!'); } @@ -742,7 +753,7 @@ function getReadonlyInstance(): Database { const WRITABLE_INSTANCE_MAX_WAIT = 5 * durations.MINUTE; -async function getWritableInstance(): Promise { +export async function getWritableInstance(): Promise { if (pausedWriteQueue) { const { promise, resolve } = explodePromise(); pausedWriteQueue.push(resolve); @@ -3499,6 +3510,7 @@ const SEEN_STATUS_SEEN = sqlConstant(SeenStatus.Seen); const CALL_STATUS_MISSED = sqlConstant(DirectCallStatus.Missed); const CALL_STATUS_DELETED = sqlConstant(DirectCallStatus.Deleted); const CALL_STATUS_INCOMING = sqlConstant(CallDirection.Incoming); +const CALL_MODE_ADHOC = sqlConstant(CallMode.Adhoc); const FOUR_HOURS_IN_MS = sqlConstant(4 * 60 * 60 * 1000); async function getCallHistoryUnreadCount(): Promise { @@ -3581,6 +3593,7 @@ function getCallHistoryGroupDataSync( const { limit, offset } = pagination; const { status, conversationIds } = filter; + // TODO: DESKTOP-6827 Search Calls Tab for adhoc calls if (conversationIds != null) { strictAssert(conversationIds.length > 0, "can't filter by empty array"); @@ -3632,8 +3645,15 @@ function getCallHistoryGroupDataSync( const offsetLimit = limit > 0 ? sqlFragment`LIMIT ${limit} OFFSET ${offset}` : sqlFragment``; + // COUNT(*) OVER(): As a result of GROUP BY in the query (to limit adhoc call history + // to the single latest call), COUNT(*) changes to counting each group's counts rather + // than the total number of rows. Example: Say we have 2 group calls (A and B) and + // 10 adhoc calls on a single link. COUNT(*) ... GROUP BY returns [1, 1, 10] + // corresponding with callId A, callId B, adhoc peerId (the GROUP conditions). + // However we want COUNT(*) to do the normal thing and return total rows + // (so in the example above we want 3). COUNT(*) OVER achieves this. const projection = isCount - ? sqlFragment`COUNT(*) AS count` + ? sqlFragment`COUNT(*) OVER() AS count` : sqlFragment`peerId, ringerId, mode, type, direction, status, timestamp, possibleChildren, inPeriod`; const [query, params] = sql` @@ -3697,6 +3717,8 @@ function getCallHistoryGroupDataSync( -- Desktop Constraints: AND callsHistory.status IS c.status AND ${filterClause} + -- Skip grouping logic for adhoc calls + AND callsHistory.mode IS NOT ${CALL_MODE_ADHOC} ORDER BY timestamp DESC ) as possibleChildren, @@ -3731,6 +3753,12 @@ function getCallHistoryGroupDataSync( FROM callAndGroupInfo ) AS parentCallAndGroupInfo WHERE parent = parentCallAndGroupInfo.callId + GROUP BY + CASE + -- By spec, limit adhoc call history to the most recent call + WHEN mode IS ${CALL_MODE_ADHOC} THEN peerId + ELSE callId + END ORDER BY parentCallAndGroupInfo.timestamp DESC ${offsetLimit}; `; diff --git a/ts/sql/migrations/1010-call-links-table.ts b/ts/sql/migrations/1010-call-links-table.ts new file mode 100644 index 000000000000..61def0276b30 --- /dev/null +++ b/ts/sql/migrations/1010-call-links-table.ts @@ -0,0 +1,40 @@ +// 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'; +import { sql } from '../util'; + +export const version = 1010; + +export function updateToSchemaVersion1010( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 1010) { + return; + } + + db.transaction(() => { + const [createTable] = sql` + CREATE TABLE callLinks ( + roomId TEXT NOT NULL PRIMARY KEY, + rootKey BLOB NOT NULL, + adminKey BLOB, + name TEXT NOT NULL, + -- Enum which stores CallLinkRestrictions from ringrtc + restrictions INTEGER NOT NULL, + revoked INTEGER NOT NULL, + expiration INTEGER + ) STRICT; + `; + + db.exec(createTable); + + db.pragma('user_version = 1010'); + })(); + + logger.info('updateToSchemaVersion1010: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 199b8163f318..7a96b3047833 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -75,10 +75,11 @@ import { updateToSchemaVersion960 } from './960-untag-pni'; import { updateToSchemaVersion970 } from './970-fts5-optimize'; import { updateToSchemaVersion980 } from './980-reaction-timestamp'; import { updateToSchemaVersion990 } from './990-phone-number-sharing'; +import { updateToSchemaVersion1000 } from './1000-mark-unread-call-history-messages-as-unseen'; import { version as MAX_VERSION, - updateToSchemaVersion1000, -} from './1000-mark-unread-call-history-messages-as-unseen'; + updateToSchemaVersion1010, +} from './1010-call-links-table'; function updateToSchemaVersion1( currentVersion: number, @@ -2021,6 +2022,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion980, updateToSchemaVersion990, updateToSchemaVersion1000, + updateToSchemaVersion1010, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/sql/server/callLinks.ts b/ts/sql/server/callLinks.ts new file mode 100644 index 000000000000..71f7c6c44241 --- /dev/null +++ b/ts/sql/server/callLinks.ts @@ -0,0 +1,100 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from '@signalapp/better-sqlite3'; +import { CallLinkRootKey } from '@signalapp/ringrtc'; +import type { CallLinkRestrictions, CallLinkType } from '../../types/CallLink'; +import { + callLinkRestrictionsSchema, + callLinkRecordSchema, +} from '../../types/CallLink'; +import { callLinkToRecord, callLinkFromRecord } from '../../util/callLinks'; +import { getReadonlyInstance, getWritableInstance, prepare } from '../Server'; +import { sql } from '../util'; +import { strictAssert } from '../../util/assert'; + +export async function callLinkExists(roomId: string): Promise { + const db = getReadonlyInstance(); + const [query, params] = sql` + SELECT 1 + FROM callLinks + WHERE roomId = ${roomId}; + `; + return db.prepare(query).pluck(true).get(params) === 1; +} + +export async function getAllCallLinks(): Promise> { + const db = getReadonlyInstance(); + const [query] = sql` + SELECT * FROM callLinks; + `; + return db + .prepare(query) + .all() + .map(item => callLinkFromRecord(callLinkRecordSchema.parse(item))); +} + +function _insertCallLink(db: Database, callLink: CallLinkType): void { + const { roomId, rootKey } = callLink; + assertRoomIdMatchesRootKey(roomId, rootKey); + + const data = callLinkToRecord(callLink); + prepare( + db, + ` + INSERT INTO callLinks ( + roomId, + rootKey, + adminKey, + name, + restrictions, + revoked, + expiration + ) VALUES ( + $roomId, + $rootKey, + $adminKey, + $name, + $restrictions, + $revoked, + $expiration + ) + ` + ).run(data); +} + +export async function insertCallLink(callLink: CallLinkType): Promise { + const db = await getWritableInstance(); + _insertCallLink(db, callLink); +} + +export async function updateCallLinkState( + roomId: string, + name: string, + restrictions: CallLinkRestrictions, + expiration: number, + revoked: boolean +): Promise { + const db = await getWritableInstance(); + const restrictionsValue = callLinkRestrictionsSchema.parse(restrictions); + const [query, params] = sql` + UPDATE callLinks + SET + name = ${name}, + restrictions = ${restrictionsValue}, + expiration = ${expiration}, + revoked = ${revoked ? 1 : 0} + WHERE roomId = ${roomId}; + `; + db.prepare(query).run(params); +} + +function assertRoomIdMatchesRootKey(roomId: string, rootKey: string): void { + const derivedRoomId = CallLinkRootKey.parse(rootKey) + .deriveRoomId() + .toString('hex'); + strictAssert( + roomId === derivedRoomId, + 'passed roomId must match roomId derived from root key' + ); +} diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 8ea508a4a26f..fb2c0a1d4e0e 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -76,6 +76,7 @@ import type { ShowErrorModalActionType } from './globalModals'; import { SHOW_ERROR_MODAL } from './globalModals'; import { ButtonVariant } from '../../components/Button'; import { getConversationIdForLogging } from '../../util/idForLogging'; +import dataInterface from '../../sql/Client'; // State @@ -171,12 +172,14 @@ export type AdhocCallsType = { export type CallLinkStateType = ReadonlyDeep<{ name: string; restrictions: CallLinkRestrictions; - expiration: number; + expiration: number | null; + revoked: boolean; }>; export type CallLinksByRoomIdStateType = ReadonlyDeep< CallLinkStateType & { rootKey: string; + adminKey: string | null; } >; @@ -331,6 +334,19 @@ export type StartCallLinkLobbyType = ReadonlyDeep<{ rootKey: string; }>; +export type StartCallLinkLobbyByRoomIdType = ReadonlyDeep<{ + roomId: string; +}>; + +type StartCallLinkLobbyThunkActionType = ReadonlyDeep< + ThunkAction< + void, + RootStateType, + unknown, + StartCallLinkLobbyActionType | ShowErrorModalActionType + > +>; + // eslint-disable-next-line local-rules/type-alias-readonlydeep type StartCallingLobbyPayloadType = | { @@ -513,7 +529,7 @@ const doGroupCallPeek = ({ : null; try { - await calling.updateCallHistoryForGroupCall( + await calling.updateCallHistoryForGroupCallOnPeek( conversationId, joinState, peekInfo @@ -1717,86 +1733,147 @@ function onOutgoingAudioCallInConversation( }; } -function startCallLinkLobby({ - rootKey, -}: StartCallLinkLobbyType): ThunkAction< - void, - RootStateType, - unknown, - StartCallLinkLobbyActionType | ShowErrorModalActionType -> { +function startCallLinkLobbyByRoomId( + roomId: string +): StartCallLinkLobbyThunkActionType { return async (dispatch, getState) => { const state = getState(); + const callLink = getOwn(state.calling.callLinks, roomId); - if (state.calling.activeCallState) { - const i18n = getIntl(getState()); - dispatch({ - type: SHOW_ERROR_MODAL, - payload: { - title: i18n('icu:calling__cant-join'), - description: i18n('icu:calling__dialog-already-in-call'), - buttonVariant: ButtonVariant.Primary, - }, - }); - return; - } + strictAssert( + callLink, + `startCallLinkLobbyByRoomId(${roomId}): call link not found` + ); - const callLinkRootKey = CallLinkRootKey.parse(rootKey); - - const callLinkState = await calling.readCallLink({ callLinkRootKey }); - if (!callLinkState) { - const i18n = getIntl(getState()); - dispatch({ - type: SHOW_ERROR_MODAL, - payload: { - title: i18n('icu:calling__cant-join'), - description: i18n('icu:calling__call-link-connection-issues'), - buttonVariant: ButtonVariant.Primary, - }, - }); - return; - } - if (callLinkState.revoked || callLinkState.expiration < new Date()) { - const i18n = getIntl(getState()); - dispatch({ - type: SHOW_ERROR_MODAL, - payload: { - title: i18n('icu:calling__cant-join'), - description: i18n('icu:calling__call-link-no-longer-valid'), - buttonVariant: ButtonVariant.Primary, - }, - }); - return; - } - - const roomId = getRoomIdFromRootKey(callLinkRootKey); - const groupCall = getGroupCall(roomId, state.calling, CallMode.Adhoc); - const groupCallDeviceCount = - groupCall?.peekInfo?.deviceCount || - groupCall?.remoteParticipants.length || - 0; - - const callLobbyData = await calling.startCallLinkLobby({ - callLinkRootKey, - hasLocalAudio: groupCallDeviceCount < 8, - }); - if (!callLobbyData) { - return; - } - - dispatch({ - type: START_CALL_LINK_LOBBY, - payload: { - ...callLobbyData, - callLinkState: calling.formatCallLinkStateForRedux(callLinkState), - callLinkRootKey: rootKey, - conversationId: roomId, - isConversationTooBigToRing: false, - }, - }); + const { rootKey } = callLink; + await _startCallLinkLobby({ rootKey, dispatch, getState }); }; } +function startCallLinkLobby({ + rootKey, +}: StartCallLinkLobbyType): StartCallLinkLobbyThunkActionType { + return async (dispatch, getState) => { + await _startCallLinkLobby({ rootKey, dispatch, getState }); + }; +} + +const _startCallLinkLobby = async ({ + rootKey, + dispatch, + getState, +}: { + rootKey: string; + dispatch: ThunkDispatch< + RootStateType, + unknown, + StartCallLinkLobbyActionType | ShowErrorModalActionType + >; + getState: () => RootStateType; +}) => { + const state = getState(); + + if (state.calling.activeCallState) { + const i18n = getIntl(getState()); + dispatch({ + type: SHOW_ERROR_MODAL, + payload: { + title: i18n('icu:calling__cant-join'), + description: i18n('icu:calling__dialog-already-in-call'), + buttonVariant: ButtonVariant.Primary, + }, + }); + return; + } + + const callLinkRootKey = CallLinkRootKey.parse(rootKey); + + const callLinkState = await calling.readCallLink({ callLinkRootKey }); + if (!callLinkState) { + const i18n = getIntl(getState()); + dispatch({ + type: SHOW_ERROR_MODAL, + payload: { + title: i18n('icu:calling__cant-join'), + description: i18n('icu:calling__call-link-connection-issues'), + buttonVariant: ButtonVariant.Primary, + }, + }); + return; + } + if (callLinkState.revoked || callLinkState.expiration < new Date()) { + const i18n = getIntl(getState()); + dispatch({ + type: SHOW_ERROR_MODAL, + payload: { + title: i18n('icu:calling__cant-join'), + description: i18n('icu:calling__call-link-no-longer-valid'), + buttonVariant: ButtonVariant.Primary, + }, + }); + return; + } + + const roomId = getRoomIdFromRootKey(callLinkRootKey); + const formattedCallLinkState = + calling.formatCallLinkStateForRedux(callLinkState); + try { + const { name, restrictions, expiration, revoked } = formattedCallLinkState; + const callLinkExists = await dataInterface.callLinkExists(roomId); + if (callLinkExists) { + await dataInterface.updateCallLinkState( + roomId, + name, + restrictions, + expiration, + revoked + ); + log.info('startCallLinkLobby: Updated existing call link', roomId); + } else { + await dataInterface.insertCallLink({ + roomId, + rootKey, + adminKey: null, + name, + restrictions, + revoked, + expiration, + }); + log.info('startCallLinkLobby: Saved new call link', roomId); + } + } catch (err) { + log.error( + 'startCallLinkLobby: Call link DB error', + Errors.toLogFormat(err) + ); + } + + const groupCall = getGroupCall(roomId, state.calling, CallMode.Adhoc); + const groupCallDeviceCount = + groupCall?.peekInfo?.deviceCount || + groupCall?.remoteParticipants.length || + 0; + + const callLobbyData = await calling.startCallLinkLobby({ + callLinkRootKey, + hasLocalAudio: groupCallDeviceCount < 8, + }); + if (!callLobbyData) { + return; + } + + dispatch({ + type: START_CALL_LINK_LOBBY, + payload: { + ...callLobbyData, + callLinkState: formattedCallLinkState, + callLinkRootKey: rootKey, + conversationId: roomId, + isConversationTooBigToRing: false, + }, + }); +}; + function startCallingLobby({ conversationId, isVideoCall, @@ -2010,6 +2087,7 @@ export const actions = { setRendererCanvas, startCall, startCallLinkLobby, + startCallLinkLobbyByRoomId, startCallingLobby, switchToPresentationView, switchFromPresentationView, @@ -2194,6 +2272,7 @@ export function reducer( [conversationId]: { ...action.payload.callLinkState, rootKey: action.payload.callLinkRootKey, + adminKey: null, }, } : callLinks, @@ -2287,18 +2366,8 @@ export function reducer( case CallMode.Direct: return removeConversationFromState(state, activeCall.conversationId); case CallMode.Group: + case CallMode.Adhoc: return omit(state, 'activeCallState'); - case CallMode.Adhoc: { - // TODO: When call links persist in the DB, we can remove the removal logic here. - log.info( - `Removing active adhoc call with roomId ${activeCall.conversationId}` - ); - const { callLinks } = state; - return { - ...omit(state, 'activeCallState'), - callLinks: omit(callLinks, activeCall.conversationId), - }; - } default: throw missingCaseError(activeCall); } diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts index 6eb4b567f45d..fbf04905a1e3 100644 --- a/ts/state/getInitialState.ts +++ b/ts/state/getInitialState.ts @@ -41,9 +41,11 @@ import { getInteractionMode } from '../services/InteractionMode'; import { makeLookup } from '../util/makeLookup'; import type { CallHistoryDetails } from '../types/CallDisposition'; import type { ThemeType } from '../types/Util'; +import type { CallLinkType } from '../types/CallLink'; export function getInitialState({ badges, + callLinks, callsHistory, callsHistoryUnreadCount, stories, @@ -53,6 +55,7 @@ export function getInitialState({ theme, }: { badges: BadgesStateType; + callLinks: ReadonlyArray; callsHistory: ReadonlyArray; callsHistoryUnreadCount: number; stories: Array; @@ -95,7 +98,10 @@ export function getInitialState({ callHistoryByCallId: makeLookup(callsHistory, 'callId'), unreadCount: callsHistoryUnreadCount, }, - calling: calling(), + calling: { + ...calling(), + callLinks: makeLookup(callLinks, 'roomId'), + }, composer: composer(), conversations: { ...conversations(), diff --git a/ts/state/initializeRedux.ts b/ts/state/initializeRedux.ts index c0608e0381cd..c8a07d4a0550 100644 --- a/ts/state/initializeRedux.ts +++ b/ts/state/initializeRedux.ts @@ -12,8 +12,10 @@ import { actionCreators } from './actions'; import { createStore } from './createStore'; import { getInitialState } from './getInitialState'; import type { ThemeType } from '../types/Util'; +import type { CallLinkType } from '../types/CallLink'; export function initializeRedux({ + callLinks, callsHistory, callsHistoryUnreadCount, initialBadgesState, @@ -23,6 +25,7 @@ export function initializeRedux({ storyDistributionLists, theme, }: { + callLinks: ReadonlyArray; callsHistory: ReadonlyArray; callsHistoryUnreadCount: number; initialBadgesState: BadgesStateType; @@ -34,6 +37,7 @@ export function initializeRedux({ }): void { const initialState = getInitialState({ badges: initialBadgesState, + callLinks, callsHistory, callsHistoryUnreadCount, mainWindowStats, diff --git a/ts/state/selectors/calling.ts b/ts/state/selectors/calling.ts index bad8d0311d3e..57074088c17e 100644 --- a/ts/state/selectors/calling.ts +++ b/ts/state/selectors/calling.ts @@ -85,13 +85,9 @@ export const getCallLinkSelector = createSelector( return; } - const { name, restrictions, rootKey, expiration } = callLinkState; return { roomId, - name, - restrictions, - rootKey, - expiration, + ...callLinkState, }; } ); diff --git a/ts/state/smart/CallsTab.tsx b/ts/state/smart/CallsTab.tsx index d3c0bef9bef5..63652885de41 100644 --- a/ts/state/smart/CallsTab.tsx +++ b/ts/state/smart/CallsTab.tsx @@ -25,7 +25,7 @@ import type { ConversationType } from '../ducks/conversations'; import { SmartConversationDetails } from './ConversationDetails'; import { SmartToastManager } from './ToastManager'; import { useCallingActions } from '../ducks/calling'; -import { getActiveCallState } from '../selectors/calling'; +import { getActiveCallState, getCallLinkSelector } from '../selectors/calling'; import { useCallHistoryActions } from '../ducks/callHistory'; import { getCallHistoryEdition } from '../selectors/callHistory'; import { getHasPendingUpdate } from '../selectors/updates'; @@ -97,6 +97,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() { const allConversations = useSelector(getAllConversations); const regionCode = useSelector(getRegionCode); const getConversation = useSelector(getConversationSelector); + const getCallLink = useSelector(getCallLinkSelector); const activeCall = useSelector(getActiveCallState); const callHistoryEdition = useSelector(getCallHistoryEdition); @@ -108,6 +109,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() { const { onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, + startCallLinkLobbyByRoomId, } = useCallingActions(); const { clearAllCallHistory: clearCallHistory, @@ -167,6 +169,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() { getConversation={getConversation} getCallHistoryGroupsCount={getCallHistoryGroupsCount} getCallHistoryGroups={getCallHistoryGroups} + getCallLink={getCallLink} callHistoryEdition={callHistoryEdition} hasFailedStorySends={hasFailedStorySends} hasPendingUpdate={hasPendingUpdate} @@ -182,6 +185,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() { renderToastManager={renderToastManager} regionCode={regionCode} savePreferredLeftPaneWidth={savePreferredLeftPaneWidth} + startCallLinkLobbyByRoomId={startCallLinkLobbyByRoomId} /> ); }); diff --git a/ts/test-both/util/callLinks_test.ts b/ts/test-both/util/callLinks_test.ts new file mode 100644 index 000000000000..bc60a694b073 --- /dev/null +++ b/ts/test-both/util/callLinks_test.ts @@ -0,0 +1,39 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { callLinkToRecord, callLinkFromRecord } from '../../util/callLinks'; +import type { CallLinkType } from '../../types/CallLink'; +import { CallLinkRestrictions } from '../../types/CallLink'; +import { MONTH } from '../../util/durations/constants'; + +const CALL_LINK: CallLinkType = { + adminKey: null, + expiration: Date.now() + MONTH, + name: 'Fun Link', + restrictions: CallLinkRestrictions.None, + revoked: false, + roomId: 'c097eb04cc278d6bc7ed9fb2ddeac00dc9646ae6ddb38513dad9a8a4fe3c38f4', + rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg', +}; + +const CALL_LINK_WITH_ADMIN_KEY: CallLinkType = { + adminKey: 'xXPI77e6MoVHYREW8iKYmQ==', + expiration: Date.now() + MONTH, + name: 'Fun Link', + restrictions: CallLinkRestrictions.None, + revoked: false, + roomId: 'c097eb04cc278d6bc7ed9fb2ddeac00dc9646ae6ddb38513dad9a8a4fe3c38f4', + rootKey: 'bpmc-mrgn-hntf-mffd-mndd-xbxk-zmgq-qszg', +}; + +describe('callLinks', () => { + it('callLinkToRecord() and callLinkFromRecord() can convert to record and back', () => { + [CALL_LINK, CALL_LINK_WITH_ADMIN_KEY].forEach(callLink => { + const record = callLinkToRecord(callLink); + const returnedCallLink = callLinkFromRecord(record); + assert.deepEqual(returnedCallLink, callLink); + }); + }); +}); diff --git a/ts/test-electron/sql/getCallHistoryGroups_test.ts b/ts/test-electron/sql/getCallHistoryGroups_test.ts index 1397a5b88d66..1647eb96c342 100644 --- a/ts/test-electron/sql/getCallHistoryGroups_test.ts +++ b/ts/test-electron/sql/getCallHistoryGroups_test.ts @@ -12,8 +12,10 @@ import type { ServiceIdString } from '../../types/ServiceId'; import type { CallHistoryDetails, CallHistoryGroup, + CallStatus, } from '../../types/CallDisposition'; import { + AdhocCallStatus, CallDirection, CallHistoryFilterStatus, CallType, @@ -22,8 +24,13 @@ import { import { strictAssert } from '../../util/assert'; import type { ConversationAttributesType } from '../../model-types'; -const { removeAll, getCallHistoryGroups, saveCallHistory, saveConversation } = - dataInterface; +const { + removeAll, + getCallHistoryGroups, + getCallHistoryGroupsCount, + saveCallHistory, + saveConversation, +} = dataInterface; function toGroup(calls: Array): CallHistoryGroup { const firstCall = calls.at(0); @@ -41,6 +48,19 @@ function toGroup(calls: Array): CallHistoryGroup { }; } +function toAdhocGroup(call: CallHistoryDetails): CallHistoryGroup { + strictAssert(call != null, 'needs call'); + return { + peerId: call.peerId, + mode: call.mode, + type: call.type, + direction: call.direction, + timestamp: call.timestamp, + status: call.status, + children: [], + }; +} + describe('sql/getCallHistoryGroups', () => { beforeEach(async () => { await removeAll(); @@ -255,4 +275,158 @@ describe('sql/getCallHistoryGroups', () => { assert.deepEqual(groups, [toGroup([call])]); }); + + it('should support Missed status filter', async () => { + const now = Date.now(); + const conversationId = generateUuid(); + + function toCall( + callId: string, + timestamp: number, + type: CallType, + status: CallStatus + ) { + return { + callId, + peerId: conversationId, + ringerId: generateAci(), + mode: CallMode.Direct, + type, + direction: CallDirection.Incoming, + timestamp, + status, + }; + } + + const call1 = toCall( + '1', + now - 10, + CallType.Video, + DirectCallStatus.Accepted + ); + const call2 = toCall('2', now, CallType.Audio, DirectCallStatus.Missed); + + await saveCallHistory(call1); + await saveCallHistory(call2); + + const groups = await getCallHistoryGroups( + { status: CallHistoryFilterStatus.Missed, conversationIds: null }, + { offset: 0, limit: 0 } + ); + + assert.deepEqual(groups, [toGroup([call2])]); + }); + + it('should only return the newest call for an adhoc call roomId', async () => { + const now = Date.now(); + const roomId = generateUuid(); + + function toCall(callId: string, timestamp: number, type: CallType) { + return { + callId, + peerId: roomId, + ringerId: null, + mode: CallMode.Adhoc, + type, + direction: CallDirection.Outgoing, + timestamp, + status: AdhocCallStatus.Joined, + }; + } + + const call1 = toCall('1', now - 10, CallType.Group); + const call2 = toCall('2', now, CallType.Group); + + await saveCallHistory(call1); + await saveCallHistory(call2); + + const groups = await getCallHistoryGroups( + { status: CallHistoryFilterStatus.All, conversationIds: null }, + { offset: 0, limit: 0 } + ); + + assert.deepEqual(groups, [toAdhocGroup(call2)]); + }); +}); + +describe('sql/getCallHistoryGroupsCount', () => { + beforeEach(async () => { + await removeAll(); + }); + + it('counts', async () => { + const now = Date.now(); + const conversationId = generateUuid(); + + function toCall(callId: string, timestamp: number, type: CallType) { + return { + callId, + peerId: conversationId, + ringerId: generateAci(), + mode: CallMode.Direct, + type, + direction: CallDirection.Incoming, + timestamp, + status: DirectCallStatus.Accepted, + }; + } + + const call1 = toCall('1', now - 30, CallType.Video); + const call2 = toCall('2', now - 20, CallType.Video); + const call3 = toCall('3', now - 10, CallType.Audio); + const call4 = toCall('4', now, CallType.Video); + + await saveCallHistory(call1); + await saveCallHistory(call2); + await saveCallHistory(call3); + await saveCallHistory(call4); + + const result = await getCallHistoryGroupsCount({ + status: CallHistoryFilterStatus.All, + conversationIds: null, + }); + + assert.equal(result, 3); + }); + + it('should only count each call link roomId once if it had multiple calls', async () => { + const now = Date.now(); + const roomId1 = generateUuid(); + const roomId2 = generateUuid(); + + function toCall( + callId: string, + roomId: string, + timestamp: number, + type: CallType + ) { + return { + callId, + peerId: roomId, + ringerId: null, + mode: CallMode.Adhoc, + type, + direction: CallDirection.Outgoing, + timestamp, + status: AdhocCallStatus.Joined, + }; + } + + const call1 = toCall('1', roomId1, now - 20, CallType.Group); + const call2 = toCall('2', roomId1, now - 10, CallType.Group); + const call3 = toCall('3', roomId1, now, CallType.Group); + const call4 = toCall('4', roomId2, now, CallType.Group); + + await saveCallHistory(call1); + await saveCallHistory(call2); + await saveCallHistory(call3); + await saveCallHistory(call4); + + const result = await getCallHistoryGroupsCount({ + status: CallHistoryFilterStatus.All, + conversationIds: null, + }); + + assert.equal(result, 2); + }); }); diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index bf5d5fc5ae7c..471196f08aec 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -1314,10 +1314,11 @@ describe('calling duck', () => { callingService, 'peekGroupCall' ); - this.callingServiceUpdateCallHistoryForGroupCall = this.sandbox.stub( - callingService, - 'updateCallHistoryForGroupCall' - ); + this.callingServiceUpdateCallHistoryForGroupCallOnPeek = + this.sandbox.stub( + callingService, + 'updateCallHistoryForGroupCallOnPeek' + ); this.clock = this.sandbox.useFakeTimers(); }); diff --git a/ts/test-node/util/callDisposition_test.ts b/ts/test-node/util/callDisposition_test.ts new file mode 100644 index 000000000000..0b59321fb90c --- /dev/null +++ b/ts/test-node/util/callDisposition_test.ts @@ -0,0 +1,80 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import type { PeekInfo } from '@signalapp/ringrtc'; +import uuid from 'uuid'; +import { + getPeerIdFromConversation, + getCallIdFromEra, + getGroupCallMeta, +} from '../../util/callDisposition'; +import { + getDefaultConversation, + getDefaultGroup, +} from '../../test-both/helpers/getDefaultConversation'; +import { uuidToBytes } from '../../util/uuidToBytes'; + +const MOCK_ERA = 'abc'; +const MOCK_CALL_ID = '16919744041952114874'; + +const MOCK_PEEK_INFO_BASE: PeekInfo = { + devices: [], + deviceCount: 0, + deviceCountIncludingPendingDevices: 0, + deviceCountExcludingPendingDevices: 0, + pendingUsers: [], +}; + +describe('utils/callDisposition', () => { + describe('getCallIdFromEra', () => { + it('returns callId from era', () => { + // just to ensure the mock is correct + assert.strictEqual(getCallIdFromEra(MOCK_ERA), MOCK_CALL_ID); + }); + }); + + describe('getGroupCallMeta', () => { + it('returns null if missing eraId or creator', () => { + assert.isNull(getGroupCallMeta({ ...MOCK_PEEK_INFO_BASE })); + assert.isNull( + getGroupCallMeta({ ...MOCK_PEEK_INFO_BASE, eraId: MOCK_ERA }) + ); + assert.isNull( + getGroupCallMeta({ + ...MOCK_PEEK_INFO_BASE, + creator: Buffer.from(uuidToBytes(uuid())), + }) + ); + }); + + it('returns group call meta when all fields are provided', () => { + const id = uuid(); + assert.deepStrictEqual( + getGroupCallMeta({ + ...MOCK_PEEK_INFO_BASE, + eraId: MOCK_ERA, + creator: Buffer.from(uuidToBytes(id)), + }), + { callId: MOCK_CALL_ID, ringerId: id } + ); + }); + }); + + describe('getPeerIdFromConversation', () => { + it('returns serviceId for direct conversation', () => { + const conversation = getDefaultConversation(); + assert.strictEqual( + getPeerIdFromConversation(conversation), + conversation.serviceId + ); + }); + it('returns groupId for group conversation', () => { + const conversation = getDefaultGroup(); + assert.strictEqual( + getPeerIdFromConversation(conversation), + conversation.groupId + ); + }); + }); +}); diff --git a/ts/types/CallDisposition.ts b/ts/types/CallDisposition.ts index 599393225d5b..7ff643e0cee7 100644 --- a/ts/types/CallDisposition.ts +++ b/ts/types/CallDisposition.ts @@ -65,7 +65,13 @@ export enum GroupCallStatus { Deleted = DirectCallStatus.Deleted, } -export type CallStatus = DirectCallStatus | GroupCallStatus; +export enum AdhocCallStatus { + Pending = DirectCallStatus.Pending, + Joined = GroupCallStatus.Joined, + Deleted = DirectCallStatus.Deleted, +} + +export type CallStatus = DirectCallStatus | GroupCallStatus | AdhocCallStatus; export type CallDetails = Readonly<{ callId: string; @@ -133,6 +139,7 @@ const callEventSchema = z.union([ const callStatusSchema = z.union([ z.nativeEnum(DirectCallStatus), z.nativeEnum(GroupCallStatus), + z.nativeEnum(AdhocCallStatus), ]); export const callDetailsSchema = z.object({ diff --git a/ts/types/CallLink.ts b/ts/types/CallLink.ts index 8e428bb40055..80466b2fa521 100644 --- a/ts/types/CallLink.ts +++ b/ts/types/CallLink.ts @@ -2,16 +2,11 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { ReadonlyDeep } from 'type-fest'; import { z } from 'zod'; -import type { CallLinkRestrictions as RingRTCCallLinkRestrictions } from '@signalapp/ringrtc'; import type { ConversationType } from '../state/ducks/conversations'; -export type CallLinkConversationType = ReadonlyDeep< - Omit & { - type: 'callLink'; - storySendMode?: undefined; - acknowledgedGroupNameCollisions?: undefined; - } ->; +/** + * Restrictions + */ // Must match `CallLinkRestrictions` in @signalapp/ringrtc export enum CallLinkRestrictions { @@ -20,14 +15,56 @@ export enum CallLinkRestrictions { Unknown = 2, } -export const callLinkRestrictionsSchema = z.nativeEnum( - CallLinkRestrictions -) satisfies z.ZodType; +export const callLinkRestrictionsSchema = z.nativeEnum(CallLinkRestrictions); + +export function toCallLinkRestrictions( + restrictions: number +): CallLinkRestrictions { + return callLinkRestrictionsSchema.parse(restrictions); +} + +/** + * Link + */ export type CallLinkType = Readonly<{ roomId: string; rootKey: string; + adminKey: string | null; name: string; restrictions: CallLinkRestrictions; - expiration: number; + revoked: boolean; + expiration: number | null; }>; + +// Ephemeral conversation-like type to satisfy components +export type CallLinkConversationType = ReadonlyDeep< + Omit & { + type: 'callLink'; + storySendMode?: undefined; + acknowledgedGroupNameCollisions?: undefined; + } +>; + +// DB Record +export type CallLinkRecord = Readonly<{ + roomId: string; + rootKey: Uint8Array | null; + adminKey: Uint8Array | null; + name: string; + restrictions: number; + expiration: number | null; + revoked: 1 | 0; // sqlite's version of boolean +}>; + +export const callLinkRecordSchema = z.object({ + roomId: z.string(), + // credentials + rootKey: z.instanceof(Uint8Array).nullable(), + adminKey: z.instanceof(Uint8Array).nullable(), + // state + name: z.string(), + restrictions: callLinkRestrictionsSchema, + expiration: z.number().int(), + revoked: z.union([z.literal(1), z.literal(0)]), +}) satisfies z.ZodType; diff --git a/ts/util/callDisposition.ts b/ts/util/callDisposition.ts index 3c2b44fd30ef..75003090185a 100644 --- a/ts/util/callDisposition.ts +++ b/ts/util/callDisposition.ts @@ -12,6 +12,7 @@ import { RingUpdate, } from '@signalapp/ringrtc'; import { v4 as generateGuid } from 'uuid'; +import { isEqual } from 'lodash'; import { strictAssert } from './assert'; import { SignalService as Proto } from '../protobuf'; import { bytesToUuid, uuidToBytes } from './uuidToBytes'; @@ -57,6 +58,7 @@ import { RemoteCallEvent, callHistoryDetailsSchema, callDetailsSchema, + AdhocCallStatus, } from '../types/CallDisposition'; import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationModel } from '../models/conversations'; @@ -467,6 +469,21 @@ export function getCallDetailsFromGroupCallMeta( }); } +export function getCallDetailsForAdhocCall( + peerId: AciString | string, + callId: string +): CallDetails { + return callDetailsSchema.parse({ + callId, + peerId, + ringerId: null, + mode: CallMode.Adhoc, + type: CallType.Group, + direction: CallDirection.Outgoing, + timestamp: Date.now(), + }); +} + // Call Event Details // ------------------ @@ -500,7 +517,7 @@ export function transitionCallHistory( } const prevStatus = callHistory?.status ?? null; - let status: DirectCallStatus | GroupCallStatus; + let status: CallStatus; if (mode === CallMode.Direct) { status = transitionDirectCallStatus( @@ -515,8 +532,11 @@ export function transitionCallHistory( direction ); } else if (mode === CallMode.Adhoc) { - // TODO: DESKTOP-6653 - strictAssert(false, 'cannot transitionCallHistory for adhoc calls yet'); + status = transitionAdhocCallStatus( + prevStatus as AdhocCallStatus | null, + event, + direction + ); } else { throw missingCaseError(mode); } @@ -557,7 +577,8 @@ function transitionTimestamp( // Deleted call history should never be changed if ( callHistory.status === DirectCallStatus.Deleted || - callHistory.status === GroupCallStatus.Deleted + callHistory.status === GroupCallStatus.Deleted || + callHistory.status === AdhocCallStatus.Deleted ) { return callHistory.timestamp; } @@ -567,7 +588,8 @@ function transitionTimestamp( if ( callHistory.status === DirectCallStatus.Accepted || callHistory.status === GroupCallStatus.Accepted || - callHistory.status === GroupCallStatus.Joined + callHistory.status === GroupCallStatus.Joined || + callHistory.status === AdhocCallStatus.Joined ) { if (callEvent.event === RemoteCallEvent.Accepted) { return latestTimestamp; @@ -595,7 +617,8 @@ function transitionTimestamp( callHistory.status === GroupCallStatus.OutgoingRing || callHistory.status === GroupCallStatus.Ringing || callHistory.status === DirectCallStatus.Missed || - callHistory.status === GroupCallStatus.Missed + callHistory.status === GroupCallStatus.Missed || + callHistory.status === AdhocCallStatus.Pending ) { return latestTimestamp; } @@ -750,6 +773,57 @@ function transitionGroupCallStatus( throw missingCaseError(event); } +function transitionAdhocCallStatus( + status: AdhocCallStatus | null, + callEvent: CallEvent, + direction: CallDirection +): AdhocCallStatus { + log.info( + `transitionAdhocCallStatus: status=${status} callEvent=${callEvent} direction=${direction}` + ); + + // In all cases if we get a delete event, we need to delete the call, and never + // transition from deleted. + if ( + callEvent === RemoteCallEvent.Delete || + callEvent === LocalCallEvent.Delete || + status === AdhocCallStatus.Deleted + ) { + return AdhocCallStatus.Deleted; + } + + // The Accepted event is used to indicate we have fully joined an adhoc call. + // If admin approval was required for a call link, this event indicates approval + // was granted. + if ( + callEvent === RemoteCallEvent.Accepted || + callEvent === LocalCallEvent.Accepted + ) { + return AdhocCallStatus.Joined; + } + + if (status === AdhocCallStatus.Joined) { + return status; + } + + // For Adhoc calls, ringing and corresponding events are not supported currently. + // However we handle those events here to be exhaustive. + if ( + callEvent === RemoteCallEvent.NotAccepted || + callEvent === LocalCallEvent.Missed || + callEvent === LocalCallEvent.Declined || + callEvent === LocalCallEvent.Hangup || + callEvent === LocalCallEvent.RemoteHangup || + callEvent === LocalCallEvent.Started || + // never actually happens, but need for exhaustive check + callEvent === LocalCallEvent.Ringing + ) { + return AdhocCallStatus.Pending; + } + + throw missingCaseError(callEvent); +} + // actions // ------- @@ -809,6 +883,70 @@ async function updateLocalCallHistory( ); } +export async function updateLocalAdhocCallHistory( + callEvent: CallEventDetails +): Promise { + log.info( + 'updateLocalAdhocCallHistory: Processing call event:', + formatCallEvent(callEvent) + ); + + const prevCallHistory = + (await window.Signal.Data.getCallHistory( + callEvent.callId, + callEvent.peerId + )) ?? null; + + if (prevCallHistory != null) { + log.info( + 'updateLocalAdhocCallHistory: Found previous call history:', + formatCallHistory(prevCallHistory) + ); + } else { + log.info('updateLocalAdhocCallHistory: No previous call history'); + } + + let callHistory: CallHistoryDetails; + try { + callHistory = transitionCallHistory(prevCallHistory, callEvent); + } catch (error) { + log.error( + "updateLocalAdhocCallHistory: Couldn't transition call history:", + formatCallEvent(callEvent), + Errors.toLogFormat(error) + ); + return null; + } + + strictAssert( + callHistory.status === AdhocCallStatus.Pending || + callHistory.status === AdhocCallStatus.Joined || + callHistory.status === AdhocCallStatus.Deleted, + `updateLocalAdhocCallHistory: callHistory must have adhoc status (was ${callHistory.status})` + ); + + const isDeleted = callHistory.status === AdhocCallStatus.Deleted; + if (prevCallHistory != null && isEqual(prevCallHistory, callHistory)) { + log.info( + 'updateLocalAdhocCallHistory: Next call history is identical, skipping save' + ); + } else { + log.info( + 'updateLocalAdhocCallHistory: Saving call history:', + formatCallHistory(callHistory) + ); + await window.Signal.Data.saveCallHistory(callHistory); + } + + if (isDeleted) { + window.reduxActions.callHistory.removeCallHistory(callHistory.callId); + } else { + window.reduxActions.callHistory.addCallHistory(callHistory); + } + + return callHistory; +} + async function saveCallHistory( callHistory: CallHistoryDetails, conversation: ConversationModel, diff --git a/ts/util/callLinks.ts b/ts/util/callLinks.ts index a4832ca65e4c..79f96fa8ed6e 100644 --- a/ts/util/callLinks.ts +++ b/ts/util/callLinks.ts @@ -10,7 +10,16 @@ import { } from './zkgroup'; import { getCheckedCallLinkAuthCredentialsForToday } from '../services/groupCredentialFetcher'; import * as durations from './durations'; -import type { CallLinkConversationType, CallLinkType } from '../types/CallLink'; +import * as Bytes from '../Bytes'; +import type { + CallLinkConversationType, + CallLinkType, + CallLinkRecord, +} from '../types/CallLink'; +import { + callLinkRecordSchema, + toCallLinkRestrictions, +} from '../types/CallLink'; import type { LocalizerType } from '../types/Util'; export function getRoomIdFromRootKey(rootKey: CallLinkRootKey): string { @@ -71,3 +80,47 @@ export function callLinkToConversation( badges: [], }; } + +/** + * DB record conversions + */ + +export function callLinkToRecord(callLink: CallLinkType): CallLinkRecord { + if (callLink.rootKey == null) { + throw new Error('CallLink.callLinkToRecord: rootKey is null'); + } + + // rootKey has a RingRTC parsing function, adminKey is just bytes + const rootKey = callLink.rootKey + ? CallLinkRootKey.parse(callLink.rootKey).bytes + : null; + const adminKey = callLink.adminKey + ? Bytes.fromBase64(callLink.adminKey) + : null; + return callLinkRecordSchema.parse({ + roomId: callLink.roomId, + rootKey, + adminKey, + name: callLink.name, + restrictions: callLink.restrictions, + revoked: callLink.revoked ? 1 : 0, + expiration: callLink.expiration, + }); +} + +export function callLinkFromRecord(record: CallLinkRecord): CallLinkType { + // root keys in memory are strings for simplicity + const rootKey = CallLinkRootKey.fromBytes( + record.rootKey as Buffer + ).toString(); + const adminKey = record.adminKey ? Bytes.toBase64(record.adminKey) : null; + return { + roomId: record.roomId, + rootKey, + adminKey, + name: record.name, + restrictions: toCallLinkRestrictions(record.restrictions), + revoked: record.revoked === 1, + expiration: record.expiration, + }; +} diff --git a/ts/util/callingNotification.ts b/ts/util/callingNotification.ts index b9044ed62348..04961f93e62e 100644 --- a/ts/util/callingNotification.ts +++ b/ts/util/callingNotification.ts @@ -129,7 +129,6 @@ export function getCallingNotificationText( return getGroupCallNotificationText(groupCallEnded, callCreator, i18n); } if (callHistory.mode === CallMode.Adhoc) { - // TODO: DESKTOP-6653 return null; } throw missingCaseError(callHistory.mode); diff --git a/ts/windows/main/preload_test.ts b/ts/windows/main/preload_test.ts index 43b0a75d9055..ee5d64205935 100644 --- a/ts/windows/main/preload_test.ts +++ b/ts/windows/main/preload_test.ts @@ -36,6 +36,7 @@ window.testUtilities = { await Stickers.load(); initializeRedux({ + callLinks: [], callsHistory: [], callsHistoryUnreadCount: 0, initialBadgesState: { byId: {} },