Basic call link join support

This commit is contained in:
ayumi-signal 2024-02-22 13:19:50 -08:00 committed by GitHub
parent 2bfb6e7481
commit 96b3413feb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 2438 additions and 509 deletions

View file

@ -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"

View file

@ -2469,6 +2469,7 @@ ipc.on('get-config', async event => {
registrationChallengeUrl: config.get<string>('registrationChallengeUrl'),
serverPublicParams: config.get<string>('serverPublicParams'),
serverTrustRoot: config.get<string>('serverTrustRoot'),
genericServerPublicParams: config.get<string>('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') {

View file

@ -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"
}

View file

@ -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
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" fill="none"><path fill="#5151F6" fill-rule="evenodd" d="M10.302 5.625c-1.22 0-2.203 0-3 .065-.82.067-1.54.209-2.206.548a5.625 5.625 0 0 0-2.458 2.458c-.34.667-.481 1.387-.548 2.207-.065.796-.065 1.78-.065 2.999v8.196c0 1.22 0 2.203.065 3 .067.82.208 1.54.548 2.206a5.625 5.625 0 0 0 2.458 2.458c.667.34 1.387.481 2.207.548.796.065 1.78.065 2.999.065h7.296c1.22 0 2.203 0 3-.065.82-.067 1.54-.209 2.206-.548a5.625 5.625 0 0 0 2.458-2.458c.34-.667.48-1.387.548-2.207.065-.796.065-1.78.065-2.999v-.032l4.775 4.775c1.559 1.56 4.225.455 4.225-1.75V10.909c0-2.205-2.666-3.31-4.225-1.75l-4.775 4.775v-.032c0-1.22 0-2.203-.065-3-.067-.82-.209-1.54-.548-2.206a5.625 5.625 0 0 0-2.458-2.458c-.667-.34-1.387-.481-2.207-.548-.796-.065-1.78-.065-2.999-.065h-7.296Zm13.323 8.325c0-1.279-.001-2.17-.058-2.864-.055-.68-.159-1.072-.31-1.368a3.374 3.374 0 0 0-1.475-1.475c-.296-.151-.687-.255-1.368-.31-.694-.057-1.585-.058-2.864-.058h-7.2c-1.279 0-2.17 0-2.864.058-.68.055-1.072.159-1.368.31a3.375 3.375 0 0 0-1.475 1.475c-.151.296-.255.687-.31 1.368-.057.694-.058 1.585-.058 2.864v8.1c0 1.279 0 2.17.057 2.864.056.68.16 1.072.31 1.368.324.635.84 1.152 1.476 1.475.296.151.687.255 1.368.31.694.057 1.585.058 2.864.058h7.2c1.279 0 2.17 0 2.864-.058.68-.055 1.072-.159 1.368-.31a3.374 3.374 0 0 0 1.475-1.475c.151-.296.255-.687.31-1.368.057-.694.058-1.585.058-2.864v-8.1Zm2.25 4.05c0 .566.225 1.109.625 1.51l5.74 5.74a.21.21 0 0 0 .116.066.24.24 0 0 0 .13-.017.239.239 0 0 0 .104-.08.21.21 0 0 0 .035-.128V10.909a.21.21 0 0 0-.035-.128.238.238 0 0 0-.104-.08.24.24 0 0 0-.13-.017.21.21 0 0 0-.115.066L26.5 16.49c-.4.401-.625.944-.625 1.51Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -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",

View file

@ -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;
}

View file

@ -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
);
}
}

View file

@ -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');
}

View file

@ -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;
}

View file

@ -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%;

View file

@ -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';

View file

@ -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'

View file

@ -57,7 +57,7 @@ export type Props = {
loading?: boolean;
acceptedMessageRequest: boolean;
conversationType: 'group' | 'direct';
conversationType: 'group' | 'direct' | 'callLink';
isMe: boolean;
noteToSelf?: boolean;
phoneNumber?: string;

View file

@ -74,6 +74,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): 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> = {}): 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'),

View file

