From f08865c3deab598e4ef45dd4082a497f1cc754b3 Mon Sep 17 00:00:00 2001 From: timmydoza Date: Wed, 12 May 2021 14:34:35 -0600 Subject: [PATCH] Release recording start stop feature (#518) * Ahoyapps 957 recording start stop (#379) * Add useIsRecording hook and test * Add recordingrules route to local development server * Add info variant to snackbar component * Add recording indicator to MainParticipantInfo with test * Add InfoIcon * Add RecordingButton to menu component * Upgrade Twilio dependency * Add RecordingRules type to types.ts * Install notistack library * Add new icons * Add icons and snackbar to Menu component * Update snackbar to accept React components as message * Create RecordingButton component * Add InfoIconOutlined * Update styles in Menu component * Add tests for RecordingButton * Install twilio-video 2.9.0 * Add updateRecordingRules functions * Fix tests * Update recording icon animation * Add tooltip to MainParticipantInfo * Fix settings menu location * Add new recording UI to Menu component * Update Menu tests * Change Menu Item text * Some cleanup * Video 4708 recording start stop UI updates (#491) * Fix tests after merging in master * Add recordingrules route to server * Fix TS errors * Remove ConfirmRecordingDialog from Menu * Add RecordingNotificationsComponent to app * Add RecodringNotificationTests * Update Menu tests * Fix linting issues * Update snapshot tests * Add firebase recording rules (#493) * Add updateRecordingRules function to useFirebaseAuth * Add additional files to .gcloudignore * Add newline * Video 4885 recording cypress tests (#495) * Conditionally render recording rules button (#510) * Move the RoomType setting to src/state/index.ts. Also report recordingRule errors correctly * Only render the recording button in group rooms. Add tests * Remove log * readme and changelog for recording start stop feature (#509) * Add date to changelog Co-authored-by: olipyskoty <77076398+olipyskoty@users.noreply.github.com> Co-authored-by: Sean Coleman --- .circleci/config.yml | 5 +- .gcloudignore | 4 +- CHANGELOG.md | 6 + README.md | 5 +- cypress/integration/twilio-video.spec.js | 30 +++- package-lock.json | 65 ++++---- package.json | 2 +- server/index.ts | 5 + src/App.tsx | 2 + .../MainParticipantInfo.test.tsx | 22 ++- .../MainParticipantInfo.tsx | 58 ++++++- src/components/MenuBar/Menu/Menu.test.tsx | 148 +++++++++++++++++- src/components/MenuBar/Menu/Menu.tsx | 45 +++++- .../DeviceSelectionScreen.test.tsx | 2 +- .../DeviceSelectionScreen.tsx | 2 +- .../SettingsMenu/SettingsMenu.tsx | 2 +- .../RecordingNoticifaction.test.tsx | 70 +++++++++ .../RecordingNotifications.tsx | 76 +++++++++ src/components/Snackbar/Snackbar.test.tsx | 7 + src/components/Snackbar/Snackbar.tsx | 15 +- .../__snapshots__/Snackbar.test.tsx.snap | 59 +++++++ .../useIsRecording/useIsRecording.test.ts | 54 +++++++ src/hooks/useIsRecording/useIsRecording.ts | 26 +++ src/icons/InfoIcon.tsx | 13 ++ src/icons/StartRecordingIcon.tsx | 16 ++ src/icons/StopRecordingIcon.tsx | 16 ++ src/state/index.test.tsx | 4 +- src/state/index.tsx | 56 ++++++- src/state/useFirebaseAuth/useFirebaseAuth.ts | 35 ++++- .../usePasscodeAuth/usePasscodeAuth.test.tsx | 2 +- src/state/usePasscodeAuth/usePasscodeAuth.ts | 34 +++- src/types.ts | 9 ++ 32 files changed, 808 insertions(+), 87 deletions(-) create mode 100644 src/components/RecordingNotifications/RecordingNoticifaction.test.tsx create mode 100644 src/components/RecordingNotifications/RecordingNotifications.tsx create mode 100644 src/hooks/useIsRecording/useIsRecording.test.ts create mode 100644 src/hooks/useIsRecording/useIsRecording.ts create mode 100644 src/icons/InfoIcon.tsx create mode 100644 src/icons/StartRecordingIcon.tsx create mode 100644 src/icons/StopRecordingIcon.tsx diff --git a/.circleci/config.yml b/.circleci/config.yml index 2b1136a79..debae8c4c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -46,8 +46,6 @@ jobs: - run: name: 'Build app with Firebase auth disabled' command: npm run build - environment: - REACT_APP_TOKEN_ENDPOINT: 'http://localhost:8081/token' - run: name: 'Set environment variables for local token server (used by Cypress tests)' @@ -59,6 +57,9 @@ jobs: - run: npm run cypress:ci + - store_artifacts: + path: cypress/screenshots + - store_test_results: path: test-reports diff --git a/.gcloudignore b/.gcloudignore index 94539359f..8ac9abd7f 100644 --- a/.gcloudignore +++ b/.gcloudignore @@ -15,6 +15,8 @@ # Node.js dependencies: node_modules/ + +# Application source and tests src/ +cypress/ coverage/ -cypress/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b23f7d7d..4cceb4b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.4.0 (May 12, 2021) + +### New Feature + +- This release adds a Start-Stop Recording feature for Group Rooms. This feature allows users to control when to record the contents of the Video Room. Recordings are on a per Track basis and are accessible via the Twilio Console. This feature is powered by the [Twilio Recording Rules API](https://www.twilio.com/docs/video/api/recording-rules). For more information on the Recording Rules API, please see this [blog post](https://www.twilio.com/blog/video-recording-rules-api). + ## 0.3.2 (May 11, 2021) ### New Feature diff --git a/README.md b/README.md index 2f98ad976..c640dc619 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,11 @@ If any errors occur after running a [Twilio CLI RTC Plugin](https://github.com/t After running the command [to deploy the app to Twilio](#deploy-the-app-to-twilio), the room type will be returned in the command line output. Each room type provides a different video experience. More details about these room types can be found [here](https://www.twilio.com/docs/video/tutorials/understanding-video-rooms). The rest of this section explains how these room types affect the behavior of the video app. -_Group_ - The Group room type allows up to fifty participants to join a video room in the app. The Network Quality Level (NQL) indicators and dominant speaker are demonstrated with this room type. Also, the VP8 video codec with simulcast enabled along with a bandwidth profile are set by default in order to provide an optimal group video app experience. +_Group_ - The Group room type allows up to fifty participants to join a video room in the app. The Network Quality Level (NQL) indicators, dominant speaker, and start-stop recordings are demonstrated with this room type. Also, the VP8 video codec with simulcast enabled along with a bandwidth profile are set by default in order to provide an optimal group video app experience. _Small Group_ - The Small Group room type provides an identical group video app experience except for a smaller limit of four participants. -_Peer-to-peer_ - Although up to ten participants can join a room using the Peer-to-peer (P2P) room type, it is ideal for a one to one video experience. The NQL indicators, bandwidth profiles, and dominant speaker cannot be used with this room type. Thus, they are not demonstrated in the video app. Also, the VP8 video codec with simulcast disabled and 720p minimum video capturing dimensions are also set by default in order to provide an optimal one to one video app experience. If more than ten participants join a room with this room type, then the video app will present an error. +_Peer-to-peer_ - Although up to ten participants can join a room using the Peer-to-peer (P2P) room type, it is ideal for a one to one video experience. The NQL indicators, bandwidth profiles, dominant speaker, and start-stop recordings cannot be used with this room type. Thus, they are not demonstrated in the video app. Also, the VP8 video codec with simulcast disabled and 720p minimum video capturing dimensions are also set by default in order to provide an optimal one to one video app experience. If more than ten participants join a room with this room type, then the video app will present an error. _Go_ - The Go room type provides a similar Peer-to-peer video app experience except for a smaller limit of two participants. If more than two participants join a room with this room type, then the video app will present an error. @@ -109,6 +109,7 @@ The Video app has the following features: - [x] [Dominant speaker](https://www.twilio.com/docs/video/detecting-dominant-speaker) indicator - [x] [Network quality](https://www.twilio.com/docs/video/using-network-quality-api) indicator - [x] Defines participant bandwidth usage with the [Bandwidth Profile API](https://www.twilio.com/docs/video/tutorials/using-bandwidth-profile-api) +- [x] Start and stop recording with the [Recording Rules API](https://www.twilio.com/docs/video/api/recording-rules) ## Browser Support diff --git a/cypress/integration/twilio-video.spec.js b/cypress/integration/twilio-video.spec.js index 176de95ff..608bce14e 100644 --- a/cypress/integration/twilio-video.spec.js +++ b/cypress/integration/twilio-video.spec.js @@ -82,6 +82,32 @@ context('A video app user', () => { cy.getParticipant('test1').should('not.exist'); cy.get('[data-cy-main-participant]').should('contain', 'testuser'); }); + + describe('the recording start/stop feature', () => { + before(() => { + cy.get('footer [data-cy-more-button]').click(); + cy.get('[data-cy-recording-button]').click(); + cy.wait(2000); + }); + + after(() => { + cy.wait(3000); + }); + + it('should see the recording indicator and notification after clicking "Start Recording"', () => { + cy.get('[data-cy-recording-indicator]').should('be.visible'); + cy.contains('Recording has started').should('be.visible'); + cy.get('footer [data-cy-more-button]').click(); + cy.get('[data-cy-recording-button]').click(); + }); + + it('should see "Recording Complete" notification, and not the recording indicator after clicking "Stop Recording"', () => { + cy.get('footer [data-cy-more-button]').click(); + cy.get('[data-cy-recording-button]').click(); + cy.get('[data-cy-recording-indicator]').should('not.exist'); + cy.contains('Recording Complete').should('be.visible'); + }); + }); }); describe('when entering a room with one participant', () => { @@ -112,7 +138,7 @@ context('A video app user', () => { // to make the message list taller than its container so that we can test the scrolling behavior: before(() => { cy.get('[data-cy-chat-button]').click(); - // Create an array with 15 values, then send a message when looping over each of them: + // Create an array with 15 values, then send a message when looping over each of them: Array(15) .fill(true) .forEach((_, i) => { @@ -121,7 +147,7 @@ context('A video app user', () => { message: 'welcome to the chat! - ' + i, }); }); - // Wait 1 second for the above to complete: + // Wait 1 second for the above to complete: cy.wait(1000); cy.contains('welcome to the chat! - 14'); }); diff --git a/package-lock.json b/package-lock.json index 47e2c384e..900a802cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3619,9 +3619,9 @@ } }, "tslib": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", - "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz", + "integrity": "sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==" } } }, @@ -3927,9 +3927,9 @@ } }, "tslib": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", - "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz", + "integrity": "sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==" }, "type-fest": { "version": "0.3.1", @@ -4390,9 +4390,9 @@ "optional": true }, "@twilio-labs/plugin-rtc": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@twilio-labs/plugin-rtc/-/plugin-rtc-0.8.1.tgz", - "integrity": "sha512-U+biVhSBiXv0Ym8wnLvBo/X+MPEsH1vryDNpghWRfFjksA/BltbcUzILOGk+panQEGlP6ybEr5tY+Obo9Q0azQ==", + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@twilio-labs/plugin-rtc/-/plugin-rtc-0.8.2.tgz", + "integrity": "sha512-GBT4FojZz/gx+IqHOmRvDJzan/YolJ3dhbEbQ4xynogO9j1jA3hR/O2j7aKJ5F5lCBzzAGz993ni33fgol3AIQ==", "requires": { "@oclif/command": "^1.5.19", "@oclif/config": "^1.14.0", @@ -4462,9 +4462,9 @@ } }, "@twilio/cli-core": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@twilio/cli-core/-/cli-core-5.20.0.tgz", - "integrity": "sha512-02lLD6sUKj7BYjVBlu03cFgdEEt1nwBlfQ3m63gO6Scz17K0Odt7RWH5bO9H5JRextCNSdvw77whRhZUhWbSvg==", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/@twilio/cli-core/-/cli-core-5.21.0.tgz", + "integrity": "sha512-0ym48rNPIoHY10nPYYbt/+LzyUxUflRekXBqFjSqht9f+gd/quh22RT+x5NZdVide2tMx3DzpOryuPraHzojjQ==", "requires": { "@oclif/command": "^1.7.0", "@oclif/config": "^1.16.0", @@ -4477,7 +4477,7 @@ "fs-extra": "^9.0.1", "https-proxy-agent": "^5.0.0", "inquirer": "^7.3.0", - "keytar": "^6.0.1", + "keytar": "^7.6.0", "qs": "^6.9.4", "semver": "^7.3.2", "tsv": "^0.2.0", @@ -7620,9 +7620,9 @@ } }, "tslib": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", - "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz", + "integrity": "sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==" }, "type-fest": { "version": "0.21.3", @@ -17127,13 +17127,13 @@ } }, "keytar": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/keytar/-/keytar-6.0.1.tgz", - "integrity": "sha512-1Ihpf2tdM3sLwGMkYHXYhVC/hx5BDR7CWFL4IrBA3IDZo0xHhS2nM+tU9Y+u/U7okNfbVkwmKsieLkcWRMh93g==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.6.0.tgz", + "integrity": "sha512-H3cvrTzWb11+iv0NOAnoNAPgEapVZnYLVHZQyxmh7jdmVfR/c0jNNFEZ6AI38W/4DeTGTaY66ZX4Z1SbfKPvCQ==", "optional": true, "requires": { "node-addon-api": "^3.0.0", - "prebuild-install": "5.3.4" + "prebuild-install": "^6.0.0" } }, "keyv": { @@ -20660,26 +20660,25 @@ } }, "prebuild-install": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-5.3.4.tgz", - "integrity": "sha512-AkKN+pf4fSEihjapLEEj8n85YIw/tN6BQqkhzbDc0RvEZGdkpJBGMUYx66AAMcPG2KzmPQS7Cm16an4HVBRRMA==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-6.1.1.tgz", + "integrity": "sha512-M+cKwofFlHa5VpTWub7GLg5RLcunYIcLqtY5pKcls/u7xaAb8FrXZ520qY8rkpYy5xw90tYCyMO0MP5ggzR3Sw==", "optional": true, "requires": { "detect-libc": "^1.0.3", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", - "mkdirp": "^0.5.1", + "mkdirp-classic": "^0.5.3", "napi-build-utils": "^1.0.1", - "node-abi": "^2.7.0", + "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", - "which-pm-runs": "^1.0.0" + "tunnel-agent": "^0.6.0" } }, "precond": { @@ -21866,9 +21865,9 @@ } }, "resolve-alpn": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.0.0.tgz", - "integrity": "sha512-rTuiIEqFmGxne4IovivKSDzld2lWW9QCjqv80SYjPgf+gS35eaCAjaP54CCwGAwBtnCsvNLYtqxe1Nw+i6JEmA==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.1.1.tgz", + "integrity": "sha512-0KbFjFPR2bnJhNx1t8Ad6RqVc8+QPJC4y561FYyC/Q/6OzB3fhUzB5PEgitYhPK6aifwR5gXBSnDMllaDWixGQ==" }, "resolve-cwd": { "version": "3.0.0", @@ -26312,12 +26311,6 @@ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" }, - "which-pm-runs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.0.0.tgz", - "integrity": "sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs=", - "optional": true - }, "wide-align": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", diff --git a/package.json b/package.json index f6cb9f1cf..de616ed88 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "dependencies": { "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", - "@twilio-labs/plugin-rtc": "^0.8.1", + "@twilio-labs/plugin-rtc": "^0.8.2", "@twilio/conversations": "^1.1.0", "@types/d3-timer": "^1.0.9", "@types/dotenv": "^8.2.0", diff --git a/server/index.ts b/server/index.ts index f08a95c3e..ce1c0fcf4 100644 --- a/server/index.ts +++ b/server/index.ts @@ -14,11 +14,16 @@ app.use(express.json()); const tokenFunction: ServerlessFunction = require('@twilio-labs/plugin-rtc/src/serverless/functions/token').handler; const tokenEndpoint = createExpressHandler(tokenFunction); +const recordingRulesFunction: ServerlessFunction = require('@twilio-labs/plugin-rtc/src/serverless/functions/recordingrules') + .handler; +const recordingRulesEndpoint = createExpressHandler(recordingRulesFunction); + const noopMiddleware: RequestHandler = (_, __, next) => next(); const authMiddleware = process.env.REACT_APP_SET_AUTH === 'firebase' ? require('./firebaseAuthMiddleware') : noopMiddleware; app.all('/token', authMiddleware, tokenEndpoint); +app.all('/recordingrules', authMiddleware, recordingRulesEndpoint); app.use((req, res, next) => { // Here we add Cache-Control headers in accordance with the create-react-app best practices. diff --git a/src/App.tsx b/src/App.tsx index e73fafd65..9c6e65edf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import MenuBar from './components/MenuBar/MenuBar'; import MobileTopMenuBar from './components/MobileTopMenuBar/MobileTopMenuBar'; import PreJoinScreens from './components/PreJoinScreens/PreJoinScreens'; import ReconnectingNotification from './components/ReconnectingNotification/ReconnectingNotification'; +import RecordingNotifications from './components/RecordingNotifications/RecordingNotifications'; import Room from './components/Room/Room'; import useHeight from './hooks/useHeight/useHeight'; @@ -41,6 +42,7 @@ export default function App() { ) : (
+ diff --git a/src/components/MainParticipantInfo/MainParticipantInfo.test.tsx b/src/components/MainParticipantInfo/MainParticipantInfo.test.tsx index 0da22938a..f1f34921a 100644 --- a/src/components/MainParticipantInfo/MainParticipantInfo.test.tsx +++ b/src/components/MainParticipantInfo/MainParticipantInfo.test.tsx @@ -3,10 +3,12 @@ import React from 'react'; import MainParticipantInfo from './MainParticipantInfo'; import AvatarIcon from '../../icons/AvatarIcon'; import { shallow } from 'enzyme'; + +import useIsRecording from '../../hooks/useIsRecording/useIsRecording'; import useIsTrackSwitchedOff from '../../hooks/useIsTrackSwitchedOff/useIsTrackSwitchedOff'; +import useParticipantIsReconnecting from '../../hooks/useParticipantIsReconnecting/useParticipantIsReconnecting'; import usePublications from '../../hooks/usePublications/usePublications'; import useTrack from '../../hooks/useTrack/useTrack'; -import useParticipantIsReconnecting from '../../hooks/useParticipantIsReconnecting/useParticipantIsReconnecting'; import useVideoContext from '../../hooks/useVideoContext/useVideoContext'; jest.mock('../../hooks/useParticipantNetworkQualityLevel/useParticipantNetworkQualityLevel', () => () => 4); @@ -15,12 +17,14 @@ jest.mock('../../hooks/useIsTrackSwitchedOff/useIsTrackSwitchedOff'); jest.mock('../../hooks/useTrack/useTrack'); jest.mock('../../hooks/useVideoContext/useVideoContext'); jest.mock('../../hooks/useParticipantIsReconnecting/useParticipantIsReconnecting'); +jest.mock('../../hooks/useIsRecording/useIsRecording'); const mockUsePublications = usePublications as jest.Mock; const mockUseIsTrackSwitchedOff = useIsTrackSwitchedOff as jest.Mock; const mockUseTrack = useTrack as jest.Mock; const mockUseVideoContext = useVideoContext as jest.Mock; const mockUseParticipantIsReconnecting = useParticipantIsReconnecting as jest.Mock; +const mockUseIsRecording = useIsRecording as jest.Mock; describe('the MainParticipantInfo component', () => { beforeEach(jest.clearAllMocks); @@ -115,4 +119,20 @@ describe('the MainParticipantInfo component', () => { ); expect(wrapper.text()).toContain('mockIdentity - Screen'); }); + + it('should not render the recording indicator when isRecording is false', () => { + mockUseIsRecording.mockImplementationOnce(() => false); + const wrapper = shallow( + mock children + ); + expect(wrapper.text()).not.toContain('Recording'); + }); + + it('should render the recording indicator when isRecording is true', () => { + mockUseIsRecording.mockImplementationOnce(() => true); + const wrapper = shallow( + mock children + ); + expect(wrapper.text()).toContain('Recording'); + }); }); diff --git a/src/components/MainParticipantInfo/MainParticipantInfo.tsx b/src/components/MainParticipantInfo/MainParticipantInfo.tsx index 7f43680d6..d9dc3bfff 100644 --- a/src/components/MainParticipantInfo/MainParticipantInfo.tsx +++ b/src/components/MainParticipantInfo/MainParticipantInfo.tsx @@ -3,17 +3,19 @@ import clsx from 'clsx'; import { makeStyles, Theme } from '@material-ui/core/styles'; import { LocalAudioTrack, LocalVideoTrack, Participant, RemoteAudioTrack, RemoteVideoTrack } from 'twilio-video'; +import AudioLevelIndicator from '../AudioLevelIndicator/AudioLevelIndicator'; import AvatarIcon from '../../icons/AvatarIcon'; +import NetworkQualityLevel from '../NetworkQualityLevel/NetworkQualityLevel'; +import Tooltip from '@material-ui/core/Tooltip'; import Typography from '@material-ui/core/Typography'; +import useIsRecording from '../../hooks/useIsRecording/useIsRecording'; import useIsTrackSwitchedOff from '../../hooks/useIsTrackSwitchedOff/useIsTrackSwitchedOff'; +import useParticipantIsReconnecting from '../../hooks/useParticipantIsReconnecting/useParticipantIsReconnecting'; import usePublications from '../../hooks/usePublications/usePublications'; import useScreenShareParticipant from '../../hooks/useScreenShareParticipant/useScreenShareParticipant'; import useTrack from '../../hooks/useTrack/useTrack'; import useVideoContext from '../../hooks/useVideoContext/useVideoContext'; -import useParticipantIsReconnecting from '../../hooks/useParticipantIsReconnecting/useParticipantIsReconnecting'; -import AudioLevelIndicator from '../AudioLevelIndicator/AudioLevelIndicator'; -import NetworkQualityLevel from '../NetworkQualityLevel/NetworkQualityLevel'; const useStyles = makeStyles((theme: Theme) => ({ container: { @@ -71,6 +73,41 @@ const useStyles = makeStyles((theme: Theme) => ({ transform: 'scale(2)', }, }, + recordingIndicator: { + position: 'absolute', + bottom: 0, + display: 'flex', + alignItems: 'center', + background: 'rgba(0, 0, 0, 0.5)', + color: 'white', + padding: '0.1em 0.3em 0.1em 0', + fontSize: '1.2rem', + height: '28px', + [theme.breakpoints.down('sm')]: { + bottom: 'auto', + right: 0, + top: 0, + }, + }, + circle: { + height: '12px', + width: '12px', + background: 'red', + borderRadius: '100%', + margin: '0 0.6em', + animation: `1.25s $pulsate ease-out infinite`, + }, + '@keyframes pulsate': { + '0%': { + background: `#A90000`, + }, + '50%': { + background: '#f00', + }, + '100%': { + background: '#A90000', + }, + }, })); interface MainParticipantInfoProps { @@ -100,6 +137,8 @@ export default function MainParticipantInfo({ participant, children }: MainParti const isVideoSwitchedOff = useIsTrackSwitchedOff(videoTrack as LocalVideoTrack | RemoteVideoTrack); const isParticipantReconnecting = useParticipantIsReconnecting(participant); + const isRecording = useIsRecording(); + return (
+ {isRecording && ( + +
+
+ + Recording + +
+
+ )} {(!isVideoEnabled || isVideoSwitchedOff) && (
diff --git a/src/components/MenuBar/Menu/Menu.test.tsx b/src/components/MenuBar/Menu/Menu.test.tsx index af8e4ae75..d5f3a8593 100644 --- a/src/components/MenuBar/Menu/Menu.test.tsx +++ b/src/components/MenuBar/Menu/Menu.test.tsx @@ -8,15 +8,155 @@ import Menu from './Menu'; import MenuContainer from '@material-ui/core/Menu'; import MoreIcon from '@material-ui/icons/MoreVert'; import { shallow } from 'enzyme'; +import { render, fireEvent, waitForElement } from '@testing-library/react'; + +import { useAppState } from '../../../state'; import useFlipCameraToggle from '../../../hooks/useFlipCameraToggle/useFlipCameraToggle'; import useMediaQuery from '@material-ui/core/useMediaQuery'; +import useIsRecording from '../../../hooks/useIsRecording/useIsRecording'; jest.mock('../../../hooks/useFlipCameraToggle/useFlipCameraToggle'); jest.mock('@material-ui/core/useMediaQuery'); +jest.mock('../../../state'); +jest.mock('../../../hooks/useVideoContext/useVideoContext', () => () => ({ room: { sid: 'mockRoomSid' } })); +jest.mock('../../../hooks/useIsRecording/useIsRecording'); + const mockUseFlipCameraToggle = useFlipCameraToggle as jest.Mock; const mockUseMediaQuery = useMediaQuery as jest.Mock; +const mockUseAppState = useAppState as jest.Mock; +const mockUseIsRecording = useIsRecording as jest.Mock; describe('the Menu component', () => { + let mockUpdateRecordingRules: jest.Mock; + + beforeEach(() => jest.clearAllMocks()); + + beforeAll(() => { + mockUpdateRecordingRules = jest.fn(() => Promise.resolve()); + mockUseAppState.mockImplementation(() => ({ + isFetching: false, + updateRecordingRules: mockUpdateRecordingRules, + roomType: 'group', + })); + mockUseFlipCameraToggle.mockImplementation(() => ({ + flipCameraDisabled: false, + flipCameraSupported: false, + })); + }); + + describe('the recording button', () => { + describe('while recording is in progress', () => { + beforeAll(() => { + mockUseIsRecording.mockImplementation(() => true); + }); + + it('should display "Stop Recording"', () => { + const { getByText } = render(); + fireEvent.click(getByText('More')); + + expect(getByText('Stop Recording')).toBeTruthy(); + }); + + it('should correctly update recording rules and display the snackbar when the user clicks on the Stop Recording button', () => { + const { getByText } = render(); + fireEvent.click(getByText('More')); + + fireEvent.click(getByText('Stop Recording')); + + expect(mockUpdateRecordingRules).toHaveBeenCalledWith('mockRoomSid', [{ all: true, type: 'exclude' }]); + waitForElement(() => getByText('You can view the recording in the Twilio Console')); + }); + }); + + describe('while recording is not in progress', () => { + beforeAll(() => { + mockUseIsRecording.mockImplementation(() => false); + }); + + it('should render the recording button in group rooms', () => { + mockUseAppState.mockImplementation(() => ({ + isFetching: false, + updateRecordingRules: mockUpdateRecordingRules, + roomType: 'group', + })); + const { getByText } = render(); + fireEvent.click(getByText('More')); + expect(getByText('Start Recording')).toBeTruthy(); + }); + + it('should render the recording button in group-small rooms', () => { + mockUseAppState.mockImplementation(() => ({ + isFetching: false, + updateRecordingRules: mockUpdateRecordingRules, + roomType: 'group-small', + })); + const { getByText } = render(); + fireEvent.click(getByText('More')); + expect(getByText('Start Recording')).toBeTruthy(); + }); + + it('should not render the recording button in go rooms', () => { + mockUseAppState.mockImplementation(() => ({ + isFetching: false, + updateRecordingRules: mockUpdateRecordingRules, + roomType: 'go', + })); + const { getByText, queryByText } = render(); + fireEvent.click(getByText('More')); + expect(queryByText('Start Recording')).toBeNull(); + }); + + it('should not render the recording button in peer-to-peer rooms', () => { + mockUseAppState.mockImplementation(() => ({ + isFetching: false, + updateRecordingRules: mockUpdateRecordingRules, + roomType: 'peer-to-peer', + })); + const { getByText, queryByText } = render(); + fireEvent.click(getByText('More')); + expect(queryByText('Start Recording')).toBeNull(); + }); + + it('should render the recording button when roomType is undefined', () => { + mockUseAppState.mockImplementation(() => ({ + isFetching: false, + updateRecordingRules: mockUpdateRecordingRules, + roomType: undefined, + })); + const { getByText } = render(); + fireEvent.click(getByText('More')); + expect(getByText('Start Recording')).toBeTruthy(); + }); + + it('should display "Start Recording"', () => { + const { getByText } = render(); + fireEvent.click(getByText('More')); + + expect(getByText('Start Recording')).toBeTruthy(); + }); + + it('should correctly update recording rules and display the snackbar when the user clicks on the Start Recording button', () => { + const { getByText } = render(); + fireEvent.click(getByText('More')); + + fireEvent.click(getByText('Start Recording')); + expect(mockUpdateRecordingRules).toHaveBeenCalledWith('mockRoomSid', [{ all: true, type: 'include' }]); + }); + + it('should disable the Start Recording button when isFetching is true', async () => { + mockUseAppState.mockImplementationOnce(() => ({ isFetching: true })); + const wrapper = shallow(); + + expect( + wrapper + .find(MenuItem) + .at(0) + .prop('disabled') + ).toBe(true); + }); + }); + }); + describe('on desktop devices', () => { beforeAll(() => { mockUseMediaQuery.mockImplementation(() => false); @@ -38,7 +178,7 @@ describe('the Menu component', () => { expect(wrapper.find(AboutDialog).prop('open')).toBe(false); wrapper .find(MenuItem) - .at(1) + .at(2) .simulate('click'); expect(wrapper.find(AboutDialog).prop('open')).toBe(true); }); @@ -48,7 +188,7 @@ describe('the Menu component', () => { expect(wrapper.find(DeviceSelectionDialog).prop('open')).toBe(false); wrapper .find(MenuItem) - .at(0) + .at(1) .simulate('click'); expect(wrapper.find(DeviceSelectionDialog).prop('open')).toBe(true); }); @@ -86,7 +226,7 @@ describe('the Menu component', () => { expect( wrapper .find(MenuItem) - .at(0) + .at(1) .prop('disabled') ).toBe(false); }); @@ -101,7 +241,7 @@ describe('the Menu component', () => { expect( wrapper .find(MenuItem) - .at(0) + .at(1) .prop('disabled') ).toBe(true); }); diff --git a/src/components/MenuBar/Menu/Menu.tsx b/src/components/MenuBar/Menu/Menu.tsx index 1ced08043..d90b81d8f 100644 --- a/src/components/MenuBar/Menu/Menu.tsx +++ b/src/components/MenuBar/Menu/Menu.tsx @@ -1,16 +1,18 @@ import React, { useState, useRef } from 'react'; import AboutDialog from '../../AboutDialog/AboutDialog'; -import Button from '@material-ui/core/Button'; import DeviceSelectionDialog from '../../DeviceSelectionDialog/DeviceSelectionDialog'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import FlipCameraIcon from '../../../icons/FlipCameraIcon'; import InfoIconOutlined from '../../../icons/InfoIconOutlined'; -import MenuContainer from '@material-ui/core/Menu'; -import MenuItem from '@material-ui/core/MenuItem'; import MoreIcon from '@material-ui/icons/MoreVert'; +import StartRecordingIcon from '../../../icons/StartRecordingIcon'; +import StopRecordingIcon from '../../../icons/StopRecordingIcon'; import SettingsIcon from '../../../icons/SettingsIcon'; -import Typography from '@material-ui/core/Typography'; -import { styled, Theme, useMediaQuery } from '@material-ui/core'; +import { Button, styled, Theme, useMediaQuery, Menu as MenuContainer, MenuItem, Typography } from '@material-ui/core'; + +import { useAppState } from '../../../state'; +import useIsRecording from '../../../hooks/useIsRecording/useIsRecording'; +import useVideoContext from '../../../hooks/useVideoContext/useVideoContext'; +import FlipCameraIcon from '../../../icons/FlipCameraIcon'; import useFlipCameraToggle from '../../../hooks/useFlipCameraToggle/useFlipCameraToggle'; export const IconContainer = styled('div')({ @@ -22,21 +24,31 @@ export const IconContainer = styled('div')({ export default function Menu(props: { buttonClassName?: string }) { const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm')); + const [aboutOpen, setAboutOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); + const { isFetching, updateRecordingRules, roomType } = useAppState(); + const { room } = useVideoContext(); + const isRecording = useIsRecording(); + const anchorRef = useRef(null); const { flipCameraDisabled, toggleFacingMode, flipCameraSupported } = useFlipCameraToggle(); return ( <> -