Basic call link join support
This commit is contained in:
parent
2bfb6e7481
commit
96b3413feb
75 changed files with 2438 additions and 509 deletions
|
@ -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"
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
1
images/icons/v3/video/video-display-bold.svg
Normal file
1
images/icons/v3/video/video-display-bold.svg
Normal 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 |
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
83
stylesheets/components/CallingAdhocCallInfo.scss
Normal file
83
stylesheets/components/CallingAdhocCallInfo.scss
Normal 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;
|
||||
}
|
|
@ -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%;
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -57,7 +57,7 @@ export type Props = {
|
|||
loading?: boolean;
|
||||
|
||||
acceptedMessageRequest: boolean;
|
||||
conversationType: 'group' | 'direct';
|
||||
conversationType: 'group' | 'direct' | 'callLink';
|
||||
isMe: boolean;
|
||||
noteToSelf?: boolean;
|
||||
phoneNumber?: string;
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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);
|
||||
|
|
136
ts/components/CallingAdhocCallInfo.stories.tsx
Normal file
136
ts/components/CallingAdhocCallInfo.stories.tsx
Normal 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} />;
|
||||
}
|
162
ts/components/CallingAdhocCallInfo.tsx
Normal file
162
ts/components/CallingAdhocCallInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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} />;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -136,6 +136,7 @@ export function LinkPreview(): JSX.Element {
|
|||
contentType: IMAGE_JPEG,
|
||||
}),
|
||||
isStickerPack: false,
|
||||
isCallLink: false,
|
||||
title: LONG_TITLE,
|
||||
},
|
||||
],
|
||||
|
|
|
@ -452,6 +452,7 @@ function ForwardMessageEditor({
|
|||
onClose={removeLinkPreview}
|
||||
title={linkPreview.title}
|
||||
url={linkPreview.url}
|
||||
isCallLink={linkPreview.isCallLink}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -71,6 +71,7 @@ LinkPreview.args = {
|
|||
}),
|
||||
title: 'Cats & Kittens LOL',
|
||||
url: 'https://www.catsandkittens.lolcats/kittens/page/1',
|
||||
isCallLink: false,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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()}
|
||||
</>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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}`
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -17,6 +17,7 @@ describe('both/state/ducks/linkPreviews', () => {
|
|||
domain: 'signal.org',
|
||||
url: 'https://www.signal.org',
|
||||
isStickerPack: false,
|
||||
isCallLink: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -983,6 +983,7 @@ describe('both/state/ducks/stories', () => {
|
|||
digest: 'digest-1',
|
||||
size: 0,
|
||||
},
|
||||
isCallLink: false,
|
||||
};
|
||||
const messageAttributes = {
|
||||
...getStoryMessage(storyId),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
33
ts/types/CallLink.ts
Normal 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;
|
||||
}>;
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -59,6 +59,7 @@ export const rendererConfigSchema = z.object({
|
|||
registrationChallengeUrl: configRequiredStringSchema,
|
||||
serverPublicParams: configRequiredStringSchema,
|
||||
serverTrustRoot: configRequiredStringSchema,
|
||||
genericServerPublicParams: configRequiredStringSchema,
|
||||
serverUrl: configRequiredStringSchema,
|
||||
sfuUrl: configRequiredStringSchema,
|
||||
storageUrl: configRequiredStringSchema,
|
||||
|
|
1
ts/types/Storage.d.ts
vendored
1
ts/types/Storage.d.ts
vendored
|
@ -141,6 +141,7 @@ export type StorageAccessType = {
|
|||
serverTimeSkew: number;
|
||||
unidentifiedDeliveryIndicators: boolean;
|
||||
groupCredentials: ReadonlyArray<GroupCredentialType>;
|
||||
callLinkAuthCredentials: ReadonlyArray<GroupCredentialType>;
|
||||
lastReceivedAtCounter: number;
|
||||
preferredReactionEmoji: ReadonlyArray<string>;
|
||||
skinTone: number;
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -9,6 +9,7 @@ type GenericLinkPreviewType<Image> = {
|
|||
domain?: string;
|
||||
url: string;
|
||||
isStickerPack?: boolean;
|
||||
isCallLink: boolean;
|
||||
image?: Readonly<Image>;
|
||||
date?: number;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
10
ts/util/callLinkRootKeyToUrl.ts
Normal file
10
ts/util/callLinkRootKeyToUrl.ts
Normal 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
73
ts/util/callLinks.ts
Normal 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: [],
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
8
ts/util/isAdhocCallingEnabled.ts
Normal file
8
ts/util/isAdhocCallingEnabled.ts
Normal 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'));
|
||||
}
|
31
ts/util/isGroupOrAdhocCall.ts
Normal file
31
ts/util/isGroupOrAdhocCall.ts
Normal 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)
|
||||
);
|
||||
}
|
|
@ -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
1
ts/window.d.ts
vendored
|
@ -193,6 +193,7 @@ declare global {
|
|||
getHostName: () => string;
|
||||
getInteractionMode: () => 'mouse' | 'keyboard';
|
||||
getServerPublicParams: () => string;
|
||||
getGenericServerPublicParams: () => string;
|
||||
getSfuUrl: () => string;
|
||||
getSocketStatus: () => SocketStatus;
|
||||
getSyncRequest: (timeoutMillis?: number) => SyncRequest;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue