diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 0b1fceb06f..1483b6fb9a 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 9f39196cdb..cb74620987 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 121677faeb..6b16d6ad8a 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 0000000000..5fe79f691f --- /dev/null +++ b/images/icons/v2/share-screen-solid-28.svg @@ -0,0 +1 @@ + diff --git a/main.js b/main.js index a6a10d8785..41cf806efe 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 ed9aa5b288..850821cd6d 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 0000000000..9df19c78c8 --- /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 0000000000..ac391bab4a --- /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 0000000000..6eb9fd7f25 --- /dev/null +++ b/screenShare.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + diff --git a/screenShare_preload.js b/screenShare_preload.js new file mode 100644 index 0000000000..001f86cc6b --- /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 0000000000..83c40ddb3b Binary files /dev/null and b/sounds/navigation_selection-complete-celebration.ogg differ diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 1274e44f35..7f5b31cc0d 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 0000000000..e062a9b6eb --- /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 0000000000..4ee39ce980 --- /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 b89dff99b5..3ac0d53b41 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 89453bf40e..7ccd85529d 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 899062e8cf..e525612c6e 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 06e5d6f507..014dc66037 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 b1c1325fad..f363d788cb 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 ? ( @@ -308,6 +375,13 @@ export const CallScreen: React.FC = ({ controlsFadeClass )} > + {isScreenSharingEnabled() ? ( + + ) : null} = ({ > {hasLocalVideo && !isLonelyInGroup ? ( diff --git a/ts/components/CallingButton.stories.tsx b/ts/components/CallingButton.stories.tsx index 76c1c09a7f..9e8dc99952 100644 --- a/ts/components/CallingButton.stories.tsx +++ b/ts/components/CallingButton.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -14,11 +14,9 @@ import enMessages from '../../_locales/en/messages.json'; const i18n = setupI18n('en', enMessages); const createProps = (overrideProps: Partial = {}): PropsType => ({ - buttonType: select( - 'buttonType', - CallingButtonType, - overrideProps.buttonType || CallingButtonType.HANG_UP - ), + buttonType: + overrideProps.buttonType || + select('buttonType', CallingButtonType, CallingButtonType.HANG_UP), i18n, onClick: action('on-click'), tooltipDirection: select( @@ -30,9 +28,16 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ const story = storiesOf('Components/CallingButton', module); -story.add('Default', () => { - const props = createProps(); - return ; +story.add('Kitchen Sink', () => { + return ( + <> + {Object.keys(CallingButtonType).map(buttonType => ( + + ))} + > + ); }); story.add('Audio On', () => { @@ -83,3 +88,17 @@ story.add('Tooltip right', () => { }); return ; }); + +story.add('Presenting On', () => { + const props = createProps({ + buttonType: CallingButtonType.PRESENTING_ON, + }); + return ; +}); + +story.add('Presenting Off', () => { + const props = createProps({ + buttonType: CallingButtonType.PRESENTING_OFF, + }); + return ; +}); diff --git a/ts/components/CallingButton.tsx b/ts/components/CallingButton.tsx index 80972b4b9f..9ca6be2974 100644 --- a/ts/components/CallingButton.tsx +++ b/ts/components/CallingButton.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; @@ -12,6 +12,9 @@ export enum CallingButtonType { AUDIO_OFF = 'AUDIO_OFF', AUDIO_ON = 'AUDIO_ON', HANG_UP = 'HANG_UP', + PRESENTING_DISABLED = 'PRESENTING_DISABLED', + PRESENTING_OFF = 'PRESENTING_OFF', + PRESENTING_ON = 'PRESENTING_ON', VIDEO_DISABLED = 'VIDEO_DISABLED', VIDEO_OFF = 'VIDEO_OFF', VIDEO_ON = 'VIDEO_ON', @@ -32,9 +35,11 @@ export const CallingButton = ({ }: PropsType): JSX.Element => { let classNameSuffix = ''; let tooltipContent = ''; + let disabled = false; if (buttonType === CallingButtonType.AUDIO_DISABLED) { classNameSuffix = 'audio--disabled'; tooltipContent = i18n('calling__button--audio-disabled'); + disabled = true; } else if (buttonType === CallingButtonType.AUDIO_OFF) { classNameSuffix = 'audio--off'; tooltipContent = i18n('calling__button--audio-on'); @@ -44,6 +49,7 @@ export const CallingButton = ({ } else if (buttonType === CallingButtonType.VIDEO_DISABLED) { classNameSuffix = 'video--disabled'; tooltipContent = i18n('calling__button--video-disabled'); + disabled = true; } else if (buttonType === CallingButtonType.VIDEO_OFF) { classNameSuffix = 'video--off'; tooltipContent = i18n('calling__button--video-on'); @@ -53,6 +59,16 @@ export const CallingButton = ({ } else if (buttonType === CallingButtonType.HANG_UP) { classNameSuffix = 'hangup'; tooltipContent = i18n('calling__hangup'); + } else if (buttonType === CallingButtonType.PRESENTING_DISABLED) { + classNameSuffix = 'presenting--disabled'; + tooltipContent = i18n('calling__button--presenting-disabled'); + disabled = true; + } else if (buttonType === CallingButtonType.PRESENTING_ON) { + classNameSuffix = 'presenting--on'; + tooltipContent = i18n('calling__button--presenting-off'); + } else if (buttonType === CallingButtonType.PRESENTING_OFF) { + classNameSuffix = 'presenting--off'; + tooltipContent = i18n('calling__button--presenting-on'); } const className = classNames( @@ -68,9 +84,10 @@ export const CallingButton = ({ > diff --git a/ts/components/CallingParticipantsList.stories.tsx b/ts/components/CallingParticipantsList.stories.tsx index 67955a6908..377034ea24 100644 --- a/ts/components/CallingParticipantsList.stories.tsx +++ b/ts/components/CallingParticipantsList.stories.tsx @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -23,6 +23,8 @@ function createParticipant( demuxId: 2, hasRemoteAudio: Boolean(participantProps.hasRemoteAudio), hasRemoteVideo: Boolean(participantProps.hasRemoteVideo), + presenting: Boolean(participantProps.presenting), + sharingScreen: Boolean(participantProps.sharingScreen), videoAspectRatio: 1.3, ...getDefaultConversation({ avatarPath: participantProps.avatarPath, @@ -69,7 +71,7 @@ story.add('Many Participants', () => { }), createParticipant({ hasRemoteAudio: true, - hasRemoteVideo: true, + presenting: true, name: 'Rage Trunks', title: 'Rage Trunks', }), diff --git a/ts/components/CallingParticipantsList.tsx b/ts/components/CallingParticipantsList.tsx index 0b729ec877..f6f679fde8 100644 --- a/ts/components/CallingParticipantsList.tsx +++ b/ts/components/CallingParticipantsList.tsx @@ -13,8 +13,9 @@ import { sortByTitle } from '../util/sortByTitle'; import { ConversationType } from '../state/ducks/conversations'; type ParticipantType = ConversationType & { - hasAudio?: boolean; - hasVideo?: boolean; + hasRemoteAudio?: boolean; + hasRemoteVideo?: boolean; + presenting?: boolean; }; export type PropsType = { @@ -130,12 +131,15 @@ export const CallingParticipantsList = React.memo( )} - {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 47760d5216..54d31412f7 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 b9e5db5bfa..5b77ba6e2d 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 0000000000..208ee32b41 --- /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 0000000000..054b245a27 --- /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])} + + + + {i18n('calling__presenting--stop')} + + + + + ); +}; diff --git a/ts/components/CallingSelectPresentingSourcesModal.stories.tsx b/ts/components/CallingSelectPresentingSourcesModal.stories.tsx new file mode 100644 index 0000000000..b0c0829879 --- /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 0000000000..660b3dcf85 --- /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 ( + { + onSourceClick({ + id: source.id, + name: source.name, + }); + }} + type="button" + > + + + {source.appIcon ? ( + + ) : null} + + {source.name} + + + + ); +}; + +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} + /> + ))} + + + setPresenting()} + variant={ButtonVariant.Secondary} + > + {i18n('cancel')} + + setPresenting(sourceToPresent)} + > + {i18n('calling__SelectPresentingSourcesModal--confirm')} + + + + ); +}; diff --git a/ts/components/CallingToastManager.tsx b/ts/components/CallingToastManager.tsx new file mode 100644 index 0000000000..63af47cb47 --- /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 ( + + {toastMessage} + + ); +}; diff --git a/ts/components/GroupCallOverflowArea.stories.tsx b/ts/components/GroupCallOverflowArea.stories.tsx index 507a52f23f..99415d7c65 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 267505feed..77027fe661 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 d56bba2ebe..678de4f232 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 bc944af5d3..0000000000 --- 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 0000000000..f1f57f7d99 --- /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')} + + {i18n('calling__presenting--permission-instruction-step1')} + {i18n('calling__presenting--permission-instruction-step2')} + {i18n('calling__presenting--permission-instruction-step3')} + {i18n('calling__presenting--permission-instruction-step4')} + + + + {i18n('calling__presenting--permission-cancel')} + + { + openSystemPreferencesAction(); + toggleScreenRecordingPermissionsDialog(); + }} + variant={ButtonVariant.Primary} + > + {i18n('calling__presenting--permission-open')} + + + + ); +}; diff --git a/ts/hooks/useActivateSpeakerViewOnPresenting.ts b/ts/hooks/useActivateSpeakerViewOnPresenting.ts new file mode 100644 index 0000000000..d6536f62eb --- /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 da45e35a51..2aacd5bec0 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 75e76b0f00..c91127a4fd 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 f18e7cb378..6fa0f3f501 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 9025f7b1c4..67304de06b 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 476be86364..c969f99a62 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 c48e2a827d..0d9127ca3e 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 0000000000..a2c7576ee2 --- /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 0948329945..9aedc1f8d7 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 18d043ed2c..58efd93890 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 0000000000..94a862262b --- /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 07fbccbb26..488412b315 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 6c1ffe0825..04af50355d 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"
{i18n('calling__presenting--macos-permission-description')}