From 96b3413febf007981579afb399ee6ddfa5300dc4 Mon Sep 17 00:00:00 2001 From: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> Date: Thu, 22 Feb 2024 13:19:50 -0800 Subject: [PATCH] Basic call link join support --- _locales/en/messages.json | 64 ++ app/main.ts | 5 + config/default.json | 3 +- config/production.json | 1 + images/icons/v3/video/video-display-bold.svg | 1 + package.json | 2 +- protos/SignalService.proto | 7 +- stylesheets/_modules.scss | 42 ++ stylesheets/components/Avatar.scss | 4 + .../components/CallingAdhocCallInfo.scss | 83 +++ stylesheets/components/CallingLobby.scss | 13 + stylesheets/manifest.scss | 1 + ts/RemoteConfig.ts | 1 + ts/components/Avatar.tsx | 2 +- ts/components/CallManager.stories.tsx | 2 + ts/components/CallManager.tsx | 162 +++-- ts/components/CallParticipantCount.tsx | 27 +- ts/components/CallScreen.tsx | 21 +- .../CallingAdhocCallInfo.stories.tsx | 136 ++++ ts/components/CallingAdhocCallInfo.tsx | 162 +++++ ts/components/CallingLobby.stories.tsx | 40 +- ts/components/CallingLobby.tsx | 36 +- ts/components/CallingPipRemoteVideo.tsx | 16 +- ts/components/CallingPreCallInfo.tsx | 4 +- ts/components/CallingToastManager.tsx | 8 +- ts/components/ErrorModal.stories.tsx | 10 +- ts/components/ErrorModal.tsx | 9 +- .../ForwardMessagesModal.stories.tsx | 1 + ts/components/ForwardMessagesModal.tsx | 1 + ts/components/GlobalModalContainer.tsx | 5 +- ts/components/StoryCreator.stories.tsx | 1 + ts/components/TextAttachment.stories.tsx | 8 + ts/components/ToastManager.stories.tsx | 2 + ts/components/ToastManager.tsx | 8 + .../conversation/CallingNotification.tsx | 3 + ts/components/conversation/Message.tsx | 29 + .../StagedLinkPreview.stories.tsx | 1 + .../conversation/TimelineMessage.stories.tsx | 28 + ts/groups.ts | 4 +- ts/services/LinkPreview.ts | 37 + ts/services/calling.ts | 655 ++++++++++++------ ts/services/groupCredentialFetcher.ts | 204 ++++-- ts/state/ducks/calling.ts | 646 +++++++++++++---- ts/state/ducks/callingHelpers.ts | 3 + ts/state/ducks/globalModals.ts | 8 +- ts/state/selectors/calling.ts | 62 +- ts/state/selectors/message.ts | 3 +- ts/state/smart/CallManager.tsx | 53 +- ts/state/smart/ConversationHeader.tsx | 6 +- ts/state/smart/GlobalModalContainer.tsx | 14 +- .../shouldUseFullSizeLinkPreviewImage_test.ts | 1 + ts/test-both/state/ducks/linkPreviews_test.ts | 1 + ts/test-both/util/privacy_test.ts | 28 + ts/test-electron/models/conversations_test.ts | 2 + ts/test-electron/state/ducks/calling_test.ts | 23 + ts/test-electron/state/ducks/stories_test.ts | 1 + .../state/selectors/calling_test.ts | 1 + ts/textsecure/WebAPI.ts | 11 +- ts/types/CallLink.ts | 33 + ts/types/Calling.ts | 10 +- ts/types/LinkPreview.ts | 11 +- ts/types/RendererConfig.ts | 1 + ts/types/Storage.d.ts | 1 + ts/types/Toast.tsx | 2 + ts/types/message/LinkPreviews.ts | 1 + ts/util/callDisposition.ts | 3 + ts/util/callLinkRootKeyToUrl.ts | 10 + ts/util/callLinks.ts | 73 ++ ts/util/callingNotification.ts | 4 + ts/util/isAdhocCallingEnabled.ts | 8 + ts/util/isGroupOrAdhocCall.ts | 31 + ts/util/privacy.ts | 23 +- ts/window.d.ts | 1 + ts/windows/main/phase1-ipc.ts | 15 + yarn.lock | 8 +- 75 files changed, 2438 insertions(+), 509 deletions(-) create mode 100644 images/icons/v3/video/video-display-bold.svg create mode 100644 stylesheets/components/CallingAdhocCallInfo.scss create mode 100644 ts/components/CallingAdhocCallInfo.stories.tsx create mode 100644 ts/components/CallingAdhocCallInfo.tsx create mode 100644 ts/types/CallLink.ts create mode 100644 ts/util/callLinkRootKeyToUrl.ts create mode 100644 ts/util/callLinks.ts create mode 100644 ts/util/isAdhocCallingEnabled.ts create mode 100644 ts/util/isGroupOrAdhocCall.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 52109bea2d00..1193893665a5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1776,6 +1776,54 @@ "messageformat": "Call is full", "description": "Text in the call lobby when you can't join because the call is full" }, + "icu:calling__cant-join": { + "messageformat": "Can't join call", + "description": "Modal dialog title when you can't join a call" + }, + "icu:calling__dialog-already-in-call": { + "messageformat": "You are already in a call.", + "description": "Error message when clicking a call link to join a call, but you're in a call already." + }, + "icu:calling__call-link-connection-issues": { + "messageformat": "Could not fetch call link information. Please check your network connection and try again.", + "description": "Error message when unable to fetch call link info from server." + }, + "icu:calling__call-link-copied": { + "messageformat": "Call link copied.", + "description": "Toast shown when a call link is copied to the clipboard." + }, + "icu:calling__call-link-no-longer-valid": { + "messageformat": "This call link is no longer valid.", + "description": "Error message when a call link has been revoked or expires." + }, + "icu:calling__call-link-default-title": { + "messageformat": "Signal Call", + "description": "Default title for Signal call links." + }, + "icu:calling__join-request-denied": { + "messageformat": "Your request to join this call has been denied.", + "description": "Error message when a request to join a call link call was rejected by a call admin." + }, + "icu:calling__join-request-denied-title": { + "messageformat": "Join request denied", + "description": "Title of error when a request to join a call link call was rejected by a call admin." + }, + "icu:calling__removed-from-call": { + "messageformat": "Someone has removed you from the call.", + "description": "Error message when in a call then a call admin removes you." + }, + "icu:calling__removed-from-call-title": { + "messageformat": "Removed from call", + "description": "Title of error when in a call then a call admin removes you." + }, + "icu:CallingLobby__CallLinkNotice": { + "messageformat": "Anyone who joins this call via the link will see your name and photo.", + "description": "Toast shown in call lobby before joining a call link call to inform user that profile info will be shared." + }, + "icu:CallingLobby__CallLinkNotice--phone-sharing": { + "messageformat": "Anyone who joins this call via the link will see your name, photo, and phone number.", + "description": "Toast shown in call lobby before joining a call link call to inform user that profile info will be shared, when the phone number sharing account setting is enabled." + }, "icu:CallingLobbyJoinButton--join": { "messageformat": "Join", "description": "Button label in the call lobby for joining a call" @@ -2691,6 +2739,10 @@ "messageformat": "Some attachments are too large to display.", "description": "Shown in a message bubble if any attachments are left on message when too-large attachments are dropped" }, + "icu:message--call-link-description": { + "messageformat": "Use this link to join a Signal call", + "description": "Shown in message previews for Signal call links." + }, "icu:donation--missing": { "messageformat": "Unable to fetch donation details", "description": "Aria label for donation when we can't fetch the details." @@ -3663,6 +3715,14 @@ "messageformat": "Audio call", "description": "Shown in the call lobby for a direct 1:1 call when the caller's video is disabled, to specify that an audio call will be placed when clicking the Start button." }, + "icu:CallControls__InfoDisplay--adhoc-call": { + "messageformat": "Call link", + "description": "Shown in the call lobby for a call link." + }, + "icu:CallControls__InfoDisplay--adhoc-join-request-pending": { + "messageformat": "Waiting to be let in", + "description": "Shown in the call lobby for call link calls when requesting to join a call which requires admin approval." + }, "icu:CallControls__JoinLeaveButton--hangup-1-1": { "messageformat": "End", "description": "Title for the hangup button for a direct 1:1 call with only 2 participants." @@ -3803,6 +3863,10 @@ "messageformat": "A window", "description": "Title for the select your screen sharing sources modal" }, + "icu:CallingAdhocCallInfo__CopyLink": { + "messageformat": "Copy link", + "description": "Menu item in the in-call info popup for call link calls. The action is to add the call link to the clipboard." + }, "icu:callingDeviceSelection__label--video": { "messageformat": "Video", "description": "Label for video input selector" diff --git a/app/main.ts b/app/main.ts index 4cd154d89bac..31ee1ced52ab 100644 --- a/app/main.ts +++ b/app/main.ts @@ -2469,6 +2469,7 @@ ipc.on('get-config', async event => { registrationChallengeUrl: config.get('registrationChallengeUrl'), serverPublicParams: config.get('serverPublicParams'), serverTrustRoot: config.get('serverTrustRoot'), + genericServerPublicParams: config.get('genericServerPublicParams'), theme, appStartInitialSpellcheckSetting, @@ -2619,6 +2620,10 @@ function handleSignalRoute(route: ParsedSignalRoute) { mainWindow.webContents.send('start-call-lobby', { conversationId: route.args.conversationId, }); + } else if (route.key === 'linkCall') { + mainWindow.webContents.send('start-call-link', { + key: route.args.key, + }); } else if (route.key === 'showWindow') { mainWindow.webContents.send('show-window'); } else if (route.key === 'setIsPresenting') { diff --git a/config/default.json b/config/default.json index bd90d5488fc3..4767ebc76181 100644 --- a/config/default.json +++ b/config/default.json @@ -24,5 +24,6 @@ "buildExpiration": 0, "certificateAuthority": "-----BEGIN CERTIFICATE-----\nMIIF2zCCA8OgAwIBAgIUAMHz4g60cIDBpPr1gyZ/JDaaPpcwDQYJKoZIhvcNAQEL\nBQAwdTELMAkGA1UEBhMCVVMxEzARBgNVBAgTCkNhbGlmb3JuaWExFjAUBgNVBAcT\nDU1vdW50YWluIFZpZXcxHjAcBgNVBAoTFVNpZ25hbCBNZXNzZW5nZXIsIExMQzEZ\nMBcGA1UEAxMQU2lnbmFsIE1lc3NlbmdlcjAeFw0yMjAxMjYwMDQ1NTFaFw0zMjAx\nMjQwMDQ1NTBaMHUxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYw\nFAYDVQQHEw1Nb3VudGFpbiBWaWV3MR4wHAYDVQQKExVTaWduYWwgTWVzc2VuZ2Vy\nLCBMTEMxGTAXBgNVBAMTEFNpZ25hbCBNZXNzZW5nZXIwggIiMA0GCSqGSIb3DQEB\nAQUAA4ICDwAwggIKAoICAQDEecifxMHHlDhxbERVdErOhGsLO08PUdNkATjZ1kT5\n1uPf5JPiRbus9F4J/GgBQ4ANSAjIDZuFY0WOvG/i0qvxthpW70ocp8IjkiWTNiA8\n1zQNQdCiWbGDU4B1sLi2o4JgJMweSkQFiyDynqWgHpw+KmvytCzRWnvrrptIfE4G\nPxNOsAtXFbVH++8JO42IaKRVlbfpe/lUHbjiYmIpQroZPGPY4Oql8KM3o39ObPnT\no1WoM4moyOOZpU3lV1awftvWBx1sbTBL02sQWfHRxgNVF+Pj0fdDMMFdFJobArrL\nVfK2Ua+dYN4pV5XIxzVarSRW73CXqQ+2qloPW/ynpa3gRtYeGWV4jl7eD0PmeHpK\nOY78idP4H1jfAv0TAVeKpuB5ZFZ2szcySxrQa8d7FIf0kNJe9gIRjbQ+XrvnN+ZZ\nvj6d+8uBJq8LfQaFhlVfI0/aIdggScapR7w8oLpvdflUWqcTLeXVNLVrg15cEDwd\nlV8PVscT/KT0bfNzKI80qBq8LyRmauAqP0CDjayYGb2UAabnhefgmRY6aBE5mXxd\nbyAEzzCS3vDxjeTD8v8nbDq+SD6lJi0i7jgwEfNDhe9XK50baK15Udc8Cr/ZlhGM\njNmWqBd0jIpaZm1rzWA0k4VwXtDwpBXSz8oBFshiXs3FD6jHY2IhOR3ppbyd4qRU\npwIDAQABo2MwYTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV\nHQ4EFgQUtfNLxuXWS9DlgGuMUMNnW7yx83EwHwYDVR0jBBgwFoAUtfNLxuXWS9Dl\ngGuMUMNnW7yx83EwDQYJKoZIhvcNAQELBQADggIBABUeiryS0qjykBN75aoHO9bV\nPrrX+DSJIB9V2YzkFVyh/io65QJMG8naWVGOSpVRwUwhZVKh3JVp/miPgzTGAo7z\nhrDIoXc+ih7orAMb19qol/2Ha8OZLa75LojJNRbZoCR5C+gM8C+spMLjFf9k3JVx\ndajhtRUcR0zYhwsBS7qZ5Me0d6gRXD0ZiSbadMMxSw6KfKk3ePmPb9gX+MRTS63c\n8mLzVYB/3fe/bkpq4RUwzUHvoZf+SUD7NzSQRQQMfvAHlxk11TVNxScYPtxXDyiy\n3Cssl9gWrrWqQ/omuHipoH62J7h8KAYbr6oEIq+Czuenc3eCIBGBBfvCpuFOgckA\nXXE4MlBasEU0MO66GrTCgMt9bAmSw3TrRP12+ZUFxYNtqWluRU8JWQ4FCCPcz9pg\nMRBOgn4lTxDZG+I47OKNuSRjFEP94cdgxd3H/5BK7WHUz1tAGQ4BgepSXgmjzifF\nT5FVTDTl3ZnWUVBXiHYtbOBgLiSIkbqGMCLtrBtFIeQ7RRTb3L+IE9R0UB0cJB3A\nXbf1lVkOcmrdu2h8A32aCwtr5S1fBF1unlG7imPmqJfpOMWa8yIF/KWVm29JAPq8\nLrsybb0z5gg8w7ZblEuB9zOW9M3l60DXuJO6l7g+deV6P96rv2unHS8UlvWiVWDy\n9qfgAJizyy3kqM4lOwBH\n-----END CERTIFICATE-----\n", "serverPublicParams": "ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUjlENAErBme1YHmOSpU6tr6doJ66dPzVAWIanmO/5mgjNEDeK7DDqQdB1xd03HT2Qs2TxY3kCK8aAb/0iM0HQiXjxZ9HIgYhbtvGEnDKW5ILSUydqH/KBhW4Pb0jZWnqN/YgbWDKeJxnDbYcUob5ZY5Lt5ZCMKuaGUvCJRrCtuugSMaqjowCGRempsDdJEt+cMaalhZ6gczklJB/IbdwENW9KeVFPoFNFzhxWUIS5ML9riVYhAtE6JE5jX0xiHNVIIPthb458cfA8daR0nYfYAUKogQArm0iBezOO+mPk5vCM=", - "serverTrustRoot": "BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx" + "serverTrustRoot": "BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx", + "genericServerPublicParams": "AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N" } diff --git a/config/production.json b/config/production.json index 226f8dbdfcfe..dee6cc5644a6 100644 --- a/config/production.json +++ b/config/production.json @@ -14,5 +14,6 @@ "registrationChallengeUrl": "https://signalcaptchas.org/registration/generate.html", "serverPublicParams": "AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P+NameAZYOD12qRkxosQQP5uux6B2nRyZ7sAV54DgFyLiRcq1FvwKw2EPQdk4HDoePrO/RNUbyNddnM/mMgj4FW65xCoT1LmjrIjsv/Ggdlx46ueczhMgtBunx1/w8k8V+l8LVZ8gAT6wkU5J+DPQalQguMg12Jzug3q4TbdHiGCmD9EunCwOmsLuLJkz6EcSYXtrlDEnAM+hicw7iergYLLlMXpfTdGxJCWJmP4zqUFeTTmsmhsjGBt7NiEB/9pFFEB3pSbf4iiUukw63Eo8Aqnf4iwob6X1QviCWuc8t0I=", "serverTrustRoot": "BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF", + "genericServerPublicParams": "AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN", "updatesEnabled": true } diff --git a/images/icons/v3/video/video-display-bold.svg b/images/icons/v3/video/video-display-bold.svg new file mode 100644 index 000000000000..b2af68f2bbf5 --- /dev/null +++ b/images/icons/v3/video/video-display-bold.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package.json b/package.json index 7b1ceb66a4df..62fcddbe6198 100644 --- a/package.json +++ b/package.json @@ -201,7 +201,7 @@ "@electron/notarize": "2.1.0", "@formatjs/intl": "2.6.7", "@mixer/parallel-prettier": "2.0.3", - "@signalapp/mock-server": "5.0.1", + "@signalapp/mock-server": "5.1.0", "@storybook/addon-a11y": "7.4.5", "@storybook/addon-actions": "7.4.5", "@storybook/addon-controls": "7.4.5", diff --git a/protos/SignalService.proto b/protos/SignalService.proto index e7943f12f45c..c3aeb0c148b2 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -606,6 +606,11 @@ message SyncMessage { optional Event event = 6; } + message CallLinkUpdate { + optional bytes rootKey = 1; + optional bytes adminPasskey = 2; + } + message CallLogEvent { enum Type { CLEAR = 0; @@ -634,7 +639,7 @@ message SyncMessage { reserved 17; // pniIdentity optional PniChangeNumber pniChangeNumber = 18; optional CallEvent callEvent = 19; - reserved 20; // callLinkUpdate + optional CallLinkUpdate callLinkUpdate = 20; optional CallLogEvent callLogEvent = 21; } diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 253a663462a2..cd977264e181 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -7528,3 +7528,45 @@ button.module-image__border-overlay:focus { } } } + +.module-message__action { + @include button-reset(); + margin-top: 5px; + padding: 10px; + text-align: center; + width: 100%; + @include font-body-1-bold(); + border-top: 1px solid; +} +.module-message__action--outgoing { + border-top-color: rgba(255, 255, 255, 0.5); +} +.module-message__action--incoming { + @include light-theme { + border-top-color: rgba(0, 0, 0, 0.25); + } + @include dark-theme { + border-top-color: rgba(255, 255, 255, 0.25); + } +} + +.module-message__link-preview__call-link-icon { + display: flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + margin-inline-end: 12px; + background: #e5e5fe; + @include rounded-corners(); + &::before { + content: ''; + display: block; + width: 36px; + height: 36px; + @include color-svg( + '../images/icons/v3/video/video-display-bold.svg', + #5151f6 + ); + } +} diff --git a/stylesheets/components/Avatar.scss b/stylesheets/components/Avatar.scss index 576d1feaef42..d568bc7468ba 100644 --- a/stylesheets/components/Avatar.scss +++ b/stylesheets/components/Avatar.scss @@ -91,6 +91,10 @@ background-color: WindowText; } + &--callLink { + -webkit-mask-image: url('../images/icons/v3/video/video-display-bold.svg'); + } + &--direct { -webkit-mask-image: url('../images/icons/v3/person/person.svg'); } diff --git a/stylesheets/components/CallingAdhocCallInfo.scss b/stylesheets/components/CallingAdhocCallInfo.scss new file mode 100644 index 000000000000..7d41c097084f --- /dev/null +++ b/stylesheets/components/CallingAdhocCallInfo.scss @@ -0,0 +1,83 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.CallingAdhocCallInfo { + width: 100%; + height: auto; + margin-block-end: auto; + padding-block-end: 16px; + overflow: auto; +} + +.CallingAdhocCallInfo__width-container { + display: flex; + flex-direction: column; + width: 360px; + height: auto; + padding-block: 1px; + padding-inline: 1px; + margin-block-end: 102px; + margin-inline-start: 90px; + overflow: hidden; +} + +.CallingAdhocCallInfo__overlay { + background: transparent; +} + +.CallingAdhocCallInfo__overlay-container { + flex-direction: column; + padding: 0; + justify-content: flex-end; + align-items: start; +} + +.CallingAdhocCallInfo__Overlay { + align-items: start; +} + +.CallingAdhocCallInfo__MenuItem { + @include button-reset; + @include font-body-2; + display: flex; + padding-block: 8px; + padding-inline: 10px 2px; + align-items: center; +} + +.CallingAdhocCallInfo__MenuItemIcon { + background: $color-gray-65; + display: flex; + width: 32px; + height: 32px; + margin-inline-end: 8px; + border-radius: 32px; + align-items: center; + justify-content: center; +} + +.CallingAdhocCallInfo__MenuItemIcon:before { + content: ''; + display: inline-block; + height: 18px; + width: 18px; +} + +.CallingAdhocCallInfo__MenuItemIcon--copy-link:before { + @include color-svg('../images/icons/v3/copy/copy.svg', $color-gray-15); +} + +.CallingAdhocCallInfo__MenuItemIcon--share-via-signal:before { + @include color-svg('../images/icons/v3/forward/forward.svg', $color-gray-15); +} + +.CallingAdhocCallInfo__MenuItemText { + display: flex; +} + +.CallingAdhocCallInfo__Divider { + display: flex; + margin-block: 16px; + margin-inline: 10px; + border: 1px solid $color-gray-65; +} diff --git a/stylesheets/components/CallingLobby.scss b/stylesheets/components/CallingLobby.scss index 0c7dcdfee186..24c8ece364cf 100644 --- a/stylesheets/components/CallingLobby.scss +++ b/stylesheets/components/CallingLobby.scss @@ -36,6 +36,19 @@ } } +.CallingLobby__CallLinkNotice { + @include font-caption; + display: flex; + padding-block: 12px; + padding-inline: 18px; + margin-block-end: 32px; + width: 340px; + background: $color-black-alpha-60; + color: $color-white; + border-radius: 10px; + text-align: center; +} + .CallingLobby__Footer { display: flex; width: 100%; diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 9fbf763fe92d..9546dd229d8a 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -38,6 +38,7 @@ @import './components/BetterAvatarBubble.scss'; @import './components/Button.scss'; @import './components/CallsTab.scss'; +@import './components/CallingAdhocCallInfo.scss'; @import './components/CallingAudioIndicator.scss'; @import './components/CallingStatusIndicator.scss'; @import './components/CallingButton.scss'; diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index 1eeccffd4758..a4c2312d8fb1 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -15,6 +15,7 @@ import { HashType } from './types/Crypto'; import { getCountryCode } from './types/PhoneNumber'; export type ConfigKeyType = + | 'desktop.calling.adhoc' | 'desktop.clientExpiration' | 'desktop.groupMultiTypingIndicators' | 'desktop.internalUser' diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index 5537042bbb94..178a9682e13a 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -57,7 +57,7 @@ export type Props = { loading?: boolean; acceptedMessageRequest: boolean; - conversationType: 'group' | 'direct'; + conversationType: 'group' | 'direct' | 'callLink'; isMe: boolean; noteToSelf?: boolean; phoneNumber?: string; diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index b00cf064b8d5..98965ed25d70 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -74,6 +74,7 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ hangUpActiveCall: action('hang-up-active-call'), i18n, incomingCall: null, + callLink: undefined, isGroupCallRaiseHandEnabled: true, isGroupCallReactionsEnabled: true, keyChangeOk: action('key-change-ok'), @@ -101,6 +102,7 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ setPresenting: action('toggle-presenting'), setRendererCanvas: action('set-renderer-canvas'), setOutgoingRing: action('set-outgoing-ring'), + showToast: action('show-toast'), startCall: action('start-call'), stopRingtone: action('stop-ringtone'), switchToPresentationView: action('switch-to-presentation-view'), diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 3c3674dede52..f51315a3bfa4 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -15,6 +15,7 @@ import type { SafetyNumberProps } from './SafetyNumberChangeDialog'; import { SafetyNumberChangeDialog } from './SafetyNumberChangeDialog'; import type { ActiveCallType, + CallingConversationType, CallViewMode, GroupCallVideoRequest, PresentedSource, @@ -43,12 +44,19 @@ import type { SetRendererCanvasType, StartCallType, } from '../state/ducks/calling'; +import { CallLinkRestrictions } from '../types/CallLink'; +import type { CallLinkType } from '../types/CallLink'; import type { LocalizerType, ThemeType } from '../types/Util'; import { missingCaseError } from '../util/missingCaseError'; import { CallingToastProvider } from './CallingToast'; import type { SmartReactionPicker } from '../state/smart/ReactionPicker'; import type { Props as ReactionPickerProps } from './conversation/ReactionPicker'; import * as log from '../logging/log'; +import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall'; +import { CallingAdhocCallInfo } from './CallingAdhocCallInfo'; +import { callLinkRootKeyToUrl } from '../util/callLinkRootKeyToUrl'; +import { ToastType } from '../types/Toast'; +import type { ShowToastAction } from '../state/ducks/toast'; const GROUP_CALL_RING_DURATION = 60 * 1000; @@ -73,6 +81,7 @@ export type GroupIncomingCall = Readonly<{ export type PropsType = { activeCall?: ActiveCallType; availableCameras: Array; + callLink: CallLinkType | undefined; cancelCall: (_: CancelCallType) => void; changeCallView: (mode: CallViewMode) => void; closeNeedPermissionScreen: () => void; @@ -116,6 +125,7 @@ export type PropsType = { setOutgoingRing: (_: boolean) => void; setPresenting: (_?: PresentedSource) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; + showToast: ShowToastAction; stopRingtone: () => unknown; switchToPresentationView: () => void; switchFromPresentationView: () => void; @@ -135,6 +145,7 @@ type ActiveCallManagerPropsType = PropsType & { function ActiveCallManager({ activeCall, availableCameras, + callLink, cancelCall, changeCallView, closeNeedPermissionScreen, @@ -161,6 +172,7 @@ function ActiveCallManager({ setPresenting, setRendererCanvas, setOutgoingRing, + showToast, startCall, switchToPresentationView, switchFromPresentationView, @@ -224,6 +236,18 @@ function ActiveCallManager({ [setGroupCallVideoRequest, conversation.id] ); + const onCopyCallLink = useCallback(async () => { + if (!callLink) { + return; + } + + const link = callLinkRootKeyToUrl(callLink.rootKey); + if (link) { + await window.navigator.clipboard.writeText(link); + showToast({ toastType: ToastType.CopiedCallLink }); + } + }, [callLink, showToast]); + const onSafetyNumberDialogCancel = useCallback(() => { hangUpActiveCall('safety number dialog cancel'); }, [hangUpActiveCall]); @@ -234,6 +258,7 @@ function ActiveCallManager({ | undefined | Array>; let isConvoTooBigToRing = false; + let isAdhocJoinRequestPending = false; switch (activeCall.callMode) { case CallMode.Direct: { @@ -256,11 +281,15 @@ function ActiveCallManager({ groupMembers = undefined; break; } - case CallMode.Group: { + case CallMode.Group: + case CallMode.Adhoc: { showCallLobby = activeCall.joinState !== GroupCallJoinState.Joined; isCallFull = activeCall.deviceCount >= activeCall.maxDevices; isConvoTooBigToRing = activeCall.isConversationTooBigToRing; ({ groupMembers } = activeCall); + isAdhocJoinRequestPending = + callLink?.restrictions === CallLinkRestrictions.AdminApproval && + activeCall.joinState === GroupCallJoinState.Pending; break; } default: @@ -272,12 +301,13 @@ function ActiveCallManager({ <> {settingsDialogOpen && renderDeviceSelection()} - {showParticipantsList && activeCall.callMode === CallMode.Group ? ( - - ) : null} + {showParticipantsList && + (activeCall.callMode === CallMode.Adhoc && callLink ? ( + + ) : ( + + ))} ); } @@ -325,31 +365,27 @@ function ActiveCallManager({ } let isHandRaised = false; - if (activeCall.callMode === CallMode.Group) { + if (isGroupOrAdhocActiveCall(activeCall)) { const { raisedHands, localDemuxId } = activeCall; if (localDemuxId) { isHandRaised = raisedHands.has(localDemuxId); } } - const groupCallParticipantsForParticipantsList = - activeCall.callMode === CallMode.Group - ? [ - ...activeCall.remoteParticipants.map(participant => ({ - ...participant, - hasRemoteAudio: participant.hasRemoteAudio, - hasRemoteVideo: participant.hasRemoteVideo, - presenting: participant.presenting, - })), - { - ...me, - hasRemoteAudio: hasLocalAudio, - hasRemoteVideo: hasLocalVideo, - isHandRaised, - presenting: Boolean(activeCall.presentingSource), - }, - ] - : []; + const groupCallParticipantsForParticipantsList = isGroupOrAdhocActiveCall( + activeCall + ) + ? [ + ...activeCall.remoteParticipants, + { + ...me, + hasRemoteAudio: hasLocalAudio, + hasRemoteVideo: hasLocalVideo, + isHandRaised, + presenting: Boolean(activeCall.presentingSource), + }, + ] + : []; return ( <> @@ -393,15 +429,25 @@ function ActiveCallManager({ /> ) : null} {settingsDialogOpen && renderDeviceSelection()} - {showParticipantsList && activeCall.callMode === CallMode.Group ? ( - - ) : null} - {activeCall.callMode === CallMode.Group && + {showParticipantsList && + (activeCall.callMode === CallMode.Adhoc && callLink ? ( + + ) : ( + + ))} + {isGroupOrAdhocActiveCall(activeCall) && activeCall.conversationsWithSafetyNumberChanges.length ? ( { @@ -527,7 +573,7 @@ function hasRemoteParticipants( return remoteParticipants.length > 0; } -function isLonelyGroup(conversation: ConversationType): boolean { +function isLonelyGroup(conversation: CallingConversationType): boolean { return (conversation.sortedGroupMembers?.length ?? 0) < 2; } @@ -563,28 +609,30 @@ function getShouldRing({ ); } + // Adhoc calls can't be incoming. + throw missingCaseError(incomingCall); } if (activeCall != null) { - if (activeCall.callMode === CallMode.Direct) { - return ( - activeCall.callState === CallState.Prering || - activeCall.callState === CallState.Ringing - ); + switch (activeCall.callMode) { + case CallMode.Direct: + return ( + activeCall.callState === CallState.Prering || + activeCall.callState === CallState.Ringing + ); + case CallMode.Group: + case CallMode.Adhoc: + return ( + activeCall.outgoingRing && + isConnected(activeCall.connectionState) && + isJoined(activeCall.joinState) && + !hasRemoteParticipants(activeCall.remoteParticipants) && + !isLonelyGroup(activeCall.conversation) + ); + default: + throw missingCaseError(activeCall); } - - if (activeCall.callMode === CallMode.Group) { - return ( - activeCall.outgoingRing && - isConnected(activeCall.connectionState) && - isJoined(activeCall.joinState) && - !hasRemoteParticipants(activeCall.remoteParticipants) && - !isLonelyGroup(activeCall.conversation) - ); - } - - throw missingCaseError(activeCall); } return false; diff --git a/ts/components/CallParticipantCount.tsx b/ts/components/CallParticipantCount.tsx index 5e711f562eab..52fc5213faca 100644 --- a/ts/components/CallParticipantCount.tsx +++ b/ts/components/CallParticipantCount.tsx @@ -3,27 +3,46 @@ import React from 'react'; import type { LocalizerType } from '../types/Util'; +import { CallMode } from '../types/Calling'; export type PropsType = { + callMode: CallMode; i18n: LocalizerType; + isAdhocJoinRequestPending?: boolean; groupMemberCount?: number; participantCount: number; toggleParticipants: () => void; }; export function CallParticipantCount({ + callMode, i18n, + isAdhocJoinRequestPending, groupMemberCount, participantCount, toggleParticipants, }: PropsType): JSX.Element { + const isToggleVisible = + Boolean(participantCount) || callMode === CallMode.Adhoc; const count = participantCount || groupMemberCount || 1; - const innerText = i18n('icu:CallControls__InfoDisplay--participants', { - count: String(count), - }); + let innerText: string | undefined; + if (callMode === CallMode.Adhoc) { + if (isAdhocJoinRequestPending) { + innerText = i18n( + 'icu:CallControls__InfoDisplay--adhoc-join-request-pending' + ); + } else if (!participantCount) { + innerText = i18n('icu:CallControls__InfoDisplay--adhoc-call'); + } + } + if (!innerText) { + innerText = i18n('icu:CallControls__InfoDisplay--participants', { + count: String(count), + }); + } // Call not started, can't click to show participants - if (!participantCount) { + if (!isToggleVisible) { return ( { setShowReactionPicker(false); sendGroupCallReaction({ + callMode: activeCall.callMode, conversationId: conversation.id, value: emoji, }); @@ -932,12 +944,13 @@ export function CallScreen({ } function getCallModeClassSuffix( - callMode: CallMode.Direct | CallMode.Group + callMode: CallMode.Direct | CallMode.Group | CallMode.Adhoc ): string { switch (callMode) { case CallMode.Direct: return 'direct'; case CallMode.Group: + case CallMode.Adhoc: return 'group'; default: throw missingCaseError(callMode); diff --git a/ts/components/CallingAdhocCallInfo.stories.tsx b/ts/components/CallingAdhocCallInfo.stories.tsx new file mode 100644 index 000000000000..a7ea684cfca7 --- /dev/null +++ b/ts/components/CallingAdhocCallInfo.stories.tsx @@ -0,0 +1,136 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import { sample } from 'lodash'; +import { action } from '@storybook/addon-actions'; + +import type { Meta } from '@storybook/react'; +import type { PropsType } from './CallingAdhocCallInfo'; +import { CallingAdhocCallInfo } from './CallingAdhocCallInfo'; +import { AvatarColors } from '../types/Colors'; +import type { GroupCallRemoteParticipantType } from '../types/Calling'; +import { generateAci } from '../types/ServiceId'; +import { getDefaultConversationWithServiceId } from '../test-both/helpers/getDefaultConversation'; +import { setupI18n } from '../util/setupI18n'; +import enMessages from '../../_locales/en/messages.json'; +import type { CallLinkType } from '../types/CallLink'; +import { CallLinkRestrictions } from '../types/CallLink'; + +const i18n = setupI18n('en', enMessages); + +function createParticipant( + participantProps: Partial +): GroupCallRemoteParticipantType { + return { + aci: generateAci(), + demuxId: 2, + hasRemoteAudio: Boolean(participantProps.hasRemoteAudio), + hasRemoteVideo: Boolean(participantProps.hasRemoteVideo), + isHandRaised: Boolean(participantProps.isHandRaised), + mediaKeysReceived: Boolean(participantProps.mediaKeysReceived), + presenting: Boolean(participantProps.presenting), + sharingScreen: Boolean(participantProps.sharingScreen), + videoAspectRatio: 1.3, + ...getDefaultConversationWithServiceId({ + avatarPath: participantProps.avatarPath, + color: sample(AvatarColors), + isBlocked: Boolean(participantProps.isBlocked), + name: participantProps.name, + profileName: participantProps.title, + title: String(participantProps.title), + }), + }; +} + +function getCallLink(overrideProps: Partial = {}): CallLinkType { + // Normally, roomId would be derived from rootKey however we don't want to import + // ringrtc in storybook + return { + roomId: 'abcd1234abcd1234abcd1234abcd1234abcd1234', + rootKey: 'abcd-abcd-abcd-abcd-abcd-abcd-abcd-abcd', + name: 'Axolotl Discuss', + restrictions: CallLinkRestrictions.None, + expiration: Date.now() + 30 * 24 * 60 * 60 * 1000, + ...overrideProps, + }; +} + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + callLink: getCallLink(overrideProps.callLink || {}), + i18n, + ourServiceId: generateAci(), + participants: overrideProps.participants || [], + onClose: action('on-close'), + onCopyCallLink: action('on-copy-call-link'), +}); + +export default { + title: 'Components/CallingAdhocCallInfo', +} satisfies Meta; + +export function NoOne(): JSX.Element { + const props = createProps(); + return ; +} + +export function SoloCall(): JSX.Element { + const props = createProps({ + participants: [ + createParticipant({ + title: 'Bardock', + }), + ], + }); + return ; +} + +export function ManyParticipants(): JSX.Element { + const props = createProps({ + participants: [ + createParticipant({ + title: 'Son Goku', + }), + createParticipant({ + hasRemoteAudio: true, + hasRemoteVideo: true, + presenting: true, + name: 'Rage Trunks', + title: 'Rage Trunks', + }), + createParticipant({ + hasRemoteAudio: true, + title: 'Prince Vegeta', + }), + createParticipant({ + hasRemoteAudio: true, + hasRemoteVideo: true, + name: 'Goku Black', + title: 'Goku Black', + }), + createParticipant({ + isHandRaised: true, + title: 'Supreme Kai Zamasu', + }), + createParticipant({ + hasRemoteAudio: false, + hasRemoteVideo: true, + isHandRaised: true, + title: 'Chi Chi', + }), + createParticipant({ + title: 'Someone With A Really Long Name', + }), + ], + }); + return ; +} + +export function Overflow(): JSX.Element { + const props = createProps({ + participants: Array(50) + .fill(null) + .map(() => createParticipant({ title: 'Kirby' })), + }); + return ; +} diff --git a/ts/components/CallingAdhocCallInfo.tsx b/ts/components/CallingAdhocCallInfo.tsx new file mode 100644 index 000000000000..a68eabb7889f --- /dev/null +++ b/ts/components/CallingAdhocCallInfo.tsx @@ -0,0 +1,162 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/* eslint-disable react/no-array-index-key */ + +import React from 'react'; +import classNames from 'classnames'; + +import { Avatar, AvatarSize } from './Avatar'; +import { ContactName } from './conversation/ContactName'; +import { InContactsIcon } from './InContactsIcon'; +import type { CallLinkType } from '../types/CallLink'; +import type { LocalizerType } from '../types/Util'; +import type { ServiceIdString } from '../types/ServiceId'; +import { sortByTitle } from '../util/sortByTitle'; +import type { ConversationType } from '../state/ducks/conversations'; +import { ModalHost } from './ModalHost'; +import { isInSystemContacts } from '../util/isInSystemContacts'; + +type ParticipantType = ConversationType & { + hasRemoteAudio?: boolean; + hasRemoteVideo?: boolean; + isHandRaised?: boolean; + presenting?: boolean; +}; + +export type PropsType = { + readonly callLink: CallLinkType; + readonly i18n: LocalizerType; + readonly ourServiceId: ServiceIdString | undefined; + readonly participants: Array; + readonly onClose: () => void; + readonly onCopyCallLink: () => void; +}; + +export function CallingAdhocCallInfo({ + i18n, + ourServiceId, + participants, + onClose, + onCopyCallLink, +}: PropsType): JSX.Element | null { + const sortedParticipants = React.useMemo>( + () => sortByTitle(participants), + [participants] + ); + + return ( + +
+
+
+ {!participants.length && i18n('icu:calling__in-this-call--zero')} + {participants.length === 1 && + i18n('icu:calling__in-this-call--one')} + {participants.length > 1 && + i18n('icu:calling__in-this-call--many', { + people: String(participants.length), + })} +
+
+
    + {sortedParticipants.map( + (participant: ParticipantType, index: number) => ( +
  • +
    + + {ourServiceId && participant.serviceId === ourServiceId ? ( + + {i18n('icu:you')} + + ) : ( + <> + + {isInSystemContacts(participant) ? ( + + {' '} + + + ) : null} + + )} +
    + + + +
  • + ) + )} +
+
+
+ +
+
+ + ); +} diff --git a/ts/components/CallingLobby.stories.tsx b/ts/components/CallingLobby.stories.tsx index e636fdd00c0c..d225e9a61402 100644 --- a/ts/components/CallingLobby.stories.tsx +++ b/ts/components/CallingLobby.stories.tsx @@ -19,6 +19,7 @@ import { getDefaultConversationWithServiceId, } from '../test-both/helpers/getDefaultConversation'; import { CallingToastProvider } from './CallingToast'; +import { CallMode } from '../types/Calling'; const i18n = setupI18n('en', enMessages); @@ -33,24 +34,28 @@ const camera = { }; const createProps = (overrideProps: Partial = {}): PropsType => { - const isGroupCall = overrideProps.isGroupCall ?? false; - const conversation = isGroupCall - ? getDefaultConversation({ - title: 'Tahoe Trip', - type: 'group', - }) - : getDefaultConversation(); + const callMode = overrideProps.callMode ?? CallMode.Direct; + const conversation = + callMode === CallMode.Group + ? getDefaultConversation({ + title: 'Tahoe Trip', + type: 'group', + }) + : getDefaultConversation(); return { availableCameras: overrideProps.availableCameras || [camera], + callMode, conversation, groupMembers: overrideProps.groupMembers || - (isGroupCall ? times(3, () => getDefaultConversation()) : undefined), + (callMode === CallMode.Group + ? times(3, () => getDefaultConversation()) + : undefined), hasLocalAudio: overrideProps.hasLocalAudio ?? true, hasLocalVideo: overrideProps.hasLocalVideo ?? false, i18n, - isGroupCall, + isAdhocJoinRequestPending: false, isConversationTooBigToRing: false, isCallFull: overrideProps.isCallFull ?? false, me: @@ -133,13 +138,16 @@ export function InitiallyMuted(): JSX.Element { } export function GroupCallWithNoPeekedParticipants(): JSX.Element { - const props = createProps({ isGroupCall: true, peekedParticipants: [] }); + const props = createProps({ + callMode: CallMode.Group, + peekedParticipants: [], + }); return ; } export function GroupCallWith1PeekedParticipant(): JSX.Element { const props = createProps({ - isGroupCall: true, + callMode: CallMode.Group, peekedParticipants: [{ title: 'Sam' }].map(fakePeekedParticipant), }); return ; @@ -148,7 +156,7 @@ export function GroupCallWith1PeekedParticipant(): JSX.Element { export function GroupCallWith1PeekedParticipantSelf(): JSX.Element { const serviceId = generateAci(); const props = createProps({ - isGroupCall: true, + callMode: CallMode.Group, me: getDefaultConversation({ id: generateUuid(), serviceId, @@ -160,7 +168,7 @@ export function GroupCallWith1PeekedParticipantSelf(): JSX.Element { export function GroupCallWith4PeekedParticipants(): JSX.Element { const props = createProps({ - isGroupCall: true, + callMode: CallMode.Group, peekedParticipants: ['Sam', 'Cayce', 'April', 'Logan', 'Carl'].map(title => fakePeekedParticipant({ title }) ), @@ -170,7 +178,7 @@ export function GroupCallWith4PeekedParticipants(): JSX.Element { export function GroupCallWith4PeekedParticipantsParticipantsList(): JSX.Element { const props = createProps({ - isGroupCall: true, + callMode: CallMode.Group, peekedParticipants: ['Sam', 'Cayce', 'April', 'Logan', 'Carl'].map(title => fakePeekedParticipant({ title }) ), @@ -181,7 +189,7 @@ export function GroupCallWith4PeekedParticipantsParticipantsList(): JSX.Element export function GroupCallWithCallFull(): JSX.Element { const props = createProps({ - isGroupCall: true, + callMode: CallMode.Group, isCallFull: true, peekedParticipants: ['Sam', 'Cayce'].map(title => fakePeekedParticipant({ title }) @@ -192,7 +200,7 @@ export function GroupCallWithCallFull(): JSX.Element { export function GroupCallWith0PeekedParticipantsBigGroup(): JSX.Element { const props = createProps({ - isGroupCall: true, + callMode: CallMode.Group, groupMembers: times(100, () => getDefaultConversation()), }); return ; diff --git a/ts/components/CallingLobby.tsx b/ts/components/CallingLobby.tsx index a0b27bf4f4c6..980a9d78cb63 100644 --- a/ts/components/CallingLobby.tsx +++ b/ts/components/CallingLobby.tsx @@ -19,17 +19,22 @@ import { CallingLobbyJoinButton, CallingLobbyJoinButtonVariant, } from './CallingLobbyJoinButton'; +import { CallMode } from '../types/Calling'; +import type { CallingConversationType } from '../types/Calling'; import type { LocalizerType } from '../types/Util'; import { useIsOnline } from '../hooks/useIsOnline'; import * as KeyboardLayout from '../services/keyboardLayout'; import type { ConversationType } from '../state/ducks/conversations'; import { useCallingToasts } from './CallingToast'; import { CallingButtonToastsContainer } from './CallingToastManager'; +import { isGroupOrAdhocCallMode } from '../util/isGroupOrAdhocCall'; +import { isSharingPhoneNumberWithEverybody } from '../util/phoneNumberSharingMode'; export type PropsType = { availableCameras: Array; + callMode: CallMode; conversation: Pick< - ConversationType, + CallingConversationType, | 'acceptedMessageRequest' | 'avatarPath' | 'color' @@ -54,8 +59,8 @@ export type PropsType = { hasLocalAudio: boolean; hasLocalVideo: boolean; i18n: LocalizerType; + isAdhocJoinRequestPending: boolean; isConversationTooBigToRing: boolean; - isGroupCall: boolean; isCallFull?: boolean; me: Readonly< Pick @@ -75,12 +80,13 @@ export type PropsType = { export function CallingLobby({ availableCameras, + callMode, conversation, groupMembers, hasLocalAudio, hasLocalVideo, i18n, - isGroupCall = false, + isAdhocJoinRequestPending, isCallFull = false, isConversationTooBigToRing, me, @@ -99,6 +105,8 @@ export function CallingLobby({ const shouldShowLocalVideo = hasLocalVideo && availableCameras.length > 0; + const isGroupOrAdhocCall = isGroupOrAdhocCallMode(callMode); + const toggleAudio = React.useCallback((): void => { setLocalAudio({ enabled: !hasLocalAudio }); }, [hasLocalAudio, setLocalAudio]); @@ -161,12 +169,12 @@ export function CallingLobby({ : CallingButtonType.AUDIO_OFF; const isRingButtonVisible: boolean = - isGroupCall && + isGroupOrAdhocCall && peekedParticipants.length === 0 && (groupMembers || []).length > 1; let preCallInfoRingMode: RingMode; - if (isGroupCall) { + if (isGroupOrAdhocCall) { preCallInfoRingMode = outgoingRing && !isConversationTooBigToRing ? RingMode.WillRing @@ -205,10 +213,12 @@ export function CallingLobby({ } const callStatus = React.useMemo(() => { - if (isGroupCall) { + if (isGroupOrAdhocCall) { return ( + {callMode === CallMode.Adhoc && ( +
+ {isSharingPhoneNumberWithEverybody() + ? i18n('icu:CallingLobby__CallLinkNotice--phone-sharing') + : i18n('icu:CallingLobby__CallLinkNotice')} +
+ )} + { - if (activeCall.callMode !== CallMode.Group) { + if (!isGroupOrAdhocActiveCall(activeCall)) { return undefined; } return maxBy(activeCall.remoteParticipants, participant => participant.presenting ? Infinity : participant.speakerTime || -Infinity ); - }, [activeCall.callMode, activeCall.remoteParticipants]); + }, [activeCall]); useEffect(() => { - if (activeCall.callMode !== CallMode.Group) { + if (!isGroupOrAdhocActiveCall(activeCall)) { return; } @@ -136,8 +138,7 @@ export function CallingPipRemoteVideo({ ); } }, [ - activeCall.callMode, - activeCall.remoteParticipants, + activeCall, activeGroupCallSpeaker, isPageVisible, setGroupCallVideoRequest, @@ -149,6 +150,10 @@ export function CallingPipRemoteVideo({ if (!hasRemoteVideo) { return ; } + assertDev( + conversation.type === 'direct', + 'CallingPipRemoteVideo for direct call must be associated with direct conversation' + ); return (
; } diff --git a/ts/components/CallingPreCallInfo.tsx b/ts/components/CallingPreCallInfo.tsx index 3ecf92d632bb..5c0a55ea5b05 100644 --- a/ts/components/CallingPreCallInfo.tsx +++ b/ts/components/CallingPreCallInfo.tsx @@ -3,6 +3,7 @@ import React from 'react'; import type { ConversationType } from '../state/ducks/conversations'; +import type { CallingConversationType } from '../types/Calling'; import type { LocalizerType } from '../types/Util'; import { Avatar, AvatarSize } from './Avatar'; import { getParticipantName } from '../util/callingGetParticipantName'; @@ -17,7 +18,7 @@ export enum RingMode { export type PropsType = { conversation: Pick< - ConversationType, + CallingConversationType, | 'acceptedMessageRequest' | 'avatarPath' | 'color' @@ -114,6 +115,7 @@ export function CallingPreCallInfo({ memberNames = [getParticipantName(conversation)]; break; case 'group': + case 'callLink': memberNames = groupMembers .filter(member => member.id !== me.id) .map(getParticipantName); diff --git a/ts/components/CallingToastManager.tsx b/ts/components/CallingToastManager.tsx index 393786e3e5d2..13cf8bea755d 100644 --- a/ts/components/CallingToastManager.tsx +++ b/ts/components/CallingToastManager.tsx @@ -10,6 +10,7 @@ import { CallingToastProvider, useCallingToasts } from './CallingToast'; import { usePrevious } from '../hooks/usePrevious'; import { difference as setDifference } from '../util/setUtil'; import { isMoreRecentThan } from '../util/timestamp'; +import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall'; type PropsType = { activeCall: ActiveCallType; @@ -24,13 +25,16 @@ function getCurrentPresenter( if (activeCall.presentingSource) { return { id: ME }; } - if (activeCall.callMode === CallMode.Direct) { + if ( + activeCall.callMode === CallMode.Direct && + activeCall.conversation.type === 'direct' + ) { const isOtherPersonPresenting = activeCall.remoteParticipants.some( participant => participant.presenting ); return isOtherPersonPresenting ? activeCall.conversation : undefined; } - if (activeCall.callMode === CallMode.Group) { + if (isGroupOrAdhocActiveCall(activeCall)) { return activeCall.remoteParticipants.find( participant => participant.presenting ); diff --git a/ts/components/ErrorModal.stories.tsx b/ts/components/ErrorModal.stories.tsx index 20775c3187c4..676f591c40e4 100644 --- a/ts/components/ErrorModal.stories.tsx +++ b/ts/components/ErrorModal.stories.tsx @@ -10,12 +10,14 @@ import { ErrorModal } from './ErrorModal'; import { setupI18n } from '../util/setupI18n'; import enMessages from '../../_locales/en/messages.json'; +import { ButtonVariant } from './Button'; const i18n = setupI18n('en', enMessages); const createProps = (overrideProps: Partial = {}): PropsType => ({ - title: overrideProps.title ?? '', + buttonVariant: overrideProps.buttonVariant ?? undefined, description: overrideProps.description ?? '', + title: overrideProps.title ?? '', i18n, onClose: action('onClick'), }); @@ -30,6 +32,12 @@ export function Normal(): JSX.Element { return ; } +export function PrimaryButton(): JSX.Element { + return ( + + ); +} + export function CustomStrings(): JSX.Element { return ( + ); diff --git a/ts/components/ForwardMessagesModal.stories.tsx b/ts/components/ForwardMessagesModal.stories.tsx index cad8f7752628..6b7a01bdb36e 100644 --- a/ts/components/ForwardMessagesModal.stories.tsx +++ b/ts/components/ForwardMessagesModal.stories.tsx @@ -136,6 +136,7 @@ export function LinkPreview(): JSX.Element { contentType: IMAGE_JPEG, }), isStickerPack: false, + isCallLink: false, title: LONG_TITLE, }, ], diff --git a/ts/components/ForwardMessagesModal.tsx b/ts/components/ForwardMessagesModal.tsx index af84278451cb..6746bd3a1cfd 100644 --- a/ts/components/ForwardMessagesModal.tsx +++ b/ts/components/ForwardMessagesModal.tsx @@ -452,6 +452,7 @@ function ForwardMessageEditor({ onClose={removeLinkPreview} title={linkPreview.title} url={linkPreview.url} + isCallLink={linkPreview.isCallLink} />
) : null} diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx index 2c67ae0cd0b1..e734d17e0655 100644 --- a/ts/components/GlobalModalContainer.tsx +++ b/ts/components/GlobalModalContainer.tsx @@ -40,8 +40,11 @@ export type PropsType = { editHistoryMessages: EditHistoryMessagesType | undefined; renderEditHistoryMessagesModal: () => JSX.Element; // ErrorModal - errorModalProps: { description?: string; title?: string } | undefined; + errorModalProps: + | { buttonVariant?: ButtonVariant; description?: string; title?: string } + | undefined; renderErrorModal: (opts: { + buttonVariant?: ButtonVariant; description?: string; title?: string; }) => JSX.Element; diff --git a/ts/components/StoryCreator.stories.tsx b/ts/components/StoryCreator.stories.tsx index ff1f7720f3de..0c3f67edc587 100644 --- a/ts/components/StoryCreator.stories.tsx +++ b/ts/components/StoryCreator.stories.tsx @@ -71,6 +71,7 @@ LinkPreview.args = { }), title: 'Cats & Kittens LOL', url: 'https://www.catsandkittens.lolcats/kittens/page/1', + isCallLink: false, }, }; diff --git a/ts/components/TextAttachment.stories.tsx b/ts/components/TextAttachment.stories.tsx index 34a967e99957..92e9cd32cc88 100644 --- a/ts/components/TextAttachment.stories.tsx +++ b/ts/components/TextAttachment.stories.tsx @@ -184,6 +184,7 @@ export function LinkPreview(): JSX.Element { preview: { url: 'https://www.signal.org/workworkwork', title: 'Signal >> Careers', + isCallLink: false, }, }} /> @@ -200,6 +201,7 @@ export function LinkPreviewThumbnail(): JSX.Element { preview: { url: 'https://www.signal.org/workworkwork', title: 'Signal >> Careers', + isCallLink: false, }, }} /> @@ -216,6 +218,7 @@ export function LinkPreviewLongTitle(): JSX.Element { title: '2021 Etihad Airways Abu Dhabi Grand Prix Race Summary - F1 RaceCast Dec 10 to Dec 12 - ESPN', url: 'https://www.espn.com/f1/race/_/id/600001776', + isCallLink: false, }, text: 'Spoiler alert!', textForegroundColor: 4294704123, @@ -232,6 +235,7 @@ export function LinkPreviewJustUrl(): JSX.Element { color: 4294951251, preview: { url: 'https://www.rolex.com/en-us/watches/day-date/m228236-0012.html', + isCallLink: false, }, }} /> @@ -246,6 +250,7 @@ export function LinkPreviewJustUrlText(): JSX.Element { color: 4294951251, preview: { url: 'https://www.rolex.com/en-us/watches/day-date/m228236-0012.html', + isCallLink: false, }, text: 'Check this out!', }} @@ -261,6 +266,7 @@ export function LinkPreviewReallyLongDomain(): JSX.Element { color: 4294951251, preview: { url: 'https://llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch.international/', + isCallLink: false, }, }} /> @@ -284,6 +290,7 @@ export function LinkPreviewWRJ(): JSX.Element { preview: { title: 'Romeo and Juliet: Entire Play', url: 'http://shakespeare.mit.edu/romeo_juliet/full.html', + isCallLink: false, }, }} /> @@ -307,6 +314,7 @@ export function TextBackgroundAndLinkPreview(): JSX.Element { preview: { title: 'A really long title so that the we can test the margins', url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + isCallLink: false, }, }} /> diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx index 7e5a31654dbf..c5e2a207518f 100644 --- a/ts/components/ToastManager.stories.tsx +++ b/ts/components/ToastManager.stories.tsx @@ -61,6 +61,8 @@ function getToast(toastType: ToastType): AnyToast { }; case ToastType.ConversationUnarchived: return { toastType: ToastType.ConversationUnarchived }; + case ToastType.CopiedCallLink: + return { toastType: ToastType.CopiedCallLink }; case ToastType.CopiedUsername: return { toastType: ToastType.CopiedUsername }; case ToastType.CopiedUsernameLink: diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index 6d7750126dd4..04e1fe9c3950 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -188,6 +188,14 @@ export function renderToast({ ); } + if (toastType === ToastType.CopiedCallLink) { + return ( + + {i18n('icu:calling__call-link-copied')} + + ); + } + if (toastType === ToastType.CopiedUsername) { return ( diff --git a/ts/components/conversation/CallingNotification.tsx b/ts/components/conversation/CallingNotification.tsx index 29d1adbbdff4..6adeced5880a 100644 --- a/ts/components/conversation/CallingNotification.tsx +++ b/ts/components/conversation/CallingNotification.tsx @@ -216,6 +216,9 @@ function renderCallingNotificationButton( } break; } + case CallMode.Adhoc: + log.warn('CallingNotification for adhoc call, should never happen'); + return null; default: log.error(missingCaseError(props.callHistory.mode)); return null; diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index dc07386ab7ea..05b02c84b634 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -1229,6 +1229,9 @@ export class Message extends React.PureComponent { />
) : null} + {first.isCallLink && ( +
+ )}
{ ); } + private renderAction(): JSX.Element | null { + const { direction, i18n, previews } = this.props; + if (previews?.length !== 1) { + return null; + } + + const onlyPreview = previews[0]; + if (onlyPreview.isCallLink) { + return ( + + ); + } + + return null; + } + private renderError(): ReactNode { const { status, direction } = this.props; @@ -2406,6 +2434,7 @@ export class Message extends React.PureComponent { {this.renderPayment()} {this.renderEmbeddedContact()} {this.renderText()} + {this.renderAction()} {this.renderMetadata()} {this.renderSendMessageButton()} diff --git a/ts/components/conversation/StagedLinkPreview.stories.tsx b/ts/components/conversation/StagedLinkPreview.stories.tsx index 093bcbccd621..ab3b8746e076 100644 --- a/ts/components/conversation/StagedLinkPreview.stories.tsx +++ b/ts/components/conversation/StagedLinkPreview.stories.tsx @@ -32,6 +32,7 @@ const getDefaultProps = (): Props => ({ onClose: action('onClose'), title: 'This is a super-sweet site', url: 'https://www.signal.org', + isCallLink: false, }); // eslint-disable-next-line react/function-component-definition diff --git a/ts/components/conversation/TimelineMessage.stories.tsx b/ts/components/conversation/TimelineMessage.stories.tsx index 60ec0ef8e216..608ea5c65610 100644 --- a/ts/components/conversation/TimelineMessage.stories.tsx +++ b/ts/components/conversation/TimelineMessage.stories.tsx @@ -426,6 +426,7 @@ export function EmojiMessages(): JSX.Element { width: 320, }), isStickerPack: false, + isCallLink: false, title: 'Signal', description: 'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.', @@ -894,6 +895,7 @@ LinkPreviewInGroup.args = { width: 320, }), isStickerPack: false, + isCallLink: false, title: 'Signal', description: 'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.', @@ -931,6 +933,7 @@ LinkPreviewWithQuote.args = { width: 320, }), isStickerPack: false, + isCallLink: false, title: 'Signal', description: 'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.', @@ -956,6 +959,7 @@ LinkPreviewWithSmallImage.args = { width: 50, }), isStickerPack: false, + isCallLink: false, title: 'Signal', description: 'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.', @@ -973,6 +977,7 @@ LinkPreviewWithoutImage.args = { { domain: 'signal.org', isStickerPack: false, + isCallLink: false, title: 'Signal', description: 'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.', @@ -990,6 +995,7 @@ LinkPreviewWithNoDescription.args = { { domain: 'signal.org', isStickerPack: false, + isCallLink: false, title: 'Signal', url: 'https://www.signal.org', date: Date.now(), @@ -1005,6 +1011,7 @@ LinkPreviewWithLongDescription.args = { { domain: 'signal.org', isStickerPack: false, + isCallLink: false, title: 'Signal', description: Array(10) .fill( @@ -1032,6 +1039,7 @@ LinkPreviewWithSmallImageLongDescription.args = { width: 50, }), isStickerPack: false, + isCallLink: false, title: 'Signal', description: Array(10) .fill( @@ -1059,6 +1067,7 @@ LinkPreviewWithNoDate.args = { width: 320, }), isStickerPack: false, + isCallLink: false, title: 'Signal', description: 'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.', @@ -1082,6 +1091,7 @@ LinkPreviewWithTooNewADate.args = { width: 320, }), isStickerPack: false, + isCallLink: false, title: 'Signal', description: 'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.', @@ -1093,6 +1103,23 @@ LinkPreviewWithTooNewADate.args = { text: 'Be sure to look at https://www.signal.org', }; +export const LinkPreviewWithCallLink = Template.bind({}); +LinkPreviewWithCallLink.args = { + previews: [ + { + url: 'https://signal.link/call/#key=hzcn-pcff-ctsc-bdbf-stcr-tzpc-bhqx-kghh', + title: 'Camping Prep', + description: 'Use this link to join a Signal call', + image: undefined, + date: undefined, + isCallLink: true, + isStickerPack: false, + }, + ], + status: 'sent', + text: 'Use this link to join a Signal call: https://signal.link/call/#key=hzcn-pcff-ctsc-bdbf-stcr-tzpc-bhqx-kghh', +}; + export function Image(): JSX.Element { const darkImageProps = createProps({ attachments: [ @@ -1672,6 +1699,7 @@ NotApprovedWithLinkPreview.args = { width: 320, }), isStickerPack: false, + isCallLink: false, title: 'Signal', description: 'Say "hello" to a different messaging experience. An unexpected focus on privacy, combined with all of the features you expect.', diff --git a/ts/groups.ts b/ts/groups.ts index e080286117d3..1cd28277cef2 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -15,7 +15,7 @@ import { v4 as getGuid } from 'uuid'; import LRU from 'lru-cache'; import * as log from './logging/log'; import { - getCheckedCredentialsForToday, + getCheckedGroupCredentialsForToday, maybeFetchNewCredentials, } from './services/groupCredentialFetcher'; import { storageServiceUploadJob } from './services/storage'; @@ -1687,7 +1687,7 @@ async function makeRequestWithTemporalRetry({ secretParams: string; request: (sender: MessageSender, options: GroupCredentialsType) => Promise; }): Promise { - const groupCredentials = getCheckedCredentialsForToday( + const groupCredentials = getCheckedGroupCredentialsForToday( `makeRequestWithTemporalRetry/${logId}` ); diff --git a/ts/services/LinkPreview.ts b/ts/services/LinkPreview.ts index b5ce15779679..08aa4b957261 100644 --- a/ts/services/LinkPreview.ts +++ b/ts/services/LinkPreview.ts @@ -3,6 +3,7 @@ import { debounce, omit } from 'lodash'; +import { CallLinkRootKey } from '@signalapp/ringrtc'; import type { LinkPreviewWithHydratedData } from '../types/message/LinkPreviews'; import type { LinkPreviewImage, @@ -28,6 +29,8 @@ import { imageToBlurHash } from '../util/imageToBlurHash'; import { maybeParseUrl } from '../util/url'; import { sniffImageMimeType } from '../util/sniffImageMimeType'; import { drop } from '../util/drop'; +import { linkCallRoute } from '../util/signalRoutes'; +import { calling } from './calling'; const LINK_PREVIEW_TIMEOUT = 60 * SECOND; @@ -164,6 +167,7 @@ export async function addLinkPreview( window.reduxActions.linkPreviews.addLinkPreview( { url, + isCallLink: false, }, source, conversationId @@ -220,6 +224,7 @@ export async function addLinkPreview( date: dropNull(result.date), domain: LinkPreview.getDomain(result.url), isStickerPack: LinkPreview.isStickerPack(result.url), + isCallLink: LinkPreview.isCallLink(result.url), }, source, conversationId @@ -274,6 +279,7 @@ export function sanitizeLinkPreview( date: dropNull(item.date), domain: LinkPreview.getDomain(item.url), isStickerPack: LinkPreview.isStickerPack(item.url), + isCallLink: LinkPreview.isCallLink(item.url), }; } @@ -284,6 +290,7 @@ export function sanitizeLinkPreview( date: dropNull(item.date), domain: LinkPreview.getDomain(item.url), isStickerPack: LinkPreview.isStickerPack(item.url), + isCallLink: LinkPreview.isCallLink(item.url), }; } @@ -303,6 +310,9 @@ async function getPreview( if (LinkPreview.isGroupLink(url)) { return getGroupPreview(url, abortSignal); } + if (LinkPreview.isCallLink(url)) { + return getCallLinkPreview(url, abortSignal); + } // This is already checked elsewhere, but we want to be extra-careful. if (!LinkPreview.shouldPreviewHref(url)) { @@ -563,3 +573,30 @@ async function getGroupPreview( url, }; } + +async function getCallLinkPreview( + url: string, + _abortSignal: Readonly +): Promise { + const parsedUrl = linkCallRoute.fromUrl(url); + if (parsedUrl == null) { + throw new Error('Failed to parse call link URL'); + } + + const callLinkRootKey = CallLinkRootKey.parse(parsedUrl.args.key); + const callLinkState = await calling.readCallLink({ callLinkRootKey }); + if (!callLinkState) { + return null; + } + + return { + url, + title: + callLinkState.name === '' + ? window.i18n('icu:calling__call-link-default-title') + : callLinkState.name, + description: window.i18n('icu:message--call-link-description'), + image: undefined, + date: null, + }; +} diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 0680adee3748..b48eddb37485 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -6,7 +6,9 @@ import { ipcRenderer } from 'electron'; import type { AudioDevice, CallId, + CallLinkState, DeviceId, + GroupCallObserver, PeekInfo, UserId, VideoFrameSource, @@ -18,6 +20,7 @@ import { Call, CallingMessage, CallMessageUrgency, + CallLinkRootKey, CallLogLevel, CallState, CanvasVideoRenderer, @@ -40,8 +43,10 @@ import { import { uniqBy, noop } from 'lodash'; import Long from 'long'; +import type { CallLinkAuthCredentialPresentation } from '@signalapp/libsignal-client/zkgroup'; import type { ActionsType as CallingReduxActionsType, + CallLinkStateType, GroupCallParticipantInfoType, GroupCallPeekInfoType, } from '../state/ducks/calling'; @@ -123,6 +128,11 @@ import { import { isNormalNumber } from '../util/isNormalNumber'; import { LocalCallEvent } from '../types/CallDisposition'; import { isInSystemContacts } from '../util/isInSystemContacts'; +import { + getRoomIdFromRootKey, + getCallLinkAuthCredentialPresentation, +} from '../util/callLinks'; +import { isAdhocCallingEnabled } from '../util/isAdhocCallingEnabled'; const { processGroupCallRingCancellation, @@ -157,6 +167,7 @@ type CallingReduxInterface = Pick< | 'callStateChange' | 'cancelIncomingGroupCallRing' | 'groupCallAudioLevelsChange' + | 'groupCallEnded' | 'groupCallRaisedHandsChange' | 'groupCallStateChange' | 'outgoingCall' @@ -168,6 +179,7 @@ type CallingReduxInterface = Pick< | 'remoteVideoChange' | 'setPresenting' | 'startCallingLobby' + | 'startCallLinkLobby' | 'peekNotConnectedGroupCall' > & { areAnyCallsActiveOrRinging(): boolean; @@ -302,7 +314,7 @@ export class CallingClass { private deviceReselectionTimer?: NodeJS.Timeout; - private callsByConversation: { [conversationId: string]: Call | GroupCall }; + private callsLookup: { [key: string]: Call | GroupCall }; private hadLocalVideoBeforePresenting?: boolean; @@ -314,7 +326,7 @@ export class CallingClass { }); this.videoRenderer = new CanvasVideoRenderer(); - this.callsByConversation = {}; + this.callsLookup = {}; } initialize(reduxInterface: CallingReduxInterface, sfuUrl: string): void { @@ -412,6 +424,11 @@ export class CallingClass { } case CallMode.Group: break; + case CallMode.Adhoc: + log.error( + 'startCallingLobby() not implemented for adhoc calls. Did you mean: startCallLinkLobby()?' + ); + return; default: throw missingCaseError(callMode); } @@ -511,6 +528,80 @@ export class CallingClass { } } + async readCallLink({ + callLinkRootKey, + }: Readonly<{ + callLinkRootKey: CallLinkRootKey; + }>): Promise { + if (!this._sfuUrl) { + throw new Error('Missing SFU URL; not handling call link'); + } + + const roomId = getRoomIdFromRootKey(callLinkRootKey); + 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}`); + return; + } + + log.info('readCallLink: success', result); + return result.value; + } + + async startCallLinkLobby({ + callLinkRootKey, + hasLocalAudio, + hasLocalVideo = true, + }: Readonly<{ + callLinkRootKey: CallLinkRootKey; + hasLocalAudio: boolean; + hasLocalVideo?: boolean; + }>): Promise< + | undefined + | { + callMode: CallMode.Adhoc; + connectionState: GroupCallConnectionState; + hasLocalAudio: boolean; + hasLocalVideo: boolean; + joinState: GroupCallJoinState; + peekInfo?: GroupCallPeekInfoType; + remoteParticipants: Array; + } + > { + const roomId = getRoomIdFromRootKey(callLinkRootKey); + log.info('startCallLinkLobby() for roomId', roomId); + + await this.startDeviceReselectionTimer(); + + const authCredentialPresentation = + await getCallLinkAuthCredentialPresentation(callLinkRootKey); + + const groupCall = this.connectCallLinkCall({ + roomId, + authCredentialPresentation, + callLinkRootKey, + adminPasskey: undefined, + }); + + groupCall.setOutgoingAudioMuted(!hasLocalAudio); + groupCall.setOutgoingVideoMuted(!hasLocalVideo); + + this.enableLocalCamera(); + + return { + callMode: CallMode.Adhoc, + ...this.formatGroupCallForRedux(groupCall), + }; + } + async startOutgoingDirectCall( conversationId: string, hasLocalAudio: boolean, @@ -575,12 +666,12 @@ export class CallingClass { } private getDirectCall(conversationId: string): undefined | Call { - const call = getOwn(this.callsByConversation, conversationId); + const call = getOwn(this.callsLookup, conversationId); return call instanceof Call ? call : undefined; } private getGroupCall(conversationId: string): undefined | GroupCall { - const call = getOwn(this.callsByConversation, conversationId); + const call = getOwn(this.callsLookup, conversationId); return call instanceof GroupCall ? call : undefined; } @@ -659,6 +750,45 @@ export class CallingClass { ); } + public async peekCallLinkCall( + roomId: string, + rootKey: string | undefined + ): Promise { + log.info(`peekCallLinkCall: For roomId ${roomId}`); + const statefulPeekInfo = this.getGroupCall(roomId)?.getPeekInfo(); + if (statefulPeekInfo) { + return statefulPeekInfo; + } + + if (!rootKey) { + throw new Error( + 'Missing call link root key, cannot do stateless peeking' + ); + } + + if (!this._sfuUrl) { + throw new Error('Missing SFU URL; not peeking call link call'); + } + + const callLinkRootKey = CallLinkRootKey.parse(rootKey); + + const authCredentialPresentation = + await getCallLinkAuthCredentialPresentation(callLinkRootKey); + + const result = await RingRTC.peekCallLinkCall( + this._sfuUrl, + authCredentialPresentation.serialize(), + callLinkRootKey + ); + if (!result.success) { + throw new Error( + `Failed to peek call link, error ${result.errorStatusCode}, roomId ${roomId}.` + ); + } + + return result.value; + } + /** * Connect to a conversation's group call and connect it to Redux. * @@ -695,7 +825,6 @@ export class CallingClass { const groupIdBuffer = Buffer.from(Bytes.fromBase64(groupId)); - let updateMessageState = GroupCallUpdateMessageState.SentNothing; let isRequestingMembershipProof = false; const outerGroupCall = RingRTC.getGroupCall( @@ -704,174 +833,7 @@ export class CallingClass { Buffer.alloc(0), AUDIO_LEVEL_INTERVAL_MS, { - onLocalDeviceStateChanged: groupCall => { - const localDeviceState = groupCall.getLocalDeviceState(); - const peekInfo = groupCall.getPeekInfo() ?? null; - - log.info( - 'GroupCall#onLocalDeviceStateChanged', - formatLocalDeviceState(localDeviceState), - peekInfo != null ? formatPeekInfo(peekInfo) : '(No PeekInfo)' - ); - - const groupCallMeta = getGroupCallMeta(peekInfo); - - if (groupCallMeta != null) { - 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) - ); - } - } - - if ( - localDeviceState.connectionState === ConnectionState.NotConnected - ) { - // NOTE: This assumes that only one call is active at a time. For example, if - // there are two calls using the camera, this will disable both of them. - // That's fine for now, but this will break if that assumption changes. - this.disableLocalVideo(); - - delete this.callsByConversation[conversationId]; - - if ( - updateMessageState === GroupCallUpdateMessageState.SentJoin && - peekInfo?.eraId != null - ) { - updateMessageState = GroupCallUpdateMessageState.SentLeft; - void this.sendGroupCallUpdateMessage( - conversationId, - peekInfo?.eraId - ); - } - } else { - this.callsByConversation[conversationId] = groupCall; - - // NOTE: This assumes only one active call at a time. See comment above. - if (localDeviceState.videoMuted) { - this.disableLocalVideo(); - } else { - this.videoCapturer.enableCaptureAndSend(groupCall); - } - - if ( - updateMessageState === GroupCallUpdateMessageState.SentNothing && - localDeviceState.joinState === JoinState.Joined && - peekInfo?.eraId != null - ) { - updateMessageState = GroupCallUpdateMessageState.SentJoin; - void this.sendGroupCallUpdateMessage( - conversationId, - peekInfo?.eraId - ); - } - } - - this.syncGroupCallToRedux(conversationId, groupCall); - }, - onRemoteDeviceStatesChanged: groupCall => { - const localDeviceState = groupCall.getLocalDeviceState(); - const peekInfo = groupCall.getPeekInfo(); - - log.info( - 'GroupCall#onRemoteDeviceStatesChanged', - formatLocalDeviceState(localDeviceState), - peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)' - ); - - this.syncGroupCallToRedux(conversationId, groupCall); - }, - onAudioLevels: groupCall => { - const remoteDeviceStates = groupCall.getRemoteDeviceStates(); - if (!remoteDeviceStates) { - return; - } - const localAudioLevel = groupCall.getLocalDeviceState().audioLevel; - - this.reduxInterface?.groupCallAudioLevelsChange({ - conversationId, - localAudioLevel, - remoteDeviceStates, - }); - }, - onLowBandwidthForVideo: (_groupCall, _recovered) => { - // TODO: Implement handling of "low outgoing bandwidth for video" notification. - }, - - /** - * @param reactions A list of reactions received by the client ordered - * from oldest to newest. - */ - onReactions: (_groupCall, reactions) => { - this.reduxInterface?.receiveGroupCallReactions({ - conversationId, - reactions, - }); - }, - onRaisedHands: (_groupCall, raisedHands) => { - this.reduxInterface?.groupCallRaisedHandsChange({ - conversationId, - raisedHands, - }); - }, - onPeekChanged: groupCall => { - const localDeviceState = groupCall.getLocalDeviceState(); - const peekInfo = groupCall.getPeekInfo() ?? null; - - log.info( - 'GroupCall#onPeekChanged', - formatLocalDeviceState(localDeviceState), - peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)' - ); - - const { eraId } = peekInfo ?? {}; - - if ( - updateMessageState === GroupCallUpdateMessageState.SentNothing && - localDeviceState.connectionState !== ConnectionState.NotConnected && - localDeviceState.joinState === JoinState.Joined && - eraId - ) { - updateMessageState = GroupCallUpdateMessageState.SentJoin; - void this.sendGroupCallUpdateMessage(conversationId, eraId); - } - - void this.updateCallHistoryForGroupCall( - conversationId, - convertJoinState(localDeviceState.joinState), - peekInfo - ); - this.syncGroupCallToRedux(conversationId, groupCall); - }, + ...this.getGroupCallObserver(conversationId, CallMode.Group), async requestMembershipProof(groupCall) { if (isRequestingMembershipProof) { return; @@ -893,20 +855,6 @@ export class CallingClass { isRequestingMembershipProof = false; } }, - requestGroupMembers: groupCall => { - groupCall.setGroupMembers(this.getGroupCallMembers(conversationId)); - }, - onEnded: (groupCall, endedReason) => { - const localDeviceState = groupCall.getLocalDeviceState(); - const peekInfo = groupCall.getPeekInfo(); - - log.info( - 'GroupCall#onEnded', - endedReason, - formatLocalDeviceState(localDeviceState), - peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)' - ); - }, } ); @@ -918,7 +866,62 @@ export class CallingClass { outerGroupCall.connect(); - this.syncGroupCallToRedux(conversationId, outerGroupCall); + this.syncGroupCallToRedux(conversationId, outerGroupCall, CallMode.Group); + + return outerGroupCall; + } + + connectCallLinkCall({ + roomId, + authCredentialPresentation, + callLinkRootKey, + adminPasskey, + }: { + roomId: string; + authCredentialPresentation: CallLinkAuthCredentialPresentation; + callLinkRootKey: CallLinkRootKey; + adminPasskey: Buffer | undefined; + }): GroupCall { + if (!isAdhocCallingEnabled()) { + throw new Error( + 'Adhoc calling is not enabled; not connecting call link call' + ); + } + + const existing = this.getGroupCall(roomId); + if (existing) { + const isExistingCallNotConnected = + existing.getLocalDeviceState().connectionState === + ConnectionState.NotConnected; + if (isExistingCallNotConnected) { + existing.connect(); + } + return existing; + } + + if (!this._sfuUrl) { + throw new Error('Missing SFU URL; not connecting group call link call'); + } + + const outerGroupCall = RingRTC.getCallLinkCall( + this._sfuUrl, + authCredentialPresentation.serialize(), + callLinkRootKey, + adminPasskey, + Buffer.alloc(0), + AUDIO_LEVEL_INTERVAL_MS, + this.getGroupCallObserver(roomId, CallMode.Adhoc) + ); + + if (!outerGroupCall) { + // This should be very rare, likely due to RingRTC not being able to get a lock + // or memory or something like that. + throw new Error('Failed to get a group call instance; cannot start call'); + } + + outerGroupCall.connect(); + + this.syncGroupCallToRedux(roomId, outerGroupCall, CallMode.Adhoc); return outerGroupCall; } @@ -971,6 +974,250 @@ export class CallingClass { groupCall.join(); } + private getGroupCallObserver( + conversationId: string, + callMode: CallMode.Group | CallMode.Adhoc + ): GroupCallObserver { + let updateMessageState = GroupCallUpdateMessageState.SentNothing; + + return { + onLocalDeviceStateChanged: groupCall => { + const localDeviceState = groupCall.getLocalDeviceState(); + const peekInfo = groupCall.getPeekInfo() ?? null; + + log.info( + 'GroupCall#onLocalDeviceStateChanged', + formatLocalDeviceState(localDeviceState), + 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) + ); + } + } + + if (localDeviceState.connectionState === ConnectionState.NotConnected) { + // NOTE: This assumes that only one call is active at a time. For example, if + // there are two calls using the camera, this will disable both of them. + // That's fine for now, but this will break if that assumption changes. + this.disableLocalVideo(); + + delete this.callsLookup[conversationId]; + + if ( + updateMessageState === GroupCallUpdateMessageState.SentJoin && + peekInfo?.eraId != null + ) { + updateMessageState = GroupCallUpdateMessageState.SentLeft; + if (callMode === CallMode.Group) { + void this.sendGroupCallUpdateMessage( + conversationId, + peekInfo?.eraId + ); + } + } + } else { + this.callsLookup[conversationId] = groupCall; + + // NOTE: This assumes only one active call at a time. See comment above. + if (localDeviceState.videoMuted) { + this.disableLocalVideo(); + } else { + this.videoCapturer.enableCaptureAndSend(groupCall); + } + + if ( + updateMessageState === GroupCallUpdateMessageState.SentNothing && + localDeviceState.joinState === JoinState.Joined && + peekInfo?.eraId != null + ) { + updateMessageState = GroupCallUpdateMessageState.SentJoin; + if (callMode === CallMode.Group) { + void this.sendGroupCallUpdateMessage( + conversationId, + peekInfo?.eraId + ); + } + } + } + + this.syncGroupCallToRedux(conversationId, groupCall, callMode); + }, + onRemoteDeviceStatesChanged: groupCall => { + const localDeviceState = groupCall.getLocalDeviceState(); + const peekInfo = groupCall.getPeekInfo(); + + log.info( + 'GroupCall#onRemoteDeviceStatesChanged', + formatLocalDeviceState(localDeviceState), + peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)' + ); + + this.syncGroupCallToRedux(conversationId, groupCall, callMode); + }, + onAudioLevels: groupCall => { + const remoteDeviceStates = groupCall.getRemoteDeviceStates(); + if (!remoteDeviceStates) { + return; + } + const localAudioLevel = groupCall.getLocalDeviceState().audioLevel; + + this.reduxInterface?.groupCallAudioLevelsChange({ + callMode, + conversationId, + localAudioLevel, + remoteDeviceStates, + }); + }, + onLowBandwidthForVideo: (_groupCall, _recovered) => { + // TODO: Implement handling of "low outgoing bandwidth for video" notification. + }, + + /** + * @param reactions A list of reactions received by the client ordered + * from oldest to newest. + */ + onReactions: (_groupCall, reactions) => { + this.reduxInterface?.receiveGroupCallReactions({ + callMode, + conversationId, + reactions, + }); + }, + onRaisedHands: (_groupCall, raisedHands) => { + this.reduxInterface?.groupCallRaisedHandsChange({ + callMode, + conversationId, + raisedHands, + }); + }, + onPeekChanged: groupCall => { + const localDeviceState = groupCall.getLocalDeviceState(); + const peekInfo = groupCall.getPeekInfo() ?? null; + + log.info( + 'GroupCall#onPeekChanged', + formatLocalDeviceState(localDeviceState), + 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); + } + + void this.updateCallHistoryForGroupCall( + conversationId, + convertJoinState(localDeviceState.joinState), + peekInfo + ); + } + // TODO: Call history for adhoc calls + + this.syncGroupCallToRedux(conversationId, groupCall, callMode); + }, + async requestMembershipProof(_groupCall) { + log.error('GroupCall#requestMembershipProof not implemented.'); + }, + requestGroupMembers: groupCall => { + groupCall.setGroupMembers(this.getGroupCallMembers(conversationId)); + }, + onEnded: (groupCall, endedReason) => { + const localDeviceState = groupCall.getLocalDeviceState(); + const peekInfo = groupCall.getPeekInfo(); + + log.info( + 'GroupCall#onEnded', + endedReason, + formatLocalDeviceState(localDeviceState), + peekInfo ? formatPeekInfo(peekInfo) : '(No PeekInfo)' + ); + + this.reduxInterface?.groupCallEnded({ + conversationId, + endedReason, + }); + }, + }; + } + + public async joinCallLinkCall({ + roomId, + rootKey, + hasLocalAudio, + hasLocalVideo, + }: { + roomId: string; + rootKey: string; + hasLocalAudio: boolean; + hasLocalVideo: boolean; + }): Promise { + const haveMediaPermissions = await this.requestPermissions(hasLocalVideo); + if (!haveMediaPermissions) { + log.info('Permissions were denied, but allow joining call link call'); + } + + await this.startDeviceReselectionTimer(); + + const callLinkRootKey = CallLinkRootKey.parse(rootKey); + const authCredentialPresentation = + await getCallLinkAuthCredentialPresentation(callLinkRootKey); + + // RingRTC reuses the same type GroupCall between Adhoc and Group calls. + const groupCall = this.connectCallLinkCall({ + roomId, + authCredentialPresentation, + callLinkRootKey, + adminPasskey: undefined, + }); + + groupCall.setOutgoingAudioMuted(!hasLocalAudio); + groupCall.setOutgoingVideoMuted(!hasLocalVideo); + this.videoCapturer.enableCaptureAndSend(groupCall); + + groupCall.join(); + } + private getCallIdForConversation(conversationId: string): undefined | CallId { return this.getDirectCall(conversationId)?.callId; } @@ -1130,6 +1377,17 @@ export class CallingClass { }; } + public formatCallLinkStateForRedux( + callLinkState: CallLinkState + ): CallLinkStateType { + const { name, restrictions, expiration } = callLinkState; + return { + name, + restrictions, + expiration: expiration.getTime(), + }; + } + public getGroupCallVideoFrameSource( conversationId: string, demuxId: number @@ -1167,10 +1425,12 @@ export class CallingClass { private syncGroupCallToRedux( conversationId: string, - groupCall: GroupCall + groupCall: GroupCall, + callMode: CallMode.Group | CallMode.Adhoc ): void { this.reduxInterface?.groupCallStateChange({ conversationId, + callMode, ...this.formatGroupCallForRedux(groupCall), }); } @@ -1287,7 +1547,7 @@ export class CallingClass { hangup(conversationId: string, reason: string): void { log.info(`CallingClass.hangup(${conversationId}): ${reason}`); - const specificCall = getOwn(this.callsByConversation, conversationId); + const specificCall = getOwn(this.callsLookup, conversationId); if (!specificCall) { log.error( `hangup: Trying to hang up a non-existent call for conversation ${conversationId}` @@ -1296,7 +1556,7 @@ export class CallingClass { ipcRenderer.send('close-screen-share-controller'); - const entries = Object.entries(this.callsByConversation); + const entries = Object.entries(this.callsLookup); log.info(`hangup: ${entries.length} call(s) to hang up...`); entries.forEach(([callConversationId, call]) => { @@ -1317,13 +1577,14 @@ export class CallingClass { } hangupAllCalls(reason: string): void { - for (const conversationId of Object.keys(this.callsByConversation)) { + const conversationIds = Object.keys(this.callsLookup); + for (const conversationId of conversationIds) { this.hangup(conversationId, reason); } } setOutgoingAudio(conversationId: string, enabled: boolean): void { - const call = getOwn(this.callsByConversation, conversationId); + const call = getOwn(this.callsLookup, conversationId); if (!call) { log.warn('Trying to set outgoing audio for a non-existent call'); return; @@ -1339,7 +1600,7 @@ export class CallingClass { } setOutgoingVideo(conversationId: string, enabled: boolean): void { - const call = getOwn(this.callsByConversation, conversationId); + const call = getOwn(this.callsLookup, conversationId); if (!call) { log.warn('Trying to set outgoing video for a non-existent call'); return; @@ -1413,7 +1674,7 @@ export class CallingClass { hasLocalVideo: boolean, source?: PresentedSource ): Promise { - const call = getOwn(this.callsByConversation, conversationId); + const call = getOwn(this.callsLookup, conversationId); if (!call) { log.warn('Trying to set presenting for a non-existent call'); return; @@ -2156,7 +2417,7 @@ export class CallingClass { } private attachToCall(conversation: ConversationModel, call: Call): void { - this.callsByConversation[conversation.id] = call; + this.callsLookup[conversation.id] = call; const { reduxInterface } = this; if (!reduxInterface) { @@ -2174,7 +2435,7 @@ export class CallingClass { if (call.state === CallState.Ended) { this.stopDeviceReselectionTimer(); this.lastMediaDeviceSettings = undefined; - delete this.callsByConversation[conversation.id]; + delete this.callsLookup[conversation.id]; } const localCallEvent = getLocalCallEventFromDirectCall(call); diff --git a/ts/services/groupCredentialFetcher.ts b/ts/services/groupCredentialFetcher.ts index a5a5436d5f82..41a57c962516 100644 --- a/ts/services/groupCredentialFetcher.ts +++ b/ts/services/groupCredentialFetcher.ts @@ -2,7 +2,11 @@ // SPDX-License-Identifier: AGPL-3.0-only import { first, last, sortBy } from 'lodash'; -import { AuthCredentialWithPniResponse } from '@signalapp/libsignal-client/zkgroup'; +import { + AuthCredentialWithPniResponse, + CallLinkAuthCredentialResponse, + GenericServerPublicParams, +} from '@signalapp/libsignal-client/zkgroup'; import { getClientZkAuthOperations } from '../util/zkgroup'; @@ -23,14 +27,14 @@ type RequestDatesType = { startDayInMs: number; endDayInMs: number; }; -type NextCredentialsType = { +export type NextCredentialsType = { today: GroupCredentialType; tomorrow: GroupCredentialType; }; let started = false; -function getCheckedCredentials(reason: string): CredentialsDataType { +function getCheckedGroupCredentials(reason: string): CredentialsDataType { const result = window.storage.get('groupCredentials'); strictAssert( result !== undefined, @@ -39,6 +43,17 @@ function getCheckedCredentials(reason: string): CredentialsDataType { return result; } +function getCheckedCallLinkAuthCredentials( + reason: string +): CredentialsDataType { + const result = window.storage.get('callLinkAuthCredentials'); + strictAssert( + result !== undefined, + `getCheckedCallLinkAuthCredentials: no credentials found, ${reason}` + ); + return result; +} + export async function initializeGroupCredentialFetcher(): Promise { if (started) { return; @@ -97,46 +112,57 @@ export async function runWithRetry( } } -// In cases where we are at a day boundary, we might need to use tomorrow in a retry -export function getCheckedCredentialsForToday( - reason: string +function getCredentialsForToday( + credentials: CredentialsDataType ): NextCredentialsType { - const data = getCheckedCredentials(reason); - const today = toDayMillis(Date.now()); - const todayIndex = data.findIndex( + const todayIndex = credentials.findIndex( (item: GroupCredentialType) => item.redemptionTime === today ); if (todayIndex < 0) { throw new Error( 'getCredentialsForToday: Cannot find credentials for today. ' + - `First: ${first(data)?.redemptionTime}, ` + - `last: ${last(data)?.redemptionTime}` + `First: ${first(credentials)?.redemptionTime}, ` + + `last: ${last(credentials)?.redemptionTime}` ); } return { - today: data[todayIndex], - tomorrow: data[todayIndex + 1], + today: credentials[todayIndex], + tomorrow: credentials[todayIndex + 1], }; } +// In cases where we are at a day boundary, we might need to use tomorrow in a retry +export function getCheckedGroupCredentialsForToday( + reason: string +): NextCredentialsType { + return getCredentialsForToday(getCheckedGroupCredentials(reason)); +} + +export function getCheckedCallLinkAuthCredentialsForToday( + reason: string +): NextCredentialsType { + return getCredentialsForToday(getCheckedCallLinkAuthCredentials(reason)); +} + export async function maybeFetchNewCredentials(): Promise { const logId = 'maybeFetchNewCredentials'; - const aci = window.textsecure.storage.user.getAci(); - if (!aci) { + const maybeAci = window.textsecure.storage.user.getAci(); + if (!maybeAci) { log.info(`${logId}: no ACI, returning early`); return; } + const aci = maybeAci; - const previous: CredentialsDataType = + const prevGroupCredentials: CredentialsDataType = window.storage.get('groupCredentials') ?? []; - const requestDates = getDatesForRequest(previous); - if (!requestDates) { - log.info(`${logId}: no new credentials needed`); - return; - } + const prevCallLinkAuthCredentials: CredentialsDataType = + window.storage.get('callLinkAuthCredentials') ?? []; + + const requestDates = getDatesForRequest(prevGroupCredentials); + const requestDatesCallLinks = getDatesForRequest(prevCallLinkAuthCredentials); const { server } = window.textsecure; if (!server) { @@ -144,7 +170,22 @@ export async function maybeFetchNewCredentials(): Promise { return; } - const { startDayInMs, endDayInMs } = requestDates; + let startDayInMs: number; + let endDayInMs: number; + if (requestDates) { + startDayInMs = requestDates.startDayInMs; + endDayInMs = requestDates.endDayInMs; + if (requestDatesCallLinks) { + startDayInMs = Math.min(startDayInMs, requestDatesCallLinks.startDayInMs); + endDayInMs = Math.max(endDayInMs, requestDatesCallLinks.endDayInMs); + } + } else if (requestDatesCallLinks) { + startDayInMs = requestDatesCallLinks.startDayInMs; + endDayInMs = requestDatesCallLinks.endDayInMs; + } else { + log.info(`${logId}: no new credentials needed`); + return; + } log.info( `${logId}: fetching credentials for ${startDayInMs} through ${endDayInMs}` ); @@ -156,8 +197,11 @@ export async function maybeFetchNewCredentials(): Promise { // Received credentials depend on us knowing up-to-date PNI. Use the latest // value from the server and log error on mismatch. - const { pni: untaggedPni, credentials: rawCredentials } = - await server.getGroupCredentials({ startDayInMs, endDayInMs }); + const { + pni: untaggedPni, + credentials: rawCredentials, + callLinkAuthCredentials, + } = await server.getGroupCredentials({ startDayInMs, endDayInMs }); strictAssert( untaggedPni, 'Server must give pni along with group credentials' @@ -169,42 +213,98 @@ export async function maybeFetchNewCredentials(): Promise { log.error(`${logId}: local PNI ${localPni}, does not match remote ${pni}`); } - const newCredentials = sortCredentials(rawCredentials).map( - (item: GroupCredentialType) => { - const authCredential = - clientZKAuthOperations.receiveAuthCredentialWithPniAsServiceId( - toAciObject(aci), - toPniObject(pni), - item.redemptionTime, - new AuthCredentialWithPniResponse( - Buffer.from(item.credential, 'base64') - ) - ); - const credential = authCredential.serialize().toString('base64'); + function formatCredential(item: GroupCredentialType): GroupCredentialType { + const authCredential = + clientZKAuthOperations.receiveAuthCredentialWithPniAsServiceId( + toAciObject(aci), + toPniObject(pni), + item.redemptionTime, + new AuthCredentialWithPniResponse( + Buffer.from(item.credential, 'base64') + ) + ); + const credential = authCredential.serialize().toString('base64'); - return { - redemptionTime: item.redemptionTime * durations.SECOND, - credential, - }; - } + return { + redemptionTime: item.redemptionTime * durations.SECOND, + credential, + }; + } + + const newGroupCredentials = + sortCredentials(rawCredentials).map(formatCredential); + const genericServerPublicParamsBase64 = window.getGenericServerPublicParams(); + const genericServerPublicParams = new GenericServerPublicParams( + Buffer.from(genericServerPublicParamsBase64, 'base64') + ); + + function formatCallingCredential( + item: GroupCredentialType + ): GroupCredentialType { + const response = new CallLinkAuthCredentialResponse( + Buffer.from(item.credential, 'base64') + ); + const authCredential = response.receive( + toAciObject(aci), + item.redemptionTime, + genericServerPublicParams + ); + const credential = authCredential.serialize().toString('base64'); + + return { + redemptionTime: item.redemptionTime * durations.SECOND, + credential, + }; + } + + const newCallLinkAuthCredentialsRaw = sortCredentials( + callLinkAuthCredentials + ); + const newCallLinkAuthCredentials = newCallLinkAuthCredentialsRaw.map( + formatCallingCredential ); const today = toDayMillis(Date.now()); - const previousCleaned = previous - ? previous.filter( - (item: GroupCredentialType) => item.redemptionTime >= today - ) - : []; - const finalCredentials = [...previousCleaned, ...newCredentials]; + const prevGroupCredentialsCleaned = + prevGroupCredentials?.filter( + (item: GroupCredentialType) => item.redemptionTime >= today + ) ?? []; + const prevCallLinkAuthCredentialsCleaned = + prevCallLinkAuthCredentials?.filter( + (item: GroupCredentialType) => item.redemptionTime >= today + ) ?? []; + const finalGroupCredentials = [ + ...prevGroupCredentialsCleaned, + ...newGroupCredentials, + ]; + const finalCallLinkAuthCredentials = [ + ...prevCallLinkAuthCredentialsCleaned, + ...newCallLinkAuthCredentials, + ]; log.info( - `${logId}: saving ${newCredentials.length} new credentials, ` + - `cleaning up ${previous.length - previousCleaned.length} old ` + - `credentials, haveToday=${haveToday(finalCredentials)}` + `${logId}: saving ${ + finalGroupCredentials.length + } new group credentials, cleaning up ${ + prevGroupCredentials.length - prevGroupCredentialsCleaned.length + } old group credentials, haveToday=${haveToday(finalGroupCredentials)}` + ); + log.info( + `${logId}: saving ${ + finalCallLinkAuthCredentials.length + } new call link auth credentials, cleaning up ${ + prevCallLinkAuthCredentials.length - + prevCallLinkAuthCredentialsCleaned.length + } old call link auth credentials, haveToday=${haveToday( + finalCallLinkAuthCredentials + )}` ); - // Note: we don't wait for this to finish - await window.storage.put('groupCredentials', finalCredentials); + await window.storage.put('groupCredentials', finalGroupCredentials); + await window.storage.put( + 'callLinkAuthCredentials', + finalCallLinkAuthCredentials + ); log.info(`${logId}: Save complete.`); } diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index ab1fb90108ec..ee3031f292ff 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -7,12 +7,16 @@ import { hasScreenCapturePermission, openSystemPreferences, } from 'mac-screen-capture-permissions'; -import { has, omit } from 'lodash'; +import { omit } from 'lodash'; import type { ReadonlyDeep } from 'type-fest'; -import type { Reaction as CallReaction } from '@signalapp/ringrtc'; +import { + CallLinkRootKey, + GroupCallEndReason, + type Reaction as CallReaction, +} from '@signalapp/ringrtc'; import { getOwn } from '../../util/getOwn'; import * as Errors from '../../types/errors'; -import { getPlatform } from '../selectors/user'; +import { getIntl, getPlatform } from '../selectors/user'; import { isConversationTooBigToRing } from '../../conversations/isConversationTooBigToRing'; import { missingCaseError } from '../../util/missingCaseError'; import { drop } from '../../util/drop'; @@ -28,6 +32,7 @@ import type { PresentedSource, PresentableSource, } from '../../types/Calling'; +import type { CallLinkRestrictions } from '../../types/CallLink'; import { CALLING_REACTIONS_LIFETIME, MAX_CALLING_REACTIONS, @@ -41,6 +46,7 @@ import { } from '../../types/Calling'; import { callingTones } from '../../util/callingTones'; import { requestCameraPermissions } from '../../util/callingPermissions'; +import { getRoomIdFromRootKey } from '../../util/callLinks'; import { sleep } from '../../util/sleep'; import { LatestQueue } from '../../util/LatestQueue'; import type { AciString } from '../../types/ServiceId'; @@ -48,7 +54,7 @@ import type { ConversationChangedActionType, ConversationRemovedActionType, } from './conversations'; -import { getConversationCallMode, updateLastMessage } from './conversations'; +import { updateLastMessage } from './conversations'; import * as log from '../../logging/log'; import { strictAssert } from '../../util/assert'; import { waitForOnline } from '../../util/waitForOnline'; @@ -62,6 +68,13 @@ import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions'; import { isAnybodyElseInGroupCall } from './callingHelpers'; import { SafetyNumberChangeSource } from '../../components/SafetyNumberChangeDialog'; +import { + isGroupOrAdhocCallMode, + isGroupOrAdhocCallState, +} from '../../util/isGroupOrAdhocCall'; +import type { ShowErrorModalActionType } from './globalModals'; +import { SHOW_ERROR_MODAL } from './globalModals'; +import { ButtonVariant } from '../../components/Button'; // State @@ -112,7 +125,7 @@ type GroupCallRingStateType = ReadonlyDeep< // eslint-disable-next-line local-rules/type-alias-readonlydeep export type GroupCallStateType = { - callMode: CallMode.Group; + callMode: CallMode.Group | CallMode.Adhoc; conversationId: string; connectionState: GroupCallConnectionState; localDemuxId: number | undefined; @@ -125,6 +138,7 @@ export type GroupCallStateType = { // eslint-disable-next-line local-rules/type-alias-readonlydeep export type ActiveCallStateType = { + callMode: CallMode; conversationId: string; hasLocalAudio: boolean; hasLocalVideo: boolean; @@ -148,9 +162,32 @@ export type CallsByConversationType = { [conversationId: string]: DirectCallStateType | GroupCallStateType; }; +// eslint-disable-next-line local-rules/type-alias-readonlydeep +export type AdhocCallsType = { + [roomId: string]: GroupCallStateType; +}; + +export type CallLinkStateType = ReadonlyDeep<{ + name: string; + restrictions: CallLinkRestrictions; + expiration: number; +}>; + +export type CallLinksByRoomIdStateType = ReadonlyDeep< + CallLinkStateType & { + rootKey: string; + } +>; + +export type CallLinksByRoomIdType = ReadonlyDeep<{ + [roomId: string]: CallLinksByRoomIdStateType; +}>; + // eslint-disable-next-line local-rules/type-alias-readonlydeep export type CallingStateType = MediaDeviceSettings & { callsByConversation: CallsByConversationType; + adhocCalls: AdhocCallsType; + callLinks: CallLinksByRoomIdType; activeCallState?: ActiveCallStateType; }; @@ -181,6 +218,7 @@ export type DeclineCallType = ReadonlyDeep<{ // eslint-disable-next-line local-rules/type-alias-readonlydeep type GroupCallStateChangeArgumentType = { + callMode: CallMode.Group | CallMode.Adhoc; connectionState: GroupCallConnectionState; conversationId: string; hasLocalAudio: boolean; @@ -192,6 +230,7 @@ type GroupCallStateChangeArgumentType = { }; type GroupCallReactionsReceivedArgumentType = ReadonlyDeep<{ + callMode: CallMode; conversationId: string; reactions: Array; }>; @@ -231,10 +270,12 @@ export type SendGroupCallRaiseHandType = ReadonlyDeep<{ }>; export type SendGroupCallReactionType = ReadonlyDeep<{ + callMode: CallMode; conversationId: string; value: string; }>; type SendGroupCallReactionLocalCopyType = ReadonlyDeep<{ + callMode: CallMode; conversationId: string; value: string; timestamp: number; @@ -252,7 +293,7 @@ type StartDirectCallType = ReadonlyDeep<{ export type StartCallType = ReadonlyDeep< StartDirectCallType & { - callMode: CallMode.Direct | CallMode.Group; + callMode: CallMode.Direct | CallMode.Group | CallMode.Adhoc; } >; @@ -285,6 +326,10 @@ export type StartCallingLobbyType = ReadonlyDeep<{ isVideoCall: boolean; }>; +export type StartCallLinkLobbyType = ReadonlyDeep<{ + rootKey: string; +}>; + // eslint-disable-next-line local-rules/type-alias-readonlydeep type StartCallingLobbyPayloadType = | { @@ -305,6 +350,21 @@ type StartCallingLobbyPayloadType = remoteParticipants: Array; }; +// eslint-disable-next-line local-rules/type-alias-readonlydeep +type StartCallLinkLobbyPayloadType = { + callMode: CallMode.Adhoc; + conversationId: string; + connectionState: GroupCallConnectionState; + joinState: GroupCallJoinState; + hasLocalAudio: boolean; + hasLocalVideo: boolean; + isConversationTooBigToRing: boolean; + peekInfo?: GroupCallPeekInfoType; + remoteParticipants: Array; + callLinkState: CallLinkStateType; + callLinkRootKey: string; +}; + // eslint-disable-next-line local-rules/type-alias-readonlydeep export type SetLocalPreviewType = { element: React.RefObject | undefined; @@ -319,10 +379,18 @@ export type SetRendererCanvasType = { export const getActiveCall = ({ activeCallState, + adhocCalls, callsByConversation, -}: CallingStateType): undefined | DirectCallStateType | GroupCallStateType => - activeCallState && - getOwn(callsByConversation, activeCallState.conversationId); +}: CallingStateType): undefined | DirectCallStateType | GroupCallStateType => { + if (!activeCallState) { + return; + } + + const { callMode, conversationId } = activeCallState; + return callMode === CallMode.Adhoc + ? getOwn(adhocCalls, conversationId) + : getOwn(callsByConversation, conversationId); +}; const getGroupCallRingState = ( call: Readonly @@ -337,23 +405,26 @@ const getGroupCallRingState = ( // get an update), and we also don't want to update too often. That's why we use a // "latest queue". const peekQueueByConversation = new Map(); -const doGroupCallPeek = ( - conversationId: string, +const doGroupCallPeek = ({ + conversationId, + callMode, + dispatch, + getState, +}: { + conversationId: string; + callMode: CallMode.Group | CallMode.Adhoc; dispatch: ThunkDispatch< RootStateType, unknown, PeekGroupCallFulfilledActionType - >, - getState: () => RootStateType -) => { + >; + getState: () => RootStateType; +}) => { const conversation = getOwn( getState().conversations.conversationLookup, conversationId ); - if ( - !conversation || - getConversationCallMode(conversation) !== CallMode.Group - ) { + if (!conversation || !isGroupOrAdhocCallMode(callMode)) { return; } @@ -379,7 +450,7 @@ const doGroupCallPeek = ( ); if ( existingCall != null && - existingCall.callMode === CallMode.Group && + isGroupOrAdhocCallState(existingCall) && existingCall.connectionState !== GroupCallConnectionState.NotConnected ) { log.info( @@ -395,7 +466,15 @@ const doGroupCallPeek = ( let peekInfo = null; try { - peekInfo = await calling.peekGroupCall(conversationId); + if (callMode === CallMode.Group) { + peekInfo = await calling.peekGroupCall(conversationId); + } else { + const rootKey: string | undefined = getOwn( + state.calling.callLinks, + conversationId + )?.rootKey; + peekInfo = await calling.peekCallLinkCall(conversationId, rootKey); + } } catch (err) { log.error('Group call peeking failed', Errors.toLogFormat(err)); return; @@ -409,20 +488,23 @@ const doGroupCallPeek = ( `doGroupCallPeek/groupv2(${conversation.groupId}): Found ${peekInfo.deviceCount} devices` ); - const joinState = - existingCall?.callMode === CallMode.Group ? existingCall.joinState : null; + if (callMode === CallMode.Group) { + const joinState = isGroupOrAdhocCallState(existingCall) + ? existingCall.joinState + : null; - try { - await calling.updateCallHistoryForGroupCall( - conversationId, - joinState, - peekInfo - ); - } catch (error) { - log.error( - 'doGroupCallPeek/groupv2: Failed to update call history', - Errors.toLogFormat(error) - ); + try { + await calling.updateCallHistoryForGroupCall( + conversationId, + joinState, + peekInfo + ); + } catch (error) { + log.error( + 'doGroupCallPeek/groupv2: Failed to update call history', + Errors.toLogFormat(error) + ); + } } const formattedPeekInfo = calling.formatGroupCallPeekInfoForRedux(peekInfo); @@ -430,6 +512,7 @@ const doGroupCallPeek = ( dispatch({ type: PEEK_GROUP_CALL_FULFILLED, payload: { + callMode, conversationId, peekInfo: formattedPeekInfo, }, @@ -447,11 +530,13 @@ const CANCEL_INCOMING_GROUP_CALL_RING = 'calling/CANCEL_INCOMING_GROUP_CALL_RING'; const CHANGE_CALL_VIEW = 'calling/CHANGE_CALL_VIEW'; const START_CALLING_LOBBY = 'calling/START_CALLING_LOBBY'; +const START_CALL_LINK_LOBBY = 'calling/START_CALL_LINK_LOBBY'; const CALL_STATE_CHANGE_FULFILLED = 'calling/CALL_STATE_CHANGE_FULFILLED'; const CHANGE_IO_DEVICE_FULFILLED = 'calling/CHANGE_IO_DEVICE_FULFILLED'; const CLOSE_NEED_PERMISSION_SCREEN = 'calling/CLOSE_NEED_PERMISSION_SCREEN'; const DECLINE_DIRECT_CALL = 'calling/DECLINE_DIRECT_CALL'; const GROUP_CALL_AUDIO_LEVELS_CHANGE = 'calling/GROUP_CALL_AUDIO_LEVELS_CHANGE'; +const GROUP_CALL_ENDED = 'calling/GROUP_CALL_ENDED'; const GROUP_CALL_RAISED_HANDS_CHANGE = 'calling/GROUP_CALL_RAISED_HANDS_CHANGE'; const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE'; const GROUP_CALL_REACTIONS_RECEIVED = 'calling/GROUP_CALL_REACTIONS_RECEIVED'; @@ -503,6 +588,12 @@ type StartCallingLobbyActionType = { payload: StartCallingLobbyPayloadType; }; +// eslint-disable-next-line local-rules/type-alias-readonlydeep +type StartCallLinkLobbyActionType = { + type: 'calling/START_CALL_LINK_LOBBY'; + payload: StartCallLinkLobbyPayloadType; +}; + type CallStateChangeFulfilledActionType = ReadonlyDeep<{ type: 'calling/CALL_STATE_CHANGE_FULFILLED'; payload: CallStateChangeType; @@ -524,6 +615,7 @@ type DeclineCallActionType = ReadonlyDeep<{ }>; type GroupCallAudioLevelsChangeActionPayloadType = ReadonlyDeep<{ + callMode: CallMode; conversationId: string; localAudioLevel: number; remoteDeviceStates: ReadonlyArray<{ audioLevel: number; demuxId: number }>; @@ -534,7 +626,18 @@ type GroupCallAudioLevelsChangeActionType = ReadonlyDeep<{ payload: GroupCallAudioLevelsChangeActionPayloadType; }>; +type GroupCallEndedActionPayloadType = ReadonlyDeep<{ + conversationId: string; + endedReason: GroupCallEndReason; +}>; + +export type GroupCallEndedActionType = ReadonlyDeep<{ + type: 'calling/GROUP_CALL_ENDED'; + payload: GroupCallEndedActionPayloadType; +}>; + type GroupCallRaisedHandsChangeActionPayloadType = ReadonlyDeep<{ + callMode: CallMode; conversationId: string; raisedHands: ReadonlyArray; }>; @@ -551,6 +654,7 @@ export type GroupCallStateChangeActionType = { }; type GroupCallReactionsReceivedActionPayloadType = ReadonlyDeep<{ + callMode: CallMode; conversationId: string; reactions: Array; timestamp: number; @@ -617,6 +721,7 @@ type OutgoingCallActionType = ReadonlyDeep<{ export type PeekGroupCallFulfilledActionType = ReadonlyDeep<{ type: 'calling/PEEK_GROUP_CALL_FULFILLED'; payload: { + callMode: CallMode; conversationId: string; peekInfo: GroupCallPeekInfoType; }; @@ -709,6 +814,7 @@ export type CallingActionType = | CancelIncomingGroupCallRingActionType | ChangeCallViewActionType | StartCallingLobbyActionType + | StartCallLinkLobbyActionType | CallStateChangeFulfilledActionType | ChangeIODeviceFulfilledActionType | CloseNeedPermissionScreenActionType @@ -716,6 +822,7 @@ export type CallingActionType = | ConversationRemovedActionType | DeclineCallActionType | GroupCallAudioLevelsChangeActionType + | GroupCallEndedActionType | GroupCallRaisedHandsChangeActionType | GroupCallStateChangeActionType | GroupCallReactionsReceivedActionType @@ -753,7 +860,8 @@ function acceptCall( return async (dispatch, getState) => { const { conversationId, asVideoCall } = payload; - const call = getOwn(getState().calling.callsByConversation, conversationId); + const callingState = getState().calling; + const call = getOwn(callingState.callsByConversation, conversationId); if (!call) { log.error('Trying to accept a non-existent call'); return; @@ -766,6 +874,9 @@ function acceptCall( case CallMode.Group: await calling.joinGroupCall(conversationId, true, asVideoCall, false); break; + case CallMode.Adhoc: + log.error('Failed to accept adhoc call, this should never happen.'); + break; default: throw missingCaseError(call); } @@ -903,6 +1014,11 @@ function declineCall( } break; } + case CallMode.Adhoc: + log.error( + 'Cannot decline an adhoc call because adhoc calls should never be incoming.' + ); + break; default: throw missingCaseError(call); } @@ -950,6 +1066,45 @@ function groupCallAudioLevelsChange( return { type: GROUP_CALL_AUDIO_LEVELS_CHANGE, payload }; } +function groupCallEnded( + payload: GroupCallEndedActionPayloadType +): ThunkAction< + void, + RootStateType, + unknown, + GroupCallEndedActionType | ShowErrorModalActionType +> { + return (dispatch, getState) => { + const { endedReason } = payload; + if (endedReason === GroupCallEndReason.DeniedRequestToJoinCall) { + const i18n = getIntl(getState()); + dispatch({ + type: SHOW_ERROR_MODAL, + payload: { + title: i18n('icu:calling__join-request-denied-title'), + description: i18n('icu:calling__join-request-denied'), + buttonVariant: ButtonVariant.Primary, + }, + }); + return; + } + if (endedReason === GroupCallEndReason.RemovedFromCall) { + const i18n = getIntl(getState()); + dispatch({ + type: SHOW_ERROR_MODAL, + payload: { + title: i18n('icu:calling__removed-from-call-title'), + description: i18n('icu:calling__removed-from-call'), + buttonVariant: ButtonVariant.Primary, + }, + }); + return; + } + + dispatch({ type: GROUP_CALL_ENDED, payload }); + }; +} + function receiveGroupCallReactions( payload: GroupCallReactionsReceivedArgumentType ): ThunkAction< @@ -959,12 +1114,12 @@ function receiveGroupCallReactions( GroupCallReactionsReceivedActionType | GroupCallReactionsExpiredActionType > { return async dispatch => { - const { conversationId } = payload; + const { callMode, conversationId } = payload; const timestamp = Date.now(); dispatch({ type: GROUP_CALL_REACTIONS_RECEIVED, - payload: { ...payload, timestamp }, + payload: { ...payload, callMode, timestamp }, }); await sleep(CALLING_REACTIONS_LIFETIME); @@ -984,9 +1139,13 @@ function groupCallRaisedHandsChange( GroupCallRaisedHandsChangeActionType > { return async (dispatch, getState) => { - const { conversationId, raisedHands } = payload; + const { callMode, conversationId, raisedHands } = payload; - const existingCall = getGroupCall(conversationId, getState().calling); + const existingCall = getGroupCall( + conversationId, + getState().calling, + callMode + ); const isFirstHandRaised = existingCall && !existingCall.raisedHands?.length && @@ -1005,7 +1164,7 @@ function groupCallStateChange( return async (dispatch, getState) => { let didSomeoneStartPresenting: boolean; const activeCall = getActiveCall(getState().calling); - if (activeCall?.callMode === CallMode.Group) { + if (isGroupOrAdhocCallState(activeCall)) { const wasSomeonePresenting = activeCall.remoteParticipants.some( participant => participant.presenting ); @@ -1066,10 +1225,15 @@ function hangUpActiveCall( }, }); - if (activeCall.callMode === CallMode.Group) { + if (isGroupOrAdhocCallState(activeCall)) { // We want to give the group call time to disconnect. await sleep(1000); - doGroupCallPeek(conversationId, dispatch, getState); + doGroupCallPeek({ + conversationId, + callMode: activeCall.callMode, + dispatch, + getState, + }); } }; } @@ -1086,7 +1250,7 @@ function keyChanged( return; } - if (activeCall.callMode === CallMode.Group) { + if (isGroupOrAdhocCallState(activeCall)) { const acisChanged = new Set(activeCallState.safetyNumberChangedAcis); // Iterate over each participant to ensure that the service id passed in @@ -1146,13 +1310,13 @@ function sendGroupCallReaction( SendGroupCallReactionActionType | GroupCallReactionsExpiredActionType > { return async dispatch => { - const { conversationId } = payload; + const { callMode, conversationId } = payload; const timestamp = Date.now(); calling.sendGroupCallReaction(payload.conversationId, payload.value); dispatch({ type: SEND_GROUP_CALL_REACTION, - payload: { ...payload, timestamp }, + payload: { ...payload, callMode, timestamp }, }); await sleep(CALLING_REACTIONS_LIFETIME); @@ -1216,9 +1380,19 @@ function peekGroupCallForTheFirstTime( return (dispatch, getState) => { const call = getOwn(getState().calling.callsByConversation, conversationId); const shouldPeek = - !call || (call.callMode === CallMode.Group && !call.peekInfo); + !call || (isGroupOrAdhocCallState(call) && !call.peekInfo); + const callMode = call?.callMode ?? CallMode.Group; + if (callMode === CallMode.Direct) { + return; + } + if (shouldPeek) { - doGroupCallPeek(conversationId, dispatch, getState); + doGroupCallPeek({ + conversationId, + callMode, + dispatch, + getState, + }); } }; } @@ -1230,12 +1404,17 @@ function peekGroupCallIfItHasMembers( const call = getOwn(getState().calling.callsByConversation, conversationId); const shouldPeek = call && - call.callMode === CallMode.Group && + isGroupOrAdhocCallState(call) && call.joinState === GroupCallJoinState.NotJoined && call.peekInfo && call.peekInfo.deviceCount > 0; if (shouldPeek) { - doGroupCallPeek(conversationId, dispatch, getState); + doGroupCallPeek({ + conversationId, + callMode: call.callMode, + dispatch, + getState, + }); } }; } @@ -1245,7 +1424,12 @@ function peekNotConnectedGroupCall( ): ThunkAction { return (dispatch, getState) => { const { conversationId } = payload; - doGroupCallPeek(conversationId, dispatch, getState); + doGroupCallPeek({ + conversationId, + callMode: CallMode.Group, + dispatch, + getState, + }); }; } @@ -1338,7 +1522,7 @@ function setLocalVideo( let enabled: boolean; if (await requestCameraPermissions()) { if ( - activeCall.callMode === CallMode.Group || + isGroupOrAdhocCallState(activeCall) || (activeCall.callMode === CallMode.Direct && activeCall.callState) ) { calling.setOutgoingVideo(activeCall.conversationId, payload.enabled); @@ -1440,7 +1624,7 @@ function onOutgoingVideoCallInConversation( const isOngoingGroupCall = call && ourAci && - call.callMode === CallMode.Group && + isGroupOrAdhocCallState(call) && call.peekInfo && isAnybodyElseInGroupCall(call.peekInfo, ourAci); @@ -1520,6 +1704,86 @@ function onOutgoingAudioCallInConversation( }; } +function startCallLinkLobby({ + rootKey, +}: StartCallLinkLobbyType): ThunkAction< + void, + RootStateType, + unknown, + StartCallLinkLobbyActionType | ShowErrorModalActionType +> { + return async (dispatch, getState) => { + 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 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, + }, + }); + }; +} + function startCallingLobby({ conversationId, isVideoCall, @@ -1546,7 +1810,11 @@ function startCallingLobby({ ); // The group call device count is considered 0 for a direct call. - const groupCall = getGroupCall(conversationId, state.calling); + const groupCall = getGroupCall( + conversationId, + state.calling, + CallMode.Group + ); const groupCallDeviceCount = groupCall?.peekInfo?.deviceCount || groupCall?.remoteParticipants.length || @@ -1576,12 +1844,13 @@ function startCall( payload: StartCallType ): ThunkAction { return async (dispatch, getState) => { + const { conversationId, hasLocalAudio, hasLocalVideo } = payload; switch (payload.callMode) { case CallMode.Direct: await calling.startOutgoingDirectCall( - payload.conversationId, - payload.hasLocalAudio, - payload.hasLocalVideo + conversationId, + hasLocalAudio, + hasLocalVideo ); dispatch({ type: START_DIRECT_CALL, @@ -1606,15 +1875,37 @@ function startCall( } await calling.joinGroupCall( - payload.conversationId, - payload.hasLocalAudio, - payload.hasLocalVideo, + conversationId, + hasLocalAudio, + hasLocalVideo, outgoingRing ); // The calling service should already be wired up to Redux so we don't need to // dispatch anything here. break; } + case CallMode.Adhoc: { + const state = getState(); + + const callLink = getOwn(state.calling.callLinks, conversationId); + if (!callLink) { + log.error( + `startCall: Failed to start call link call because roomId ${conversationId} is missing from calling state` + ); + return; + } + + await calling.joinCallLinkCall({ + roomId: conversationId, + rootKey: callLink.rootKey, + hasLocalAudio, + hasLocalVideo, + }); + + // The calling service should already be wired up to Redux so we don't need to + // dispatch anything here. + break; + } default: throw missingCaseError(payload.callMode); } @@ -1674,6 +1965,7 @@ export const actions = { declineCall, getPresentingSources, groupCallAudioLevelsChange, + groupCallEnded, groupCallRaisedHandsChange, groupCallStateChange, hangUpActiveCall, @@ -1704,6 +1996,7 @@ export const actions = { setPresenting, setRendererCanvas, startCall, + startCallLinkLobby, startCallingLobby, switchToPresentationView, switchFromPresentationView, @@ -1731,16 +2024,22 @@ export function getEmptyState(): CallingStateType { selectedSpeaker: undefined, callsByConversation: {}, + adhocCalls: {}, activeCallState: undefined, + callLinks: {}, }; } function getGroupCall( conversationId: string, - state: Readonly + state: Readonly, + callMode: CallMode ): undefined | GroupCallStateType { - const call = getOwn(state.callsByConversation, conversationId); - return call?.callMode === CallMode.Group ? call : undefined; + const call = + callMode === CallMode.Adhoc + ? getOwn(state.adhocCalls, conversationId) + : getOwn(state.callsByConversation, conversationId); + return isGroupOrAdhocCallState(call) ? call : undefined; } function removeConversationFromState( @@ -1752,6 +2051,40 @@ function removeConversationFromState( ? omit(state, 'activeCallState') : state), callsByConversation: omit(state.callsByConversation, conversationId), + adhocCalls: omit(state.adhocCalls, conversationId), + }; +} + +function mergeCallWithGroupCallLookups({ + state, + callMode, + conversationId, + call, +}: { + state: Readonly; + callMode: CallMode; + conversationId: string; + call: GroupCallStateType; +}): { + callsByConversation: CallsByConversationType; + adhocCalls: AdhocCallsType; +} { + const { callsByConversation, adhocCalls } = state; + const isAdhocCall = callMode === CallMode.Adhoc; + + return { + callsByConversation: isAdhocCall + ? callsByConversation + : { + ...callsByConversation, + [conversationId]: call, + }, + adhocCalls: isAdhocCall + ? { + ...adhocCalls, + [conversationId]: call, + } + : adhocCalls, }; } @@ -1759,14 +2092,18 @@ export function reducer( state: Readonly = getEmptyState(), action: Readonly ): CallingStateType { - const { callsByConversation } = state; + const { callsByConversation, adhocCalls } = state; - if (action.type === START_CALLING_LOBBY) { - const { conversationId } = action.payload; + if ( + action.type === START_CALLING_LOBBY || + action.type === START_CALL_LINK_LOBBY + ) { + const { callMode, conversationId } = action.payload; let call: DirectCallStateType | GroupCallStateType; + let newAdhocCalls: AdhocCallsType; let outgoingRing: boolean; - switch (action.payload.callMode) { + switch (callMode) { case CallMode.Direct: call = { callMode: CallMode.Direct, @@ -1775,46 +2112,72 @@ export function reducer( isVideoCall: action.payload.hasLocalVideo, }; outgoingRing = true; + newAdhocCalls = adhocCalls; break; - case CallMode.Group: { + case CallMode.Group: + case CallMode.Adhoc: { + const { connectionState, joinState, peekInfo, remoteParticipants } = + action.payload; // We expect to be in this state briefly. The Calling service should update the // call state shortly. - const existingCall = getGroupCall(conversationId, state); + const existingCall = getGroupCall(conversationId, state, callMode); const ringState = getGroupCallRingState(existingCall); call = { - callMode: CallMode.Group, + callMode, conversationId, - connectionState: action.payload.connectionState, - joinState: action.payload.joinState, + connectionState, + joinState, localDemuxId: undefined, - peekInfo: action.payload.peekInfo || + peekInfo: peekInfo || existingCall?.peekInfo || { - acis: action.payload.remoteParticipants.map(({ aci }) => aci), + acis: remoteParticipants.map(({ aci }) => aci), maxDevices: Infinity, - deviceCount: action.payload.remoteParticipants.length, + deviceCount: remoteParticipants.length, }, - remoteParticipants: action.payload.remoteParticipants, + remoteParticipants, ...ringState, }; - outgoingRing = - !ringState.ringId && - !call.peekInfo?.acis.length && - !call.remoteParticipants.length && - !action.payload.isConversationTooBigToRing; + newAdhocCalls = + callMode === CallMode.Adhoc + ? { + ...adhocCalls, + [conversationId]: call, + } + : adhocCalls; + outgoingRing = false; break; } default: throw missingCaseError(action.payload); } + const { callLinks } = state; + + const newCallsByConversation = + callMode === CallMode.Adhoc + ? callsByConversation + : { + ...callsByConversation, + [conversationId]: call, + }; + return { ...state, - callsByConversation: { - ...callsByConversation, - [action.payload.conversationId]: call, - }, + callsByConversation: newCallsByConversation, + adhocCalls: newAdhocCalls, + callLinks: + action.type === START_CALL_LINK_LOBBY + ? { + ...callLinks, + [conversationId]: { + ...action.payload.callLinkState, + rootKey: action.payload.callLinkRootKey, + }, + } + : callLinks, activeCallState: { - conversationId: action.payload.conversationId, + callMode, + conversationId, hasLocalAudio: action.payload.hasLocalAudio, hasLocalVideo: action.payload.hasLocalVideo, localAudioLevel: 0, @@ -1843,6 +2206,7 @@ export function reducer( }, }, activeCallState: { + callMode: CallMode.Direct, conversationId: action.payload.conversationId, hasLocalAudio: action.payload.hasLocalAudio, hasLocalVideo: action.payload.hasLocalVideo, @@ -1859,7 +2223,11 @@ export function reducer( } if (action.type === ACCEPT_CALL_PENDING) { - if (!has(state.callsByConversation, action.payload.conversationId)) { + const call = getOwn( + state.callsByConversation, + action.payload.conversationId + ); + if (!call) { log.warn('Unable to accept a non-existent call'); return state; } @@ -1867,6 +2235,7 @@ export function reducer( return { ...state, activeCallState: { + callMode: call.callMode, conversationId: action.payload.conversationId, hasLocalAudio: true, hasLocalVideo: action.payload.asVideoCall, @@ -1897,6 +2266,17 @@ export function reducer( return removeConversationFromState(state, activeCall.conversationId); case CallMode.Group: 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); } @@ -1905,7 +2285,7 @@ export function reducer( if (action.type === CANCEL_INCOMING_GROUP_CALL_RING) { const { conversationId, ringId } = action.payload; - const groupCall = getGroupCall(conversationId, state); + const groupCall = getGroupCall(conversationId, state, CallMode.Group); if (!groupCall || groupCall.ringId !== ringId) { return state; } @@ -1925,7 +2305,7 @@ export function reducer( if ( !activeCallState?.outgoingRing || activeCallState.conversationId !== action.payload.id || - activeCall?.callMode !== CallMode.Group || + !isGroupOrAdhocCallState(activeCall) || activeCall.joinState !== GroupCallJoinState.NotJoined || !isConversationTooBigToRing(action.payload.data) ) { @@ -1966,7 +2346,11 @@ export function reducer( const { conversationId, ringId, ringerAci } = action.payload; let groupCall: GroupCallStateType; - const existingGroupCall = getGroupCall(conversationId, state); + const existingGroupCall = getGroupCall( + conversationId, + state, + CallMode.Group + ); if (existingGroupCall) { if (existingGroupCall.ringerAci) { log.info('Group call was already ringing'); @@ -2023,6 +2407,7 @@ export function reducer( }, }, activeCallState: { + callMode: CallMode.Direct, conversationId: action.payload.conversationId, hasLocalAudio: action.payload.hasLocalAudio, hasLocalVideo: action.payload.hasLocalVideo, @@ -2085,10 +2470,10 @@ export function reducer( } if (action.type === GROUP_CALL_AUDIO_LEVELS_CHANGE) { - const { conversationId, remoteDeviceStates } = action.payload; + const { callMode, conversationId, remoteDeviceStates } = action.payload; const { activeCallState } = state; - const existingCall = getGroupCall(conversationId, state); + const existingCall = getGroupCall(conversationId, state, callMode); // The PiP check is an optimization. We don't need to update audio levels if the user // cannot see them. @@ -2125,15 +2510,18 @@ export function reducer( return { ...state, activeCallState: { ...activeCallState, localAudioLevel }, - callsByConversation: { - ...callsByConversation, - [conversationId]: { ...existingCall, remoteAudioLevels }, - }, + ...mergeCallWithGroupCallLookups({ + state, + callMode: existingCall.callMode, + conversationId, + call: { ...existingCall, remoteAudioLevels }, + }), }; } if (action.type === GROUP_CALL_STATE_CHANGE) { const { + callMode, connectionState, conversationId, hasLocalAudio, @@ -2145,7 +2533,7 @@ export function reducer( remoteParticipants, } = action.payload; - const existingCall = getGroupCall(conversationId, state); + const existingCall = getGroupCall(conversationId, state, callMode); const existingRingState = getGroupCallRingState(existingCall); const newPeekInfo = peekInfo || @@ -2188,32 +2576,37 @@ export function reducer( newRingState = {}; } + const call = { + callMode, + conversationId, + connectionState, + joinState, + localDemuxId, + peekInfo: newPeekInfo, + remoteParticipants, + raisedHands: existingCall?.raisedHands ?? [], + ...newRingState, + }; + return { ...state, - callsByConversation: { - ...callsByConversation, - [conversationId]: { - callMode: CallMode.Group, - conversationId, - connectionState, - joinState, - localDemuxId, - peekInfo: newPeekInfo, - remoteParticipants, - raisedHands: existingCall?.raisedHands ?? [], - ...newRingState, - }, - }, + ...mergeCallWithGroupCallLookups({ + state, + callMode, + conversationId, + call, + }), activeCallState: newActiveCallState, }; } if (action.type === PEEK_GROUP_CALL_FULFILLED) { - const { conversationId, peekInfo } = action.payload; + const { callMode, conversationId, peekInfo } = action.payload; const existingCall: GroupCallStateType = getGroupCall( conversationId, - state + state, + callMode ) || { callMode: CallMode.Group, conversationId, @@ -2243,13 +2636,12 @@ export function reducer( return { ...state, - callsByConversation: { - ...callsByConversation, - [conversationId]: { - ...existingCall, - peekInfo, - }, - }, + ...mergeCallWithGroupCallLookups({ + state, + callMode: existingCall.callMode, + conversationId, + call: { ...existingCall, peekInfo }, + }), }; } @@ -2257,7 +2649,7 @@ export function reducer( action.type === SEND_GROUP_CALL_REACTION || action.type === GROUP_CALL_REACTIONS_RECEIVED ) { - const { conversationId, timestamp } = action.payload; + const { callMode, conversationId, timestamp } = action.payload; if (state.activeCallState?.conversationId !== conversationId) { return state; } @@ -2270,7 +2662,7 @@ export function reducer( } else { // When sending reactions, ringrtc doesn't automatically receive back a copy of // the reaction you just sent. We handle it here and add a local copy to state. - const existingGroupCall = getGroupCall(conversationId, state); + const existingGroupCall = getGroupCall(conversationId, state, callMode); if (!existingGroupCall) { log.warn( 'Unable to update group call reactions after send reaction because existing group call is missing.' @@ -2332,10 +2724,10 @@ export function reducer( } if (action.type === GROUP_CALL_RAISED_HANDS_CHANGE) { - const { conversationId, raisedHands } = action.payload; + const { callMode, conversationId, raisedHands } = action.payload; const { activeCallState } = state; - const existingCall = getGroupCall(conversationId, state); + const existingCall = getGroupCall(conversationId, state, callMode); if ( state.activeCallState?.conversationId !== conversationId || @@ -2347,10 +2739,12 @@ export function reducer( return { ...state, - callsByConversation: { - ...callsByConversation, - [conversationId]: { ...existingCall, raisedHands: [...raisedHands] }, - }, + ...mergeCallWithGroupCallLookups({ + state, + callMode: existingCall.callMode, + conversationId, + call: { ...existingCall, raisedHands: [...raisedHands] }, + }), }; } diff --git a/ts/state/ducks/callingHelpers.ts b/ts/state/ducks/callingHelpers.ts index 7f7561a348c0..e258578905bc 100644 --- a/ts/state/ducks/callingHelpers.ts +++ b/ts/state/ducks/callingHelpers.ts @@ -34,6 +34,9 @@ export const getIncomingCall = ( call.connectionState === GroupCallConnectionState.NotConnected && isAnybodyElseInGroupCall(call.peekInfo, ourAci) ); + case CallMode.Adhoc: + // Adhoc calls cannot be incoming. + return; default: throw missingCaseError(call); } diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index 3ff208b6ae8f..dc700bfea40e 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -41,6 +41,7 @@ import { import { SHOW_TOAST } from './toast'; import type { ShowToastActionType } from './toast'; import { isDownloaded } from '../../types/Attachment'; +import type { ButtonVariant } from '../../components/Button'; // State @@ -86,6 +87,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{ deleteMessagesProps?: DeleteMessagesPropsType; editHistoryMessages?: EditHistoryMessagesType; errorModalProps?: { + buttonVariant?: ButtonVariant; description?: string; title?: string; }; @@ -308,6 +310,7 @@ type CloseErrorModalActionType = ReadonlyDeep<{ export type ShowErrorModalActionType = ReadonlyDeep<{ type: typeof SHOW_ERROR_MODAL; payload: { + buttonVariant?: ButtonVariant; description?: string; title?: string; }; @@ -729,15 +732,18 @@ function closeErrorModal(): CloseErrorModalActionType { } function showErrorModal({ + buttonVariant, description, title, }: { - title?: string; + buttonVariant?: ButtonVariant; description?: string; + title?: string; }): ShowErrorModalActionType { return { type: SHOW_ERROR_MODAL, payload: { + buttonVariant, description, title, }, diff --git a/ts/state/selectors/calling.ts b/ts/state/selectors/calling.ts index f0745e5749c6..e974bb5a5952 100644 --- a/ts/state/selectors/calling.ts +++ b/ts/state/selectors/calling.ts @@ -7,10 +7,14 @@ import type { StateType } from '../reducer'; import type { CallingStateType, CallsByConversationType, + AdhocCallsType, + CallLinksByRoomIdType, DirectCallStateType, GroupCallStateType, } from '../ducks/calling'; import { getIncomingCall as getIncomingCallHelper } from '../ducks/callingHelpers'; +import { CallMode } from '../../types/Calling'; +import type { CallLinkType } from '../../types/CallLink'; import { getUserACI } from './user'; import { getOwn } from '../../util/getOwn'; import type { AciString } from '../../types/ServiceId'; @@ -30,6 +34,38 @@ export const getCallsByConversation = createSelector( state.callsByConversation ); +export const getAdhocCalls = createSelector( + getCalling, + (state: CallingStateType): AdhocCallsType => state.adhocCalls +); + +export const getCallLinksByRoomId = createSelector( + getCalling, + (state: CallingStateType): CallLinksByRoomIdType => state.callLinks +); + +export type CallLinkSelectorType = (roomId: string) => CallLinkType | undefined; + +export const getCallLinkSelector = createSelector( + getCallLinksByRoomId, + (callLinksByRoomId: CallLinksByRoomIdType): CallLinkSelectorType => + (roomId: string): CallLinkType | undefined => { + const callLinkState = getOwn(callLinksByRoomId, roomId); + if (!callLinkState) { + return; + } + + const { name, restrictions, rootKey, expiration } = callLinkState; + return { + roomId, + name, + restrictions, + rootKey, + expiration, + }; + } +); + export type CallSelectorType = ( conversationId: string ) => CallStateType | undefined; @@ -40,15 +76,33 @@ export const getCallSelector = createSelector( getOwn(callsByConversation, conversationId) ); +export type AdhocCallSelectorType = ( + conversationId: string +) => GroupCallStateType | undefined; +export const getAdhocCallSelector = createSelector( + getAdhocCalls, + (adhocCalls: AdhocCallsType): AdhocCallSelectorType => + (roomId: string) => + getOwn(adhocCalls, roomId) +); + export const getActiveCall = createSelector( getActiveCallState, getCallSelector, - (activeCallState, callSelector): undefined | CallStateType => { - if (activeCallState && activeCallState.conversationId) { - return callSelector(activeCallState.conversationId); + getAdhocCallSelector, + ( + activeCallState, + callSelector, + adhocCallSelector + ): undefined | CallStateType => { + const { callMode, conversationId } = activeCallState || {}; + if (!conversationId) { + return undefined; } - return undefined; + return callMode === CallMode.Adhoc + ? adhocCallSelector(conversationId) + : callSelector(conversationId); } ); diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index e7cc2c69f824..91ea9a8acb28 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -42,7 +42,7 @@ import type { import type { PropsType as ProfileChangeNotificationPropsType } from '../../components/conversation/ProfileChangeNotification'; import type { QuotedAttachmentType } from '../../components/conversation/Quote'; -import { getDomain, isStickerPack } from '../../types/LinkPreview'; +import { getDomain, isCallLink, isStickerPack } from '../../types/LinkPreview'; import type { AciString, PniString, @@ -383,6 +383,7 @@ const getPreviewsForMessage = ({ return previews.map(preview => ({ ...preview, isStickerPack: isStickerPack(preview.url), + isCallLink: isCallLink(preview.url), domain: getDomain(preview.url), image: preview.image ? getPropsForAttachment(preview.image) : undefined, })); diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index 147885f68bed..8d20c010b41e 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -15,7 +15,7 @@ import { getIntl, getTheme } from '../selectors/user'; import { getMe, getConversationSelector } from '../selectors/conversations'; import { getActiveCall } from '../ducks/calling'; import type { ConversationType } from '../ducks/conversations'; -import { getIncomingCall } from '../selectors/calling'; +import { getCallLinkSelector, getIncomingCall } from '../selectors/calling'; import { isGroupCallRaiseHandEnabled } from '../../util/isGroupCallRaiseHandEnabled'; import { isGroupCallReactionsEnabled } from '../../util/isGroupCallReactionsEnabled'; import type { @@ -23,12 +23,14 @@ import type { ActiveCallType, ActiveDirectCallType, ActiveGroupCallType, + CallingConversationType, ConversationsByDemuxIdType, GroupCallRemoteParticipantType, } from '../../types/Calling'; import { isAciString } from '../../util/isAciString'; import type { AciString } from '../../types/ServiceId'; import { CallMode, CallState } from '../../types/Calling'; +import type { CallLinkType } from '../../types/CallLink'; import type { StateType } from '../reducer'; import { missingCaseError } from '../../util/missingCaseError'; import { SmartCallingDeviceSelection } from './CallingDeviceSelection'; @@ -51,6 +53,7 @@ import { isConversationTooBigToRing } from '../../conversations/isConversationTo import { strictAssert } from '../../util/assert'; import { renderEmojiPicker } from './renderEmojiPicker'; import { renderReactionPicker } from './renderReactionPicker'; +import { callLinkToConversation } from '../../util/callLinks'; function renderDeviceSelection(): JSX.Element { return ; @@ -133,7 +136,19 @@ const mapStateToActiveCallProp = ( } const conversationSelector = getConversationSelector(state); - const conversation = conversationSelector(activeCallState.conversationId); + let conversation: CallingConversationType; + if (call.callMode === CallMode.Adhoc) { + const callLinkSelector = getCallLinkSelector(state); + const callLink = callLinkSelector(activeCallState.conversationId); + if (!callLink) { + // An error is logged in mapStateToCallLinkProp + return undefined; + } + + conversation = callLinkToConversation(callLink, window.i18n); + } else { + conversation = conversationSelector(activeCallState.conversationId); + } if (!conversation) { log.error('The active call has no corresponding conversation'); return undefined; @@ -199,7 +214,8 @@ const mapStateToActiveCallProp = ( }, ], } satisfies ActiveDirectCallType; - case CallMode.Group: { + case CallMode.Group: + case CallMode.Adhoc: { const conversationsWithSafetyNumberChanges: Array = []; const groupMembers: Array = []; const remoteParticipants: Array = []; @@ -305,7 +321,7 @@ const mapStateToActiveCallProp = ( return { ...baseResult, - callMode: CallMode.Group, + callMode: call.callMode, connectionState: call.connectionState, conversationsWithSafetyNumberChanges, conversationsByDemuxId, @@ -326,6 +342,31 @@ const mapStateToActiveCallProp = ( } }; +const mapStateToCallLinkProp = (state: StateType): CallLinkType | undefined => { + const { calling } = state; + const { activeCallState } = calling; + + if (!activeCallState) { + return; + } + + const call = getActiveCall(calling); + if (call?.callMode !== CallMode.Adhoc) { + return; + } + + const callLinkSelector = getCallLinkSelector(state); + const callLink = callLinkSelector(activeCallState.conversationId); + if (!callLink) { + log.error( + 'Active call referred to a call link but no corresponding call link in state.' + ); + return; + } + + return callLink; +}; + const mapStateToIncomingCallProp = ( state: StateType ): DirectIncomingCall | GroupIncomingCall | null => { @@ -371,6 +412,9 @@ const mapStateToIncomingCallProp = ( remoteParticipants: call.remoteParticipants, }; } + case CallMode.Adhoc: + log.error('Cannot handle an incoming adhoc call'); + return null; default: throw missingCaseError(call); } @@ -381,6 +425,7 @@ const mapStateToProps = (state: StateType) => { return { activeCall: mapStateToActiveCallProp(state), + callLink: mapStateToCallLinkProp(state), bounceAppIconStart, bounceAppIconStop, availableCameras: state.calling.availableCameras, diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index 4c8a88176f8a..8f58178875ee 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -36,6 +36,7 @@ import { useSearchActions } from '../ducks/search'; import { useStoriesActions } from '../ducks/stories'; import { getCannotLeaveBecauseYouAreLastAdmin } from '../../components/conversation/conversation-details/ConversationDetails'; import { getGroupMemberships } from '../../util/getGroupMemberships'; +import { isGroupOrAdhocCallState } from '../../util/isGroupOrAdhocCall'; export type OwnProps = { id: string; @@ -59,10 +60,11 @@ const getOutgoingCallButtonStyle = ( return OutgoingCallButtonStyle.None; case CallMode.Direct: return OutgoingCallButtonStyle.Both; - case CallMode.Group: { + case CallMode.Group: + case CallMode.Adhoc: { const call = getOwn(calling.callsByConversation, conversation.id); if ( - call?.callMode === CallMode.Group && + isGroupOrAdhocCallState(call) && isAnybodyElseInGroupCall(call.peekInfo, ourAci) ) { return OutgoingCallButtonStyle.Join; diff --git a/ts/state/smart/GlobalModalContainer.tsx b/ts/state/smart/GlobalModalContainer.tsx index 146eb8283800..729faea04409 100644 --- a/ts/state/smart/GlobalModalContainer.tsx +++ b/ts/state/smart/GlobalModalContainer.tsx @@ -6,6 +6,7 @@ import { useSelector } from 'react-redux'; import type { GlobalModalsStateType } from '../ducks/globalModals'; import type { StateType } from '../reducer'; +import type { ButtonVariant } from '../../components/Button'; import { ErrorModal } from '../../components/ErrorModal'; import { GlobalModalContainer } from '../../components/GlobalModalContainer'; import { SmartAboutContactModal } from './AboutContactModal'; @@ -133,10 +134,19 @@ export function SmartGlobalModalContainer(): JSX.Element { ); const renderErrorModal = useCallback( - ({ description, title }: { description?: string; title?: string }) => ( + ({ + buttonVariant, + description, + title, + }: { + buttonVariant?: ButtonVariant; + description?: string; + title?: string; + }) => ( diff --git a/ts/test-both/linkPreviews/shouldUseFullSizeLinkPreviewImage_test.ts b/ts/test-both/linkPreviews/shouldUseFullSizeLinkPreviewImage_test.ts index 51307bce1a10..5073c0ccd532 100644 --- a/ts/test-both/linkPreviews/shouldUseFullSizeLinkPreviewImage_test.ts +++ b/ts/test-both/linkPreviews/shouldUseFullSizeLinkPreviewImage_test.ts @@ -14,6 +14,7 @@ describe('shouldUseFullSizeLinkPreviewImage', () => { domain: 'example.com', url: 'https://example.com/foo.html', isStickerPack: false, + isCallLink: false, }; it('returns false if there is no image', () => { diff --git a/ts/test-both/state/ducks/linkPreviews_test.ts b/ts/test-both/state/ducks/linkPreviews_test.ts index 951c11dc05aa..e8af013bd6cd 100644 --- a/ts/test-both/state/ducks/linkPreviews_test.ts +++ b/ts/test-both/state/ducks/linkPreviews_test.ts @@ -17,6 +17,7 @@ describe('both/state/ducks/linkPreviews', () => { domain: 'signal.org', url: 'https://www.signal.org', isStickerPack: false, + isCallLink: false, }; } diff --git a/ts/test-both/util/privacy_test.ts b/ts/test-both/util/privacy_test.ts index 55abba5f6983..940a3a242304 100644 --- a/ts/test-both/util/privacy_test.ts +++ b/ts/test-both/util/privacy_test.ts @@ -75,6 +75,34 @@ describe('Privacy', () => { }); }); + describe('redactCallLinkRoomIds', () => { + it('should redact call link room IDs', () => { + const text = + 'Log line with call link room ID 7f3d431d4512b30754915a262db43cd789f799d710525a83429d48aee8c2cd4b\n' + + 'and another IN ALL UPPERCASE 7F3D431D4512B30754915A262DB43CD789F799D710525A83429D48AEE8C2CD4B'; + + const actual = Privacy.redactCallLinkRoomIds(text); + const expected = + 'Log line with call link room ID [REDACTED]d4b\n' + + 'and another IN ALL UPPERCASE [REDACTED]D4B'; + assert.equal(actual, expected); + }); + }); + + describe('redactCallLinkRootKeys', () => { + it('should redact call link root keys', () => { + const text = + 'Log line with call link https://signal.link/call/#key=hktt-kskq-dhcn-bgkm-hbbg-qqkq-sfbp-czmc\n' + + 'and another IN ALL UPPERCASE HKTT-KSKQ-DHCN-BGKM-HBBG-QQKQ-SFBP-CZMC'; + + const actual = Privacy.redactCallLinkRootKeys(text); + const expected = + 'Log line with call link https://signal.link/call/#key=[REDACTED]hktt\n' + + 'and another IN ALL UPPERCASE [REDACTED]HKTT'; + assert.equal(actual, expected); + }); + }); + describe('redactAll', () => { it('should redact all sensitive information', () => { const encodedAppRootPath = APP_ROOT_PATH.replace(/ /g, '%20'); diff --git a/ts/test-electron/models/conversations_test.ts b/ts/test-electron/models/conversations_test.ts index 5fa3741ee2a5..707864aef318 100644 --- a/ts/test-electron/models/conversations_test.ts +++ b/ts/test-electron/models/conversations_test.ts @@ -138,6 +138,7 @@ describe('Conversations', () => { [ { url: 'https://sometest.signal.org/', + isCallLink: false, }, ] ); @@ -154,6 +155,7 @@ describe('Conversations', () => { size: 100, data: new Uint8Array(), }, + isCallLink: false, }, ] ); diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index 55dae23ed0f9..86cabc415382 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -63,6 +63,7 @@ describe('calling duck', () => { const stateWithActiveDirectCall: CallingStateTypeWithActiveCall = { ...stateWithDirectCall, activeCallState: { + callMode: CallMode.Direct, conversationId: directCallState.conversationId, hasLocalAudio: true, hasLocalVideo: false, @@ -145,6 +146,7 @@ describe('calling duck', () => { const stateWithActiveGroupCall: CallingStateTypeWithActiveCall = { ...stateWithGroupCall, activeCallState: { + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: false, @@ -473,6 +475,7 @@ describe('calling duck', () => { const result = reducer(stateWithIncomingDirectCall, action); assert.deepEqual(result.activeCallState, { + callMode: CallMode.Direct, conversationId: 'fake-direct-call-conversation-id', hasLocalAudio: true, hasLocalVideo: true, @@ -567,6 +570,7 @@ describe('calling duck', () => { const result = reducer(stateWithIncomingGroupCall, action); assert.deepEqual(result.activeCallState, { + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: true, @@ -816,6 +820,7 @@ describe('calling duck', () => { it("does nothing if there's no relevant call", () => { const action = groupCallAudioLevelsChange({ + callMode: CallMode.Group, conversationId: 'garbage', localAudioLevel: 1, remoteDeviceStates, @@ -839,6 +844,7 @@ describe('calling duck', () => { }, }; const action = groupCallAudioLevelsChange({ + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', localAudioLevel: 0.001, remoteDeviceStates, @@ -851,6 +857,7 @@ describe('calling duck', () => { it('updates the set of speaking participants, including yourself', () => { const action = groupCallAudioLevelsChange({ + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', localAudioLevel: 0.8, remoteDeviceStates, @@ -888,6 +895,7 @@ describe('calling duck', () => { const result = reducer( getEmptyState(), getAction({ + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joining, @@ -952,6 +960,7 @@ describe('calling duck', () => { const result = reducer( stateWithGroupCall, getAction({ + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, @@ -1025,6 +1034,7 @@ describe('calling duck', () => { const result = reducer( state, getAction({ + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, @@ -1078,6 +1088,7 @@ describe('calling duck', () => { const result = reducer( state, getAction({ + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, localDemuxId: 1, @@ -1118,6 +1129,7 @@ describe('calling duck', () => { const result = reducer( stateWithGroupCall, getAction({ + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, @@ -1151,6 +1163,7 @@ describe('calling duck', () => { const result = reducer( stateWithActiveGroupCall, getAction({ + callMode: CallMode.Group, conversationId: 'another-fake-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, @@ -1178,6 +1191,7 @@ describe('calling duck', () => { ); assert.deepEqual(result.activeCallState, { + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', hasLocalAudio: true, hasLocalVideo: false, @@ -1196,6 +1210,7 @@ describe('calling duck', () => { const result = reducer( stateWithActiveGroupCall, getAction({ + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, @@ -1241,6 +1256,7 @@ describe('calling duck', () => { const result = reducer( state, getAction({ + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, @@ -1270,6 +1286,7 @@ describe('calling duck', () => { const result = reducer( state, getAction({ + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joined, @@ -1484,6 +1501,7 @@ describe('calling duck', () => { it('adds reactions by timestamp', function (this: Mocha.Context) { const firstAction = getAction({ + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', reactions: [ { @@ -1504,6 +1522,7 @@ describe('calling duck', () => { const secondDate = new Date(NOW.getTime() + 1234); this.sandbox.useFakeTimers({ now: secondDate }); const secondAction = getAction({ + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', reactions: [ { @@ -1529,6 +1548,7 @@ describe('calling duck', () => { it('sets multiple reactions with the same timestamp', () => { const action = getAction({ + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', reactions: [ { @@ -1588,6 +1608,7 @@ describe('calling duck', () => { it('adds a local copy', () => { const action = getAction({ + callMode: CallMode.Group, conversationId: 'fake-group-call-conversation-id', value: '❤️', }); @@ -1858,6 +1879,7 @@ describe('calling duck', () => { isVideoCall: true, }); assert.deepEqual(result.activeCallState, { + callMode: CallMode.Direct, conversationId: 'fake-conversation-id', hasLocalAudio: true, hasLocalVideo: true, @@ -2151,6 +2173,7 @@ describe('calling duck', () => { isVideoCall: false, }); assert.deepEqual(result.activeCallState, { + callMode: CallMode.Direct, conversationId: 'fake-conversation-id', hasLocalAudio: true, hasLocalVideo: false, diff --git a/ts/test-electron/state/ducks/stories_test.ts b/ts/test-electron/state/ducks/stories_test.ts index 1ada933224e2..2c864100fb0d 100644 --- a/ts/test-electron/state/ducks/stories_test.ts +++ b/ts/test-electron/state/ducks/stories_test.ts @@ -983,6 +983,7 @@ describe('both/state/ducks/stories', () => { digest: 'digest-1', size: 0, }, + isCallLink: false, }; const messageAttributes = { ...getStoryMessage(storyId), diff --git a/ts/test-electron/state/selectors/calling_test.ts b/ts/test-electron/state/selectors/calling_test.ts index 0d3751f22f8e..8f2834621155 100644 --- a/ts/test-electron/state/selectors/calling_test.ts +++ b/ts/test-electron/state/selectors/calling_test.ts @@ -62,6 +62,7 @@ describe('state/selectors/calling', () => { const stateWithActiveDirectCall: CallingStateType = { ...stateWithDirectCall, activeCallState: { + callMode: CallMode.Direct, conversationId: 'fake-direct-call-conversation-id', hasLocalAudio: true, hasLocalVideo: false, diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 6dd4e5b1c597..27a2e4318b55 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -649,6 +649,10 @@ export type GroupCredentialsType = { groupPublicParamsHex: string; authCredentialPresentationHex: string; }; +export type CallLinkAuthCredentialsType = { + callLinkPublicParamsHex: string; + authCredentialPresentationHex: string; +}; export type GetGroupLogOptionsType = Readonly<{ startVersion: number | undefined; includeFirstState: boolean; @@ -798,6 +802,7 @@ export type GetGroupCredentialsOptionsType = Readonly<{ export type GetGroupCredentialsResultType = Readonly<{ pni?: UntaggedPniString | null; credentials: ReadonlyArray; + callLinkAuthCredentials: ReadonlyArray; }>; const verifyServiceIdResponse = z.object({ @@ -3114,10 +3119,6 @@ export function initialize({ ); } - type CredentialResponseType = { - credentials: Array; - }; - async function getGroupCredentials({ startDayInMs, endDayInMs, @@ -3132,7 +3133,7 @@ export function initialize({ 'pniAsServiceId=true', httpType: 'GET', responseType: 'json', - })) as CredentialResponseType; + })) as GetGroupCredentialsResultType; return response; } diff --git a/ts/types/CallLink.ts b/ts/types/CallLink.ts new file mode 100644 index 000000000000..8e428bb40055 --- /dev/null +++ b/ts/types/CallLink.ts @@ -0,0 +1,33 @@ +// Copyright 2024 Signal Messenger, LLC +// 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; + } +>; + +// Must match `CallLinkRestrictions` in @signalapp/ringrtc +export enum CallLinkRestrictions { + None = 0, + AdminApproval = 1, + Unknown = 2, +} + +export const callLinkRestrictionsSchema = z.nativeEnum( + CallLinkRestrictions +) satisfies z.ZodType; + +export type CallLinkType = Readonly<{ + roomId: string; + rootKey: string; + name: string; + restrictions: CallLinkRestrictions; + expiration: number; +}>; diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index 5aa89f3e3bd1..15b55991569f 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -4,6 +4,7 @@ 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'; export const MAX_CALLING_REACTIONS = 5; export const CALLING_REACTIONS_LIFETIME = 4000; @@ -12,6 +13,7 @@ export const CALLING_REACTIONS_LIFETIME = 4000; export enum CallMode { Direct = 'Direct', Group = 'Group', + Adhoc = 'Adhoc', } // Speaker and Presentation mode have the same UI, but Presentation is only set @@ -48,7 +50,7 @@ export type ActiveCallReaction = { export type ActiveCallReactionsType = ReadonlyArray; export type ActiveCallBaseType = { - conversation: ConversationType; + conversation: CallingConversationType; hasLocalAudio: boolean; hasLocalVideo: boolean; localAudioLevel: number; @@ -85,7 +87,7 @@ export type ActiveDirectCallType = ActiveCallBaseType & { }; export type ActiveGroupCallType = ActiveCallBaseType & { - callMode: CallMode.Group; + callMode: CallMode.Group | CallMode.Adhoc; connectionState: GroupCallConnectionState; conversationsByDemuxId: ConversationsByDemuxIdType; conversationsWithSafetyNumberChanges: Array; @@ -199,3 +201,7 @@ export type ChangeIODevicePayloadType = | { type: CallingDeviceType.CAMERA; selectedDevice: string } | { type: CallingDeviceType.MICROPHONE; selectedDevice: AudioDevice } | { type: CallingDeviceType.SPEAKER; selectedDevice: AudioDevice }; + +export type CallingConversationType = + | ConversationType + | CallLinkConversationType; diff --git a/ts/types/LinkPreview.ts b/ts/types/LinkPreview.ts index 8e8a25c49e4e..ab8626b61a7c 100644 --- a/ts/types/LinkPreview.ts +++ b/ts/types/LinkPreview.ts @@ -9,7 +9,11 @@ import { maybeParseUrl } from '../util/url'; import { replaceEmojiWithSpaces } from '../util/emoji'; import type { AttachmentWithHydratedData } from './Attachment'; -import { artAddStickersRoute, groupInvitesRoute } from '../util/signalRoutes'; +import { + artAddStickersRoute, + groupInvitesRoute, + linkCallRoute, +} from '../util/signalRoutes'; export type LinkPreviewImage = AttachmentWithHydratedData; @@ -95,6 +99,11 @@ export function shouldLinkifyMessage( return true; } +export function isCallLink(link = ''): boolean { + const url = maybeParseUrl(link); + return url?.protocol === 'https:' && linkCallRoute.isMatch(url); +} + export function isStickerPack(link = ''): boolean { const url = maybeParseUrl(link); return url?.protocol === 'https:' && artAddStickersRoute.isMatch(url); diff --git a/ts/types/RendererConfig.ts b/ts/types/RendererConfig.ts index bcc2a87d3779..92c75c39a8b8 100644 --- a/ts/types/RendererConfig.ts +++ b/ts/types/RendererConfig.ts @@ -59,6 +59,7 @@ export const rendererConfigSchema = z.object({ registrationChallengeUrl: configRequiredStringSchema, serverPublicParams: configRequiredStringSchema, serverTrustRoot: configRequiredStringSchema, + genericServerPublicParams: configRequiredStringSchema, serverUrl: configRequiredStringSchema, sfuUrl: configRequiredStringSchema, storageUrl: configRequiredStringSchema, diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 743a87ac17a2..56fbfb1ea954 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -141,6 +141,7 @@ export type StorageAccessType = { serverTimeSkew: number; unidentifiedDeliveryIndicators: boolean; groupCredentials: ReadonlyArray; + callLinkAuthCredentials: ReadonlyArray; lastReceivedAtCounter: number; preferredReactionEmoji: ReadonlyArray; skinTone: number; diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx index 669a79f7ed23..fc0cfdde4da6 100644 --- a/ts/types/Toast.tsx +++ b/ts/types/Toast.tsx @@ -20,6 +20,7 @@ export enum ToastType { ConversationMarkedUnread = 'ConversationMarkedUnread', ConversationRemoved = 'ConversationRemoved', ConversationUnarchived = 'ConversationUnarchived', + CopiedCallLink = 'CopiedCallLink', CopiedUsername = 'CopiedUsername', CopiedUsernameLink = 'CopiedUsernameLink', DangerousFileType = 'DangerousFileType', @@ -86,6 +87,7 @@ export type AnyToast = | { toastType: ToastType.ConversationMarkedUnread } | { toastType: ToastType.ConversationRemoved; parameters: { title: string } } | { toastType: ToastType.ConversationUnarchived } + | { toastType: ToastType.CopiedCallLink } | { toastType: ToastType.CopiedUsername } | { toastType: ToastType.CopiedUsernameLink } | { toastType: ToastType.DangerousFileType } diff --git a/ts/types/message/LinkPreviews.ts b/ts/types/message/LinkPreviews.ts index 0ffdedbf4949..c086430b145b 100644 --- a/ts/types/message/LinkPreviews.ts +++ b/ts/types/message/LinkPreviews.ts @@ -9,6 +9,7 @@ type GenericLinkPreviewType = { domain?: string; url: string; isStickerPack?: boolean; + isCallLink: boolean; image?: Readonly; date?: number; }; diff --git a/ts/util/callDisposition.ts b/ts/util/callDisposition.ts index c67956cf7afe..a37e7034b9ec 100644 --- a/ts/util/callDisposition.ts +++ b/ts/util/callDisposition.ts @@ -514,6 +514,9 @@ export function transitionCallHistory( event, direction ); + } else if (mode === CallMode.Adhoc) { + // TODO: DESKTOP-6653 + strictAssert(false, 'cannot transitionCallHistory for adhoc calls yet'); } else { throw missingCaseError(mode); } diff --git a/ts/util/callLinkRootKeyToUrl.ts b/ts/util/callLinkRootKeyToUrl.ts new file mode 100644 index 000000000000..f58b4613cfa8 --- /dev/null +++ b/ts/util/callLinkRootKeyToUrl.ts @@ -0,0 +1,10 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function callLinkRootKeyToUrl(rootKey: string): string | undefined { + if (!rootKey) { + return; + } + + return `https://signal.link/call/#key=${rootKey}`; +} diff --git a/ts/util/callLinks.ts b/ts/util/callLinks.ts new file mode 100644 index 000000000000..a4832ca65e4c --- /dev/null +++ b/ts/util/callLinks.ts @@ -0,0 +1,73 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import { CallLinkRootKey } from '@signalapp/ringrtc'; +import { Aci } from '@signalapp/libsignal-client'; +import type { CallLinkAuthCredentialPresentation } from './zkgroup'; +import { + CallLinkAuthCredential, + CallLinkSecretParams, + GenericServerPublicParams, +} from './zkgroup'; +import { getCheckedCallLinkAuthCredentialsForToday } from '../services/groupCredentialFetcher'; +import * as durations from './durations'; +import type { CallLinkConversationType, CallLinkType } from '../types/CallLink'; +import type { LocalizerType } from '../types/Util'; + +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 +): CallLinkConversationType { + const { roomId, name } = callLink; + return { + id: roomId, + type: 'callLink', + isMe: false, + title: name || i18n('icu:calling__call-link-default-title'), + sharedGroupNames: [], + acceptedMessageRequest: true, + badges: [], + }; +} diff --git a/ts/util/callingNotification.ts b/ts/util/callingNotification.ts index bb590b827d44..b9044ed62348 100644 --- a/ts/util/callingNotification.ts +++ b/ts/util/callingNotification.ts @@ -128,6 +128,10 @@ 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/util/isAdhocCallingEnabled.ts b/ts/util/isAdhocCallingEnabled.ts new file mode 100644 index 000000000000..67472a325561 --- /dev/null +++ b/ts/util/isAdhocCallingEnabled.ts @@ -0,0 +1,8 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as RemoteConfig from '../RemoteConfig'; + +export function isAdhocCallingEnabled(): boolean { + return Boolean(RemoteConfig.isEnabled('desktop.calling.adhoc')); +} diff --git a/ts/util/isGroupOrAdhocCall.ts b/ts/util/isGroupOrAdhocCall.ts new file mode 100644 index 000000000000..65f82a513af8 --- /dev/null +++ b/ts/util/isGroupOrAdhocCall.ts @@ -0,0 +1,31 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { CallMode } from '../types/Calling'; +import type { ActiveCallType, ActiveGroupCallType } from '../types/Calling'; +import type { + DirectCallStateType, + GroupCallStateType, +} from '../state/ducks/calling'; + +export function isGroupOrAdhocActiveCall( + activeCall: ActiveCallType | undefined +): activeCall is ActiveGroupCallType { + return Boolean(activeCall && isGroupOrAdhocCallMode(activeCall.callMode)); +} + +export function isGroupOrAdhocCallMode( + callMode: CallMode | undefined | null +): callMode is CallMode.Group | CallMode.Adhoc { + return callMode === CallMode.Group || callMode === CallMode.Adhoc; +} + +export function isGroupOrAdhocCallState( + callState: DirectCallStateType | GroupCallStateType | undefined +): callState is GroupCallStateType { + return Boolean( + callState && + (callState.callMode === CallMode.Group || + callState.callMode === CallMode.Adhoc) + ); +} diff --git a/ts/util/privacy.ts b/ts/util/privacy.ts index 204d3a251f71..a94d4a1b54ff 100644 --- a/ts/util/privacy.ts +++ b/ts/util/privacy.ts @@ -16,6 +16,9 @@ const UUID_OR_STORY_ID_PATTERN = /[0-9A-F]{8}-[0-9A-F]{4}-[04][0-9A-F]{3}-[089AB][0-9A-F]{3}-[0-9A-F]{9}([0-9A-F]{3})/gi; const GROUP_ID_PATTERN = /(group\()([^)]+)(\))/g; const GROUP_V2_ID_PATTERN = /(groupv2\()([^=)]+)(=?=?\))/g; +const CALL_LINK_ROOM_ID_PATTERN = /[0-9A-F]{61}([0-9A-F]{3})/gi; +const CALL_LINK_ROOT_KEY_PATTERN = + /([A-Z]{4})-[A-Z]{4}-[A-Z]{4}-[A-Z]{4}-[A-Z]{4}-[A-Z]{4}-[A-Z]{4}-[A-Z]{4}/gi; const REDACTION_PLACEHOLDER = '[REDACTED]'; export type RedactFunction = (value: string) => string; @@ -125,6 +128,22 @@ export const redactGroupIds = (text: string): string => { ); }; +export const redactCallLinkRoomIds = (text: string): string => { + if (!isString(text)) { + throw new TypeError("'text' must be a string"); + } + + return text.replace(CALL_LINK_ROOM_ID_PATTERN, `${REDACTION_PLACEHOLDER}$1`); +}; + +export const redactCallLinkRootKeys = (text: string): string => { + if (!isString(text)) { + throw new TypeError("'text' must be a string"); + } + + return text.replace(CALL_LINK_ROOT_KEY_PATTERN, `${REDACTION_PLACEHOLDER}$1`); +}; + const createRedactSensitivePaths = ( paths: ReadonlyArray ): RedactFunction => { @@ -146,7 +165,9 @@ export const redactAll: RedactFunction = compose( (text: string) => redactSensitivePaths(text), redactGroupIds, redactPhoneNumbers, - redactUuids + redactUuids, + redactCallLinkRoomIds, + redactCallLinkRootKeys ); const removeNewlines: RedactFunction = text => text.replace(/\r?\n|\r/g, ''); diff --git a/ts/window.d.ts b/ts/window.d.ts index ac4221578c32..b98495946df1 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -193,6 +193,7 @@ declare global { getHostName: () => string; getInteractionMode: () => 'mouse' | 'keyboard'; getServerPublicParams: () => string; + getGenericServerPublicParams: () => string; getSfuUrl: () => string; getSocketStatus: () => SocketStatus; getSyncRequest: (timeoutMillis?: number) => SyncRequest; diff --git a/ts/windows/main/phase1-ipc.ts b/ts/windows/main/phase1-ipc.ts index 46e9a9151291..210a50e4bf98 100644 --- a/ts/windows/main/phase1-ipc.ts +++ b/ts/windows/main/phase1-ipc.ts @@ -21,6 +21,7 @@ import type { NotificationClickData, WindowsNotificationData, } from '../../services/notifications'; +import { isAdhocCallingEnabled } from '../../util/isAdhocCallingEnabled'; // It is important to call this as early as possible window.i18n = SignalContext.i18n; @@ -52,6 +53,7 @@ window.getBuildExpiration = () => config.buildExpiration; window.getHostName = () => config.hostname; window.getServerTrustRoot = () => config.serverTrustRoot; window.getServerPublicParams = () => config.serverPublicParams; +window.getGenericServerPublicParams = () => config.genericServerPublicParams; window.getSfuUrl = () => config.sfuUrl; window.isBehindProxy = () => Boolean(config.proxyUrl); @@ -329,6 +331,19 @@ ipc.on('start-call-lobby', (_event, { conversationId }) => { }); }); +ipc.on('start-call-link', (_event, { key }) => { + if (isAdhocCallingEnabled()) { + window.reduxActions?.calling?.startCallLinkLobby({ + rootKey: key, + }); + } else { + const { unknownSignalLink } = window.Events; + if (unknownSignalLink) { + unknownSignalLink(); + } + } +}); + ipc.on('show-window', () => { window.IPC.showWindow(); }); diff --git a/yarn.lock b/yarn.lock index a5819cc08852..398208e11839 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3973,10 +3973,10 @@ type-fest "^3.5.0" uuid "^8.3.0" -"@signalapp/mock-server@5.0.1": - version "5.0.1" - resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-5.0.1.tgz#648eb44b91f1ecbb0b3dc1791101fb5d67ac0e73" - integrity sha512-KuaHznpxubFib9LJiIP6lT8Oynuni4yzrItetxVwN9YPMTlFa0TYvaf+InODnH7tnM1bY1NCku9SYy+0H2ejMQ== +"@signalapp/mock-server@5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-5.1.0.tgz#d561f3553cb744fee3f5b29686603c1aa35627c7" + integrity sha512-92J4ZeplNJ2HnSPfZRw26uJ8OMnPy053pJsWai3nZbcnbjgGgNbkJPEW8c1dz80Fex97/zcgqhEy8v3vr3LjEg== dependencies: "@signalapp/libsignal-client" "^0.39.2" debug "^4.3.2"