diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 0b1fceb06..1483b6fb9 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -1729,6 +1729,10 @@ Signal Desktop makes use of the following open source projects. ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +## mac-screen-capture-permissions + + License: MIT + ## memoizee ISC License diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 9f39196cd..cb7462098 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -691,6 +691,10 @@ "message": "About Signal Desktop", "description": "Item under the Help menu, which opens a small about window" }, + "screenShareWindow": { + "message": "Sharing screen", + "description": "Title for screen sharing window" + }, "speech": { "message": "Speech", "description": "Item under the Edit menu, with 'start/stop speaking' items below it" @@ -1249,6 +1253,18 @@ "message": "Unmute mic", "description": "Button tooltip label for turning on the microphone" }, + "calling__button--presenting-disabled": { + "message": "Presenting disabled", + "description": "Button tooltip label for when screen sharing is disabled" + }, + "calling__button--presenting-on": { + "message": "Start presenting", + "description": "Button tooltip label for starting to share screen" + }, + "calling__button--presenting-off": { + "message": "Stop presenting", + "description": "Button tooltip label for stopping screen sharing" + }, "calling__your-video-is-off": { "message": "Your camera is off", "description": "Label in the calling lobby indicating that your camera is off" @@ -1361,6 +1377,84 @@ "message": "Scroll down", "description": "Label for the \"scroll down\" button in a call's overflow area" }, + "calling__presenting--notification-title": { + "message": "You're presenting to everyone.", + "description": "Title for the share screen notification" + }, + "calling__presenting--notification-body": { + "message": "Click here to return to the call when you're ready to stop presenting.", + "description": "Body text for the share screen notification" + }, + "calling__presenting--info": { + "message": "Signal is sharing $window$.", + "description": "Text that appears in the screen sharing controller to inform person that they are presenting", + "placeholders": { + "name": { + "content": "$1", + "example": "Application" + } + } + }, + "calling__presenting--stop": { + "message": "Stop sharing", + "description": "Button for stopping screen sharing" + }, + "calling__presenting--you-stopped": { + "message": "You stopped presenting", + "description": "Toast that appears when someone stops presenting" + }, + "calling__presenting--person-ongoing": { + "message": "$name$ is presenting", + "description": "Title of call when someone is presenting", + "placeholders": { + "name": { + "content": "$1", + "example": "Maddie" + } + } + }, + "calling__presenting--person-stopped": { + "message": "$name$ stopped presenting", + "description": "Toast that appears when someone stops presenting", + "placeholders": { + "name": { + "content": "$1", + "example": "Maddie" + } + } + }, + "calling__presenting--permission-title": { + "message": "Permission needed", + "description": "Shown as the title for the modal that requests screen recording permissions" + }, + "calling__presenting--macos-permission-description": { + "message": "On an Apple Mac computer using macOS Catalina version 10.15 or later, Signal needs permission to access your computer's screen recording.", + "description": "Shown as the description for the modal that requests screen recording permissions" + }, + "calling__presenting--permission-instruction-step1": { + "message": "Go to System Preferences and then click Security & Privacy.", + "description": "Shown as the description for the modal that requests screen recording permissions" + }, + "calling__presenting--permission-instruction-step2": { + "message": "Click Privacy.", + "description": "Shown as the description for the modal that requests screen recording permissions" + }, + "calling__presenting--permission-instruction-step3": { + "message": "On the left, click Screen Recording.", + "description": "Shown as the description for the modal that requests screen recording permissions" + }, + "calling__presenting--permission-instruction-step4": { + "message": "On the right, check the Signal box.", + "description": "Shown as the description for the modal that requests screen recording permissions" + }, + "calling__presenting--permission-open": { + "message": "Open System Preferences", + "description": "The button that opens your system preferences for the needs screen record permissions modal" + }, + "calling__presenting--permission-cancel": { + "message": "Dismiss", + "description": "The cancel button for the needs screen record permissions modal" + }, "alwaysRelayCallsDescription": { "message": "Always relay calls", "description": "Description of the always relay calls setting" @@ -3240,6 +3334,22 @@ "message": "Leave call", "description": "Title for hang up button" }, + "calling__SelectPresentingSourcesModal--title": { + "message": "Share your screen", + "description": "Title for the select your screen sharing sources modal" + }, + "calling__SelectPresentingSourcesModal--confirm": { + "message": "Share screen", + "description": "Confirm button for sharing screen modal" + }, + "calling__SelectPresentingSourcesModal--entireScreen": { + "message": "Entire screen", + "description": "Title for the select your screen sharing sources modal" + }, + "calling__SelectPresentingSourcesModal--window": { + "message": "A window", + "description": "Title for the select your screen sharing sources modal" + }, "callingDeviceSelection__label--video": { "message": "Video", "description": "Label for video input selector" diff --git a/app/permissions.js b/app/permissions.js index 121677fae..6b16d6ad8 100644 --- a/app/permissions.js +++ b/app/permissions.js @@ -23,18 +23,29 @@ function _createPermissionHandler(userConfig) { return (webContents, permission, callback, details) => { // We default 'media' permission to false, but the user can override that for // the microphone and camera. - if ( - permission === 'media' && - details.mediaTypes.includes('audio') && - userConfig.get('mediaPermissions') - ) { - return callback(true); - } - if ( - permission === 'media' && - details.mediaTypes.includes('video') && - userConfig.get('mediaCameraPermissions') - ) { + if (permission === 'media') { + if ( + details.mediaTypes.includes('audio') || + details.mediaTypes.includes('video') + ) { + if ( + details.mediaTypes.includes('audio') && + userConfig.get('mediaPermissions') + ) { + return callback(true); + } + if ( + details.mediaTypes.includes('video') && + userConfig.get('mediaCameraPermissions') + ) { + return callback(true); + } + + return callback(false); + } + + // If it doesn't have 'video' or 'audio', it's probably screenshare. + // TODO: DESKTOP-1611 return callback(true); } diff --git a/images/icons/v2/share-screen-solid-28.svg b/images/icons/v2/share-screen-solid-28.svg new file mode 100644 index 000000000..5fe79f691 --- /dev/null +++ b/images/icons/v2/share-screen-solid-28.svg @@ -0,0 +1 @@ + diff --git a/main.js b/main.js index a6a10d878..41cf806ef 100644 --- a/main.js +++ b/main.js @@ -753,6 +753,61 @@ function setupAsStandalone() { } } +let screenShareWindow; +function showScreenShareWindow(sourceName) { + if (screenShareWindow) { + screenShareWindow.show(); + return; + } + + const width = 480; + + const { screen } = electron; + const display = screen.getPrimaryDisplay(); + const options = { + alwaysOnTop: true, + autoHideMenuBar: true, + backgroundColor: '#2e2e2e', + darkTheme: true, + frame: false, + fullscreenable: false, + height: 44, + maximizable: false, + minimizable: false, + resizable: false, + show: false, + title: locale.messages.screenShareWindow.message, + width, + webPreferences: { + ...defaultWebPrefs, + nodeIntegration: false, + nodeIntegrationInWorker: false, + contextIsolation: false, + preload: path.join(__dirname, 'screenShare_preload.js'), + }, + x: Math.floor(display.size.width / 2) - width / 2, + y: 24, + }; + + screenShareWindow = new BrowserWindow(options); + + handleCommonWindowEvents(screenShareWindow); + + screenShareWindow.loadURL(prepareFileUrl([__dirname, 'screenShare.html'])); + + screenShareWindow.on('closed', () => { + screenShareWindow = null; + }); + + screenShareWindow.once('ready-to-show', () => { + screenShareWindow.show(); + screenShareWindow.webContents.send( + 'render-screen-sharing-controller', + sourceName + ); + }); +} + let aboutWindow; function showAbout() { if (aboutWindow) { @@ -1503,6 +1558,22 @@ ipc.on('close-about', () => { } }); +ipc.on('close-screen-share-controller', () => { + if (screenShareWindow) { + screenShareWindow.close(); + } +}); + +ipc.on('stop-screen-share', () => { + if (mainWindow) { + mainWindow.webContents.send('stop-screen-share'); + } +}); + +ipc.on('show-screen-share', (event, sourceName) => { + showScreenShareWindow(sourceName); +}); + ipc.on('update-tray-icon', (event, unreadCount) => { if (tray) { tray.updateIcon(unreadCount); diff --git a/package.json b/package.json index ed9aa5b28..850821cd6 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "linkify-it": "2.2.0", "lodash": "4.17.21", "lru-cache": "6.0.0", + "mac-screen-capture-permissions": "2.0.0", "memoizee": "0.4.14", "mkdirp": "0.5.2", "moment": "2.29.1", @@ -147,7 +148,7 @@ "redux-ts-utils": "3.2.2", "reselect": "4.0.0", "rimraf": "2.6.2", - "ringrtc": "https://github.com/signalapp/signal-ringrtc-node.git#b43c6b728d62b6d386d95705e128f32f44edb650", + "ringrtc": "https://github.com/signalapp/signal-ringrtc-node.git#17b22fc9d47605867608193202c54be06bce6f56", "rotating-file-stream": "2.1.5", "sanitize-filename": "1.6.3", "sanitize.css": "11.0.0", @@ -302,7 +303,8 @@ "asarUnpack": [ "**/*.node", "node_modules/zkgroup/libzkgroup.*", - "node_modules/@signalapp/signal-client/build/*.node" + "node_modules/@signalapp/signal-client/build/*.node", + "node_modules/mac-screen-capture-permissions/build/Release/*.node" ], "artifactName": "${name}-mac-${version}.${ext}", "category": "public.app-category.social-networking", @@ -392,6 +394,7 @@ "config/local-${env.SIGNAL_ENV}.json", "background.html", "about.html", + "screenShare.html", "settings.html", "permissions_popup.html", "debug_log.html", @@ -408,6 +411,7 @@ "preload.bundle.js", "preload_utils.js", "about_preload.js", + "screenShare_preload.js", "settings_preload.js", "permissions_popup_preload.js", "debug_log_preload.js", @@ -448,6 +452,7 @@ "node_modules/better-sqlite3/build/Release/better_sqlite3.node", "node_modules/@signalapp/signal-client/build/*${platform}*.node", "node_modules/ringrtc/build/${platform}/**", + "node_modules/mac-screen-capture-permissions/build/Release/*.node", "!**/node_modules/ffi-napi/deps", "!**/node_modules/react-dom/*/*.development.js", "!node_modules/.cache" diff --git a/patches/electron-util+0.13.1.patch b/patches/electron-util+0.13.1.patch new file mode 100644 index 000000000..9df19c78c --- /dev/null +++ b/patches/electron-util+0.13.1.patch @@ -0,0 +1,22 @@ +diff --git a/node_modules/electron-util/index.d.ts b/node_modules/electron-util/index.d.ts +index 8d493d5..3408e21 100644 +--- a/node_modules/electron-util/index.d.ts ++++ b/node_modules/electron-util/index.d.ts +@@ -1,7 +1,7 @@ + /// + /// + /// +-import {AllElectron, Remote, BrowserWindow, Size, Rectangle, Session, MenuItemConstructorOptions, MenuItem} from 'electron'; ++import {RemoteMainInterface, BrowserWindow, Size, Rectangle, Session, MenuItemConstructorOptions, MenuItem} from 'electron'; + import {Options as NewGithubIssueUrlOptions} from 'new-github-issue-url'; + import {RequireAtLeastOne} from 'type-fest'; + +@@ -14,7 +14,7 @@ Access the Electron APIs in both the main and renderer process without having to + api.app.quit(); // The `app` API is usually only available in the main process. + ``` + */ +-export const api: AllElectron | Remote; ++export const api: RemoteMainInterface; + + /** + Check for various things. diff --git a/patches/mac-screen-capture-permissions+2.0.0.patch b/patches/mac-screen-capture-permissions+2.0.0.patch new file mode 100644 index 000000000..ac391bab4 --- /dev/null +++ b/patches/mac-screen-capture-permissions+2.0.0.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/mac-screen-capture-permissions/screen-capture-permissions.m b/node_modules/mac-screen-capture-permissions/screen-capture-permissions.m +index d9d6a00..78fa83f 100644 +--- a/node_modules/mac-screen-capture-permissions/screen-capture-permissions.m ++++ b/node_modules/mac-screen-capture-permissions/screen-capture-permissions.m +@@ -2,6 +2,8 @@ + #import + #include + ++CG_EXTERN bool CGPreflightScreenCaptureAccess(void) CG_AVAILABLE_STARTING(10.15); ++ + static napi_value hasPermissions(napi_env env, napi_callback_info info) { + napi_status status; + bool hasPermissions; diff --git a/screenShare.html b/screenShare.html new file mode 100644 index 000000000..6eb9fd7f2 --- /dev/null +++ b/screenShare.html @@ -0,0 +1,22 @@ + + + + + + + + + + +
+ + + diff --git a/screenShare_preload.js b/screenShare_preload.js new file mode 100644 index 000000000..001f86cc6 --- /dev/null +++ b/screenShare_preload.js @@ -0,0 +1,59 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/* global window */ + +const React = require('react'); +const ReactDOM = require('react-dom'); +const url = require('url'); +const { ipcRenderer } = require('electron'); + +const i18n = require('./js/modules/i18n'); +const { + getEnvironment, + setEnvironment, + parseEnvironment, +} = require('./ts/environment'); +const { + CallingScreenSharingController, +} = require('./ts/components/CallingScreenSharingController'); + +const config = url.parse(window.location.toString(), true).query; +const { locale } = config; +const localeMessages = ipcRenderer.sendSync('locale-data'); +setEnvironment(parseEnvironment(config.environment)); + +window.React = React; +window.ReactDOM = ReactDOM; +window.getAppInstance = () => config.appInstance; +window.getEnvironment = getEnvironment; +window.getVersion = () => config.version; +window.i18n = i18n.setup(locale, localeMessages); + +let renderComponent; +window.registerScreenShareControllerRenderer = f => { + renderComponent = f; +}; + +function renderScreenSharingController(event, presentedSourceName) { + if (!renderComponent) { + setTimeout(renderScreenSharingController, 100); + return; + } + + const props = { + i18n: window.i18n, + onCloseController: () => ipcRenderer.send('close-screen-share-controller'), + onStopSharing: () => ipcRenderer.send('stop-screen-share'), + presentedSourceName, + }; + + renderComponent(CallingScreenSharingController, props); +} + +ipcRenderer.once( + 'render-screen-sharing-controller', + renderScreenSharingController +); + +require('./ts/logging/set_up_renderer_logging').initialize(); diff --git a/sounds/navigation_selection-complete-celebration.ogg b/sounds/navigation_selection-complete-celebration.ogg new file mode 100755 index 000000000..83c40ddb3 Binary files /dev/null and b/sounds/navigation_selection-complete-celebration.ogg differ diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 1274e44f3..7f5b31cc0 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5989,6 +5989,19 @@ button.module-image__border-overlay:focus { $color-white ); } + + &--presenting { + $icon: '../images/icons/v2/share-screen-solid-28.svg'; + &--on { + @include calling-button-icon-on($icon); + } + &--off { + @include calling-button-icon-off($icon); + } + &--disabled { + @include calling-button-icon-disabled($icon); + } + } } @keyframes module-ongoing-call__controls--fade-in { @@ -6286,6 +6299,10 @@ button.module-image__border-overlay:focus { height: 100%; transform: rotateY(180deg); width: 100%; + + &--presenting { + transform: inherit; + } } &--audio-muted::before { @@ -6323,6 +6340,7 @@ button.module-image__border-overlay:focus { } &__toast { + @include button-reset(); @include font-body-1-bold; background-color: $color-gray-75; border-radius: 8px; @@ -6649,6 +6667,17 @@ button.module-image__border-overlay:focus { width: 16px; } } + + &__presenting { + @include color-svg( + '../images/icons/v2/share-screen-solid-28.svg', + $color-white + ); + display: inline-block; + margin-left: 18px; + height: 16px; + width: 16px; + } } .module-call-need-permission-screen { diff --git a/stylesheets/components/CallingScreenSharingController.scss b/stylesheets/components/CallingScreenSharingController.scss new file mode 100644 index 000000000..e062a9b6e --- /dev/null +++ b/stylesheets/components/CallingScreenSharingController.scss @@ -0,0 +1,35 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-CallingScreenSharingController { + align-items: center; + display: flex; + justify-content: space-between; + padding: 4px 16px; + -webkit-app-region: drag; + + &__text { + @include font-body-2; + color: $color-gray-05; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + white-space: nowrap; + width: 212px; + } + + &__buttons { + align-items: center; + display: flex; + -webkit-app-region: no-drag; + } + + &__close { + @include button-reset; + @include color-svg('../images/icons/v2/x-24.svg', $color-gray-25); + cursor: pointer; + margin-left: 12px; + height: 20px; + width: 20px; + } +} diff --git a/stylesheets/components/CallingSelectPresentingSourcesModal.scss b/stylesheets/components/CallingSelectPresentingSourcesModal.scss new file mode 100644 index 000000000..4ee39ce98 --- /dev/null +++ b/stylesheets/components/CallingSelectPresentingSourcesModal.scss @@ -0,0 +1,84 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-CallingSelectPresentingSourcesModal { + // specificity + &.module-Modal { + max-width: 665px; + position: relative; + padding-bottom: 48px; + } + + &__footer { + background-color: $color-gray-95; + bottom: 0; + margin-left: -16px; + margin-top: 0; + padding: 16px; + position: absolute; + width: 100%; + } + + &__sources { + margin-bottom: 20px; + margin-left: -6px; + margin-right: -6px; + + &:last-child { + margin-bottom: 0; + } + } + + &__title { + margin-bottom: 12px; + } + + &__source { + @include button-reset(); + + border-radius: 4px; + border: 1px solid $color-gray-60; + margin-bottom: 14px; + margin-left: 6px; + margin-right: 6px; + overflow: hidden; + padding: 8px; + text-align: center; + width: 200px; + + &--selected { + background-color: $ultramarine-ui-dark; + border: 1px solid $ultramarine-ui-dark; + } + + img { + display: inline-block; + } + } + + &__screenshot { + max-height: 102px; + max-width: 184px; + } + + &__name { + &--container { + align-items: center; + display: flex; + margin-top: 8px; + } + + &--text { + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; + white-space: nowrap; + width: 100%; + } + + &--icon { + margin-right: 8px; + } + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index b89dff99b..3ac0d53b4 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -31,6 +31,8 @@ @import './components/Avatar.scss'; @import './components/AvatarInput.scss'; @import './components/Button.scss'; +@import './components/CallingScreenSharingController.scss'; +@import './components/CallingSelectPresentingSourcesModal.scss'; @import './components/ContactPill.scss'; @import './components/ContactPills.scss'; @import './components/ContactSpoofingReviewDialog.scss'; diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index 89453bf40..7ccd85529 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -11,8 +11,10 @@ export type ConfigKeyType = | 'desktop.gv2' | 'desktop.mandatoryProfileSharing' | 'desktop.messageRequests' + | 'desktop.screensharing' | 'desktop.storage' | 'desktop.storageWrite3' + | 'desktop.worksAtSignal' | 'global.groupsv2.maxGroupSize' | 'global.groupsv2.groupSizeHardLimit'; type ConfigValueType = { diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index 899062e8c..e525612c6 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -68,6 +68,7 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ declineCall: action('decline-call'), getGroupCallVideoFrameSource: (_: string, demuxId: number) => fakeGetGroupCallVideoFrameSource(demuxId), + getPresentingSources: action('get-presenting-sources'), hangUp: action('hang-up'), i18n, keyChangeOk: action('key-change-ok'), @@ -78,16 +79,21 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ }), uuid: 'cb0dd0c8-7393-41e9-a0aa-d631c4109541', }, + openSystemPreferencesAction: action('open-system-preferences-action'), renderDeviceSelection: () =>
, renderSafetyNumberViewer: (_: SafetyNumberViewerProps) =>
, setGroupCallVideoRequest: action('set-group-call-video-request'), setLocalAudio: action('set-local-audio'), setLocalPreview: action('set-local-preview'), setLocalVideo: action('set-local-video'), + setPresenting: action('toggle-presenting'), setRendererCanvas: action('set-renderer-canvas'), startCall: action('start-call'), toggleParticipants: action('toggle-participants'), togglePip: action('toggle-pip'), + toggleScreenRecordingPermissionsDialog: action( + 'toggle-screen-recording-permissions-dialog' + ), toggleSettings: action('toggle-settings'), toggleSpeakerView: action('toggle-speaker-view'), }); @@ -104,7 +110,9 @@ story.add('Ongoing Direct Call', () => ( callMode: CallMode.Direct, callState: CallState.Accepted, peekedParticipants: [], - remoteParticipants: [{ hasRemoteVideo: true }], + remoteParticipants: [ + { hasRemoteVideo: true, presenting: false, title: 'Remy' }, + ], }, })} /> @@ -148,7 +156,9 @@ story.add('Call Request Needed', () => ( callMode: CallMode.Direct, callState: CallState.Accepted, peekedParticipants: [], - remoteParticipants: [{ hasRemoteVideo: true }], + remoteParticipants: [ + { hasRemoteVideo: true, presenting: false, title: 'Mike' }, + ], }, })} /> diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 06e5d6f50..014dc6603 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -6,6 +6,7 @@ import { CallNeedPermissionScreen } from './CallNeedPermissionScreen'; import { CallScreen } from './CallScreen'; import { CallingLobby } from './CallingLobby'; import { CallingParticipantsList } from './CallingParticipantsList'; +import { CallingSelectPresentingSourcesModal } from './CallingSelectPresentingSourcesModal'; import { CallingPip } from './CallingPip'; import { IncomingCallBar } from './IncomingCallBar'; import { @@ -19,6 +20,7 @@ import { CallState, GroupCallJoinState, GroupCallVideoRequest, + PresentedSource, VideoFrameSource, } from '../types/Calling'; import { ConversationType } from '../state/ducks/conversations'; @@ -52,6 +54,7 @@ export type PropsType = { conversationId: string, demuxId: number ) => VideoFrameSource; + getPresentingSources: () => void; incomingCall?: { call: DirectCallStateType; conversation: ConversationType; @@ -65,13 +68,16 @@ export type PropsType = { declineCall: (_: DeclineCallType) => void; i18n: LocalizerType; me: MeType; + openSystemPreferencesAction: () => unknown; setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void; setLocalAudio: (_: SetLocalAudioType) => void; setLocalVideo: (_: SetLocalVideoType) => void; setLocalPreview: (_: SetLocalPreviewType) => void; + setPresenting: (_?: PresentedSource) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; hangUp: (_: HangUpType) => void; togglePip: () => void; + toggleScreenRecordingPermissionsDialog: () => unknown; toggleSettings: () => void; toggleSpeakerView: () => void; }; @@ -89,17 +95,21 @@ const ActiveCallManager: React.FC = ({ i18n, keyChangeOk, getGroupCallVideoFrameSource, + getPresentingSources, me, + openSystemPreferencesAction, renderDeviceSelection, renderSafetyNumberViewer, setGroupCallVideoRequest, setLocalAudio, setLocalPreview, setLocalVideo, + setPresenting, setRendererCanvas, startCall, toggleParticipants, togglePip, + toggleScreenRecordingPermissionsDialog, toggleSettings, toggleSpeakerView, }) => { @@ -110,6 +120,7 @@ const ActiveCallManager: React.FC = ({ joinedAt, peekedParticipants, pip, + presentingSourcesAvailable, settingsDialogOpen, showParticipantsList, } = activeCall; @@ -238,13 +249,15 @@ const ActiveCallManager: React.FC = ({ ? [ ...activeCall.remoteParticipants.map(participant => ({ ...participant, - hasAudio: participant.hasRemoteAudio, - hasVideo: participant.hasRemoteVideo, + hasRemoteAudio: participant.hasRemoteAudio, + hasRemoteVideo: participant.hasRemoteVideo, + presenting: participant.presenting, })), { ...me, - hasAudio: hasLocalAudio, - hasVideo: hasLocalVideo, + hasRemoteAudio: hasLocalAudio, + hasRemoteVideo: hasLocalVideo, + presenting: Boolean(activeCall.presentingSource), }, ] : []; @@ -253,22 +266,35 @@ const ActiveCallManager: React.FC = ({ <> + {presentingSourcesAvailable && presentingSourcesAvailable.length ? ( + + ) : null} {settingsDialogOpen && renderDeviceSelection()} {showParticipantsList && activeCall.callMode === CallMode.Group ? ( ({ activeCall: createActiveCallProp(overrideProps), getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource, + getPresentingSources: action('get-presenting-sources'), hangUp: action('hang-up'), i18n, me: { @@ -145,14 +150,19 @@ const createProps = ( profileName: 'Morty Smith', title: 'Morty Smith', }, + openSystemPreferencesAction: action('open-system-preferences-action'), setGroupCallVideoRequest: action('set-group-call-video-request'), setLocalAudio: action('set-local-audio'), setLocalPreview: action('set-local-preview'), setLocalVideo: action('set-local-video'), + setPresenting: action('toggle-presenting'), setRendererCanvas: action('set-renderer-canvas'), stickyControls: boolean('stickyControls', false), toggleParticipants: action('toggle-participants'), togglePip: action('toggle-pip'), + toggleScreenRecordingPermissionsDialog: action( + 'toggle-screen-recording-permissions-dialog' + ), toggleSettings: action('toggle-settings'), toggleSpeakerView: action('toggle-speaker-view'), }); @@ -249,6 +259,8 @@ story.add('Group call - 1', () => ( demuxId: 0, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 1.3, ...getDefaultConversation({ isBlocked: false, @@ -266,6 +278,8 @@ const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({ demuxId: index, hasRemoteAudio: index % 3 !== 0, hasRemoteVideo: index % 4 !== 0, + presenting: false, + sharingScreen: false, videoAspectRatio: 1.3, ...getDefaultConversation({ isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1, @@ -303,6 +317,8 @@ story.add('Group call - reconnecting', () => ( demuxId: 0, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 1.3, ...getDefaultConversation({ isBlocked: false, diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index b1c1325fa..f363d788c 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -21,18 +21,23 @@ import { CallState, GroupCallConnectionState, GroupCallVideoRequest, + PresentedSource, VideoFrameSource, } from '../types/Calling'; +import { CallingToastManager } from './CallingToastManager'; import { ColorType } from '../types/Colors'; -import { LocalizerType } from '../types/Util'; -import { missingCaseError } from '../util/missingCaseError'; import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant'; import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants'; -import { GroupCallToastManager } from './GroupCallToastManager'; +import { LocalizerType } from '../types/Util'; +import { isScreenSharingEnabled } from '../util/isScreenSharingEnabled'; +import { missingCaseError } from '../util/missingCaseError'; +import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting'; +import { NeedsScreenRecordingPermissionsModal } from './NeedsScreenRecordingPermissionsModal'; export type PropsType = { activeCall: ActiveCallType; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; + getPresentingSources: () => void; hangUp: (_: HangUpType) => void; i18n: LocalizerType; joinedAt?: number; @@ -44,14 +49,17 @@ export type PropsType = { profileName?: string; title: string; }; + openSystemPreferencesAction: () => unknown; setGroupCallVideoRequest: (_: Array) => void; setLocalAudio: (_: SetLocalAudioType) => void; setLocalVideo: (_: SetLocalVideoType) => void; setLocalPreview: (_: SetLocalPreviewType) => void; + setPresenting: (_?: PresentedSource) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; stickyControls: boolean; toggleParticipants: () => void; togglePip: () => void; + toggleScreenRecordingPermissionsDialog: () => unknown; toggleSettings: () => void; toggleSpeakerView: () => void; }; @@ -59,18 +67,22 @@ export type PropsType = { export const CallScreen: React.FC = ({ activeCall, getGroupCallVideoFrameSource, + getPresentingSources, hangUp, i18n, joinedAt, me, + openSystemPreferencesAction, setGroupCallVideoRequest, setLocalAudio, setLocalVideo, setLocalPreview, + setPresenting, setRendererCanvas, stickyControls, toggleParticipants, togglePip, + toggleScreenRecordingPermissionsDialog, toggleSettings, toggleSpeakerView, }) => { @@ -78,9 +90,19 @@ export const CallScreen: React.FC = ({ conversation, hasLocalAudio, hasLocalVideo, + isInSpeakerView, + presentingSource, + remoteParticipants, + showNeedsScreenRecordingPermissionsWarning, showParticipantsList, } = activeCall; + useActivateSpeakerViewOnPresenting( + remoteParticipants, + isInSpeakerView, + toggleSpeakerView + ); + const toggleAudio = useCallback(() => { setLocalAudio({ enabled: !hasLocalAudio, @@ -93,6 +115,14 @@ export const CallScreen: React.FC = ({ }); }, [setLocalVideo, hasLocalVideo]); + const togglePresenting = useCallback(() => { + if (presentingSource) { + setPresenting(); + } else { + getPresentingSources(); + } + }, [getPresentingSources, presentingSource, setPresenting]); + const [acceptedDuration, setAcceptedDuration] = useState(null); const [showControls, setShowControls] = useState(true); @@ -151,7 +181,11 @@ export const CallScreen: React.FC = ({ }; }, [toggleAudio, toggleVideo]); - const hasRemoteVideo = activeCall.remoteParticipants.some( + const currentPresenter = remoteParticipants.find( + participant => participant.presenting + ); + + const hasRemoteVideo = remoteParticipants.some( remoteParticipant => remoteParticipant.hasRemoteVideo ); @@ -183,16 +217,22 @@ export const CallScreen: React.FC = ({ case CallMode.Group: participantCount = activeCall.remoteParticipants.length + 1; headerMessage = undefined; - headerTitle = activeCall.remoteParticipants.length - ? undefined - : i18n('calling__in-this-call--zero'); + + if (currentPresenter) { + headerTitle = i18n('calling__presenting--person-ongoing', [ + currentPresenter.title, + ]); + } else if (!activeCall.remoteParticipants.length) { + headerTitle = i18n('calling__in-this-call--zero'); + } + isConnected = activeCall.connectionState === GroupCallConnectionState.Connected; remoteParticipantsElement = ( @@ -206,9 +246,15 @@ export const CallScreen: React.FC = ({ activeCall.callMode === CallMode.Group && !activeCall.remoteParticipants.length; - const videoButtonType = hasLocalVideo - ? CallingButtonType.VIDEO_ON - : CallingButtonType.VIDEO_OFF; + let videoButtonType: CallingButtonType; + if (presentingSource) { + videoButtonType = CallingButtonType.VIDEO_DISABLED; + } else if (hasLocalVideo) { + videoButtonType = CallingButtonType.VIDEO_ON; + } else { + videoButtonType = CallingButtonType.VIDEO_OFF; + } + const audioButtonType = hasLocalAudio ? CallingButtonType.AUDIO_ON : CallingButtonType.AUDIO_OFF; @@ -222,6 +268,23 @@ export const CallScreen: React.FC = ({ !showControls && !isAudioOnly && isConnected, }); + const isGroupCall = activeCall.callMode === CallMode.Group; + const localPreviewVideoClass = classNames({ + 'module-ongoing-call__footer__local-preview__video': true, + 'module-ongoing-call__footer__local-preview__video--presenting': Boolean( + presentingSource + ), + }); + + let presentingButtonType: CallingButtonType; + if (presentingSource) { + presentingButtonType = CallingButtonType.PRESENTING_ON; + } else if (currentPresenter) { + presentingButtonType = CallingButtonType.PRESENTING_DISABLED; + } else { + presentingButtonType = CallingButtonType.PRESENTING_OFF; + } + return (
= ({ }} role="group" > - {activeCall.callMode === CallMode.Group ? ( - ) : null} +
= ({ {hasLocalVideo && isLonelyInGroup ? (
- {participant.hasAudio === false ? ( + {participant.hasRemoteAudio === false ? ( ) : null} - {participant.hasVideo === false ? ( + {participant.hasRemoteVideo === false ? ( ) : null} + {participant.presenting ? ( + + ) : null}
) diff --git a/ts/components/CallingPip.stories.tsx b/ts/components/CallingPip.stories.tsx index 47760d521..54d31412f 100644 --- a/ts/components/CallingPip.stories.tsx +++ b/ts/components/CallingPip.stories.tsx @@ -49,7 +49,9 @@ const defaultCall: ActiveCallType = { callMode: CallMode.Direct as CallMode.Direct, callState: CallState.Accepted, peekedParticipants: [], - remoteParticipants: [{ hasRemoteVideo: true }], + remoteParticipants: [ + { hasRemoteVideo: true, presenting: false, title: 'Arsene' }, + ], }; const createProps = (overrideProps: Partial = {}): PropsType => ({ @@ -79,7 +81,9 @@ story.add('Contact (with avatar and no video)', () => { ...conversation, avatarPath: 'https://www.fillmurray.com/64/64', }, - remoteParticipants: [{ hasRemoteVideo: false }], + remoteParticipants: [ + { hasRemoteVideo: false, presenting: false, title: 'Julian' }, + ], }, }); return ; diff --git a/ts/components/CallingPipRemoteVideo.tsx b/ts/components/CallingPipRemoteVideo.tsx index b9e5db5bf..5b77ba6e2 100644 --- a/ts/components/CallingPipRemoteVideo.tsx +++ b/ts/components/CallingPipRemoteVideo.tsx @@ -96,9 +96,8 @@ export const CallingPipRemoteVideo = ({ return undefined; } - return maxBy( - activeCall.remoteParticipants, - participant => participant.speakerTime || -Infinity + return maxBy(activeCall.remoteParticipants, participant => + participant.presenting ? Infinity : participant.speakerTime || -Infinity ); }, [activeCall.callMode, activeCall.remoteParticipants]); diff --git a/ts/components/CallingScreenSharingController.stories.tsx b/ts/components/CallingScreenSharingController.stories.tsx new file mode 100644 index 000000000..208ee32b4 --- /dev/null +++ b/ts/components/CallingScreenSharingController.stories.tsx @@ -0,0 +1,29 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { + CallingScreenSharingController, + PropsType, +} from './CallingScreenSharingController'; + +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +const createProps = (): PropsType => ({ + i18n, + onCloseController: action('on-close-controller'), + onStopSharing: action('on-stop-sharing'), + presentedSourceName: 'Application', +}); + +const story = storiesOf('Components/CallingScreenSharingController', module); + +story.add('Controller', () => { + return ; +}); diff --git a/ts/components/CallingScreenSharingController.tsx b/ts/components/CallingScreenSharingController.tsx new file mode 100644 index 000000000..054b245a2 --- /dev/null +++ b/ts/components/CallingScreenSharingController.tsx @@ -0,0 +1,39 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { Button, ButtonVariant } from './Button'; +import { LocalizerType } from '../types/Util'; + +export type PropsType = { + i18n: LocalizerType; + onCloseController: () => unknown; + onStopSharing: () => unknown; + presentedSourceName: string; +}; + +export const CallingScreenSharingController = ({ + i18n, + onCloseController, + onStopSharing, + presentedSourceName, +}: PropsType): JSX.Element => { + return ( +
+
+ {i18n('calling__presenting--info', [presentedSourceName])} +
+
+ +
+
+ ); +}; diff --git a/ts/components/CallingSelectPresentingSourcesModal.stories.tsx b/ts/components/CallingSelectPresentingSourcesModal.stories.tsx new file mode 100644 index 000000000..b0c082987 --- /dev/null +++ b/ts/components/CallingSelectPresentingSourcesModal.stories.tsx @@ -0,0 +1,61 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { + CallingSelectPresentingSourcesModal, + PropsType, +} from './CallingSelectPresentingSourcesModal'; + +import { setup as setupI18n } from '../../js/modules/i18n'; +import enMessages from '../../_locales/en/messages.json'; + +const i18n = setupI18n('en', enMessages); + +const createProps = (): PropsType => ({ + i18n, + presentingSourcesAvailable: [ + { + id: 'screen', + name: 'Entire Screen', + thumbnail: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P/1PwAF8AL1sEVIPAAAAABJRU5ErkJggg==', + }, + { + id: 'window:123', + name: 'Bozirro Airhorse', + thumbnail: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z1D4HwAF5wJxzsNOIAAAAABJRU5ErkJggg==', + }, + { + id: 'window:456', + name: 'Discoverer', + thumbnail: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8HwHwAFHQIIj4yLtgAAAABJRU5ErkJggg==', + }, + { + id: 'window:789', + name: 'Signal Beta', + thumbnail: '', + }, + { + id: 'window:xyz', + name: 'Window that has a really long name and overflows', + thumbnail: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+O/wHwAEhgJAyqFnAgAAAABJRU5ErkJggg==', + }, + ], + setPresenting: action('set-presenting'), +}); + +const story = storiesOf( + 'Components/CallingSelectPresentingSourcesModal', + module +); + +story.add('Modal', () => { + return ; +}); diff --git a/ts/components/CallingSelectPresentingSourcesModal.tsx b/ts/components/CallingSelectPresentingSourcesModal.tsx new file mode 100644 index 000000000..660b3dcf8 --- /dev/null +++ b/ts/components/CallingSelectPresentingSourcesModal.tsx @@ -0,0 +1,137 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { groupBy } from 'lodash'; +import { Button, ButtonVariant } from './Button'; +import { LocalizerType } from '../types/Util'; +import { Modal } from './Modal'; +import { PresentedSource, PresentableSource } from '../types/Calling'; +import { Theme } from '../util/theme'; + +export type PropsType = { + i18n: LocalizerType; + presentingSourcesAvailable: Array; + setPresenting: (_?: PresentedSource) => void; +}; + +const Source = ({ + onSourceClick, + source, + sourceToPresent, +}: { + onSourceClick: (source: PresentedSource) => void; + source: PresentableSource; + sourceToPresent?: PresentedSource; +}): JSX.Element => { + return ( + + ); +}; + +export const CallingSelectPresentingSourcesModal = ({ + i18n, + presentingSourcesAvailable, + setPresenting, +}: PropsType): JSX.Element | null => { + const [sourceToPresent, setSourceToPresent] = useState< + PresentedSource | undefined + >(undefined); + + if (!presentingSourcesAvailable.length) { + throw new Error('No sources available for presenting'); + } + + const sources = groupBy(presentingSourcesAvailable, source => + source.id.startsWith('screen') + ); + + return ( + { + setPresenting(sourceToPresent); + }} + theme={Theme.Dark} + title={i18n('calling__SelectPresentingSourcesModal--title')} + > +
+ {i18n('calling__SelectPresentingSourcesModal--entireScreen')} +
+
+ {sources.true.map(source => ( + setSourceToPresent(selectedSource)} + source={source} + sourceToPresent={sourceToPresent} + /> + ))} +
+
+ {i18n('calling__SelectPresentingSourcesModal--window')} +
+
+ {sources.false.map(source => ( + setSourceToPresent(selectedSource)} + source={source} + sourceToPresent={sourceToPresent} + /> + ))} +
+ + + + +
+ ); +}; diff --git a/ts/components/CallingToastManager.tsx b/ts/components/CallingToastManager.tsx new file mode 100644 index 000000000..63af47cb4 --- /dev/null +++ b/ts/components/CallingToastManager.tsx @@ -0,0 +1,163 @@ +// Copyright 2020-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { + ActiveCallType, + CallMode, + GroupCallConnectionState, +} from '../types/Calling'; +import { ConversationType } from '../state/ducks/conversations'; +import { LocalizerType } from '../types/Util'; + +type PropsType = { + activeCall: ActiveCallType; + i18n: LocalizerType; +}; + +type ToastType = + | { + message: string; + type: 'dismissable' | 'static'; + } + | undefined; + +function getReconnectingToast({ activeCall, i18n }: PropsType): ToastType { + if ( + activeCall.callMode === CallMode.Group && + activeCall.connectionState === GroupCallConnectionState.Reconnecting + ) { + return { + message: i18n('callReconnecting'), + type: 'static', + }; + } + return undefined; +} + +const ME = Symbol('me'); + +function getCurrentPresenter( + activeCall: Readonly +): ConversationType | typeof ME | undefined { + if (activeCall.presentingSource) { + return ME; + } + if (activeCall.callMode === CallMode.Direct) { + const isOtherPersonPresenting = activeCall.remoteParticipants.some( + participant => participant.presenting + ); + return isOtherPersonPresenting ? activeCall.conversation : undefined; + } + if (activeCall.callMode === CallMode.Group) { + return activeCall.remoteParticipants.find( + participant => participant.presenting + ); + } + return undefined; +} + +function useScreenSharingToast({ activeCall, i18n }: PropsType): ToastType { + const [result, setResult] = useState(undefined); + + const [previousPresenter, setPreviousPresenter] = useState< + undefined | { id: string | typeof ME; title?: string } + >(undefined); + + const previousPresenterId = previousPresenter?.id; + const previousPresenterTitle = previousPresenter?.title; + + useEffect(() => { + const currentPresenter = getCurrentPresenter(activeCall); + if (!currentPresenter && previousPresenterId) { + if (previousPresenterId === ME) { + setResult({ + type: 'dismissable', + message: i18n('calling__presenting--you-stopped'), + }); + } else if (previousPresenterTitle) { + setResult({ + type: 'dismissable', + message: i18n('calling__presenting--person-stopped', [ + previousPresenterTitle, + ]), + }); + } + } + }, [activeCall, i18n, previousPresenterId, previousPresenterTitle]); + + useEffect(() => { + const currentPresenter = getCurrentPresenter(activeCall); + if (currentPresenter === ME) { + setPreviousPresenter({ + id: ME, + }); + } else if (!currentPresenter) { + setPreviousPresenter(undefined); + } else { + const { id, title } = currentPresenter; + setPreviousPresenter({ id, title }); + } + }, [activeCall]); + + return result; +} + +const DEFAULT_DELAY = 5000; + +// In the future, this component should show toasts when users join or leave. See +// DESKTOP-902. +export const CallingToastManager: React.FC = props => { + const reconnectingToast = getReconnectingToast(props); + const screenSharingToast = useScreenSharingToast(props); + + let toast: ToastType; + if (reconnectingToast) { + toast = reconnectingToast; + } else if (screenSharingToast) { + toast = screenSharingToast; + } + + const [toastMessage, setToastMessage] = useState(''); + const timeoutRef = useRef(null); + + const dismissToast = useCallback(() => { + if (timeoutRef) { + setToastMessage(''); + } + }, [setToastMessage, timeoutRef]); + + useEffect(() => { + if (toast) { + if (toast.type === 'dismissable') { + if (timeoutRef && timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(dismissToast, DEFAULT_DELAY); + } + + setToastMessage(toast.message); + } + + return () => { + if (timeoutRef && timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [dismissToast, setToastMessage, timeoutRef, toast]); + + const isVisible = Boolean(toastMessage); + + return ( + + ); +}; diff --git a/ts/components/GroupCallOverflowArea.stories.tsx b/ts/components/GroupCallOverflowArea.stories.tsx index 507a52f23..99415d7c6 100644 --- a/ts/components/GroupCallOverflowArea.stories.tsx +++ b/ts/components/GroupCallOverflowArea.stories.tsx @@ -22,6 +22,8 @@ const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({ demuxId: index, hasRemoteAudio: index % 3 !== 0, hasRemoteVideo: index % 4 !== 0, + presenting: false, + sharingScreen: false, videoAspectRatio: 1.3, ...getDefaultConversation({ isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1, diff --git a/ts/components/GroupCallRemoteParticipant.stories.tsx b/ts/components/GroupCallRemoteParticipant.stories.tsx index 267505fee..77027fe66 100644 --- a/ts/components/GroupCallRemoteParticipant.stories.tsx +++ b/ts/components/GroupCallRemoteParticipant.stories.tsx @@ -42,6 +42,8 @@ const createProps = ( demuxId: 123, hasRemoteAudio: false, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 1.3, ...getDefaultConversation({ isBlocked: Boolean(isBlocked), diff --git a/ts/components/GroupCallRemoteParticipants.tsx b/ts/components/GroupCallRemoteParticipants.tsx index d56bba2eb..678de4f23 100644 --- a/ts/components/GroupCallRemoteParticipants.tsx +++ b/ts/components/GroupCallRemoteParticipants.tsx @@ -105,7 +105,8 @@ export const GroupCallRemoteParticipants: React.FC = ({ // 2. Split participants into two groups: ones in the main grid and ones in the overflow // sidebar. // - // We start by sorting by `speakerTime` so that the most recent speakers are first in + // We start by sorting by `presenting` first since presenters should be on the main grid + // then we sort by `speakerTime` so that the most recent speakers are next in // line for the main grid. Then we split the list in two: one for the grid and one for // the overflow area. // @@ -119,7 +120,9 @@ export const GroupCallRemoteParticipants: React.FC = ({ remoteParticipants .concat() .sort( - (a, b) => (b.speakerTime || -Infinity) - (a.speakerTime || -Infinity) + (a, b) => + Number(b.presenting || 0) - Number(a.presenting || 0) || + (b.speakerTime || -Infinity) - (a.speakerTime || -Infinity) ), [remoteParticipants] ); @@ -275,18 +278,23 @@ export const GroupCallRemoteParticipants: React.FC = ({ if (isPageVisible) { setGroupCallVideoRequest([ ...gridParticipants.map(participant => { - if (participant.hasRemoteVideo) { - return { - demuxId: participant.demuxId, - width: Math.floor( - gridParticipantHeight * - participant.videoAspectRatio * - VIDEO_REQUEST_SCALAR - ), - height: Math.floor(gridParticipantHeight * VIDEO_REQUEST_SCALAR), - }; + let scalar: number; + if (participant.sharingScreen) { + // We want best-resolution video if someone is sharing their screen. This code + // is extra-defensive against strange devicePixelRatios. + scalar = Math.max(window.devicePixelRatio || 1, 1); + } else if (participant.hasRemoteVideo) { + scalar = VIDEO_REQUEST_SCALAR; + } else { + scalar = 0; } - return nonRenderedRemoteParticipant(participant); + return { + demuxId: participant.demuxId, + width: Math.floor( + gridParticipantHeight * participant.videoAspectRatio * scalar + ), + height: Math.floor(gridParticipantHeight * scalar), + }; }), ...overflowedParticipants.map(participant => { if (participant.hasRemoteVideo) { diff --git a/ts/components/GroupCallToastManager.tsx b/ts/components/GroupCallToastManager.tsx deleted file mode 100644 index bc944af5d..000000000 --- a/ts/components/GroupCallToastManager.tsx +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2020-2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import React, { useState, useEffect } from 'react'; -import classNames from 'classnames'; -import { GroupCallConnectionState } from '../types/Calling'; -import { LocalizerType } from '../types/Util'; - -type PropsType = { - connectionState: GroupCallConnectionState; - i18n: LocalizerType; -}; - -// In the future, this component should show toasts when users join or leave. See -// DESKTOP-902. -export const GroupCallToastManager: React.FC = ({ - connectionState, - i18n, -}) => { - const [isVisible, setIsVisible] = useState(false); - - useEffect(() => { - setIsVisible(connectionState === GroupCallConnectionState.Reconnecting); - }, [connectionState, setIsVisible]); - - const message = i18n('callReconnecting'); - - return ( -
- {message} -
- ); -}; diff --git a/ts/components/NeedsScreenRecordingPermissionsModal.tsx b/ts/components/NeedsScreenRecordingPermissionsModal.tsx new file mode 100644 index 000000000..f1f57f7d9 --- /dev/null +++ b/ts/components/NeedsScreenRecordingPermissionsModal.tsx @@ -0,0 +1,60 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { LocalizerType } from '../types/Util'; +import { Theme } from '../util/theme'; +import { Modal } from './Modal'; +import { Button, ButtonVariant } from './Button'; + +type PropsType = { + i18n: LocalizerType; + openSystemPreferencesAction: () => unknown; + toggleScreenRecordingPermissionsDialog: () => unknown; +}; + +function focusRef(el: HTMLElement | null) { + if (el) { + el.focus(); + } +} + +export const NeedsScreenRecordingPermissionsModal = ({ + i18n, + openSystemPreferencesAction, + toggleScreenRecordingPermissionsDialog, +}: PropsType): JSX.Element => { + return ( + +

{i18n('calling__presenting--macos-permission-description')}

+
    +
  1. {i18n('calling__presenting--permission-instruction-step1')}
  2. +
  3. {i18n('calling__presenting--permission-instruction-step2')}
  4. +
  5. {i18n('calling__presenting--permission-instruction-step3')}
  6. +
  7. {i18n('calling__presenting--permission-instruction-step4')}
  8. +
+ + + + +
+ ); +}; diff --git a/ts/hooks/useActivateSpeakerViewOnPresenting.ts b/ts/hooks/useActivateSpeakerViewOnPresenting.ts new file mode 100644 index 000000000..d6536f62e --- /dev/null +++ b/ts/hooks/useActivateSpeakerViewOnPresenting.ts @@ -0,0 +1,29 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { useEffect } from 'react'; +import { usePrevious } from '../util/hooks'; + +type RemoteParticipant = { + hasRemoteVideo: boolean; + presenting: boolean; + title: string; + uuid?: string; +}; + +export function useActivateSpeakerViewOnPresenting( + remoteParticipants: ReadonlyArray, + isInSpeakerView: boolean, + toggleSpeakerView: () => void +): void { + const presenterUuid = remoteParticipants.find( + participant => participant.presenting + )?.uuid; + const prevPresenterUuid = usePrevious(presenterUuid, presenterUuid); + + useEffect(() => { + if (prevPresenterUuid !== presenterUuid && !isInSpeakerView) { + toggleSpeakerView(); + } + }, [isInSpeakerView, presenterUuid, prevPresenterUuid, toggleSpeakerView]); +} diff --git a/ts/services/calling.ts b/ts/services/calling.ts index da45e35a5..2aacd5bec 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -1,8 +1,9 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable class-methods-use-this */ +import { desktopCapturer, ipcRenderer } from 'electron'; import { Call, CallEndedReason, @@ -44,6 +45,8 @@ import { MediaDeviceSettings, GroupCallConnectionState, GroupCallJoinState, + PresentableSource, + PresentedSource, } from '../types/Calling'; import { ConversationModel } from '../models/conversations'; import { @@ -64,6 +67,7 @@ import { REQUESTED_VIDEO_HEIGHT, REQUESTED_VIDEO_FRAMERATE, } from '../calling/constants'; +import { notify } from './notify'; const RINGRTC_HTTP_METHOD_TO_OUR_HTTP_METHOD: Map< HttpMethod, @@ -100,12 +104,14 @@ export class CallingClass { private callsByConversation: { [conversationId: string]: Call | GroupCall }; + private hadLocalVideoBeforePresenting?: boolean; + constructor() { - this.videoCapturer = new GumVideoCapturer( - REQUESTED_VIDEO_WIDTH, - REQUESTED_VIDEO_HEIGHT, - REQUESTED_VIDEO_FRAMERATE - ); + this.videoCapturer = new GumVideoCapturer({ + maxWidth: REQUESTED_VIDEO_WIDTH, + maxHeight: REQUESTED_VIDEO_HEIGHT, + maxFramerate: REQUESTED_VIDEO_FRAMERATE, + }); this.videoRenderer = new CanvasVideoRenderer(); this.callsByConversation = {}; @@ -127,6 +133,10 @@ export class CallingClass { RingRTC.handleLogMessage = this.handleLogMessage.bind(this); RingRTC.handleSendHttpRequest = this.handleSendHttpRequest.bind(this); RingRTC.handleSendCallMessage = this.handleSendCallMessage.bind(this); + + ipcRenderer.on('stop-screen-share', () => { + uxActions.setPresenting(); + }); } async startCallingLobby( @@ -247,7 +257,7 @@ export class CallingClass { } stopCallingLobby(conversationId?: string): void { - this.disableLocalCamera(); + this.disableLocalVideo(); this.stopDeviceReselectionTimer(); this.lastMediaDeviceSettings = undefined; @@ -441,7 +451,7 @@ export class CallingClass { // 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.disableLocalCamera(); + this.disableLocalVideo(); delete this.callsByConversation[conversationId]; @@ -457,7 +467,7 @@ export class CallingClass { // NOTE: This assumes only one active call at a time. See comment above. if (localDeviceState.videoMuted) { - this.disableLocalCamera(); + this.disableLocalVideo(); } else { this.videoCapturer.enableCaptureAndSend(groupCall); } @@ -689,6 +699,8 @@ export class CallingClass { demuxId: remoteDeviceState.demuxId, hasRemoteAudio: !remoteDeviceState.audioMuted, hasRemoteVideo: !remoteDeviceState.videoMuted, + presenting: Boolean(remoteDeviceState.presenting), + sharingScreen: Boolean(remoteDeviceState.sharingScreen), speakerTime: normalizeGroupCallTimestamp( remoteDeviceState.speakerTime ), @@ -807,6 +819,8 @@ export class CallingClass { return; } + ipcRenderer.send('close-screen-share-controller'); + if (call instanceof Call) { RingRTC.hangup(call.callId); } else if (call instanceof GroupCall) { @@ -851,6 +865,101 @@ export class CallingClass { } } + private setOutgoingVideoIsScreenShare( + call: Call | GroupCall, + enabled: boolean + ): void { + if (call instanceof Call) { + RingRTC.setOutgoingVideoIsScreenShare(call.callId, enabled); + // Note: there is no "presenting" API for direct calls. + } else if (call instanceof GroupCall) { + call.setOutgoingVideoIsScreenShare(enabled); + call.setPresenting(enabled); + } else { + throw missingCaseError(call); + } + } + + async getPresentingSources(): Promise> { + const sources = await desktopCapturer.getSources({ + fetchWindowIcons: true, + thumbnailSize: { height: 102, width: 184 }, + types: ['window', 'screen'], + }); + + const presentableSources: Array = []; + + sources.forEach(source => { + // If electron can't retrieve a thumbnail then it won't be able to + // present this source so we filter these out. + if (source.thumbnail.isEmpty()) { + return; + } + presentableSources.push({ + appIcon: + source.appIcon && !source.appIcon.isEmpty() + ? source.appIcon.toDataURL() + : undefined, + id: source.id, + name: source.name, + thumbnail: source.thumbnail.toDataURL(), + }); + }); + + return presentableSources; + } + + setPresenting( + conversationId: string, + hasLocalVideo: boolean, + source?: PresentedSource + ): void { + const call = getOwn(this.callsByConversation, conversationId); + if (!call) { + window.log.warn('Trying to set presenting for a non-existent call'); + return; + } + + this.videoCapturer.disable(); + if (source) { + this.hadLocalVideoBeforePresenting = hasLocalVideo; + this.videoCapturer.enableCaptureAndSend(call, { + // 15fps is much nicer but takes up a lot more CPU. + maxFramerate: 5, + maxHeight: 1080, + maxWidth: 1920, + screenShareSourceId: source.id, + }); + this.setOutgoingVideo(conversationId, true); + } else { + this.setOutgoingVideo( + conversationId, + Boolean(this.hadLocalVideoBeforePresenting) || hasLocalVideo + ); + this.hadLocalVideoBeforePresenting = undefined; + } + + const isPresenting = Boolean(source); + this.setOutgoingVideoIsScreenShare(call, isPresenting); + + if (source) { + ipcRenderer.send('show-screen-share', source.name); + notify({ + icon: 'images/icons/v2/video-solid-24.svg', + message: window.i18n('calling__presenting--notification-body'), + onNotificationClick: () => { + if (this.uxActions) { + this.uxActions.setPresenting(); + } + }, + silent: true, + title: window.i18n('calling__presenting--notification-title'), + }); + } else { + ipcRenderer.send('close-screen-share-controller'); + } + } + private async startDeviceReselectionTimer(): Promise { // Poll once await this.pollForMediaDevices(); @@ -1066,7 +1175,7 @@ export class CallingClass { this.videoCapturer.enableCapture(); } - disableLocalCamera(): void { + disableLocalVideo(): void { this.videoCapturer.disable(); } @@ -1387,6 +1496,14 @@ export class CallingClass { hasVideo: call.remoteVideoEnabled, }); }; + + // eslint-disable-next-line no-param-reassign + call.handleRemoteSharingScreen = () => { + uxActions.remoteSharingScreenChange({ + conversationId: conversation.id, + isSharingScreen: Boolean(call.remoteSharingScreen), + }); + }; } private async handleLogMessage( diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 75e76b0f0..c91127a4f 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -1,10 +1,16 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { ipcRenderer } from 'electron'; import { ThunkAction } from 'redux-thunk'; import { CallEndedReason } from 'ringrtc'; +import { + hasScreenCapturePermission, + openSystemPreferences, +} from 'mac-screen-capture-permissions'; import { has, omit } from 'lodash'; import { getOwn } from '../../util/getOwn'; +import { getPlatform } from '../selectors/user'; import { missingCaseError } from '../../util/missingCaseError'; import { notify } from '../../services/notify'; import { calling } from '../../services/calling'; @@ -18,6 +24,8 @@ import { GroupCallJoinState, GroupCallVideoRequest, MediaDeviceSettings, + PresentedSource, + PresentableSource, } from '../../types/Calling'; import { callingTones } from '../../util/callingTones'; import { requestCameraPermissions } from '../../util/callingPermissions'; @@ -43,6 +51,8 @@ export type GroupCallParticipantInfoType = { demuxId: number; hasRemoteAudio: boolean; hasRemoteVideo: boolean; + presenting: boolean; + sharingScreen: boolean; speakerTime?: number; videoAspectRatio: number; }; @@ -53,6 +63,7 @@ export type DirectCallStateType = { callState?: CallState; callEndedReason?: CallEndedReason; isIncoming: boolean; + isSharingScreen?: boolean; isVideoCall: boolean; hasRemoteVideo?: boolean; }; @@ -73,8 +84,11 @@ export type ActiveCallStateType = { isInSpeakerView: boolean; joinedAt?: number; pip: boolean; + presentingSource?: PresentedSource; + presentingSourcesAvailable?: Array; safetyNumberChangedUuids: Array; settingsDialogOpen: boolean; + showNeedsScreenRecordingPermissionsWarning?: boolean; showParticipantsList: boolean; }; @@ -160,6 +174,11 @@ export type RemoteVideoChangeType = { hasVideo: boolean; }; +type RemoteSharingScreenChangeType = { + conversationId: string; + isSharingScreen: boolean; +}; + export type SetLocalAudioType = { enabled: boolean; }; @@ -236,10 +255,15 @@ const OUTGOING_CALL = 'calling/OUTGOING_CALL'; const PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED = 'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED'; const REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES'; +const REMOTE_SHARING_SCREEN_CHANGE = 'calling/REMOTE_SHARING_SCREEN_CHANGE'; const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE'; const RETURN_TO_ACTIVE_CALL = 'calling/RETURN_TO_ACTIVE_CALL'; const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED'; const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_FULFILLED'; +const SET_PRESENTING = 'calling/SET_PRESENTING'; +const SET_PRESENTING_SOURCES = 'calling/SET_PRESENTING_SOURCES'; +const TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS = + 'calling/TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS'; const START_DIRECT_CALL = 'calling/START_DIRECT_CALL'; const TOGGLE_PARTICIPANTS = 'calling/TOGGLE_PARTICIPANTS'; const TOGGLE_PIP = 'calling/TOGGLE_PIP'; @@ -326,6 +350,11 @@ type RefreshIODevicesActionType = { payload: MediaDeviceSettings; }; +type RemoteSharingScreenChangeActionType = { + type: 'calling/REMOTE_SHARING_SCREEN_CHANGE'; + payload: RemoteSharingScreenChangeType; +}; + type RemoteVideoChangeActionType = { type: 'calling/REMOTE_VIDEO_CHANGE'; payload: RemoteVideoChangeType; @@ -345,6 +374,16 @@ type SetLocalVideoFulfilledActionType = { payload: SetLocalVideoType; }; +type SetPresentingFulfilledActionType = { + type: 'calling/SET_PRESENTING'; + payload?: PresentedSource; +}; + +type SetPresentingSourcesActionType = { + type: 'calling/SET_PRESENTING_SOURCES'; + payload: Array; +}; + type ShowCallLobbyActionType = { type: 'calling/SHOW_CALL_LOBBY'; payload: ShowCallLobbyType; @@ -355,6 +394,10 @@ type StartDirectCallActionType = { payload: StartDirectCallType; }; +type ToggleNeedsScreenRecordingPermissionsActionType = { + type: 'calling/TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS'; +}; + type ToggleParticipantsActionType = { type: 'calling/TOGGLE_PARTICIPANTS'; }; @@ -387,14 +430,18 @@ export type CallingActionType = | OutgoingCallActionType | PeekNotConnectedGroupCallFulfilledActionType | RefreshIODevicesActionType + | RemoteSharingScreenChangeActionType | RemoteVideoChangeActionType | ReturnToActiveCallActionType | SetLocalAudioActionType | SetLocalVideoFulfilledActionType + | SetPresentingSourcesActionType | ShowCallLobbyActionType | StartDirectCallActionType + | ToggleNeedsScreenRecordingPermissionsActionType | ToggleParticipantsActionType | TogglePipActionType + | SetPresentingFulfilledActionType | ToggleSettingsActionType | ToggleSpeakerViewActionType; @@ -438,6 +485,7 @@ function callStateChange( } if (callState === CallState.Ended) { await callingTones.playEndCall(); + ipcRenderer.send('close-screen-share-controller'); } dispatch({ @@ -519,10 +567,59 @@ function declineCall(payload: DeclineCallType): DeclineCallActionType { }; } +function getPresentingSources(): ThunkAction< + void, + RootStateType, + unknown, + | SetPresentingSourcesActionType + | ToggleNeedsScreenRecordingPermissionsActionType +> { + return async (dispatch, getState) => { + // We check if the user has permissions first before calling desktopCapturer + // Next we call getPresentingSources so that one gets the prompt for permissions, + // if necessary. + // Finally, we have the if statement which shows the modal, if needed. + // It is in this exact order so that during first-time-use one will be + // prompted for permissions and if they so happen to deny we can still + // capture that state correctly. + const platform = getPlatform(getState()); + const needsPermission = + platform === 'darwin' && !hasScreenCapturePermission(); + + const sources = await calling.getPresentingSources(); + + if (needsPermission) { + dispatch({ + type: TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS, + }); + return; + } + + dispatch({ + type: SET_PRESENTING_SOURCES, + payload: sources, + }); + }; +} + function groupCallStateChange( payload: GroupCallStateChangeArgumentType ): ThunkAction { - return (dispatch, getState) => { + return async (dispatch, getState) => { + let didSomeoneStartPresenting: boolean; + const activeCall = getActiveCall(getState().calling); + if (activeCall?.callMode === CallMode.Group) { + const wasSomeonePresenting = activeCall.remoteParticipants.some( + participant => participant.presenting + ); + const isSomeonePresenting = payload.remoteParticipants.some( + participant => participant.presenting + ); + didSomeoneStartPresenting = !wasSomeonePresenting && isSomeonePresenting; + } else { + didSomeoneStartPresenting = false; + } + dispatch({ type: GROUP_CALL_STATE_CHANGE, payload: { @@ -530,6 +627,10 @@ function groupCallStateChange( ourUuid: getState().user.ourUuid, }, }); + + if (didSomeoneStartPresenting) { + callingTones.someonePresenting(); + } }; } @@ -601,6 +702,17 @@ function receiveIncomingCall( }; } +function openSystemPreferencesAction(): ThunkAction< + void, + RootStateType, + unknown, + never +> { + return () => { + openSystemPreferences(); + }; +} + function outgoingCall(payload: StartDirectCallType): OutgoingCallActionType { callingTones.playRingtone(); @@ -694,6 +806,15 @@ function refreshIODevices( }; } +function remoteSharingScreenChange( + payload: RemoteSharingScreenChangeType +): RemoteSharingScreenChangeActionType { + return { + type: REMOTE_SHARING_SCREEN_CHANGE, + payload, + }; +} + function remoteVideoChange( payload: RemoteVideoChangeType ): RemoteVideoChangeActionType { @@ -764,7 +885,7 @@ function setLocalVideo( } else if (payload.enabled) { calling.enableLocalCamera(); } else { - calling.disableLocalCamera(); + calling.disableLocalVideo(); } ({ enabled } = payload); } else { @@ -797,6 +918,35 @@ function setGroupCallVideoRequest( }; } +function setPresenting( + sourceToPresent?: PresentedSource +): ThunkAction { + return async (dispatch, getState) => { + const callingState = getState().calling; + const { activeCallState } = callingState; + const activeCall = getActiveCall(callingState); + if (!activeCall || !activeCallState) { + window.log.warn('Trying to present when no call is active'); + return; + } + + calling.setPresenting( + activeCall.conversationId, + activeCallState.hasLocalVideo, + sourceToPresent + ); + + dispatch({ + type: SET_PRESENTING, + payload: sourceToPresent, + }); + + if (sourceToPresent) { + await callingTones.someonePresenting(); + } + }; +} + function startCallingLobby( payload: StartCallingLobbyType ): ThunkAction { @@ -857,6 +1007,12 @@ function togglePip(): TogglePipActionType { }; } +function toggleScreenRecordingPermissionsDialog(): ToggleNeedsScreenRecordingPermissionsActionType { + return { + type: TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS, + }; +} + function toggleSettings(): ToggleSettingsActionType { return { type: TOGGLE_SETTINGS, @@ -871,31 +1027,36 @@ function toggleSpeakerView(): ToggleSpeakerViewActionType { export const actions = { acceptCall, - cancelCall, callStateChange, + cancelCall, changeIODevice, closeNeedPermissionScreen, declineCall, + getPresentingSources, groupCallStateChange, hangUp, - keyChanged, keyChangeOk, - receiveIncomingCall, + keyChanged, + openSystemPreferencesAction, outgoingCall, peekNotConnectedGroupCall, + receiveIncomingCall, refreshIODevices, + remoteSharingScreenChange, remoteVideoChange, returnToActiveCall, - setLocalPreview, - setRendererCanvas, - setLocalAudio, - setLocalVideo, setGroupCallVideoRequest, - startCallingLobby, + setLocalAudio, + setLocalPreview, + setLocalVideo, + setPresenting, + setRendererCanvas, showCallLobby, startCall, + startCallingLobby, toggleParticipants, togglePip, + toggleScreenRecordingPermissionsDialog, toggleSettings, toggleSpeakerView, }; @@ -1270,6 +1431,26 @@ export function reducer( }; } + if (action.type === REMOTE_SHARING_SCREEN_CHANGE) { + const { conversationId, isSharingScreen } = action.payload; + const call = getOwn(state.callsByConversation, conversationId); + if (call?.callMode !== CallMode.Direct) { + window.log.warn('Cannot update remote video for a non-direct call'); + return state; + } + + return { + ...state, + callsByConversation: { + ...callsByConversation, + [conversationId]: { + ...call, + isSharingScreen, + }, + }, + }; + } + if (action.type === REMOTE_VIDEO_CHANGE) { const { conversationId, hasVideo } = action.payload; const call = getOwn(state.callsByConversation, conversationId); @@ -1427,6 +1608,59 @@ export function reducer( }; } + if (action.type === SET_PRESENTING) { + const { activeCallState } = state; + if (!activeCallState) { + window.log.warn('Cannot toggle presenting when there is no active call'); + return state; + } + + return { + ...state, + activeCallState: { + ...activeCallState, + presentingSource: action.payload, + presentingSourcesAvailable: undefined, + }, + }; + } + + if (action.type === SET_PRESENTING_SOURCES) { + const { activeCallState } = state; + if (!activeCallState) { + window.log.warn( + 'Cannot set presenting sources when there is no active call' + ); + return state; + } + + return { + ...state, + activeCallState: { + ...activeCallState, + presentingSourcesAvailable: action.payload, + }, + }; + } + + if (action.type === TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS) { + const { activeCallState } = state; + if (!activeCallState) { + window.log.warn( + 'Cannot set presenting sources when there is no active call' + ); + return state; + } + + return { + ...state, + activeCallState: { + ...activeCallState, + showNeedsScreenRecordingPermissionsWarning: !activeCallState.showNeedsScreenRecordingPermissionsWarning, + }, + }; + } + if (action.type === TOGGLE_SPEAKER_VIEW) { const { activeCallState } = state; if (!activeCallState) { diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index f18e7cb37..6fa0f3f50 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -78,7 +78,12 @@ const mapStateToActiveCallProp = ( isInSpeakerView: activeCallState.isInSpeakerView, joinedAt: activeCallState.joinedAt, pip: activeCallState.pip, + presentingSource: activeCallState.presentingSource, + presentingSourcesAvailable: activeCallState.presentingSourcesAvailable, settingsDialogOpen: activeCallState.settingsDialogOpen, + showNeedsScreenRecordingPermissionsWarning: Boolean( + activeCallState.showNeedsScreenRecordingPermissionsWarning + ), showParticipantsList: activeCallState.showParticipantsList, }; @@ -93,6 +98,9 @@ const mapStateToActiveCallProp = ( remoteParticipants: [ { hasRemoteVideo: Boolean(call.hasRemoteVideo), + presenting: Boolean(call.isSharingScreen), + title: conversation.title, + uuid: conversation.uuid, }, ], }; @@ -119,6 +127,8 @@ const mapStateToActiveCallProp = ( demuxId: remoteParticipant.demuxId, hasRemoteAudio: remoteParticipant.hasRemoteAudio, hasRemoteVideo: remoteParticipant.hasRemoteVideo, + presenting: remoteParticipant.presenting, + sharingScreen: remoteParticipant.sharingScreen, speakerTime: remoteParticipant.speakerTime, videoAspectRatio: remoteParticipant.videoAspectRatio, }); diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index 9025f7b1c..67304de06 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -86,6 +86,8 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 4 / 3, }, ], @@ -129,6 +131,188 @@ describe('calling duck', () => { }); describe('actions', () => { + describe('getPresentingSources', () => { + beforeEach(function beforeEach() { + this.callingServiceGetPresentingSources = this.sandbox + .stub(callingService, 'getPresentingSources') + .resolves([ + { + id: 'foo.bar', + name: 'Foo Bar', + thumbnail: 'xyz', + }, + ]); + }); + + it('retrieves sources from the calling service', async function test() { + const { getPresentingSources } = actions; + const dispatch = sinon.spy(); + await getPresentingSources()(dispatch, getEmptyRootState, null); + + sinon.assert.calledOnce(this.callingServiceGetPresentingSources); + }); + + it('dispatches SET_PRESENTING_SOURCES', async function test() { + const { getPresentingSources } = actions; + const dispatch = sinon.spy(); + await getPresentingSources()(dispatch, getEmptyRootState, null); + + sinon.assert.calledOnce(dispatch); + sinon.assert.calledWith(dispatch, { + type: 'calling/SET_PRESENTING_SOURCES', + payload: [ + { + id: 'foo.bar', + name: 'Foo Bar', + thumbnail: 'xyz', + }, + ], + }); + }); + }); + + describe('remoteSharingScreenChange', () => { + it("updates whether someone's screen is being shared", () => { + const { remoteSharingScreenChange } = actions; + + const payload = { + conversationId: 'fake-direct-call-conversation-id', + isSharingScreen: true, + }; + + const state = { + ...stateWithActiveDirectCall, + }; + const nextState = reducer(state, remoteSharingScreenChange(payload)); + + const expectedState = { + ...stateWithActiveDirectCall, + callsByConversation: { + 'fake-direct-call-conversation-id': { + ...stateWithActiveDirectCall.callsByConversation[ + 'fake-direct-call-conversation-id' + ], + isSharingScreen: true, + }, + }, + }; + + assert.deepEqual(nextState, expectedState); + }); + }); + + describe('setPresenting', () => { + beforeEach(function beforeEach() { + this.callingServiceSetPresenting = this.sandbox.stub( + callingService, + 'setPresenting' + ); + }); + + it('calls setPresenting on the calling service', function test() { + const { setPresenting } = actions; + const dispatch = sinon.spy(); + const presentedSource = { + id: 'window:786', + name: 'Application', + }; + const getState = () => ({ + ...getEmptyRootState(), + calling: { + ...stateWithActiveGroupCall, + }, + }); + + setPresenting(presentedSource)(dispatch, getState, null); + + sinon.assert.calledOnce(this.callingServiceSetPresenting); + sinon.assert.calledWith( + this.callingServiceSetPresenting, + 'fake-group-call-conversation-id', + false, + presentedSource + ); + }); + + it('dispatches SET_PRESENTING', () => { + const { setPresenting } = actions; + const dispatch = sinon.spy(); + const presentedSource = { + id: 'window:786', + name: 'Application', + }; + const getState = () => ({ + ...getEmptyRootState(), + calling: { + ...stateWithActiveGroupCall, + }, + }); + + setPresenting(presentedSource)(dispatch, getState, null); + + sinon.assert.calledOnce(dispatch); + sinon.assert.calledWith(dispatch, { + type: 'calling/SET_PRESENTING', + payload: presentedSource, + }); + }); + + it('turns off presenting when no value is passed in', () => { + const dispatch = sinon.spy(); + const { setPresenting } = actions; + const presentedSource = { + id: 'window:786', + name: 'Application', + }; + + const getState = () => ({ + ...getEmptyRootState(), + calling: { + ...stateWithActiveGroupCall, + }, + }); + + setPresenting(presentedSource)(dispatch, getState, null); + + const action = dispatch.getCall(0).args[0]; + + const nextState = reducer(getState().calling, action); + + assert.isDefined(nextState.activeCallState); + assert.equal( + nextState.activeCallState?.presentingSource, + presentedSource + ); + assert.isUndefined( + nextState.activeCallState?.presentingSourcesAvailable + ); + }); + + it('sets the presenting value when one is passed in', () => { + const dispatch = sinon.spy(); + const { setPresenting } = actions; + + const getState = () => ({ + ...getEmptyRootState(), + calling: { + ...stateWithActiveGroupCall, + }, + }); + + setPresenting()(dispatch, getState, null); + + const action = dispatch.getCall(0).args[0]; + + const nextState = reducer(getState().calling, action); + + assert.isDefined(nextState.activeCallState); + assert.isUndefined(nextState.activeCallState?.presentingSource); + assert.isUndefined( + nextState.activeCallState?.presentingSourcesAvailable + ); + }); + }); + describe('acceptCall', () => { const { acceptCall } = actions; @@ -403,6 +587,8 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 4 / 3, }, ], @@ -429,6 +615,8 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 4 / 3, }, ], @@ -491,6 +679,8 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 16 / 9, }, ], @@ -515,6 +705,8 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 16 / 9, }, ], @@ -542,6 +734,8 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 16 / 9, }, ], @@ -571,6 +765,8 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 16 / 9, }, ], @@ -609,6 +805,8 @@ describe('calling duck', () => { demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 16 / 9, }, ], @@ -850,6 +1048,8 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 4 / 3, }, ], @@ -874,6 +1074,8 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 4 / 3, }, ], @@ -925,6 +1127,8 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 4 / 3, }, ], @@ -965,6 +1169,8 @@ describe('calling duck', () => { demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, + presenting: false, + sharingScreen: false, videoAspectRatio: 4 / 3, }, ], diff --git a/ts/types/Calling.ts b/ts/types/Calling.ts index 476be8636..c969f99a6 100644 --- a/ts/types/Calling.ts +++ b/ts/types/Calling.ts @@ -10,16 +10,31 @@ export enum CallMode { Group = 'Group', } +export type PresentableSource = { + appIcon?: string; + id: string; + name: string; + thumbnail: string; +}; + +export type PresentedSource = { + id: string; + name: string; +}; + type ActiveCallBaseType = { conversation: ConversationType; hasLocalAudio: boolean; hasLocalVideo: boolean; isInSpeakerView: boolean; + isSharingScreen?: boolean; joinedAt?: number; pip: boolean; + presentingSource?: PresentedSource; + presentingSourcesAvailable?: Array; settingsDialogOpen: boolean; + showNeedsScreenRecordingPermissionsWarning?: boolean; showParticipantsList: boolean; - showSafetyNumberDialog?: boolean; }; type ActiveDirectCallType = ActiveCallBaseType & { @@ -30,6 +45,9 @@ type ActiveDirectCallType = ActiveCallBaseType & { remoteParticipants: [ { hasRemoteVideo: boolean; + presenting: boolean; + title: string; + uuid?: string; } ]; }; @@ -100,6 +118,8 @@ export type GroupCallRemoteParticipantType = ConversationType & { demuxId: number; hasRemoteAudio: boolean; hasRemoteVideo: boolean; + presenting: boolean; + sharingScreen: boolean; speakerTime?: number; videoAspectRatio: number; }; diff --git a/ts/util/callingTones.ts b/ts/util/callingTones.ts index c48e2a827..0d9127ca3 100644 --- a/ts/util/callingTones.ts +++ b/ts/util/callingTones.ts @@ -54,6 +54,20 @@ class CallingTones { } }); } + + // eslint-disable-next-line class-methods-use-this + async someonePresenting() { + const canPlayTone = await window.getCallRingtoneNotification(); + if (!canPlayTone) { + return; + } + + const tone = new Sound({ + src: 'sounds/navigation_selection-complete-celebration.ogg', + }); + + await tone.play(); + } } export const callingTones = new CallingTones(); diff --git a/ts/util/isScreenSharingEnabled.ts b/ts/util/isScreenSharingEnabled.ts new file mode 100644 index 000000000..a2c7576ee --- /dev/null +++ b/ts/util/isScreenSharingEnabled.ts @@ -0,0 +1,12 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as RemoteConfig from '../RemoteConfig'; + +// We can remove this function once screen sharing has been turned on for everyone +export function isScreenSharingEnabled(): boolean { + return ( + RemoteConfig.isEnabled('desktop.worksAtSignal') || + RemoteConfig.isEnabled('desktop.screensharing') + ); +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 094832994..9aedc1f8d 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -2726,6 +2726,13 @@ "updated": "2020-08-26T00:10:28.628Z", "reasonDetail": "isn't react" }, + { + "rule": "jQuery-load(", + "path": "node_modules/execa/node_modules/signal-exit/index.js", + "line": " load()", + "reasonCategory": "falseMatch", + "updated": "2021-05-20T20:01:50.505Z" + }, { "rule": "jQuery-wrap(", "path": "node_modules/expand-range/node_modules/fill-range/index.js", @@ -2859,6 +2866,13 @@ "reasonCategory": "falseMatch", "updated": "2018-09-15T00:38:04.183Z" }, + { + "rule": "jQuery-load(", + "path": "node_modules/foreground-child/node_modules/signal-exit/index.js", + "line": " load()", + "reasonCategory": "falseMatch", + "updated": "2021-05-20T20:01:50.505Z" + }, { "rule": "jQuery-append(", "path": "node_modules/form-data/lib/form_data.js", @@ -2880,6 +2894,13 @@ "reasonCategory": "falseMatch", "updated": "2020-09-11T17:24:56.124Z" }, + { + "rule": "jQuery-load(", + "path": "node_modules/gauge/node_modules/signal-exit/index.js", + "line": " load()", + "reasonCategory": "falseMatch", + "updated": "2021-05-20T20:01:50.505Z" + }, { "rule": "jQuery-$(", "path": "node_modules/global-agent/node_modules/core-js/internals/collection.js", @@ -8702,6 +8723,13 @@ "reasonCategory": "falseMatch", "updated": "2019-03-09T00:08:44.242Z" }, + { + "rule": "jQuery-load(", + "path": "node_modules/loud-rejection/node_modules/signal-exit/index.js", + "line": " load()", + "reasonCategory": "falseMatch", + "updated": "2021-05-20T20:01:50.505Z" + }, { "rule": "thenify-multiArgs", "path": "node_modules/make-dir/node_modules/pify/index.js", @@ -9407,6 +9435,13 @@ "updated": "2021-05-07T20:07:48.358Z", "reasonDetail": "isn't jquery" }, + { + "rule": "jQuery-load(", + "path": "node_modules/os-locale/node_modules/signal-exit/index.js", + "line": " load()", + "reasonCategory": "falseMatch", + "updated": "2021-05-20T20:01:50.505Z" + }, { "rule": "jQuery-append(", "path": "node_modules/pac-proxy-agent/node_modules/socks/build/client/socksclient.js", @@ -11100,13 +11135,6 @@ "reasonCategory": "falseMatch", "updated": "2020-09-11T17:24:56.124Z" }, - { - "rule": "jQuery-load(", - "path": "node_modules/proper-lockfile/node_modules/signal-exit/index.js", - "line": " load()", - "reasonCategory": "falseMatch", - "updated": "2021-04-06T04:01:59.934Z" - }, { "rule": "eval", "path": "node_modules/protobufjs/dist/light/protobuf.js", @@ -12619,13 +12647,6 @@ "reasonCategory": "falseMatch", "updated": "2020-04-30T22:45:07.878Z" }, - { - "rule": "jQuery-load(", - "path": "node_modules/restore-cursor/node_modules/signal-exit/index.js", - "line": " load()", - "reasonCategory": "falseMatch", - "updated": "2020-04-25T01:47:02.583Z" - }, { "rule": "jQuery-$(", "path": "node_modules/rx-lite-aggregates/rx.lite.aggregates.min.js", @@ -12866,13 +12887,6 @@ "reasonCategory": "falseMatch", "updated": "2018-09-19T18:13:29.628Z" }, - { - "rule": "jQuery-load(", - "path": "node_modules/spawn-wrap/node_modules/signal-exit/index.js", - "line": " load()", - "reasonCategory": "falseMatch", - "updated": "2020-04-25T01:47:02.583Z" - }, { "rule": "jQuery-before(", "path": "node_modules/sshpk/lib/dhe.js", @@ -12930,6 +12944,13 @@ "reasonCategory": "falseMatch", "updated": "2021-01-20T22:42:00.662Z" }, + { + "rule": "jQuery-load(", + "path": "node_modules/term-size/node_modules/signal-exit/index.js", + "line": " load()", + "reasonCategory": "falseMatch", + "updated": "2021-05-20T20:01:50.505Z" + }, { "rule": "jQuery-after(", "path": "node_modules/test-exclude/node_modules/braces/index.js", @@ -13263,13 +13284,6 @@ "reasonCategory": "falseMatch", "updated": "2018-09-19T18:13:29.628Z" }, - { - "rule": "jQuery-load(", - "path": "node_modules/write-file-atomic/node_modules/signal-exit/index.js", - "line": " load()", - "reasonCategory": "falseMatch", - "updated": "2020-04-30T22:35:27.860Z" - }, { "rule": "jQuery-$(", "path": "node_modules/xregexp/xregexp-all.js", @@ -13517,6 +13531,13 @@ "updated": "2020-10-26T19:12:24.410Z", "reasonDetail": "Used to get the local video element for rendering." }, + { + "rule": "React-useRef", + "path": "ts/components/CallingToastManager.js", + "line": " const timeoutRef = react_1.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2021-05-13T19:40:31.751Z" + }, { "rule": "React-useRef", "path": "ts/components/CaptchaDialog.js", diff --git a/ts/window.d.ts b/ts/window.d.ts index 18d043ed2..58efd9389 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -84,6 +84,10 @@ import { ConversationModel } from './models/conversations'; import { combineNames } from './util'; import { BatcherType } from './util/batcher'; import { AttachmentList } from './components/conversation/AttachmentList'; +import { + CallingScreenSharingController, + PropsType as CallingScreenSharingControllerProps, +} from './components/CallingScreenSharingController'; import { CaptionEditor } from './components/CaptionEditor'; import { ConfirmationDialog } from './components/ConfirmationDialog'; import { ContactDetail } from './components/conversation/ContactDetail'; @@ -147,6 +151,13 @@ declare global { WhatIsThis: WhatIsThis; + registerScreenShareControllerRenderer: ( + f: ( + component: typeof CallingScreenSharingController, + props: CallingScreenSharingControllerProps + ) => void + ) => void; + attachmentDownloadQueue: Array | undefined; startupProcessingQueue: StartupQueue | undefined; baseAttachmentsPath: string; diff --git a/ts/windows/screenShare.ts b/ts/windows/screenShare.ts new file mode 100644 index 000000000..94a862262 --- /dev/null +++ b/ts/windows/screenShare.ts @@ -0,0 +1,11 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// This needs to use window.React & window.ReactDOM since it's +// not commonJS compatible. +window.registerScreenShareControllerRenderer((Component, props) => { + window.ReactDOM.render( + window.React.createElement(Component, props), + document.getElementById('app') + ); +}); diff --git a/webpack-preload.config.ts b/webpack-preload.config.ts index 07fbccbb2..488412b31 100644 --- a/webpack-preload.config.ts +++ b/webpack-preload.config.ts @@ -10,6 +10,7 @@ const context = __dirname; const { NODE_ENV: mode = 'development' } = process.env; const EXTERNAL_MODULE = new Set([ + '@signalapp/signal-client', 'backbone', 'better-sqlite3', 'ffi-napi', @@ -17,7 +18,7 @@ const EXTERNAL_MODULE = new Set([ 'fsevents', 'got', 'jquery', - '@signalapp/signal-client', + 'mac-screen-capture-permissions', 'node-fetch', 'node-sass', 'pino', diff --git a/yarn.lock b/yarn.lock index 6c1ffe082..04af50355 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6006,7 +6006,7 @@ cross-spawn@^5.0.1: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: +cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -6910,6 +6910,11 @@ electron-download@^4.1.0: semver "^5.3.0" sumchecker "^2.0.1" +electron-is-dev@^1.1.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/electron-is-dev/-/electron-is-dev-1.2.0.tgz#2e5cea0a1b3ccf1c86f577cee77363ef55deb05e" + integrity sha512-R1oD5gMBPS7PVU8gJwH6CtT0e6VSoD0+SzSnYpNm+dBkcijgA+K7VAMHDfnRq/lkKPZArpzplTW6jfiMYosdzw== + electron-mocha@8.1.1: version "8.1.1" resolved "https://registry.yarnpkg.com/electron-mocha/-/electron-mocha-8.1.1.tgz#e540e7d9ba80a024007a18533ae491c18f9a0ce2" @@ -6955,6 +6960,14 @@ electron-to-chromium@^1.3.649: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.707.tgz#71386d0ceca6727835c33ba31f507f6824d18c35" integrity sha512-BqddgxNPrcWnbDdJw7SzXVzPmp+oiyjVrc7tkQVaznPGSS9SKZatw6qxoP857M+HbOyyqJQwYQtsuFIMSTNSZA== +electron-util@^0.13.0: + version "0.13.1" + resolved "https://registry.yarnpkg.com/electron-util/-/electron-util-0.13.1.tgz#ba3b9cb7e5fdb6a51970a01e9070877cf7855ef8" + integrity sha512-CvOuAyQPaPtnDp7SspwnT1yTb1yynw6yp4LrZCfEJ7TG/kJFiZW9RqMHlCEFWMn3QNoMkNhGVeCvWJV5NsYyuQ== + dependencies: + electron-is-dev "^1.1.0" + new-github-issue-url "^0.2.1" + electron-window@^0.8.0: version "0.8.1" resolved "https://registry.yarnpkg.com/electron-window/-/electron-window-0.8.1.tgz#16ca187eb4870b0679274fc8299c5960e6ab2c5e" @@ -7748,6 +7761,21 @@ execa@^1.0.0: signal-exit "^3.0.0" strip-eof "^1.0.0" +execa@^2.0.4: + version "2.1.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-2.1.0.tgz#e5d3ecd837d2a60ec50f3da78fd39767747bbe99" + integrity sha512-Y/URAVapfbYy2Xp/gb6A0E7iR8xeqOCXsuuaoMn7A5PzrXUK84E1gyiEfq0wQd/GHA6GsoHWwhNq8anb0mleIw== + dependencies: + cross-spawn "^7.0.0" + get-stream "^5.0.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^3.0.0" + onetime "^5.1.0" + p-finally "^2.0.0" + signal-exit "^3.0.2" + strip-final-newline "^2.0.0" + execa@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/execa/-/execa-5.0.0.tgz#4029b0007998a841fbd1032e5f4de86a3c1e3376" @@ -8696,6 +8724,13 @@ get-stream@^4.0.0, get-stream@^4.1.0: dependencies: pump "^3.0.0" +get-stream@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + get-stream@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.1.0.tgz#01203cdc92597f9b909067c3e656cc1f4d3c4dc9" @@ -11450,11 +11485,28 @@ lru-queue@0.1: dependencies: es5-ext "~0.10.2" +mac-screen-capture-permissions@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mac-screen-capture-permissions/-/mac-screen-capture-permissions-2.0.0.tgz#fdef314118db4d593a88dd2d7d3e66b175c92f80" + integrity sha512-f70KKpx5WhD8mmrAwLeeee31EfSM4p1K7kBBNBVXyfWE7ZQTIbbAF2PxJ0bMsDxyyeX5roBcH+qJYlSTANtCOA== + dependencies: + electron-util "^0.13.0" + execa "^2.0.4" + macos-version "^5.2.1" + prebuild-install "^6.0.0" + macos-release@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.3.0.tgz#eb1930b036c0800adebccd5f17bc4c12de8bb71f" integrity sha512-OHhSbtcviqMPt7yfw5ef5aghS2jzFVKEFyCJndQt2YpSQ9qRVSEv2axSJI1paVThEu+FFGs584h/1YhxjVqajA== +macos-version@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/macos-version/-/macos-version-5.2.1.tgz#056c943aac8edb81d7cafef6445b7ca1d7a2e56e" + integrity sha512-OHJU8nTNxHYL1FQhD+nZawWgXKXAqDGr4kluLtaqKO4au3cR41y1mKuVShOU5U4rOYiuPanljq6oFGmV2B9DFA== + dependencies: + semver "^5.6.0" + make-dir@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.0.0.tgz#97a011751e91dd87cfadef58832ebb04936de978" @@ -12268,6 +12320,11 @@ netmask@^2.0.1: resolved "https://registry.yarnpkg.com/netmask/-/netmask-2.0.2.tgz#8b01a07644065d536383835823bc52004ebac5e7" integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg== +new-github-issue-url@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/new-github-issue-url/-/new-github-issue-url-0.2.1.tgz#e17be1f665a92de465926603e44b9f8685630c1d" + integrity sha512-md4cGoxuT4T4d/HDOXbrUHkTKrp/vp+m3aOA7XXVYwNsUNMK49g3SQicTSeV5GIz/5QVGAeYRAOlyp9OvlgsYA== + next-tick@1, next-tick@^1.0.0, next-tick@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" @@ -12624,6 +12681,13 @@ npm-run-path@^2.0.0: dependencies: path-key "^2.0.0" +npm-run-path@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-3.1.0.tgz#7f91be317f6a466efed3c9f2980ad8a4ee8b0fa5" + integrity sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg== + dependencies: + path-key "^3.0.0" + npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" @@ -13065,6 +13129,11 @@ p-finally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" +p-finally@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-2.0.1.tgz#bd6fcaa9c559a096b680806f4d657b3f0f240561" + integrity sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw== + p-is-promise@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-1.1.0.tgz#9c9456989e9f6588017b0434d56097675c3da05e" @@ -13853,6 +13922,26 @@ postcss@^7.0.0, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.17, postcss@^7.0. source-map "^0.6.1" supports-color "^6.1.0" +prebuild-install@^6.0.0: + version "6.1.2" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-6.1.2.tgz#6ce5fc5978feba5d3cbffedca0682b136a0b5bff" + integrity sha512-PzYWIKZeP+967WuKYXlTOhYBgGOvTRSfaKI89XnfJ0ansRAH7hDU45X+K+FZeI1Wb/7p/NnuctPH3g0IqKUuSQ== + dependencies: + detect-libc "^1.0.3" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^1.0.1" + node-abi "^2.21.0" + noop-logger "^0.1.1" + npmlog "^4.0.1" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^3.0.3" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + prebuild-install@^6.1.1: version "6.1.1" resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-6.1.1.tgz#6754fa6c0d55eced7f9e14408ff9e4cba6f097b4" @@ -15467,9 +15556,9 @@ rimraf@^3.0.2, rimraf@~3.0.2: dependencies: glob "^7.1.3" -"ringrtc@https://github.com/signalapp/signal-ringrtc-node.git#b43c6b728d62b6d386d95705e128f32f44edb650": - version "2.9.4" - resolved "https://github.com/signalapp/signal-ringrtc-node.git#b43c6b728d62b6d386d95705e128f32f44edb650" +"ringrtc@https://github.com/signalapp/signal-ringrtc-node.git#17b22fc9d47605867608193202c54be06bce6f56": + version "2.10.1" + resolved "https://github.com/signalapp/signal-ringrtc-node.git#17b22fc9d47605867608193202c54be06bce6f56" ripemd160@^2.0.0, ripemd160@^2.0.1: version "2.0.1"