@ -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<MediaDeviceInfo>;
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<Pick<ConversationType, 'id' | 'firstName' | 'title'>>;
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({
<>
<CallingLobby
availableCameras={availableCameras}
callMode={activeCall.callMode}
conversation={conversation}
groupMembers={groupMembers}
hasLocalAudio={hasLocalAudio}
hasLocalVideo={hasLocalVideo}
i18n={i18n}
isGroupCall={activeCall.callMode === CallMode.Group}
isAdhocJoinRequestPending={isAdhocJoinRequestPending}
isCallFull={isCallFull}
isConversationTooBigToRing={isConvoTooBigToRing}
me={me}
@ -294,14 +324,24 @@ function ActiveCallManager({
toggleSettings={toggleSettings}
/>
{settingsDialogOpen && renderDeviceSelection()}
{showParticipantsList && activeCall.callMode === CallMode.Group ? (
<CallingParticipantsList
i18n={i18n}
onClose={toggleParticipants}
ourServiceId={me.serviceId}
participants={peekedParticipants}
/>
) : null}
{showParticipantsList &&
(activeCall.callMode === CallMode.Adhoc && callLink ? (
<CallingAdhocCallInfo
callLink={callLink}
i18n={i18n}
ourServiceId={me.serviceId}
participants={peekedParticipants}
onClose={toggleParticipants}
onCopyCallLink={onCopyCallLink}
/>
) : (
<CallingParticipantsList
i18n={i18n}
onClose={toggleParticipants}
ourServiceId={me.serviceId}
participants={peekedParticipants}
/>
))}
</>
);
}
@ -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 ? (
<CallingParticipantsList
i18n={i18n}
onClose={toggleParticipants}
ourServiceId={me.serviceId}
participants={groupCallParticipantsForParticipantsList}
/>
) : null}
{activeCall.callMode === CallMode.Group &&
{showParticipantsList &&
(activeCall.callMode === CallMode.Adhoc && callLink ? (
<CallingAdhocCallInfo
callLink={callLink}
i18n={i18n}
ourServiceId={me.serviceId}
participants={groupCallParticipantsForParticipantsList}
onClose={toggleParticipants}
onCopyCallLink={onCopyCallLink}
/>
) : (
<CallingParticipantsList
i18n={i18n}
onClose={toggleParticipants}
ourServiceId={me.serviceId}
participants={groupCallParticipantsForParticipantsList}
/>
))}
{isGroupOrAdhocActiveCall(activeCall) &&
activeCall.conversationsWithSafetyNumberChanges.length ? (
<SafetyNumberChangeDialog
confirmText={i18n('icu:continueCall')}
@ -462,7 +508,7 @@ export function CallManager(props: PropsType): JSX.Element | null {
}, [shouldRing, playRingtone, stopRingtone]);
const mightBeRingingOutgoingGroupCall =
activeCall?.callMode === CallMode.Group &&
isGroupOrAdhocActiveCall(activeCall) &&
activeCall.outgoingRing &&
activeCall.joinState !== GroupCallJoinState.NotJoined;
useEffect(() => {
@ -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;

View file

@ -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 (
<span
aria-label={i18n('icu:calling__participants', {

View file

@ -85,6 +85,8 @@ import {
CallReactionBurstProvider,
useCallReactionBursts,
} from './CallReactionBurst';
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
import { assertDev } from '../util/assert';
export type PropsType = {
activeCall: ActiveCallType;
@ -378,6 +380,7 @@ export function CallScreen({
break;
}
case CallMode.Group:
case CallMode.Adhoc:
isRinging =
activeCall.outgoingRing &&
!activeCall.remoteParticipants.length &&
@ -475,7 +478,7 @@ export function CallScreen({
'module-ongoing-call__controls--fadeOut': controlsFadedOut,
});
const isGroupCall = activeCall.callMode === CallMode.Group;
const isGroupCall = isGroupOrAdhocActiveCall(activeCall);
let presentingButtonType: CallingButtonType;
if (presentingSource) {
@ -486,8 +489,9 @@ export function CallScreen({
presentingButtonType = CallingButtonType.PRESENTING_OFF;
}
const raisedHands =
activeCall.callMode === CallMode.Group ? activeCall.raisedHands : undefined;
const raisedHands = isGroupOrAdhocActiveCall(activeCall)
? activeCall.raisedHands
: undefined;
// This is the value of our hand raised as seen by remote clients. We should prefer
// to use it in UI so the user understands what remote clients see.
@ -614,6 +618,7 @@ export function CallScreen({
if (isGroupCall) {
return (
<CallParticipantCount
callMode={activeCall.callMode}
i18n={i18n}
participantCount={participantCount}
toggleParticipants={toggleParticipants}
@ -635,6 +640,7 @@ export function CallScreen({
i18n,
isRinging,
isConnected,
activeCall.callMode,
activeCall.joinedAt,
isReconnecting,
isGroupCall,
@ -647,6 +653,10 @@ export function CallScreen({
let remoteParticipantsElement: ReactNode;
switch (activeCall.callMode) {
case CallMode.Direct: {
assertDev(
conversation.type === 'direct',
'direct call must have direct conversation'
);
remoteParticipantsElement = hasCallStarted ? (
<DirectCallRemoteParticipant
conversation={conversation}
@ -661,6 +671,7 @@ export function CallScreen({
break;
}
case CallMode.Group:
case CallMode.Adhoc:
remoteParticipantsElement = (
<GroupCallRemoteParticipants
callViewMode={activeCall.viewMode}
@ -846,6 +857,7 @@ export function CallScreen({
onPick: emoji => {
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);

View file

@ -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>
): 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> = {}): 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> = {}): 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<PropsType>;
export function NoOne(): JSX.Element {
const props = createProps();
return <CallingAdhocCallInfo {...props} />;
}
export function SoloCall(): JSX.Element {
const props = createProps({
participants: [
createParticipant({
title: 'Bardock',
}),
],
});
return <CallingAdhocCallInfo {...props} />;
}
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 <CallingAdhocCallInfo {...props} />;
}
export function Overflow(): JSX.Element {
const props = createProps({
participants: Array(50)
.fill(null)
.map(() => createParticipant({ title: 'Kirby' })),
});
return <CallingAdhocCallInfo {...props} />;
}

View file

@ -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<ParticipantType>;
readonly onClose: () => void;
readonly onCopyCallLink: () => void;
};
export function CallingAdhocCallInfo({
i18n,
ourServiceId,
participants,
onClose,
onCopyCallLink,
}: PropsType): JSX.Element | null {
const sortedParticipants = React.useMemo<Array<ParticipantType>>(
() => sortByTitle(participants),
[participants]
);
return (
<ModalHost
modalName="CallingAdhocCallInfo"
moduleClassName="CallingAdhocCallInfo"
onClose={onClose}
>
<div className="CallingAdhocCallInfo module-calling-participants-list">
<div className="module-calling-participants-list__header">
<div className="module-calling-participants-list__title">
{!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),
})}
</div>
<button
type="button"
className="module-calling-participants-list__close"
onClick={onClose}
tabIndex={0}
aria-label={i18n('icu:close')}
/>
</div>
<ul className="module-calling-participants-list__list">
{sortedParticipants.map(
(participant: ParticipantType, index: number) => (
<li
className="module-calling-participants-list__contact"
// It's tempting to use `participant.serviceId` as the `key`
// here, but that can result in duplicate keys for
// participants who have joined on multiple devices.
key={index}
>
<div className="module-calling-participants-list__avatar-and-name">
<Avatar
acceptedMessageRequest={participant.acceptedMessageRequest}
avatarPath={participant.avatarPath}
badge={undefined}
color={participant.color}
conversationType="direct"
i18n={i18n}
isMe={participant.isMe}
profileName={participant.profileName}
title={participant.title}
sharedGroupNames={participant.sharedGroupNames}
size={AvatarSize.THIRTY_TWO}
/>
{ourServiceId && participant.serviceId === ourServiceId ? (
<span className="module-calling-participants-list__name">
{i18n('icu:you')}
</span>
) : (
<>
<ContactName
module="module-calling-participants-list__name"
title={participant.title}
/>
{isInSystemContacts(participant) ? (
<span>
{' '}
<InContactsIcon
className="module-calling-participants-list__contact-icon"
i18n={i18n}
/>
</span>
) : null}
</>
)}
</div>
<span
className={classNames(
'module-calling-participants-list__status-icon',
participant.isHandRaised &&
'module-calling-participants-list__hand-raised'
)}
/>
<span
className={classNames(
'module-calling-participants-list__status-icon',
participant.presenting &&
'module-calling-participants-list__presenting',
!participant.hasRemoteVideo &&
'module-calling-participants-list__muted--video'
)}
/>
<span
className={classNames(
'module-calling-participants-list__status-icon',
!participant.hasRemoteAudio &&
'module-calling-participants-list__muted--audio'
)}
/>
</li>
)
)}
</ul>
<div className="CallingAdhocCallInfo__Divider" />
<div className="CallingAdhocCallInfo__CallLinkInfo">
<button
className="CallingAdhocCallInfo__MenuItem"
onClick={onCopyCallLink}
type="button"
>
<span className="CallingAdhocCallInfo__MenuItemIcon CallingAdhocCallInfo__MenuItemIcon--copy-link" />
<span className="CallingAdhocCallInfo__MenuItemText">
{i18n('icu:CallingAdhocCallInfo__CopyLink')}
</span>
</button>
</div>
</div>
</ModalHost>
);
}

View file

@ -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> = {}): 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 <CallingLobby {...props} />;
}
export function GroupCallWith1PeekedParticipant(): JSX.Element {
const props = createProps({
isGroupCall: true,
callMode: CallMode.Group,
peekedParticipants: [{ title: 'Sam' }].map(fakePeekedParticipant),
});
return <CallingLobby {...props} />;
@ -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 <CallingLobby {...props} />;

View file

@ -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<MediaDeviceInfo>;
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<ConversationType, 'avatarPath' | 'color' | 'id' | 'serviceId'>
@ -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 (
<CallParticipantCount
callMode={callMode}
i18n={i18n}
isAdhocJoinRequestPending={isAdhocJoinRequestPending}
groupMemberCount={groupMembers?.length ?? 0}
participantCount={peekedParticipants.length}
toggleParticipants={toggleParticipants}
@ -223,7 +233,9 @@ export function CallingLobby({
}
return null;
}, [
isGroupCall,
callMode,
isAdhocJoinRequestPending,
isGroupOrAdhocCall,
peekedParticipants.length,
i18n,
hasLocalVideo,
@ -252,7 +264,7 @@ export function CallingLobby({
<CallingHeader
i18n={i18n}
isGroupCall={isGroupCall}
isGroupCall={isGroupOrAdhocCall}
participantCount={peekedParticipants.length}
toggleSettings={toggleSettings}
onCancel={onCallCanceled}
@ -280,6 +292,14 @@ export function CallingLobby({
{i18n('icu:calling__your-video-is-off')}
</div>
{callMode === CallMode.Adhoc && (
<div className="CallingLobby__CallLinkNotice">
{isSharingPhoneNumberWithEverybody()
? i18n('icu:CallingLobby__CallLinkNotice--phone-sharing')
: i18n('icu:CallingLobby__CallLinkNotice')}
</div>
)}
<CallingButtonToastsContainer
hasLocalAudio={hasLocalAudio}
outgoingRing={outgoingRing}

View file

@ -23,6 +23,8 @@ import { usePageVisibility } from '../hooks/usePageVisibility';
import { missingCaseError } from '../util/missingCaseError';
import { nonRenderedRemoteParticipant } from '../util/ringrtc/nonRenderedRemoteParticipant';
import { isReconnecting } from '../util/callingIsReconnecting';
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
import { assertDev } from '../util/assert';
// This value should be kept in sync with the hard-coded CSS height. It should also be
// less than `MAX_FRAME_HEIGHT`.
@ -97,17 +99,17 @@ export function CallingPipRemoteVideo({
const activeGroupCallSpeaker: undefined | GroupCallRemoteParticipantType =
useMemo(() => {
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 <NoVideo activeCall={activeCall} i18n={i18n} />;
}
assertDev(
conversation.type === 'direct',
'CallingPipRemoteVideo for direct call must be associated with direct conversation'
);
return (
<div className="module-calling-pip__video--remote">
<DirectCallRemoteParticipant
@ -162,6 +167,7 @@ export function CallingPipRemoteVideo({
);
}
case CallMode.Group:
case CallMode.Adhoc:
if (!activeGroupCallSpeaker) {
return <NoVideo activeCall={activeCall} i18n={i18n} />;
}

View file

@ -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);

View file

@ -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
);

View file

@ -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> = {}): 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 <ErrorModal {...createProps()} />;
}
export function PrimaryButton(): JSX.Element {
return (
<ErrorModal {...createProps({ buttonVariant: ButtonVariant.Primary })} />
);
}
export function CustomStrings(): JSX.Element {
return (
<ErrorModal

View file

@ -8,6 +8,7 @@ import { Modal } from './Modal';
import { Button, ButtonVariant } from './Button';
export type PropsType = {
buttonVariant?: ButtonVariant;
description?: string;
title?: string;
@ -22,10 +23,14 @@ function focusRef(el: HTMLElement | null) {
}
export function ErrorModal(props: PropsType): JSX.Element {
const { description, i18n, onClose, title } = props;
const { buttonVariant, description, i18n, onClose, title } = props;
const footer = (
<Button onClick={onClose} ref={focusRef} variant={ButtonVariant.Secondary}>
<Button
onClick={onClose}
ref={focusRef}
variant={buttonVariant || ButtonVariant.Secondary}
>
{i18n('icu:Confirmation--confirm')}
</Button>
);

View file

@ -136,6 +136,7 @@ export function LinkPreview(): JSX.Element {
contentType: IMAGE_JPEG,
}),
isStickerPack: false,
isCallLink: false,
title: LONG_TITLE,
},
],

View file

@ -452,6 +452,7 @@ function ForwardMessageEditor({
onClose={removeLinkPreview}
title={linkPreview.title}
url={linkPreview.url}
isCallLink={linkPreview.isCallLink}
/>
</div>
) : null}

View file

@ -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;

View file

@ -71,6 +71,7 @@ LinkPreview.args = {
}),
title: 'Cats & Kittens LOL',
url: 'https://www.catsandkittens.lolcats/kittens/page/1',
isCallLink: false,
},
};

View file

@ -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,
},
}}
/>

View file

@ -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:

View file

@ -188,6 +188,14 @@ export function renderToast({
);
}
if (toastType === ToastType.CopiedCallLink) {
return (
<Toast onClose={hideToast} timeout={3 * SECOND}>
{i18n('icu:calling__call-link-copied')}
</Toast>
);
}
if (toastType === ToastType.CopiedUsername) {
return (
<Toast onClose={hideToast} timeout={3 * SECOND}>

View file

@ -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;

View file

@ -1229,6 +1229,9 @@ export class Message extends React.PureComponent<Props, State> {
/>
</div>
) : null}
{first.isCallLink && (
<div className="module-message__link-preview__call-link-icon" />
)}
<div
className={classNames(
'module-message__link-preview__text',
@ -1931,6 +1934,31 @@ export class Message extends React.PureComponent<Props, State> {
);
}
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 (
<button
type="button"
className={classNames('module-message__action', {
'module-message__action--incoming': direction === 'incoming',
'module-message__action--outgoing': direction === 'outgoing',
})}
onClick={() => openLinkInWebBrowser(onlyPreview.url)}
>
{i18n('icu:calling__join')}
</button>
);
}
return null;
}
private renderError(): ReactNode {
const { status, direction } = this.props;
@ -2406,6 +2434,7 @@ export class Message extends React.PureComponent<Props, State> {
{this.renderPayment()}
{this.renderEmbeddedContact()}
{this.renderText()}
{this.renderAction()}
{this.renderMetadata()}
{this.renderSendMessageButton()}
</>

View file

@ -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

View file

@ -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.',

View file

@ -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<T>({
secretParams: string;
request: (sender: MessageSender, options: GroupCredentialsType) => Promise<T>;
}): Promise<T> {
const groupCredentials = getCheckedCredentialsForToday(
const groupCredentials = getCheckedGroupCredentialsForToday(
`makeRequestWithTemporalRetry/${logId}`
);

View file

@ -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<AbortSignal>
): Promise<null | LinkPreviewResult> {
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,
};
}

View file

@ -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<CallLinkState | undefined> {
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<GroupCallParticipantInfoType>;
}
> {
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<PeekInfo> {
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<void> {
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<void> {
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);

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
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.`);
}

File diff suppressed because it is too large Load diff

View file

@ -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);
}

View file

@ -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,
},

View file

@ -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);
}
);

View file

@ -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,
}));

View file

@ -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 <SmartCallingDeviceSelection />;
@ -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<ConversationType> = [];
const groupMembers: Array<ConversationType> = [];
const remoteParticipants: Array<GroupCallRemoteParticipantType> = [];
@ -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,

View file

@ -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;

View file

@ -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;
}) => (
<ErrorModal
title={title}
buttonVariant={buttonVariant}
description={description}
title={title}
i18n={i18n}
onClose={closeErrorModal}
/>

View file

@ -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', () => {

View file

@ -17,6 +17,7 @@ describe('both/state/ducks/linkPreviews', () => {
domain: 'signal.org',
url: 'https://www.signal.org',
isStickerPack: false,
isCallLink: false,
};
}

View file

@ -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');

View file

@ -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,
},
]
);

View file

@ -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,

View file

@ -983,6 +983,7 @@ describe('both/state/ducks/stories', () => {
digest: 'digest-1',
size: 0,
},
isCallLink: false,
};
const messageAttributes = {
...getStoryMessage(storyId),

View file

@ -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,

View file

@ -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<GroupCredentialType>;
callLinkAuthCredentials: ReadonlyArray<GroupCredentialType>;
}>;
const verifyServiceIdResponse = z.object({
@ -3114,10 +3119,6 @@ export function initialize({
);
}
type CredentialResponseType = {
credentials: Array<GroupCredentialType>;
};
async function getGroupCredentials({
startDayInMs,
endDayInMs,
@ -3132,7 +3133,7 @@ export function initialize({
'pniAsServiceId=true',
httpType: 'GET',
responseType: 'json',
})) as CredentialResponseType;
})) as GetGroupCredentialsResultType;
return response;
}

33
ts/types/CallLink.ts Normal file
View file

@ -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<ConversationType, 'type'> & {
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<RingRTCCallLinkRestrictions>;
export type CallLinkType = Readonly<{
roomId: string;
rootKey: string;
name: string;
restrictions: CallLinkRestrictions;
expiration: number;
}>;

View file

@ -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<ActiveCallReaction>;
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<ConversationType>;
@ -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;

View file

@ -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);

View file

@ -59,6 +59,7 @@ export const rendererConfigSchema = z.object({
registrationChallengeUrl: configRequiredStringSchema,
serverPublicParams: configRequiredStringSchema,
serverTrustRoot: configRequiredStringSchema,
genericServerPublicParams: configRequiredStringSchema,
serverUrl: configRequiredStringSchema,
sfuUrl: configRequiredStringSchema,
storageUrl: configRequiredStringSchema,

View file

@ -141,6 +141,7 @@ export type StorageAccessType = {
serverTimeSkew: number;
unidentifiedDeliveryIndicators: boolean;
groupCredentials: ReadonlyArray<GroupCredentialType>;
callLinkAuthCredentials: ReadonlyArray<GroupCredentialType>;
lastReceivedAtCounter: number;
preferredReactionEmoji: ReadonlyArray<string>;
skinTone: number;

View file

@ -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 }

View file

@ -9,6 +9,7 @@ type GenericLinkPreviewType<Image> = {
domain?: string;
url: string;
isStickerPack?: boolean;
isCallLink: boolean;
image?: Readonly<Image>;
date?: number;
};

View file

@ -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);
}

View file

@ -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}`;
}

73
ts/util/callLinks.ts Normal file
View file

@ -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<CallLinkAuthCredentialPresentation> {
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: [],
};
}

View file

@ -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);
}

View file

@ -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'));
}

View file

@ -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)
);
}

View file

@ -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<string>
): 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, '');

1
ts/window.d.ts vendored
View file

@ -193,6 +193,7 @@ declare global {
getHostName: () => string;
getInteractionMode: () => 'mouse' | 'keyboard';
getServerPublicParams: () => string;
getGenericServerPublicParams: () => string;
getSfuUrl: () => string;
getSocketStatus: () => SocketStatus;
getSyncRequest: (timeoutMillis?: number) => SyncRequest;

View file

@ -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();
});

View file

@ -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"