From 9a9f9495f1db93f11df3afcc16964085550aaa1a Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Tue, 6 Aug 2024 12:29:13 -0700 Subject: [PATCH] Support delete for call links Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> --- _locales/en/messages.json | 20 +++ stylesheets/components/CallLinkDetails.scss | 14 ++ ts/background.ts | 2 +- ts/components/CallLinkDetails.stories.tsx | 40 +++++ ts/components/CallLinkDetails.tsx | 46 +++++- ts/components/CallManager.stories.tsx | 2 +- ts/components/CallManager.tsx | 2 +- ts/components/CallParticipantCount.tsx | 2 +- ts/components/CallScreen.stories.tsx | 2 +- ts/components/CallScreen.tsx | 2 +- ts/components/CallingLobby.stories.tsx | 2 +- ts/components/CallingLobby.tsx | 2 +- ts/components/CallingPip.stories.tsx | 2 +- ts/components/CallingPipRemoteVideo.tsx | 3 +- ts/components/CallingToastManager.tsx | 2 +- ts/components/CallsList.tsx | 2 +- ts/components/CallsTab.tsx | 10 +- ts/components/IncomingCallBar.stories.tsx | 2 +- ts/components/IncomingCallBar.tsx | 2 +- .../CallingNotification.stories.tsx | 14 +- .../conversation/CallingNotification.tsx | 14 +- .../conversation/TimelineItem.stories.tsx | 2 +- .../ConversationDetails.stories.tsx | 28 +--- ts/jobs/callLinksDeleteJobQueue.ts | 70 ++++++++ ts/jobs/initializeAllJobQueues.ts | 3 + ts/services/calling.ts | 49 +++++- ts/sql/Interface.ts | 5 + ts/sql/Server.ts | 56 ++++--- .../1140-call-links-deleted-column.ts | 31 ++++ ts/sql/migrations/89-call-history.ts | 2 +- ts/sql/migrations/index.ts | 6 +- ts/sql/server/callLinks.ts | 108 +++++++++++- ts/state/ducks/callHistory.ts | 48 +++++- ts/state/ducks/calling.ts | 49 +++++- ts/state/ducks/callingHelpers.ts | 7 +- ts/state/ducks/conversations.ts | 2 +- ts/state/selectors/calling.ts | 2 +- ts/state/selectors/message.ts | 3 +- ts/state/smart/CallLinkAddNameModal.tsx | 6 +- ts/state/smart/CallLinkDetails.tsx | 12 +- ts/state/smart/CallManager.tsx | 3 +- ts/state/smart/CallsTab.tsx | 18 +- ts/state/smart/ConversationHeader.tsx | 2 +- .../helpers/getFakeCallHistoryGroup.ts | 47 ++++++ ts/test-both/util/callLinks_test.ts | 5 +- ts/test-both/util/callingNotification_test.ts | 12 +- .../sql/getCallHistoryGroups_test.ts | 16 +- ...RecentStaleRingsAndMarkOlderMissed_test.ts | 6 +- ts/test-electron/state/ducks/calling_test.ts | 2 +- .../state/ducks/conversations_test.ts | 2 +- .../state/selectors/calling_test.ts | 2 +- ts/test-node/sql/migration_1100_test.ts | 2 +- ts/test-node/sql/migration_89_test.ts | 4 +- ts/textsecure/SendMessage.ts | 2 +- ts/types/CallDisposition.ts | 8 +- ts/types/CallLink.ts | 4 + ts/types/Calling.ts | 9 +- ts/util/callDisposition.ts | 40 ++--- ts/util/callLinks.ts | 155 +----------------- ts/util/callLinksRingrtc.ts | 150 +++++++++++++++++ ts/util/callingIsReconnecting.ts | 7 +- ts/util/callingNotification.ts | 6 +- ts/util/isGroupOrAdhocCall.ts | 2 +- ts/util/onCallEventSync.ts | 2 +- ts/util/onCallLinkUpdateSync.ts | 9 +- ts/util/sendCallLinkUpdateSync.ts | 3 +- ts/util/signalRoutes.ts | 4 +- 67 files changed, 853 insertions(+), 345 deletions(-) create mode 100644 ts/components/CallLinkDetails.stories.tsx create mode 100644 ts/jobs/callLinksDeleteJobQueue.ts create mode 100644 ts/sql/migrations/1140-call-links-deleted-column.ts create mode 100644 ts/test-both/helpers/getFakeCallHistoryGroup.ts create mode 100644 ts/util/callLinksRingrtc.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 68657031b389..bb93a0e1b78e 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -7325,6 +7325,26 @@ "messageformat": "Share link via Signal", "description": "Call History > Call Link Details > Share Link via Signal Button" }, + "icu:CallLinkDetails__DeleteLink": { + "messageformat": "Delete link", + "description": "Call History > Call Link Details > Delete Link Button" + }, + "icu:CallLinkDetails__DeleteLinkModal__Title": { + "messageformat": "Delete call link?", + "description": "Call History > Call Link Details > Delete Link Modal > Title" + }, + "icu:CallLinkDetails__DeleteLinkModal__Body": { + "messageformat": "This link will no longer work for anyone who has it.", + "description": "Call History > Call Link Details > Delete Link Modal > Body" + }, + "icu:CallLinkDetails__DeleteLinkModal__Cancel": { + "messageformat": "Cancel", + "description": "Call History > Call Link Details > Delete Link Modal > Cancel Button" + }, + "icu:CallLinkDetails__DeleteLinkModal__Delete": { + "messageformat": "Delete", + "description": "Call History > Call Link Details > Delete Link Modal > Delete Button" + }, "icu:CallLinkEditModal__Title": { "messageformat": "Call link details", "description": "Call Link Edit Modal > Title" diff --git a/stylesheets/components/CallLinkDetails.scss b/stylesheets/components/CallLinkDetails.scss index ccb00edea2f8..d6ee4855b024 100644 --- a/stylesheets/components/CallLinkDetails.scss +++ b/stylesheets/components/CallLinkDetails.scss @@ -45,3 +45,17 @@ .CallLinkDetails__HeaderButton { font-weight: 600; } + +.CallLinkDetails__DeleteLink { + // Override the default icon color + .ConversationDetails-icon__icon--trash::after { + @include any-theme { + background-color: $color-accent-red; + } + } + + // Override the default label color + .ConversationDetails-panel-row__label { + color: $color-accent-red; + } +} diff --git a/ts/background.ts b/ts/background.ts index 6f504d4d0c04..aac8b618fa92 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -199,7 +199,7 @@ import { deriveStorageServiceKey } from './Crypto'; import { getThemeType } from './util/getThemeType'; import { AttachmentDownloadManager } from './jobs/AttachmentDownloadManager'; import { onCallLinkUpdateSync } from './util/onCallLinkUpdateSync'; -import { CallMode } from './types/Calling'; +import { CallMode } from './types/CallDisposition'; import type { SyncTaskType } from './util/syncTasks'; import { queueSyncTasks } from './util/syncTasks'; import type { ViewSyncTaskType } from './messageModifiers/ViewSyncs'; diff --git a/ts/components/CallLinkDetails.stories.tsx b/ts/components/CallLinkDetails.stories.tsx new file mode 100644 index 000000000000..e91f827d8552 --- /dev/null +++ b/ts/components/CallLinkDetails.stories.tsx @@ -0,0 +1,40 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import type { ComponentMeta } from '../storybook/types'; +import type { CallLinkDetailsProps } from './CallLinkDetails'; +import { CallLinkDetails } from './CallLinkDetails'; +import { setupI18n } from '../util/setupI18n'; +import enMessages from '../../_locales/en/messages.json'; +import { + FAKE_CALL_LINK, + FAKE_CALL_LINK_WITH_ADMIN_KEY, +} from '../test-both/helpers/fakeCallLink'; +import { getFakeCallLinkHistoryGroup } from '../test-both/helpers/getFakeCallHistoryGroup'; + +const i18n = setupI18n('en', enMessages); + +export default { + title: 'Components/CallLinkDetails', + component: CallLinkDetails, + argTypes: {}, + args: { + i18n, + callHistoryGroup: getFakeCallLinkHistoryGroup(), + callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY, + onDeleteCallLink: action('onDeleteCallLink'), + onOpenCallLinkAddNameModal: action('onOpenCallLinkAddNameModal'), + onStartCallLinkLobby: action('onStartCallLinkLobby'), + onShareCallLinkViaSignal: action('onShareCallLinkViaSignal'), + onUpdateCallLinkRestrictions: action('onUpdateCallLinkRestrictions'), + }, +} satisfies ComponentMeta; + +export function Admin(args: CallLinkDetailsProps): JSX.Element { + return ; +} + +export function NonAdmin(args: CallLinkDetailsProps): JSX.Element { + return ; +} diff --git a/ts/components/CallLinkDetails.tsx b/ts/components/CallLinkDetails.tsx index e6c4591adbce..40a8b57fdad7 100644 --- a/ts/components/CallLinkDetails.tsx +++ b/ts/components/CallLinkDetails.tsx @@ -1,6 +1,6 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { useState } from 'react'; import type { CallHistoryGroup } from '../types/CallDisposition'; import type { LocalizerType } from '../types/I18N'; import { CallHistoryGroupPanelSection } from './conversation/conversation-details/CallHistoryGroupPanelSection'; @@ -17,8 +17,9 @@ import { Avatar, AvatarSize } from './Avatar'; import { Button, ButtonSize, ButtonVariant } from './Button'; import { copyCallLink } from '../util/copyLinksWithToast'; import { getColorForCallLink } from '../util/getColorForCallLink'; -import { isCallLinkAdmin } from '../util/callLinks'; +import { isCallLinkAdmin } from '../types/CallLink'; import { CallLinkRestrictionsSelect } from './CallLinkRestrictionsSelect'; +import { ConfirmationDialog } from './ConfirmationDialog'; function toUrlWithoutProtocol(url: URL): string { return `${url.hostname}${url.pathname}${url.search}${url.hash}`; @@ -28,6 +29,7 @@ export type CallLinkDetailsProps = Readonly<{ callHistoryGroup: CallHistoryGroup; callLink: CallLinkType; i18n: LocalizerType; + onDeleteCallLink: () => void; onOpenCallLinkAddNameModal: () => void; onStartCallLinkLobby: () => void; onShareCallLinkViaSignal: () => void; @@ -38,11 +40,14 @@ export function CallLinkDetails({ callHistoryGroup, callLink, i18n, + onDeleteCallLink, onOpenCallLinkAddNameModal, onStartCallLinkLobby, onShareCallLinkViaSignal, onUpdateCallLinkRestrictions, }: CallLinkDetailsProps): JSX.Element { + const [isDeleteCallLinkModalOpen, setIsDeleteCallLinkModalOpen] = + useState(false); const webUrl = linkCallRoute.toWebUrl({ key: callLink.rootKey, }); @@ -144,6 +149,43 @@ export function CallLinkDetails({ onClick={onShareCallLinkViaSignal} /> + {isCallLinkAdmin(callLink) && ( + + + } + label={i18n('icu:CallLinkDetails__DeleteLink')} + onClick={() => { + setIsDeleteCallLinkModalOpen(true); + }} + /> + + )} + {isDeleteCallLinkModalOpen && ( + { + setIsDeleteCallLinkModalOpen(false); + }} + > + {i18n('icu:CallLinkDetails__DeleteLinkModal__Body')} + + )} ); } diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index 711308c6417b..6c5826d481f2 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -8,12 +8,12 @@ import type { PropsType } from './CallManager'; import { CallManager } from './CallManager'; import { CallEndedReason, - CallMode, CallState, CallViewMode, GroupCallConnectionState, GroupCallJoinState, } from '../types/Calling'; +import { CallMode } from '../types/CallDisposition'; import type { ActiveGroupCallType, GroupCallRemoteParticipantType, diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index c3c77b2564cb..d1247e28b916 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -20,11 +20,11 @@ import type { } from '../types/Calling'; import { CallEndedReason, - CallMode, CallState, GroupCallConnectionState, GroupCallJoinState, } from '../types/Calling'; +import { CallMode } from '../types/CallDisposition'; import type { ConversationType } from '../state/ducks/conversations'; import type { AcceptCallType, diff --git a/ts/components/CallParticipantCount.tsx b/ts/components/CallParticipantCount.tsx index 74016d593905..5e02ed5c9cd6 100644 --- a/ts/components/CallParticipantCount.tsx +++ b/ts/components/CallParticipantCount.tsx @@ -3,7 +3,7 @@ import React from 'react'; import type { LocalizerType } from '../types/Util'; -import { CallMode } from '../types/Calling'; +import { CallMode } from '../types/CallDisposition'; export type PropsType = { callMode: CallMode; diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index 039f0d30cf1c..d0f76427c4dd 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -12,12 +12,12 @@ import type { GroupCallRemoteParticipantType, } from '../types/Calling'; import { - CallMode, CallViewMode, CallState, GroupCallConnectionState, GroupCallJoinState, } from '../types/Calling'; +import { CallMode } from '../types/CallDisposition'; import { generateAci } from '../types/ServiceId'; import type { ConversationType } from '../state/ducks/conversations'; import { AvatarColors } from '../types/Colors'; diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index 7e7e8a77230d..4e98d011c3ce 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -33,12 +33,12 @@ import type { } from '../types/Calling'; import { CALLING_REACTIONS_LIFETIME, - CallMode, CallViewMode, CallState, GroupCallConnectionState, GroupCallJoinState, } from '../types/Calling'; +import { CallMode } from '../types/CallDisposition'; import type { ServiceIdString } from '../types/ServiceId'; import { AvatarColors } from '../types/Colors'; import type { ConversationType } from '../state/ducks/conversations'; diff --git a/ts/components/CallingLobby.stories.tsx b/ts/components/CallingLobby.stories.tsx index 145a128a97c8..c7a16ecd2ede 100644 --- a/ts/components/CallingLobby.stories.tsx +++ b/ts/components/CallingLobby.stories.tsx @@ -19,7 +19,7 @@ import { getDefaultConversationWithServiceId, } from '../test-both/helpers/getDefaultConversation'; import { CallingToastProvider } from './CallingToast'; -import { CallMode } from '../types/Calling'; +import { CallMode } from '../types/CallDisposition'; import { getDefaultCallLinkConversation } from '../test-both/helpers/fakeCallLink'; const i18n = setupI18n('en', enMessages); diff --git a/ts/components/CallingLobby.tsx b/ts/components/CallingLobby.tsx index 17070f84771d..5d834cea3e84 100644 --- a/ts/components/CallingLobby.tsx +++ b/ts/components/CallingLobby.tsx @@ -19,7 +19,7 @@ import { CallingLobbyJoinButton, CallingLobbyJoinButtonVariant, } from './CallingLobbyJoinButton'; -import { CallMode } from '../types/Calling'; +import { CallMode } from '../types/CallDisposition'; import type { CallingConversationType } from '../types/Calling'; import type { LocalizerType } from '../types/Util'; import { useIsOnline } from '../hooks/useIsOnline'; diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx index 3110336e65c8..840de8ad68fc 100644 --- a/ts/components/CallingPip.stories.tsx +++ b/ts/components/CallingPip.stories.tsx @@ -11,12 +11,12 @@ import type { PropsType } from './CallingPip'; import { CallingPip } from './CallingPip'; import type { ActiveDirectCallType } from '../types/Calling'; import { - CallMode, CallViewMode, CallState, GroupCallConnectionState, GroupCallJoinState, } from '../types/Calling'; +import { CallMode } from '../types/CallDisposition'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource'; import { setupI18n } from '../util/setupI18n'; diff --git a/ts/components/CallingPipRemoteVideo.tsx b/ts/components/CallingPipRemoteVideo.tsx index 86ef9542db89..60d61d8f9209 100644 --- a/ts/components/CallingPipRemoteVideo.tsx +++ b/ts/components/CallingPipRemoteVideo.tsx @@ -14,7 +14,8 @@ import type { GroupCallRemoteParticipantType, GroupCallVideoRequest, } from '../types/Calling'; -import { CallMode, GroupCallJoinState } from '../types/Calling'; +import { GroupCallJoinState } from '../types/Calling'; +import { CallMode } from '../types/CallDisposition'; import { AvatarColors } from '../types/Colors'; import type { SetRendererCanvasType } from '../state/ducks/calling'; import { useGetCallingFrameBuffer } from '../calling/useGetCallingFrameBuffer'; diff --git a/ts/components/CallingToastManager.tsx b/ts/components/CallingToastManager.tsx index 13cf8bea755d..69abf6d631f7 100644 --- a/ts/components/CallingToastManager.tsx +++ b/ts/components/CallingToastManager.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useMemo, useRef } from 'react'; import type { ActiveCallType } from '../types/Calling'; -import { CallMode } from '../types/Calling'; +import { CallMode } from '../types/CallDisposition'; import type { ConversationType } from '../state/ducks/conversations'; import type { LocalizerType } from '../types/Util'; import { CallingToastProvider, useCallingToasts } from './CallingToast'; diff --git a/ts/components/CallsList.tsx b/ts/components/CallsList.tsx index 1a02f4852e51..07724e6515ae 100644 --- a/ts/components/CallsList.tsx +++ b/ts/components/CallsList.tsx @@ -28,6 +28,7 @@ import { DirectCallStatus, GroupCallStatus, isSameCallHistoryGroup, + CallMode, } from '../types/CallDisposition'; import { formatDateTimeShort, isMoreRecentThan } from '../util/timestamp'; import type { ConversationType } from '../state/ducks/conversations'; @@ -47,7 +48,6 @@ 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, diff --git a/ts/components/CallsTab.tsx b/ts/components/CallsTab.tsx index cd9d9e90843a..3adf51288c33 100644 --- a/ts/components/CallsTab.tsx +++ b/ts/components/CallsTab.tsx @@ -62,7 +62,8 @@ type CallsTabProps = Readonly<{ preferredLeftPaneWidth: number; renderCallLinkDetails: ( roomId: string, - callHistoryGroup: CallHistoryGroup + callHistoryGroup: CallHistoryGroup, + onClose: () => void ) => JSX.Element; renderConversationDetails: ( conversationId: string, @@ -152,6 +153,10 @@ export function CallsTab({ [updateSelectedView] ); + const onCloseSelectedView = useCallback(() => { + updateSelectedView(null); + }, [updateSelectedView]); + useEscapeHandling( sidebarView === CallsTabSidebarView.NewCallView ? () => { @@ -328,7 +333,8 @@ export function CallsTab({ {selectedView.type === 'callLink' && renderCallLinkDetails( selectedView.roomId, - selectedView.callHistoryGroup + selectedView.callHistoryGroup, + onCloseSelectedView )} )} diff --git a/ts/components/IncomingCallBar.stories.tsx b/ts/components/IncomingCallBar.stories.tsx index 4e9aa9f71f2d..66dbb70b67b7 100644 --- a/ts/components/IncomingCallBar.stories.tsx +++ b/ts/components/IncomingCallBar.stories.tsx @@ -6,7 +6,7 @@ import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; import type { PropsType } from './IncomingCallBar'; import { IncomingCallBar } from './IncomingCallBar'; -import { CallMode } from '../types/Calling'; +import { CallMode } from '../types/CallDisposition'; import { setupI18n } from '../util/setupI18n'; import enMessages from '../../_locales/en/messages.json'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; diff --git a/ts/components/IncomingCallBar.tsx b/ts/components/IncomingCallBar.tsx index 0a6b5d0b81b7..119fe7f93bfa 100644 --- a/ts/components/IncomingCallBar.tsx +++ b/ts/components/IncomingCallBar.tsx @@ -11,7 +11,7 @@ import { getParticipantName } from '../util/callingGetParticipantName'; import { ContactName } from './conversation/ContactName'; import type { LocalizerType } from '../types/Util'; import { AvatarColors } from '../types/Colors'; -import { CallMode } from '../types/Calling'; +import { CallMode } from '../types/CallDisposition'; import type { ConversationType } from '../state/ducks/conversations'; import type { AcceptCallType, DeclineCallType } from '../state/ducks/calling'; import { missingCaseError } from '../util/missingCaseError'; diff --git a/ts/components/conversation/CallingNotification.stories.tsx b/ts/components/conversation/CallingNotification.stories.tsx index 565ebf8b0c95..2ea4358d52f1 100644 --- a/ts/components/conversation/CallingNotification.stories.tsx +++ b/ts/components/conversation/CallingNotification.stories.tsx @@ -6,7 +6,13 @@ import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; import { setupI18n } from '../../util/setupI18n'; import enMessages from '../../../_locales/en/messages.json'; -import { CallMode } from '../../types/Calling'; +import { + CallMode, + CallType, + CallDirection, + GroupCallStatus, + DirectCallStatus, +} from '../../types/CallDisposition'; import { generateAci } from '../../types/ServiceId'; import { CallingNotification, type PropsType } from './CallingNotification'; import { @@ -14,12 +20,6 @@ import { getDefaultGroup, } from '../../test-both/helpers/getDefaultConversation'; import type { CallStatus } from '../../types/CallDisposition'; -import { - CallType, - CallDirection, - GroupCallStatus, - DirectCallStatus, -} from '../../types/CallDisposition'; import type { ConversationType } from '../../state/ducks/conversations'; const i18n = setupI18n('en', enMessages); diff --git a/ts/components/conversation/CallingNotification.tsx b/ts/components/conversation/CallingNotification.tsx index 054bdae5c4f1..e0d73612c189 100644 --- a/ts/components/conversation/CallingNotification.tsx +++ b/ts/components/conversation/CallingNotification.tsx @@ -10,7 +10,13 @@ import { SystemMessage, SystemMessageKind } from './SystemMessage'; import { Button, ButtonSize, ButtonVariant } from '../Button'; import { MessageTimestamp } from './MessageTimestamp'; import type { LocalizerType } from '../../types/Util'; -import { CallMode } from '../../types/Calling'; +import { + CallMode, + CallDirection, + CallType, + DirectCallStatus, + GroupCallStatus, +} from '../../types/CallDisposition'; import type { CallingNotificationType } from '../../util/callingNotification'; import { getCallingIcon, @@ -19,12 +25,6 @@ import { import { missingCaseError } from '../../util/missingCaseError'; import { Tooltip, TooltipPlacement } from '../Tooltip'; import * as log from '../../logging/log'; -import { - CallDirection, - CallType, - DirectCallStatus, - GroupCallStatus, -} from '../../types/CallDisposition'; import { type ContextMenuTriggerType, MessageContextMenu, diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 1e3828c6b9d7..423209b84dd2 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -11,7 +11,7 @@ import enMessages from '../../../_locales/en/messages.json'; import type { PropsType as TimelineItemProps } from './TimelineItem'; import { TimelineItem } from './TimelineItem'; import { UniversalTimerNotification } from './UniversalTimerNotification'; -import { CallMode } from '../../types/Calling'; +import { CallMode } from '../../types/CallDisposition'; import { AvatarColors } from '../../types/Colors'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { WidthBreakpoint } from '../_util'; diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index de0b979e8de4..47c448e7b105 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -19,12 +19,7 @@ import { makeFakeLookupConversationWithoutServiceId } from '../../../test-both/h import { ThemeType } from '../../../types/Util'; import { DurationInSeconds } from '../../../util/durations'; import { NavTab } from '../../../state/ducks/nav'; -import { CallMode } from '../../../types/Calling'; -import { - CallDirection, - CallType, - DirectCallStatus, -} from '../../../types/CallDisposition'; +import { getFakeCallHistoryGroup } from '../../../test-both/helpers/getFakeCallHistoryGroup'; const i18n = setupI18n('en', enMessages); @@ -224,30 +219,15 @@ export const _11 = (): JSX.Element => ( ); -function mins(n: number) { - return DurationInSeconds.toMillis(DurationInSeconds.fromMinutes(n)); -} - export function WithCallHistoryGroup(): JSX.Element { const props = createProps(); return ( ); diff --git a/ts/jobs/callLinksDeleteJobQueue.ts b/ts/jobs/callLinksDeleteJobQueue.ts new file mode 100644 index 000000000000..ec43a307ca04 --- /dev/null +++ b/ts/jobs/callLinksDeleteJobQueue.ts @@ -0,0 +1,70 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import { z } from 'zod'; +import type { LoggerType } from '../types/Logging'; +import { DataReader, DataWriter } from '../sql/Client'; +import type { JOB_STATUS } from './JobQueue'; +import { JobQueue } from './JobQueue'; +import { jobQueueDatabaseStore } from './JobQueueDatabaseStore'; +import { calling } from '../services/calling'; +import { toLogFormat } from '../types/errors'; + +const callLinksDeleteJobData = z.object({ + source: z.string(), +}); + +type CallLinksDeleteJobData = z.infer; + +export class CallLinksDeleteJobQueue extends JobQueue { + protected parseData(data: unknown): CallLinksDeleteJobData { + return callLinksDeleteJobData.parse(data); + } + + protected async run( + { data }: { data: CallLinksDeleteJobData }, + { attempt, log }: { attempt: number; log: LoggerType } + ): Promise { + const { source } = data; + const logId = `callLinksDeleteJobQueue(${source}, attempt=${attempt})`; + const deletedCallLinks = await DataReader.getAllMarkedDeletedCallLinks(); + if (deletedCallLinks.length === 0) { + log.info(`${logId}: no call links to delete`); + return undefined; + } + log.info(`${logId}: deleting ${deletedCallLinks.length} call links`); + const errors: Array = []; + await Promise.all( + deletedCallLinks.map(async deletedCallLink => { + try { + // May 200 or 404 and that's fine + // Sends a CallLinkUpdate with type set to DELETE + await calling.deleteCallLink(deletedCallLink); + await DataWriter.finalizeDeleteCallLink(deletedCallLink.roomId); + log.info(`${logId}: deleted call link ${deletedCallLink.roomId}`); + } catch (error) { + log.error( + `${logId}: failed to delete call link ${deletedCallLink.roomId}`, + toLogFormat(error) + ); + errors.push(error); + } + }) + ); + log.info( + `${logId}: Deleted ${deletedCallLinks.length} call links, failed to delete ${errors.length} call links` + ); + if (errors.length > 0) { + throw new AggregateError( + errors, + `Failed to delete ${errors.length} call links` + ); + } + return undefined; + } +} + +export const callLinksDeleteJobQueue = new CallLinksDeleteJobQueue({ + store: jobQueueDatabaseStore, + queueType: 'callLinksDelete', + maxAttempts: 25, +}); diff --git a/ts/jobs/initializeAllJobQueues.ts b/ts/jobs/initializeAllJobQueues.ts index 9ce92c28b5a5..f5da45fb8a7c 100644 --- a/ts/jobs/initializeAllJobQueues.ts +++ b/ts/jobs/initializeAllJobQueues.ts @@ -3,6 +3,7 @@ import type { WebAPIType } from '../textsecure/WebAPI'; import { drop } from '../util/drop'; +import { callLinksDeleteJobQueue } from './callLinksDeleteJobQueue'; import { conversationJobQueue } from './conversationJobQueue'; import { groupAvatarJobQueue } from './groupAvatarJobQueue'; @@ -40,6 +41,7 @@ export function initializeAllJobQueues({ // Other queues drop(removeStorageKeyJobQueue.streamJobs()); drop(reportSpamJobQueue.streamJobs()); + drop(callLinksDeleteJobQueue.streamJobs()); } export async function shutdownAllJobQueues(): Promise { @@ -52,5 +54,6 @@ export async function shutdownAllJobQueues(): Promise { viewOnceOpenJobQueue.shutdown(), removeStorageKeyJobQueue.shutdown(), reportSpamJobQueue.shutdown(), + callLinksDeleteJobQueue.shutdown(), ]); } diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 51aa86620b3a..da1440f265a4 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -68,11 +68,11 @@ import type { PresentedSource, } from '../types/Calling'; import { - CallMode, GroupCallConnectionState, GroupCallJoinState, ScreenShareStatus, } from '../types/Calling'; +import { CallMode, LocalCallEvent } from '../types/CallDisposition'; import { findBestMatchingAudioDeviceIndex, findBestMatchingCameraId, @@ -135,17 +135,16 @@ import { getCallDetailsForAdhocCall, } from '../util/callDisposition'; import { isNormalNumber } from '../util/isNormalNumber'; -import { LocalCallEvent } from '../types/CallDisposition'; import type { AciString, ServiceIdString } from '../types/ServiceId'; import { isServiceIdString } from '../types/ServiceId'; import { isInSystemContacts } from '../util/isInSystemContacts'; +import { toAdminKeyBytes } from '../util/callLinks'; import { - getRoomIdFromRootKey, getCallLinkAuthCredentialPresentation, - toAdminKeyBytes, + getRoomIdFromRootKey, callLinkRestrictionsToRingRTC, callLinkStateFromRingRTC, -} from '../util/callLinks'; +} from '../util/callLinksRingrtc'; import { isAdhocCallingEnabled } from '../util/isAdhocCallingEnabled'; import { conversationJobQueue, @@ -154,7 +153,10 @@ import { import type { CallLinkType, CallLinkStateType } from '../types/CallLink'; import { CallLinkRestrictions } from '../types/CallLink'; import { getConversationIdForLogging } from '../util/idForLogging'; -import { sendCallLinkUpdateSync } from '../util/sendCallLinkUpdateSync'; +import { + sendCallLinkDeleteSync, + sendCallLinkUpdateSync, +} from '../util/sendCallLinkUpdateSync'; import { createIdenticon } from '../util/createIdenticon'; import { getColorForCallLink } from '../util/getColorForCallLink'; @@ -683,6 +685,41 @@ export class CallingClass { return callLink; } + async deleteCallLink(callLink: CallLinkType): Promise { + strictAssert( + this._sfuUrl, + 'createCallLink() missing SFU URL; not deleting call link' + ); + + const sfuUrl = this._sfuUrl; + const logId = `deleteCallLink(${callLink.roomId})`; + + const callLinkRootKey = CallLinkRootKey.parse(callLink.rootKey); + strictAssert(callLink.adminKey, 'Missing admin key'); + const callLinkAdminKey = toAdminKeyBytes(callLink.adminKey); + const authCredentialPresentation = + await getCallLinkAuthCredentialPresentation(callLinkRootKey); + + const result = await RingRTC.deleteCallLink( + sfuUrl, + authCredentialPresentation.serialize(), + callLinkRootKey, + callLinkAdminKey + ); + + if (!result.success) { + if (result.errorStatusCode === 404) { + log.info(`${logId}: Call link not found, already deleted`); + return; + } + const message = `Failed to delete call link: ${result.errorStatusCode}`; + log.error(`${logId}: ${message}`); + throw new Error(message); + } + + drop(sendCallLinkDeleteSync(callLink)); + } + async updateCallLinkName( callLink: CallLinkType, name: string diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 231b64863c62..c82857bb77d9 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -562,6 +562,7 @@ type ReadableInterface = { callLinkExists(roomId: string): boolean; getAllCallLinks: () => ReadonlyArray; getCallLinkByRoomId: (roomId: string) => CallLinkType | undefined; + getAllMarkedDeletedCallLinks(): ReadonlyArray; getMessagesBetween: ( conversationId: string, options: GetMessagesBetweenOptions @@ -794,6 +795,10 @@ type WritableInterface = { roomId: string, callLinkState: CallLinkStateType ): CallLinkType; + beginDeleteAllCallLinks(): void; + beginDeleteCallLink(roomId: string): void; + finalizeDeleteCallLink(roomId: string): void; + deleteCallLinkFromSync(roomId: string): void; migrateConversationMessages: (obsoleteId: string, currentId: string) => void; saveEditedMessage: ( mainMessage: ReadonlyDeep, diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 3033bcda3baa..8fb11ed122d7 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -168,6 +168,7 @@ import { GroupCallStatus, CallType, CallStatusValue, + CallMode, } from '../types/CallDisposition'; import { callLinkExists, @@ -176,13 +177,17 @@ import { insertCallLink, updateCallLinkAdminKeyByRoomId, updateCallLinkState, + beginDeleteAllCallLinks, + getAllMarkedDeletedCallLinks, + finalizeDeleteCallLink, + beginDeleteCallLink, + deleteCallLinkFromSync, } from './server/callLinks'; import { replaceAllEndorsementsForGroup, deleteAllEndorsementsForGroup, getGroupSendCombinedEndorsementExpiration, } from './server/groupEndorsements'; -import { CallMode } from '../types/Calling'; import { attachmentDownloadJobSchema, type AttachmentDownloadJobType, @@ -296,6 +301,7 @@ export const DataReader: ServerReadableInterface = { callLinkExists, getAllCallLinks, getCallLinkByRoomId, + getAllMarkedDeletedCallLinks, getMessagesBetween, getNearbyMessageFromDeletedSet, getMostRecentAddressableMessages, @@ -430,6 +436,10 @@ export const DataWriter: ServerWritableInterface = { insertCallLink, updateCallLinkAdminKeyByRoomId, updateCallLinkState, + beginDeleteAllCallLinks, + beginDeleteCallLink, + finalizeDeleteCallLink, + deleteCallLinkFromSync, migrateConversationMessages, saveEditedMessage, saveEditedMessages, @@ -3458,7 +3468,7 @@ function clearCallHistory( return db.transaction(() => { const timestamp = getMessageTimestampForCallLogEventTarget(db, target); - const [selectCallIdsQuery, selectCallIdsParams] = sql` + const [selectCallsQuery, selectCallsParams] = sql` SELECT callsHistory.callId FROM callsHistory WHERE @@ -3471,18 +3481,30 @@ function clearCallHistory( ); `; - const callIds = db - .prepare(selectCallIdsQuery) + const deletedCallIds: ReadonlyArray = db + .prepare(selectCallsQuery) .pluck() - .all(selectCallIdsParams); + .all(selectCallsParams); let deletedMessageIds: ReadonlyArray = []; - batchMultiVarQuery(db, callIds, (ids: ReadonlyArray): void => { + batchMultiVarQuery(db, deletedCallIds, (ids): void => { + const idsFragment = sqlJoin(ids); + + const [clearCallsHistoryQuery, clearCallsHistoryParams] = sql` + UPDATE callsHistory + SET + status = ${DirectCallStatus.Deleted}, + timestamp = ${Date.now()} + WHERE callsHistory.callId IN (${idsFragment}); + `; + + db.prepare(clearCallsHistoryQuery).run(clearCallsHistoryParams); + const [deleteMessagesQuery, deleteMessagesParams] = sql` DELETE FROM messages WHERE messages.type IS 'call-history' - AND messages.callId IN (${sqlJoin(ids)}) + AND messages.callId IN (${idsFragment}) RETURNING id; `; @@ -3494,21 +3516,6 @@ function clearCallHistory( deletedMessageIds = deletedMessageIds.concat(batchDeletedMessageIds); }); - const [clearCallsHistoryQuery, clearCallsHistoryParams] = sql` - UPDATE callsHistory - SET - status = ${DirectCallStatus.Deleted}, - timestamp = ${Date.now()} - WHERE callsHistory.timestamp <= ${timestamp}; - `; - - try { - db.prepare(clearCallsHistoryQuery).run(clearCallsHistoryParams); - } catch (error) { - logger.error(error, error.message); - throw error; - } - return deletedMessageIds; })(); } @@ -3519,9 +3526,8 @@ function markCallHistoryDeleted(db: WritableDB, callId: string): void { SET status = ${DirectCallStatus.Deleted}, timestamp = ${Date.now()} - WHERE callId = ${callId}; + WHERE callId = ${callId} `; - db.prepare(query).run(params); } @@ -4829,7 +4835,7 @@ function getNextAttachmentBackupJobs( active = 0 AND (retryAfter is NULL OR retryAfter <= ${timestamp}) - ORDER BY + ORDER BY -- type is "standard" or "thumbnail"; we prefer "standard" jobs type ASC, receivedAt DESC LIMIT ${limit} diff --git a/ts/sql/migrations/1140-call-links-deleted-column.ts b/ts/sql/migrations/1140-call-links-deleted-column.ts new file mode 100644 index 000000000000..528cc3137b52 --- /dev/null +++ b/ts/sql/migrations/1140-call-links-deleted-column.ts @@ -0,0 +1,31 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { Database } from '@signalapp/better-sqlite3'; +import type { LoggerType } from '../../types/Logging'; + +export const version = 1140; + +export function updateToSchemaVersion1140( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 1140) { + return; + } + + db.transaction(() => { + db.exec(` + DROP INDEX IF EXISTS callLinks_deleted; + + ALTER TABLE callLinks + ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0; + + CREATE INDEX callLinks_deleted + ON callLinks (deleted, roomId); + `); + })(); + + db.pragma('user_version = 1140'); + logger.info('updateToSchemaVersion1140: success!'); +} diff --git a/ts/sql/migrations/89-call-history.ts b/ts/sql/migrations/89-call-history.ts index 77956c8ddd00..6003f5a5653b 100644 --- a/ts/sql/migrations/89-call-history.ts +++ b/ts/sql/migrations/89-call-history.ts @@ -17,8 +17,8 @@ import { CallType, GroupCallStatus, callHistoryDetailsSchema, + CallMode, } from '../../types/CallDisposition'; -import { CallMode } from '../../types/Calling'; import type { WritableDB, MessageType, ConversationType } from '../Interface'; import { strictAssert } from '../../util/assert'; import { missingCaseError } from '../../util/missingCaseError'; diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index eb22748a9e33..90cce47672d3 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -89,10 +89,11 @@ import { updateToSchemaVersion1090 } from './1090-message-delete-indexes'; import { updateToSchemaVersion1100 } from './1100-optimize-mark-call-history-read-in-conversation'; import { updateToSchemaVersion1110 } from './1110-sticker-local-key'; import { updateToSchemaVersion1120 } from './1120-messages-foreign-keys-indexes'; +import { updateToSchemaVersion1130 } from './1130-isStory-index'; import { - updateToSchemaVersion1130, + updateToSchemaVersion1140, version as MAX_VERSION, -} from './1130-isStory-index'; +} from './1140-call-links-deleted-column'; function updateToSchemaVersion1( currentVersion: number, @@ -2050,6 +2051,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion1110, updateToSchemaVersion1120, updateToSchemaVersion1130, + updateToSchemaVersion1140, ]; export class DBVersionFromFutureError extends Error { diff --git a/ts/sql/server/callLinks.ts b/ts/sql/server/callLinks.ts index ce9e8e437a63..2736035c310b 100644 --- a/ts/sql/server/callLinks.ts +++ b/ts/sql/server/callLinks.ts @@ -7,15 +7,16 @@ import { callLinkRestrictionsSchema, callLinkRecordSchema, } from '../../types/CallLink'; +import { toAdminKeyBytes } from '../../util/callLinks'; import { callLinkToRecord, callLinkFromRecord, - toAdminKeyBytes, -} from '../../util/callLinks'; +} from '../../util/callLinksRingrtc'; import type { ReadableDB, WritableDB } from '../Interface'; import { prepare } from '../Server'; import { sql } from '../util'; import { strictAssert } from '../../util/assert'; +import { CallStatusValue } from '../../types/CallDisposition'; export function callLinkExists(db: ReadableDB, roomId: string): boolean { const [query, params] = sql` @@ -133,3 +134,106 @@ function assertRoomIdMatchesRootKey(roomId: string, rootKey: string): void { 'passed roomId must match roomId derived from root key' ); } + +function deleteCallHistoryByRoomId(db: WritableDB, roomId: string) { + const [ + markCallHistoryDeleteByPeerIdQuery, + markCallHistoryDeleteByPeerIdParams, + ] = sql` + UPDATE callsHistory + SET + status = ${CallStatusValue.Deleted}, + timestamp = ${Date.now()} + WHERE peerId = ${roomId} + `; + + db.prepare(markCallHistoryDeleteByPeerIdQuery).run( + markCallHistoryDeleteByPeerIdParams + ); +} + +// This should only be called from a sync message to avoid accidentally deleting +// on the client but not the server +export function deleteCallLinkFromSync(db: WritableDB, roomId: string): void { + db.transaction(() => { + const [query, params] = sql` + DELETE FROM callLinks + WHERE roomId = ${roomId}; + `; + + db.prepare(query).run(params); + + deleteCallHistoryByRoomId(db, roomId); + })(); +} + +export function beginDeleteCallLink(db: WritableDB, roomId: string): void { + db.transaction(() => { + // If adminKey is null, then we should delete the call link + const [deleteNonAdminCallLinksQuery, deleteNonAdminCallLinksParams] = sql` + DELETE FROM callLinks + WHERE adminKey IS NULL + AND roomId = ${roomId}; + `; + + const result = db + .prepare(deleteNonAdminCallLinksQuery) + .run(deleteNonAdminCallLinksParams); + + // Skip this query if the call is already deleted + if (result.changes === 0) { + // If the admin key is not null, we should mark it for deletion + const [markAdminCallLinksDeletedQuery, markAdminCallLinksDeletedParams] = + sql` + UPDATE callLinks + SET deleted = 1 + WHERE adminKey IS NOT NULL + AND roomId = ${roomId}; + `; + + db.prepare(markAdminCallLinksDeletedQuery).run( + markAdminCallLinksDeletedParams + ); + } + + deleteCallHistoryByRoomId(db, roomId); + })(); +} + +export function beginDeleteAllCallLinks(db: WritableDB): void { + db.transaction(() => { + const [markAdminCallLinksDeletedQuery] = sql` + UPDATE callLinks + SET deleted = 1 + WHERE adminKey IS NOT NULL; + `; + + db.prepare(markAdminCallLinksDeletedQuery).run(); + + const [deleteNonAdminCallLinksQuery] = sql` + DELETE FROM callLinks + WHERE adminKey IS NULL; + `; + + db.prepare(deleteNonAdminCallLinksQuery).run(); + })(); +} + +export function getAllMarkedDeletedCallLinks( + db: ReadableDB +): ReadonlyArray { + const [query] = sql` + SELECT * FROM callLinks WHERE deleted = 1; + `; + return db + .prepare(query) + .all() + .map(item => callLinkFromRecord(callLinkRecordSchema.parse(item))); +} + +export function finalizeDeleteCallLink(db: WritableDB, roomId: string): void { + const [query, params] = sql` + DELETE FROM callLinks WHERE roomId = ${roomId} AND deleted = 1; + `; + db.prepare(query).run(params); +} diff --git a/ts/state/ducks/callHistory.ts b/ts/state/ducks/callHistory.ts index 4a42248b95d6..2f082c260c6a 100644 --- a/ts/state/ducks/callHistory.ts +++ b/ts/state/ducks/callHistory.ts @@ -23,6 +23,12 @@ import { getCallHistoryLatestCall, getCallHistorySelector, } from '../selectors/callHistory'; +import { + getCallsHistoryForRedux, + getCallsHistoryUnreadCountForRedux, + loadCallsHistory, +} from '../../services/callHistoryLoader'; +import { makeLookup } from '../../util/makeLookup'; export type CallHistoryState = ReadonlyDeep<{ // This informs the app that underlying call history data has changed. @@ -34,6 +40,7 @@ export type CallHistoryState = ReadonlyDeep<{ const CALL_HISTORY_ADD = 'callHistory/ADD'; const CALL_HISTORY_REMOVE = 'callHistory/REMOVE'; const CALL_HISTORY_RESET = 'callHistory/RESET'; +const CALL_HISTORY_RELOAD = 'callHistory/RELOAD'; const CALL_HISTORY_UPDATE_UNREAD = 'callHistory/UPDATE_UNREAD'; export type CallHistoryAdd = ReadonlyDeep<{ @@ -50,6 +57,14 @@ export type CallHistoryReset = ReadonlyDeep<{ type: typeof CALL_HISTORY_RESET; }>; +export type CallHistoryReload = ReadonlyDeep<{ + type: typeof CALL_HISTORY_RELOAD; + payload: { + callsHistory: ReadonlyArray; + callsHistoryUnreadCount: number; + }; +}>; + export type CallHistoryUpdateUnread = ReadonlyDeep<{ type: typeof CALL_HISTORY_UPDATE_UNREAD; payload: number; @@ -59,6 +74,7 @@ export type CallHistoryAction = ReadonlyDeep< | CallHistoryAdd | CallHistoryRemove | CallHistoryReset + | CallHistoryReload | CallHistoryUpdateUnread >; @@ -178,9 +194,29 @@ function clearAllCallHistory(): ThunkAction< } catch (error) { log.error('Error clearing call history', Errors.toLogFormat(error)); } finally { - // Just force a reset, even if the clear failed. - dispatch(resetCallHistory()); - dispatch(updateCallHistoryUnreadCount()); + // Just force a reload, even if the clear failed. + dispatch(reloadCallHistory()); + } + }; +} + +export function reloadCallHistory(): ThunkAction< + void, + RootStateType, + unknown, + CallHistoryReload +> { + return async dispatch => { + try { + await loadCallsHistory(); + const callsHistory = getCallsHistoryForRedux(); + const callsHistoryUnreadCount = getCallsHistoryUnreadCountForRedux(); + dispatch({ + type: CALL_HISTORY_RELOAD, + payload: { callsHistory, callsHistoryUnreadCount }, + }); + } catch (error) { + log.error('Error reloading call history', Errors.toLogFormat(error)); } }; } @@ -226,6 +262,12 @@ export function reducer( ...state, unreadCount: action.payload, }; + case CALL_HISTORY_RELOAD: + return { + edition: state.edition + 1, + unreadCount: action.payload.callsHistoryUnreadCount, + callHistoryByCallId: makeLookup(action.payload.callsHistory, 'callId'), + }; default: return state; } diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 88152d17a884..a25e5bec5964 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -41,21 +41,21 @@ import { MAX_CALLING_REACTIONS, CallEndedReason, CallingDeviceType, - CallMode, CallViewMode, CallState, GroupCallConnectionState, GroupCallJoinState, } from '../../types/Calling'; +import { CallMode } from '../../types/CallDisposition'; import { callingTones } from '../../util/callingTones'; import { requestCameraPermissions } from '../../util/callingPermissions'; import { CALL_LINK_DEFAULT_STATE, - getRoomIdFromRootKey, isCallLinksCreateEnabled, toAdminKeyBytes, toCallHistoryFromUnusedCallLink, } from '../../util/callLinks'; +import { getRoomIdFromRootKey } from '../../util/callLinksRingrtc'; import { sendCallLinkUpdateSync } from '../../util/sendCallLinkUpdateSync'; import { sleep } from '../../util/sleep'; import { LatestQueue } from '../../util/LatestQueue'; @@ -92,10 +92,11 @@ import { getConversationIdForLogging } from '../../util/idForLogging'; import { DataReader, DataWriter } from '../../sql/Client'; import { isAciString } from '../../util/isAciString'; import type { CallHistoryAdd } from './callHistory'; -import { addCallHistory } from './callHistory'; +import { addCallHistory, reloadCallHistory } from './callHistory'; import { saveDraftRecordingIfNeeded } from './composer'; import type { CallHistoryDetails } from '../../types/CallDisposition'; import type { StartCallData } from '../../components/ConfirmLeaveCallModal'; +import { callLinksDeleteJobQueue } from '../../jobs/callLinksDeleteJobQueue'; import { getCallLinksByRoomId } from '../selectors/calling'; // State @@ -255,6 +256,10 @@ type HandleCallLinkUpdateActionPayloadType = ReadonlyDeep<{ callLink: CallLinkType; }>; +type HandleCallLinkDeleteActionPayloadType = ReadonlyDeep<{ + roomId: string; +}>; + type HangUpActionPayloadType = ReadonlyDeep<{ conversationId: string; }>; @@ -264,6 +269,10 @@ export type HandleCallLinkUpdateType = ReadonlyDeep<{ adminKey: string | null; }>; +export type HandleCallLinkDeleteType = ReadonlyDeep<{ + roomId: string; +}>; + export type IncomingDirectCallType = ReadonlyDeep<{ conversationId: string; isVideoCall: boolean; @@ -598,6 +607,7 @@ const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE'; const GROUP_CALL_REACTIONS_RECEIVED = 'calling/GROUP_CALL_REACTIONS_RECEIVED'; const GROUP_CALL_REACTIONS_EXPIRED = 'calling/GROUP_CALL_REACTIONS_EXPIRED'; const HANDLE_CALL_LINK_UPDATE = 'calling/HANDLE_CALL_LINK_UPDATE'; +const HANDLE_CALL_LINK_DELETE = 'calling/HANDLE_CALL_LINK_DELETE'; const HANG_UP = 'calling/HANG_UP'; const INCOMING_DIRECT_CALL = 'calling/INCOMING_DIRECT_CALL'; const INCOMING_GROUP_CALL = 'calling/INCOMING_GROUP_CALL'; @@ -740,10 +750,15 @@ type GroupCallReactionsExpiredActionType = ReadonlyDeep<{ }>; type HandleCallLinkUpdateActionType = ReadonlyDeep<{ - type: 'calling/HANDLE_CALL_LINK_UPDATE'; + type: typeof HANDLE_CALL_LINK_UPDATE; payload: HandleCallLinkUpdateActionPayloadType; }>; +type HandleCallLinkDeleteActionType = ReadonlyDeep<{ + type: typeof HANDLE_CALL_LINK_DELETE; + payload: HandleCallLinkDeleteActionPayloadType; +}>; + type HangUpActionType = ReadonlyDeep<{ type: 'calling/HANG_UP'; payload: HangUpActionPayloadType; @@ -903,6 +918,7 @@ export type CallingActionType = | GroupCallReactionsReceivedActionType | GroupCallReactionsExpiredActionType | HandleCallLinkUpdateActionType + | HandleCallLinkDeleteActionType | HangUpActionType | IncomingDirectCallActionType | IncomingGroupCallActionType @@ -1466,6 +1482,19 @@ function handleCallLinkUpdate( }; } +function handleCallLinkDelete( + payload: HandleCallLinkDeleteType +): ThunkAction { + return async dispatch => { + dispatch({ + type: HANDLE_CALL_LINK_DELETE, + payload, + }); + + dispatch(reloadCallHistory()); + }; +} + function hangUpActiveCall( reason: string ): ThunkAction { @@ -1971,6 +2000,16 @@ function createCallLink( }; } +function deleteCallLink( + roomId: string +): ThunkAction { + return async dispatch => { + await DataWriter.beginDeleteCallLink(roomId); + await callLinksDeleteJobQueue.add({ source: 'deleteCallLink' }); + dispatch(handleCallLinkDelete({ roomId })); + }; +} + function updateCallLinkName( roomId: string, name: string @@ -2394,6 +2433,7 @@ export const actions = { closeNeedPermissionScreen, createCallLink, declineCall, + deleteCallLink, denyUser, getPresentingSources, groupCallAudioLevelsChange, @@ -2402,6 +2442,7 @@ export const actions = { groupCallStateChange, hangUpActiveCall, handleCallLinkUpdate, + handleCallLinkDelete, joinedAdhocCall, leaveCurrentCallAndStartCallingLobby, onOutgoingVideoCallInConversation, diff --git a/ts/state/ducks/callingHelpers.ts b/ts/state/ducks/callingHelpers.ts index 26c8b9511005..1fbc224b58d6 100644 --- a/ts/state/ducks/callingHelpers.ts +++ b/ts/state/ducks/callingHelpers.ts @@ -3,11 +3,8 @@ // Note that this file should not important any binary addons or Node.js modules // because it can be imported by storybook -import { - CallMode, - CallState, - GroupCallConnectionState, -} from '../../types/Calling'; +import { CallState, GroupCallConnectionState } from '../../types/Calling'; +import { CallMode } from '../../types/CallDisposition'; import type { AciString } from '../../types/ServiceId'; import { missingCaseError } from '../../util/missingCaseError'; import type { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index f2615aa00788..5c8a58b72890 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -70,7 +70,7 @@ import type { DraftBodyRanges, HydratedBodyRangesType, } from '../../types/BodyRange'; -import { CallMode } from '../../types/Calling'; +import { CallMode } from '../../types/CallDisposition'; import type { MediaItemType } from '../../types/MediaItem'; import type { StoryDistributionIdString } from '../../types/StoryDistributionId'; import { normalizeStoryDistributionId } from '../../types/StoryDistributionId'; diff --git a/ts/state/selectors/calling.ts b/ts/state/selectors/calling.ts index 5d7438374af9..4b974a8175dd 100644 --- a/ts/state/selectors/calling.ts +++ b/ts/state/selectors/calling.ts @@ -13,7 +13,7 @@ import type { GroupCallStateType, } from '../ducks/calling'; import { getIncomingCall as getIncomingCallHelper } from '../ducks/callingHelpers'; -import { CallMode } from '../../types/Calling'; +import { CallMode } from '../../types/CallDisposition'; import type { CallLinkType } from '../../types/CallLink'; import { getUserACI } from './user'; import { getOwn } from '../../util/getOwn'; diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index a26473437fd3..76e1abc4f40b 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -144,8 +144,7 @@ import { } from '../../util/getTitle'; import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp'; import type { CallHistorySelectorType } from './callHistory'; -import { CallMode } from '../../types/Calling'; -import { CallDirection } from '../../types/CallDisposition'; +import { CallMode, CallDirection } from '../../types/CallDisposition'; import { getCallIdFromEra } from '../../util/callDisposition'; import { LONG_MESSAGE } from '../../types/MIME'; import type { MessageRequestResponseNotificationData } from '../../components/conversation/MessageRequestResponseNotification'; diff --git a/ts/state/smart/CallLinkAddNameModal.tsx b/ts/state/smart/CallLinkAddNameModal.tsx index 026ad42ad967..506921d6ab0d 100644 --- a/ts/state/smart/CallLinkAddNameModal.tsx +++ b/ts/state/smart/CallLinkAddNameModal.tsx @@ -9,10 +9,8 @@ import { getIntl } from '../selectors/user'; import { useGlobalModalActions } from '../ducks/globalModals'; import { getCallLinkAddNameModalRoomId } from '../selectors/globalModals'; import { strictAssert } from '../../util/assert'; -import { - isCallLinkAdmin, - isCallLinksCreateEnabled, -} from '../../util/callLinks'; +import { isCallLinksCreateEnabled } from '../../util/callLinks'; +import { isCallLinkAdmin } from '../../types/CallLink'; import { CallLinkAddNameModal } from '../../components/CallLinkAddNameModal'; export const SmartCallLinkAddNameModal = memo( diff --git a/ts/state/smart/CallLinkDetails.tsx b/ts/state/smart/CallLinkDetails.tsx index 9de1dd587e0d..845040c2148b 100644 --- a/ts/state/smart/CallLinkDetails.tsx +++ b/ts/state/smart/CallLinkDetails.tsx @@ -15,21 +15,30 @@ import type { CallLinkRestrictions } from '../../types/CallLink'; export type SmartCallLinkDetailsProps = Readonly<{ roomId: string; callHistoryGroup: CallHistoryGroup; + onClose: () => void; }>; export const SmartCallLinkDetails = memo(function SmartCallLinkDetails({ roomId, callHistoryGroup, + onClose, }: SmartCallLinkDetailsProps) { const i18n = useSelector(getIntl); const callLinkSelector = useSelector(getCallLinkSelector); - const { startCallLinkLobby, updateCallLinkRestrictions } = + + const { deleteCallLink, startCallLinkLobby, updateCallLinkRestrictions } = useCallingActions(); const { toggleCallLinkAddNameModal, showShareCallLinkViaSignal } = useGlobalModalActions(); const callLink = callLinkSelector(roomId); + const handleDeleteCallLink = useCallback(() => { + strictAssert(callLink != null, 'callLink not found'); + deleteCallLink(callLink.roomId); + onClose(); + }, [callLink, deleteCallLink, onClose]); + const handleOpenCallLinkAddNameModal = useCallback(() => { toggleCallLinkAddNameModal(roomId); }, [roomId, toggleCallLinkAddNameModal]); @@ -61,6 +70,7 @@ export const SmartCallLinkDetails = memo(function SmartCallLinkDetails({ callHistoryGroup={callHistoryGroup} callLink={callLink} i18n={i18n} + onDeleteCallLink={handleDeleteCallLink} onOpenCallLinkAddNameModal={handleOpenCallLinkAddNameModal} onStartCallLinkLobby={handleStartCallLinkLobby} onShareCallLinkViaSignal={handleShareCallLinkViaSignal} diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index 5a8bcfe47eb4..b14a93040709 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -32,7 +32,8 @@ import type { ConversationsByDemuxIdType, GroupCallRemoteParticipantType, } from '../../types/Calling'; -import { CallMode, CallState } from '../../types/Calling'; +import { CallState } from '../../types/Calling'; +import { CallMode } from '../../types/CallDisposition'; import type { AciString } from '../../types/ServiceId'; import { strictAssert } from '../../util/assert'; import { callLinkToConversation } from '../../util/callLinks'; diff --git a/ts/state/smart/CallsTab.tsx b/ts/state/smart/CallsTab.tsx index 3baf10c8d398..db1f0675953f 100644 --- a/ts/state/smart/CallsTab.tsx +++ b/ts/state/smart/CallsTab.tsx @@ -107,10 +107,15 @@ function getCallHistoryFilter({ function renderCallLinkDetails( roomId: string, - callHistoryGroup: CallHistoryGroup + callHistoryGroup: CallHistoryGroup, + onClose: () => void ): JSX.Element { return ( - + ); } @@ -167,11 +172,8 @@ export const SmartCallsTab = memo(function SmartCallsTab() { startCallLinkLobbyByRoomId, togglePip, } = useCallingActions(); - const { - clearAllCallHistory: clearCallHistory, - markCallHistoryRead, - markCallsTabViewed, - } = useCallHistoryActions(); + const { clearAllCallHistory, markCallHistoryRead, markCallsTabViewed } = + useCallHistoryActions(); const { toggleCallLinkEditModal, toggleConfirmLeaveCallModal } = useGlobalModalActions(); @@ -244,7 +246,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() { hasPendingUpdate={hasPendingUpdate} i18n={i18n} navTabsCollapsed={navTabsCollapsed} - onClearCallHistory={clearCallHistory} + onClearCallHistory={clearAllCallHistory} onMarkCallHistoryRead={markCallHistoryRead} onToggleNavTabsCollapse={toggleNavTabsCollapse} onCreateCallLink={handleCreateCallLink} diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index bb870b2760ab..e7ef23a9f775 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -10,7 +10,7 @@ import { } from '../../components/conversation/ConversationHeader'; import { getCannotLeaveBecauseYouAreLastAdmin } from '../../components/conversation/conversation-details/ConversationDetails'; import { useMinimalConversation } from '../../hooks/useMinimalConversation'; -import { CallMode } from '../../types/Calling'; +import { CallMode } from '../../types/CallDisposition'; import { PanelType } from '../../types/Panels'; import { StoryViewModeType } from '../../types/Stories'; import { strictAssert } from '../../util/assert'; diff --git a/ts/test-both/helpers/getFakeCallHistoryGroup.ts b/ts/test-both/helpers/getFakeCallHistoryGroup.ts new file mode 100644 index 000000000000..28d20eb2646c --- /dev/null +++ b/ts/test-both/helpers/getFakeCallHistoryGroup.ts @@ -0,0 +1,47 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { CallHistoryGroup } from '../../types/CallDisposition'; +import { + AdhocCallStatus, + CallDirection, + CallType, + DirectCallStatus, + CallMode, +} from '../../types/CallDisposition'; +import { DurationInSeconds } from '../../util/durations'; + +function mins(n: number) { + return DurationInSeconds.toMillis(DurationInSeconds.fromMinutes(n)); +} + +export function getFakeCallHistoryGroup( + overrides: Partial = {} +): CallHistoryGroup { + return { + peerId: '', + mode: CallMode.Direct, + type: CallType.Video, + direction: CallDirection.Incoming, + status: DirectCallStatus.Accepted, + timestamp: Date.now(), + children: [ + { callId: '123', timestamp: Date.now() }, + { callId: '122', timestamp: Date.now() - mins(30) }, + { callId: '121', timestamp: Date.now() - mins(45) }, + { callId: '121', timestamp: Date.now() - mins(60) }, + ], + ...overrides, + }; +} + +export function getFakeCallLinkHistoryGroup( + overrides: Partial = {} +): CallHistoryGroup { + return getFakeCallHistoryGroup({ + mode: CallMode.Adhoc, + type: CallType.Adhoc, + status: AdhocCallStatus.Joined, + ...overrides, + }); +} diff --git a/ts/test-both/util/callLinks_test.ts b/ts/test-both/util/callLinks_test.ts index 130a9a458852..f43885bb5094 100644 --- a/ts/test-both/util/callLinks_test.ts +++ b/ts/test-both/util/callLinks_test.ts @@ -3,7 +3,10 @@ import { assert } from 'chai'; -import { callLinkToRecord, callLinkFromRecord } from '../../util/callLinks'; +import { + callLinkToRecord, + callLinkFromRecord, +} from '../../util/callLinksRingrtc'; import { FAKE_CALL_LINK as CALL_LINK, FAKE_CALL_LINK_WITH_ADMIN_KEY as CALL_LINK_WITH_ADMIN_KEY, diff --git a/ts/test-both/util/callingNotification_test.ts b/ts/test-both/util/callingNotification_test.ts index 5e32cc5d28e7..d6bfa0eedbfd 100644 --- a/ts/test-both/util/callingNotification_test.ts +++ b/ts/test-both/util/callingNotification_test.ts @@ -3,18 +3,18 @@ import { assert } from 'chai'; import { getCallingNotificationText } from '../../util/callingNotification'; -import { CallMode } from '../../types/Calling'; +import { + CallMode, + CallDirection, + CallType, + GroupCallStatus, +} from '../../types/CallDisposition'; import { setupI18n } from '../../util/setupI18n'; import enMessages from '../../../_locales/en/messages.json'; import { getDefaultConversation, getDefaultGroup, } from '../helpers/getDefaultConversation'; -import { - CallDirection, - CallType, - GroupCallStatus, -} from '../../types/CallDisposition'; import { getPeerIdFromConversation } from '../../util/callDisposition'; import { HOUR } from '../../util/durations'; diff --git a/ts/test-electron/sql/getCallHistoryGroups_test.ts b/ts/test-electron/sql/getCallHistoryGroups_test.ts index c264c4a658b9..2c74b941e20c 100644 --- a/ts/test-electron/sql/getCallHistoryGroups_test.ts +++ b/ts/test-electron/sql/getCallHistoryGroups_test.ts @@ -6,7 +6,14 @@ import { v4 as generateUuid } from 'uuid'; import { DataReader, DataWriter } from '../../sql/Client'; -import { CallMode } from '../../types/Calling'; +import { + CallMode, + AdhocCallStatus, + CallDirection, + CallHistoryFilterStatus, + CallType, + DirectCallStatus, +} from '../../types/CallDisposition'; import { generateAci } from '../../types/ServiceId'; import type { ServiceIdString } from '../../types/ServiceId'; import type { @@ -14,13 +21,6 @@ import type { CallHistoryGroup, CallStatus, } from '../../types/CallDisposition'; -import { - AdhocCallStatus, - CallDirection, - CallHistoryFilterStatus, - CallType, - DirectCallStatus, -} from '../../types/CallDisposition'; import { strictAssert } from '../../util/assert'; import type { ConversationAttributesType } from '../../model-types'; import { diff --git a/ts/test-electron/sql/getRecentStaleRingsAndMarkOlderMissed_test.ts b/ts/test-electron/sql/getRecentStaleRingsAndMarkOlderMissed_test.ts index dd0bf72df143..43355dc64321 100644 --- a/ts/test-electron/sql/getRecentStaleRingsAndMarkOlderMissed_test.ts +++ b/ts/test-electron/sql/getRecentStaleRingsAndMarkOlderMissed_test.ts @@ -7,14 +7,14 @@ import { v4 as generateUuid } from 'uuid'; import { times } from 'lodash'; import { DataReader, DataWriter } from '../../sql/Client'; -import { CallMode } from '../../types/Calling'; -import { generateAci } from '../../types/ServiceId'; -import type { CallHistoryDetails } from '../../types/CallDisposition'; import { + CallMode, CallDirection, CallType, GroupCallStatus, } from '../../types/CallDisposition'; +import { generateAci } from '../../types/ServiceId'; +import type { CallHistoryDetails } from '../../types/CallDisposition'; import type { MaybeStaleCallHistory } from '../../sql/Server'; const { getAllCallHistory } = DataReader; diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index 16f33ef2db66..53c0b7eebc31 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -30,12 +30,12 @@ import { isAnybodyElseInGroupCall } from '../../../state/ducks/callingHelpers'; import { truncateAudioLevel } from '../../../calling/truncateAudioLevel'; import { calling as callingService } from '../../../services/calling'; import { - CallMode, CallState, CallViewMode, GroupCallConnectionState, GroupCallJoinState, } from '../../../types/Calling'; +import { CallMode } from '../../../types/CallDisposition'; import { generateAci } from '../../../types/ServiceId'; import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; import type { UnwrapPromise } from '../../../types/Util'; diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 0174ba9e4042..a6dfc0053ec3 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -36,7 +36,7 @@ import { } from '../../../state/ducks/conversations'; import { ReadStatus } from '../../../messages/MessageReadStatus'; import type { SingleServePromiseIdString } from '../../../services/singleServePromise'; -import { CallMode } from '../../../types/Calling'; +import { CallMode } from '../../../types/CallDisposition'; import { generateAci, getAciFromPrefix } from '../../../types/ServiceId'; import { generateStoryDistributionId } from '../../../types/StoryDistributionId'; import { diff --git a/ts/test-electron/state/selectors/calling_test.ts b/ts/test-electron/state/selectors/calling_test.ts index e589fdebbf6a..63b9755db97b 100644 --- a/ts/test-electron/state/selectors/calling_test.ts +++ b/ts/test-electron/state/selectors/calling_test.ts @@ -6,12 +6,12 @@ import { reducer as rootReducer } from '../../../state/reducer'; import { noopAction } from '../../../state/ducks/noop'; import { actions as userActions } from '../../../state/ducks/user'; import { - CallMode, CallState, CallViewMode, GroupCallConnectionState, GroupCallJoinState, } from '../../../types/Calling'; +import { CallMode } from '../../../types/CallDisposition'; import { generateAci } from '../../../types/ServiceId'; import { getCallsByConversation, diff --git a/ts/test-node/sql/migration_1100_test.ts b/ts/test-node/sql/migration_1100_test.ts index 7dfd03f129bf..7239b3fdf319 100644 --- a/ts/test-node/sql/migration_1100_test.ts +++ b/ts/test-node/sql/migration_1100_test.ts @@ -6,8 +6,8 @@ import { findLast } from 'lodash'; import type { WritableDB } from '../../sql/Interface'; import { markAllCallHistoryRead } from '../../sql/Server'; import { SeenStatus } from '../../MessageSeenStatus'; -import { CallMode } from '../../types/Calling'; import { + CallMode, CallDirection, CallType, DirectCallStatus, diff --git a/ts/test-node/sql/migration_89_test.ts b/ts/test-node/sql/migration_89_test.ts index 573867b8b462..5912251d13d9 100644 --- a/ts/test-node/sql/migration_89_test.ts +++ b/ts/test-node/sql/migration_89_test.ts @@ -5,15 +5,15 @@ import { assert } from 'chai'; import { v4 as generateGuid } from 'uuid'; import { jsonToObject, sql } from '../../sql/util'; -import { CallMode } from '../../types/Calling'; -import type { CallHistoryDetails } from '../../types/CallDisposition'; import { + CallMode, CallDirection, CallType, DirectCallStatus, GroupCallStatus, callHistoryDetailsSchema, } from '../../types/CallDisposition'; +import type { CallHistoryDetails } from '../../types/CallDisposition'; import type { CallHistoryDetailsFromDiskType, MessageWithCallHistoryDetails, diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 26d4a052b918..fc05909aa778 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -95,12 +95,12 @@ import { AdhocCallStatus, DirectCallStatus, GroupCallStatus, + CallMode, } from '../types/CallDisposition'; import { getBytesForPeerId, getProtoForCallHistory, } from '../util/callDisposition'; -import { CallMode } from '../types/Calling'; import { MAX_MESSAGE_COUNT } from '../util/deleteForMe.types'; export type SendMetadataType = { diff --git a/ts/types/CallDisposition.ts b/ts/types/CallDisposition.ts index 9ec61943d578..d642840895ab 100644 --- a/ts/types/CallDisposition.ts +++ b/ts/types/CallDisposition.ts @@ -3,7 +3,6 @@ import { z } from 'zod'; import Long from 'long'; -import { CallMode } from './Calling'; import type { AciString } from './ServiceId'; import { aciSchema } from './ServiceId'; import { bytesToUuid } from '../util/uuidToBytes'; @@ -11,6 +10,13 @@ import { SignalService as Proto } from '../protobuf'; import * as Bytes from '../Bytes'; import { UUID_BYTE_SIZE } from './Crypto'; +// These are strings (1) for the database (2) for Storybook. +export enum CallMode { + Direct = 'Direct', + Group = 'Group', + Adhoc = 'Adhoc', +} + export enum CallType { Audio = 'Audio', Video = 'Video', diff --git a/ts/types/CallLink.ts b/ts/types/CallLink.ts index b2b4b44bb1a3..2822ea5b1aed 100644 --- a/ts/types/CallLink.ts +++ b/ts/types/CallLink.ts @@ -98,3 +98,7 @@ export const callLinkRecordSchema = z.object({ expiration: z.number().int().nullable(), revoked: z.union([z.literal(1), z.literal(0)]), }) satisfies z.ZodType; + +export function isCallLinkAdmin(callLink: CallLinkType): boolean { + return callLink.adminKey != null; +} diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index c764a636616d..aae155163a10 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -5,17 +5,10 @@ import type { AudioDevice, Reaction as CallReaction } from '@signalapp/ringrtc'; import type { ConversationType } from '../state/ducks/conversations'; import type { AciString, ServiceIdString } from './ServiceId'; import type { CallLinkConversationType } from './CallLink'; +import type { CallMode } from './CallDisposition'; export const MAX_CALLING_REACTIONS = 5; export const CALLING_REACTIONS_LIFETIME = 4000; - -// These are strings (1) for the database (2) for Storybook. -export enum CallMode { - Direct = 'Direct', - Group = 'Group', - Adhoc = 'Adhoc', -} - // Speaker and Presentation mode have the same UI, but Presentation is only set // automatically when someone starts to present, and will revert to the previous view mode // once presentation is complete diff --git a/ts/util/callDisposition.ts b/ts/util/callDisposition.ts index 0e75f4bf7af7..99e5b9c27e78 100644 --- a/ts/util/callDisposition.ts +++ b/ts/util/callDisposition.ts @@ -18,11 +18,24 @@ import { DataReader, DataWriter } from '../sql/Client'; import { SignalService as Proto } from '../protobuf'; import { bytesToUuid, uuidToBytes } from './uuidToBytes'; import { missingCaseError } from './missingCaseError'; +import { CallEndedReason, GroupCallJoinState } from '../types/Calling'; import { - CallEndedReason, CallMode, - GroupCallJoinState, -} from '../types/Calling'; + DirectCallStatus, + GroupCallStatus, + callEventNormalizeSchema, + CallType, + CallDirection, + callEventDetailsSchema, + LocalCallEvent, + RemoteCallEvent, + callHistoryDetailsSchema, + callDetailsSchema, + AdhocCallStatus, + CallStatusValue, + callLogEventNormalizeSchema, + CallLogEvent, +} from '../types/CallDisposition'; import type { AciString } from '../types/ServiceId'; import { isAciString } from './isAciString'; import { isMe } from './whatTypeOfConversation'; @@ -49,26 +62,11 @@ import type { CallStatus, GroupCallMeta, } from '../types/CallDisposition'; -import { - DirectCallStatus, - GroupCallStatus, - callEventNormalizeSchema, - CallType, - CallDirection, - callEventDetailsSchema, - LocalCallEvent, - RemoteCallEvent, - callHistoryDetailsSchema, - callDetailsSchema, - AdhocCallStatus, - CallStatusValue, - callLogEventNormalizeSchema, - CallLogEvent, -} from '../types/CallDisposition'; import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationModel } from '../models/conversations'; import { drop } from './drop'; import { sendCallLinkUpdateSync } from './sendCallLinkUpdateSync'; +import { callLinksDeleteJobQueue } from '../jobs/callLinksDeleteJobQueue'; // utils // ----- @@ -1295,11 +1293,15 @@ export async function clearCallHistoryDataAndSync( `clearCallHistory: Clearing call history before (${latestCall.callId}, ${latestCall.timestamp})` ); const messageIds = await DataWriter.clearCallHistory(latestCall); + await DataWriter.beginDeleteAllCallLinks(); updateDeletedMessages(messageIds); log.info('clearCallHistory: Queueing sync message'); await singleProtoJobQueue.add( MessageSender.getClearCallHistoryMessage(latestCall) ); + await callLinksDeleteJobQueue.add({ + source: 'clearCallHistoryDataAndSync', + }); } catch (error) { log.error('clearCallHistory: Failed to clear call history', error); } diff --git a/ts/util/callLinks.ts b/ts/util/callLinks.ts index 0269c3f65513..36eb0a47dd8a 100644 --- a/ts/util/callLinks.ts +++ b/ts/util/callLinks.ts @@ -1,46 +1,20 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { CallLinkState as RingRTCCallLinkState } from '@signalapp/ringrtc'; -import { - CallLinkRootKey, - CallLinkRestrictions as RingRTCCallLinkRestrictions, -} from '@signalapp/ringrtc'; -import { Aci } from '@signalapp/libsignal-client'; -import { z } from 'zod'; import { v4 as generateUuid } from 'uuid'; import * as RemoteConfig from '../RemoteConfig'; -import type { CallLinkAuthCredentialPresentation } from './zkgroup'; -import { - CallLinkAuthCredential, - CallLinkSecretParams, - GenericServerPublicParams, -} from './zkgroup'; -import { getCheckedCallLinkAuthCredentialsForToday } from '../services/groupCredentialFetcher'; -import * as durations from './durations'; import * as Bytes from '../Bytes'; -import type { - CallLinkConversationType, - CallLinkType, - CallLinkRecord, - CallLinkStateType, -} from '../types/CallLink'; -import { - CallLinkNameMaxByteLength, - callLinkRecordSchema, - CallLinkRestrictions, - toCallLinkRestrictions, -} from '../types/CallLink'; +import type { CallLinkConversationType, CallLinkType } from '../types/CallLink'; +import { CallLinkRestrictions } from '../types/CallLink'; import type { LocalizerType } from '../types/Util'; import { isTestOrMockEnvironment } from '../environment'; import { getColorForCallLink } from './getColorForCallLink'; -import { unicodeSlice } from './unicodeSlice'; import { AdhocCallStatus, CallDirection, CallType, type CallHistoryDetails, + CallMode, } from '../types/CallDisposition'; -import { CallMode } from '../types/Calling'; export const CALL_LINK_DEFAULT_STATE = { name: '', @@ -56,49 +30,6 @@ export function isCallLinksCreateEnabled(): boolean { return RemoteConfig.getValue('desktop.calling.adhoc.create') === 'TRUE'; } -export function getRoomIdFromRootKey(rootKey: CallLinkRootKey): string { - return rootKey.deriveRoomId().toString('hex'); -} - -export function getCallLinkRootKeyFromUrlKey(key: string): Uint8Array { - // Returns `Buffer` which inherits from `Uint8Array` - return CallLinkRootKey.parse(key).bytes; -} - -export async function getCallLinkAuthCredentialPresentation( - callLinkRootKey: CallLinkRootKey -): Promise { - const credentials = getCheckedCallLinkAuthCredentialsForToday( - 'getCallLinkAuthCredentialPresentation' - ); - const todaysCredentials = credentials.today.credential; - const credential = new CallLinkAuthCredential( - Buffer.from(todaysCredentials, 'base64') - ); - - const genericServerPublicParamsBase64 = window.getGenericServerPublicParams(); - const genericServerPublicParams = new GenericServerPublicParams( - Buffer.from(genericServerPublicParamsBase64, 'base64') - ); - - const ourAci = window.textsecure.storage.user.getAci(); - if (ourAci == null) { - throw new Error('Failed to get our ACI'); - } - const userId = Aci.fromUuid(ourAci); - - const callLinkSecretParams = CallLinkSecretParams.deriveFromRootKey( - callLinkRootKey.bytes - ); - const presentation = credential.present( - userId, - credentials.today.redemptionTime / durations.SECOND, - genericServerPublicParams, - callLinkSecretParams - ); - return presentation; -} - export function callLinkToConversation( callLink: CallLinkType, i18n: LocalizerType @@ -131,14 +62,6 @@ export function getPlaceholderCallLinkConversation( }; } -export function toRootKeyBytes(rootKey: string): Uint8Array { - return CallLinkRootKey.parse(rootKey).bytes; -} - -export function fromRootKeyBytes(rootKey: Uint8Array): string { - return CallLinkRootKey.fromBytes(rootKey as Buffer).toString(); -} - export function toAdminKeyBytes(adminKey: string): Buffer { return Buffer.from(adminKey, 'base64'); } @@ -147,78 +70,6 @@ export function fromAdminKeyBytes(adminKey: Uint8Array): string { return Bytes.toBase64(adminKey); } -/** - * RingRTC conversions - */ - -export function callLinkStateFromRingRTC( - state: RingRTCCallLinkState -): CallLinkStateType { - return { - name: unicodeSlice(state.name, 0, CallLinkNameMaxByteLength), - restrictions: toCallLinkRestrictions(state.restrictions), - revoked: state.revoked, - expiration: state.expiration.getTime(), - }; -} - -const RingRTCCallLinkRestrictionsSchema = z.nativeEnum( - RingRTCCallLinkRestrictions -); - -export function callLinkRestrictionsToRingRTC( - restrictions: CallLinkRestrictions -): RingRTCCallLinkRestrictions { - return RingRTCCallLinkRestrictionsSchema.parse(restrictions); -} - -/** - * DB record conversions - */ - -export function callLinkToRecord(callLink: CallLinkType): CallLinkRecord { - if (callLink.rootKey == null) { - throw new Error('CallLink.callLinkToRecord: rootKey is null'); - } - - const rootKey = toRootKeyBytes(callLink.rootKey); - const adminKey = callLink.adminKey - ? toAdminKeyBytes(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 { - if (record.rootKey == null) { - throw new Error('CallLink.callLinkFromRecord: rootKey is null'); - } - - // root keys in memory are strings for simplicity - const rootKey = fromRootKeyBytes(record.rootKey); - const adminKey = record.adminKey ? fromAdminKeyBytes(record.adminKey) : null; - return { - roomId: record.roomId, - rootKey, - adminKey, - name: record.name, - restrictions: toCallLinkRestrictions(record.restrictions), - revoked: record.revoked === 1, - expiration: record.expiration, - }; -} - -export function isCallLinkAdmin(callLink: CallLinkType): boolean { - return callLink.adminKey != null; -} - export function toCallHistoryFromUnusedCallLink( callLink: CallLinkType ): CallHistoryDetails { diff --git a/ts/util/callLinksRingrtc.ts b/ts/util/callLinksRingrtc.ts new file mode 100644 index 000000000000..ff3433da547a --- /dev/null +++ b/ts/util/callLinksRingrtc.ts @@ -0,0 +1,150 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { + CallLinkRestrictions as RingRTCCallLinkRestrictions, + CallLinkRootKey, +} from '@signalapp/ringrtc'; +import type { CallLinkState as RingRTCCallLinkState } from '@signalapp/ringrtc'; +import { z } from 'zod'; +import { Aci } from '@signalapp/libsignal-client'; +import type { + CallLinkRecord, + CallLinkRestrictions, + CallLinkType, +} from '../types/CallLink'; +import { + type CallLinkStateType, + CallLinkNameMaxByteLength, + callLinkRecordSchema, + toCallLinkRestrictions, +} from '../types/CallLink'; +import { unicodeSlice } from './unicodeSlice'; +import type { CallLinkAuthCredentialPresentation } from './zkgroup'; +import { + CallLinkAuthCredential, + CallLinkSecretParams, + GenericServerPublicParams, +} from './zkgroup'; +import { getCheckedCallLinkAuthCredentialsForToday } from '../services/groupCredentialFetcher'; +import * as durations from './durations'; +import { fromAdminKeyBytes, toAdminKeyBytes } from './callLinks'; + +/** + * RingRTC conversions + */ + +export function callLinkStateFromRingRTC( + state: RingRTCCallLinkState +): CallLinkStateType { + return { + name: unicodeSlice(state.name, 0, CallLinkNameMaxByteLength), + restrictions: toCallLinkRestrictions(state.restrictions), + revoked: state.revoked, + expiration: state.expiration.getTime(), + }; +} + +const RingRTCCallLinkRestrictionsSchema = z.nativeEnum( + RingRTCCallLinkRestrictions +); + +export function callLinkRestrictionsToRingRTC( + restrictions: CallLinkRestrictions +): RingRTCCallLinkRestrictions { + return RingRTCCallLinkRestrictionsSchema.parse(restrictions); +} + +export function getRoomIdFromRootKey(rootKey: CallLinkRootKey): string { + return rootKey.deriveRoomId().toString('hex'); +} + +export function getCallLinkRootKeyFromUrlKey(key: string): Uint8Array { + // Returns `Buffer` which inherits from `Uint8Array` + return CallLinkRootKey.parse(key).bytes; +} + +export async function getCallLinkAuthCredentialPresentation( + callLinkRootKey: CallLinkRootKey +): Promise { + const credentials = getCheckedCallLinkAuthCredentialsForToday( + 'getCallLinkAuthCredentialPresentation' + ); + const todaysCredentials = credentials.today.credential; + const credential = new CallLinkAuthCredential( + Buffer.from(todaysCredentials, 'base64') + ); + + const genericServerPublicParamsBase64 = window.getGenericServerPublicParams(); + const genericServerPublicParams = new GenericServerPublicParams( + Buffer.from(genericServerPublicParamsBase64, 'base64') + ); + + const ourAci = window.textsecure.storage.user.getAci(); + if (ourAci == null) { + throw new Error('Failed to get our ACI'); + } + const userId = Aci.fromUuid(ourAci); + + const callLinkSecretParams = CallLinkSecretParams.deriveFromRootKey( + callLinkRootKey.bytes + ); + const presentation = credential.present( + userId, + credentials.today.redemptionTime / durations.SECOND, + genericServerPublicParams, + callLinkSecretParams + ); + return presentation; +} + +export function toRootKeyBytes(rootKey: string): Uint8Array { + return CallLinkRootKey.parse(rootKey).bytes; +} + +export function fromRootKeyBytes(rootKey: Uint8Array): string { + return CallLinkRootKey.fromBytes(rootKey as Buffer).toString(); +} + +/** + * DB record conversions + */ + +export function callLinkFromRecord(record: CallLinkRecord): CallLinkType { + if (record.rootKey == null) { + throw new Error('CallLink.callLinkFromRecord: rootKey is null'); + } + + // root keys in memory are strings for simplicity + const rootKey = fromRootKeyBytes(record.rootKey); + const adminKey = record.adminKey ? fromAdminKeyBytes(record.adminKey) : null; + return { + roomId: record.roomId, + rootKey, + adminKey, + name: record.name, + restrictions: toCallLinkRestrictions(record.restrictions), + revoked: record.revoked === 1, + expiration: record.expiration, + }; +} + +export function callLinkToRecord(callLink: CallLinkType): CallLinkRecord { + if (callLink.rootKey == null) { + throw new Error('CallLink.callLinkToRecord: rootKey is null'); + } + + const rootKey = toRootKeyBytes(callLink.rootKey); + const adminKey = callLink.adminKey + ? toAdminKeyBytes(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, + }); +} diff --git a/ts/util/callingIsReconnecting.ts b/ts/util/callingIsReconnecting.ts index 3ee7fe8a0174..e172bbd238eb 100644 --- a/ts/util/callingIsReconnecting.ts +++ b/ts/util/callingIsReconnecting.ts @@ -1,11 +1,8 @@ // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { - CallMode, - CallState, - GroupCallConnectionState, -} from '../types/Calling'; +import { CallState, GroupCallConnectionState } from '../types/Calling'; +import { CallMode } from '../types/CallDisposition'; import type { ActiveCallType } from '../types/Calling'; import { isGroupOrAdhocActiveCall } from './isGroupOrAdhocCall'; diff --git a/ts/util/callingNotification.ts b/ts/util/callingNotification.ts index cdfa14e962d1..508110bd43bc 100644 --- a/ts/util/callingNotification.ts +++ b/ts/util/callingNotification.ts @@ -2,16 +2,16 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { LocalizerType } from '../types/Util'; -import { CallMode } from '../types/Calling'; -import { missingCaseError } from './missingCaseError'; -import type { CallStatus } from '../types/CallDisposition'; import { + CallMode, CallDirection, DirectCallStatus, type CallHistoryDetails, CallType, GroupCallStatus, } from '../types/CallDisposition'; +import { missingCaseError } from './missingCaseError'; +import type { CallStatus } from '../types/CallDisposition'; import type { ConversationType } from '../state/ducks/conversations'; import { strictAssert } from './assert'; import { isMoreRecentThan } from './timestamp'; diff --git a/ts/util/isGroupOrAdhocCall.ts b/ts/util/isGroupOrAdhocCall.ts index 65f82a513af8..69faa6d3a919 100644 --- a/ts/util/isGroupOrAdhocCall.ts +++ b/ts/util/isGroupOrAdhocCall.ts @@ -1,7 +1,7 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { CallMode } from '../types/Calling'; +import { CallMode } from '../types/CallDisposition'; import type { ActiveCallType, ActiveGroupCallType } from '../types/Calling'; import type { DirectCallStateType, diff --git a/ts/util/onCallEventSync.ts b/ts/util/onCallEventSync.ts index db47af3f632d..9df29696a2d7 100644 --- a/ts/util/onCallEventSync.ts +++ b/ts/util/onCallEventSync.ts @@ -7,7 +7,7 @@ import { peerIdToLog, updateCallHistoryFromRemoteEvent, } from './callDisposition'; -import { CallMode } from '../types/Calling'; +import { CallMode } from '../types/CallDisposition'; export async function onCallEventSync( syncEvent: CallEventSyncEvent diff --git a/ts/util/onCallLinkUpdateSync.ts b/ts/util/onCallLinkUpdateSync.ts index 9507e5f8066d..9f0f3cbbe1b4 100644 --- a/ts/util/onCallLinkUpdateSync.ts +++ b/ts/util/onCallLinkUpdateSync.ts @@ -5,9 +5,11 @@ import { CallLinkRootKey } from '@signalapp/ringrtc'; import type { CallLinkUpdateSyncEvent } from '../textsecure/messageReceiverEvents'; import * as log from '../logging/log'; import * as Errors from '../types/errors'; -import { fromAdminKeyBytes, getRoomIdFromRootKey } from './callLinks'; +import { fromAdminKeyBytes } from './callLinks'; +import { getRoomIdFromRootKey } from './callLinksRingrtc'; import { strictAssert } from './assert'; import { CallLinkUpdateSyncType } from '../types/CallLink'; +import { DataWriter } from '../sql/Client'; export async function onCallLinkUpdateSync( syncEvent: CallLinkUpdateSyncEvent @@ -46,8 +48,9 @@ export async function onCallLinkUpdateSync( adminKey: adminKeyString, }); } else if (type === CallLinkUpdateSyncType.Delete) { - // TODO: DESKTOP-6951 - log.warn(`${logId}: Deleting call links is not supported`); + log.info(`${logId}: Deleting call link record ${roomId}`); + await DataWriter.deleteCallLinkFromSync(roomId); + window.reduxActions.calling.handleCallLinkDelete({ roomId }); } confirm(); diff --git a/ts/util/sendCallLinkUpdateSync.ts b/ts/util/sendCallLinkUpdateSync.ts index 9c662628ac08..2d544fe883f7 100644 --- a/ts/util/sendCallLinkUpdateSync.ts +++ b/ts/util/sendCallLinkUpdateSync.ts @@ -7,7 +7,8 @@ import * as Errors from '../types/errors'; import { SignalService as Proto } from '../protobuf'; import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; import MessageSender from '../textsecure/SendMessage'; -import { toAdminKeyBytes, toRootKeyBytes } from './callLinks'; +import { toAdminKeyBytes } from './callLinks'; +import { toRootKeyBytes } from './callLinksRingrtc'; export type sendCallLinkUpdateSyncCallLinkType = { rootKey: string; diff --git a/ts/util/signalRoutes.ts b/ts/util/signalRoutes.ts index 06fbbece1a28..3580589edbde 100644 --- a/ts/util/signalRoutes.ts +++ b/ts/util/signalRoutes.ts @@ -386,11 +386,11 @@ export const linkCallRoute = _route('linkCall', { }, toWebUrl(args) { const params = new URLSearchParams({ key: args.key }); - return new URL(`https://signal.link/call#${params.toString()}`); + return new URL(`https://signal.link/call/#${params.toString()}`); }, toAppUrl(args) { const params = new URLSearchParams({ key: args.key }); - return new URL(`sgnl://signal.link/call#${params.toString()}`); + return new URL(`sgnl://signal.link/call/#${params.toString()}`); }, });