diff --git a/src/components/AudioLevelIndicator/AudioLevelIndicator.tsx b/src/components/AudioLevelIndicator/AudioLevelIndicator.tsx index 2927b0a0b..2bc02ce46 100644 --- a/src/components/AudioLevelIndicator/AudioLevelIndicator.tsx +++ b/src/components/AudioLevelIndicator/AudioLevelIndicator.tsx @@ -1,6 +1,6 @@ +import { interval } from 'd3-timer'; import React, { useEffect, useRef, useState } from 'react'; import { AudioTrack, LocalAudioTrack, RemoteAudioTrack } from 'twilio-video'; -import { interval } from 'd3-timer'; import useIsTrackEnabled from '../../hooks/useIsTrackEnabled/useIsTrackEnabled'; import useMediaStreamTrack from '../../hooks/useMediaStreamTrack/useMediaStreamTrack'; @@ -20,8 +20,7 @@ export function initializeAnalyser(stream: MediaStream) { audioSource.connect(analyser); - // Here we provide a way for the audioContext to be closed. - // Closing the audioContext allows the unused audioSource to be garbage collected. + // Here we provide a way for the audioContext to be closed. Closing the audioContext allows the unused audioSource to be garbage collected. stream.addEventListener('cleanup', () => { if (audioContext.state !== 'closed') { audioContext.close(); @@ -41,19 +40,14 @@ function AudioLevelIndicator({ audioTrack, color = 'white' }: { audioTrack?: Aud useEffect(() => { if (audioTrack && mediaStreamTrack && isTrackEnabled) { - // Here we create a new MediaStream from a clone of the mediaStreamTrack. - // A clone is created to allow multiple instances of this component for a single - // AudioTrack on iOS Safari. We only clone the mediaStreamTrack on iOS. + // Cloning the mediaStreamTrack allows for multiple instances of the AudioLevelIndicator component for a single AudioTrack on iOS Safari. let newMediaStream = new MediaStream([isIOS ? mediaStreamTrack.clone() : mediaStreamTrack]); - // Here we listen for the 'stopped' event on the audioTrack. When the audioTrack is stopped, - // we stop the cloned track that is stored in 'newMediaStream'. It is important that we stop - // all tracks when they are not in use. Browsers like Firefox don't let you create a new stream - // from a new audio device while the active audio device still has active tracks. + // It is important that we stop all tracks when they are not in use. Browsers like Firefox + // don't let you create a new stream from a new audio device while the active audio device still has active tracks. const stopAllMediaStreamTracks = () => { if (isIOS) { // If we are on iOS, then we want to stop the MediaStreamTrack that we have previously cloned. - // If we are not on iOS, then we do not stop the MediaStreamTrack since it is the original and still in use. newMediaStream.getTracks().forEach(track => track.stop()); } newMediaStream.dispatchEvent(new Event('cleanup')); // Stop the audioContext @@ -62,7 +56,7 @@ function AudioLevelIndicator({ audioTrack, color = 'white' }: { audioTrack?: Aud const reinitializeAnalyser = () => { stopAllMediaStreamTracks(); - // We only clone the mediaStreamTrack on iOS. + newMediaStream = new MediaStream([isIOS ? mediaStreamTrack.clone() : mediaStreamTrack]); setAnalyser(initializeAnalyser(newMediaStream)); }; @@ -70,8 +64,6 @@ function AudioLevelIndicator({ audioTrack, color = 'white' }: { audioTrack?: Aud setAnalyser(initializeAnalyser(newMediaStream)); // Here we reinitialize the AnalyserNode on focus to avoid an issue in Safari - // where the analysers stop functioning when the user switches to a new tab - // and switches back to the app. window.addEventListener('focus', reinitializeAnalyser); return () => { @@ -109,7 +101,6 @@ function AudioLevelIndicator({ audioTrack, color = 'white' }: { audioTrack?: Aud } }, [isTrackEnabled, analyser]); - // Each instance of this component will need a unique HTML ID const clipPathId = `audio-level-clip-${getUniqueClipId()}`; return isTrackEnabled ? ( diff --git a/src/components/ChatWindow/MessageList/MessageListScrollContainer/MessageListScrollContainer.tsx b/src/components/ChatWindow/MessageList/MessageListScrollContainer/MessageListScrollContainer.tsx index c1b9b3be2..66dd85c39 100644 --- a/src/components/ChatWindow/MessageList/MessageListScrollContainer/MessageListScrollContainer.tsx +++ b/src/components/ChatWindow/MessageList/MessageListScrollContainer/MessageListScrollContainer.tsx @@ -5,7 +5,7 @@ import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward'; import { Message } from '@twilio/conversations'; import clsx from 'clsx'; import throttle from 'lodash.throttle'; -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; const styles = createStyles({ outerContainer: { @@ -45,12 +45,6 @@ interface MessageListScrollContainerProps extends WithStyles { messages: Message[]; } -interface MessageListScrollContainerState { - isScrolledToBottom: boolean; - showButton: boolean; - messageNotificationCount: number; -} - /* * This component is a scrollable container that wraps around the 'MessageList' component. * The MessageList will ultimately grow taller than its container as it continues to receive @@ -62,107 +56,81 @@ interface MessageListScrollContainerState { * * Note that this component is tested with Cypress only. */ -export class MessageListScrollContainer extends React.Component< - MessageListScrollContainerProps, - MessageListScrollContainerState -> { - chatThreadRef = React.createRef(); - state = { isScrolledToBottom: true, showButton: false, messageNotificationCount: 0 }; - scrollToBottom() { - const innerScrollContainerEl = this.chatThreadRef.current; +const MessageListScrollContainer: React.FC = ({ classes, messages, children }) => { + const chatThreadRef = useRef(null); + const [isScrolledToBottom, setIsScrolledToBottom] = useState(true); + const [showButton, setShowButton] = useState(false); + const [messageNotificationCount, setMessageNotificationCount] = useState(0); + + const scrollToBottom = () => { + const innerScrollContainerEl = chatThreadRef.current; if (!innerScrollContainerEl) return; innerScrollContainerEl.scrollTop = innerScrollContainerEl.scrollHeight; - } - - componentDidMount() { - this.scrollToBottom(); - this.chatThreadRef.current && this.chatThreadRef.current.addEventListener('scroll', this.handleScroll); - } - - // This component updates as users send new messages: - componentDidUpdate(prevProps: MessageListScrollContainerProps, prevState: MessageListScrollContainerState) { - const hasNewMessages = this.props.messages.length !== prevProps.messages.length; - - if (prevState.isScrolledToBottom && hasNewMessages) { - this.scrollToBottom(); - } else if (hasNewMessages) { - const numberOfNewMessages = this.props.messages.length - prevProps.messages.length; - - this.setState(previousState => ({ - // If there's at least one new message, show the 'new message' button: - showButton: !previousState.isScrolledToBottom, - // If 'new message' button is visible, - // messageNotificationCount will be the number of previously unread messages + the number of new messages. - // Otherwise, messageNotificationCount is set to 1: - messageNotificationCount: previousState.showButton - ? previousState.messageNotificationCount + numberOfNewMessages - : 1, - })); - } - } - - handleScroll = throttle(() => { - const innerScrollContainerEl = this.chatThreadRef.current; + }; - // Because this.handleScroll() is a throttled method, - // it's possible that it can be called after this component unmounts, and this element will be null. - // Therefore, if it doesn't exist, don't do anything: + const handleScroll = throttle(() => { + const innerScrollContainerEl = chatThreadRef.current; if (!innerScrollContainerEl) return; - // On systems using display scaling, scrollTop may return a decimal value, so we need to account for this in the - // "isScrolledToBottom" calculation. - const isScrolledToBottom = + const scrolledToBottom = Math.abs( innerScrollContainerEl.clientHeight + innerScrollContainerEl.scrollTop - innerScrollContainerEl.scrollHeight ) < 1; - this.setState(prevState => ({ - isScrolledToBottom, - showButton: isScrolledToBottom ? false : prevState.showButton, - })); + setIsScrolledToBottom(scrolledToBottom); + if (!scrolledToBottom) setShowButton(true); }, 300); - handleClick = () => { - const innerScrollContainerEl = this.chatThreadRef.current; - if (!innerScrollContainerEl) return; - - innerScrollContainerEl.scrollTo({ top: innerScrollContainerEl.scrollHeight, behavior: 'smooth' }); + useEffect(() => { + scrollToBottom(); + const innerScrollContainerEl = chatThreadRef.current; + if (innerScrollContainerEl) { + innerScrollContainerEl.addEventListener('scroll', handleScroll); + } + return () => { + if (innerScrollContainerEl) { + innerScrollContainerEl.removeEventListener('scroll', handleScroll); + } + }; + }, []); + + useEffect(() => { + const hasNewMessages = messages.length !== 0; + if (isScrolledToBottom && hasNewMessages) { + scrollToBottom(); + } else if (hasNewMessages) { + const numberOfNewMessages = messages.length; + // If 'new message' btn is visible, messageNotificationCount will be the number of prev. unread msgs + the number of new msgs. + setShowButton(!isScrolledToBottom); + setMessageNotificationCount(showButton ? messageNotificationCount + numberOfNewMessages : 1); + } + }, [messages]); - this.setState({ showButton: false }); + const handleClick = () => { + scrollToBottom(); + setShowButton(false); }; - componentWillUnmount() { - const innerScrollContainerEl = this.chatThreadRef.current; - if (!innerScrollContainerEl) return; - - innerScrollContainerEl.removeEventListener('scroll', this.handleScroll); - } - - render() { - const { classes } = this.props; - - return ( -
-
-
- {this.props.children} - -
+ return ( +
+
+
+ {children} +
- ); - } -} +
+ ); +}; export default withStyles(styles)(MessageListScrollContainer); diff --git a/src/components/PreJoinScreens/DeviceSelectionScreen/DeviceSelectionScreen.test.tsx b/src/components/PreJoinScreens/DeviceSelectionScreen/DeviceSelectionScreen.test.tsx index 8dea1874f..3ffc525f8 100644 --- a/src/components/PreJoinScreens/DeviceSelectionScreen/DeviceSelectionScreen.test.tsx +++ b/src/components/PreJoinScreens/DeviceSelectionScreen/DeviceSelectionScreen.test.tsx @@ -1,13 +1,11 @@ -import React from 'react'; -import DeviceSelectionScreen from './DeviceSelectionScreen'; -import CircularProgress from '@material-ui/core/CircularProgress'; import { shallow } from 'enzyme'; -import { Steps } from '../PreJoinScreens'; -import { useAppState } from '../../../state'; +import { setImmediate } from 'timers'; import useVideoContext from '../../../hooks/useVideoContext/useVideoContext'; -import ToggleVideoButton from '../../Buttons/ToggleVideoButton/ToggleVideoButton'; +import { useAppState } from '../../../state'; import ToggleAudioButton from '../../Buttons/ToggleAudioButton/ToggleAudioButton'; -import { setImmediate } from 'timers'; +import ToggleVideoButton from '../../Buttons/ToggleVideoButton/ToggleVideoButton'; +import { Steps } from '../PreJoinScreens'; +import DeviceSelectionScreen, { JoiningMeetingAlert } from './DeviceSelectionScreen'; const mockUseAppState = useAppState as jest.Mock; const mockUseVideoContext = useVideoContext as jest.Mock; @@ -45,7 +43,7 @@ describe('the DeviceSelectionScreen component', () => { const wrapper = shallow( {}} />); it('should show the loading screen', () => { - expect(wrapper.find(CircularProgress).exists()).toBe(true); + expect(wrapper.find(JoiningMeetingAlert).exists()).toBe(true); }); it('should disable the desktop and mobile toggle video buttons', () => { @@ -91,7 +89,7 @@ describe('the DeviceSelectionScreen component', () => { const wrapper = shallow( {}} />); it('should show the loading screen', () => { - expect(wrapper.find(CircularProgress).exists()).toBe(true); + expect(wrapper.find(JoiningMeetingAlert).exists()).toBe(true); }); it('should disable the desktop and mobile toggle video buttons', () => { diff --git a/src/components/PreJoinScreens/DeviceSelectionScreen/DeviceSelectionScreen.tsx b/src/components/PreJoinScreens/DeviceSelectionScreen/DeviceSelectionScreen.tsx index ef3f059ff..f142fc07a 100644 --- a/src/components/PreJoinScreens/DeviceSelectionScreen/DeviceSelectionScreen.tsx +++ b/src/components/PreJoinScreens/DeviceSelectionScreen/DeviceSelectionScreen.tsx @@ -1,19 +1,18 @@ -import React from 'react'; -import { makeStyles, Typography, Grid, Button, Theme, Hidden, Switch, Tooltip } from '@material-ui/core'; +import { Button, Grid, Hidden, Switch, Theme, Tooltip, Typography, makeStyles } from '@material-ui/core'; import CircularProgress from '@material-ui/core/CircularProgress'; import Divider from '@material-ui/core/Divider'; -import LocalVideoPreview from './LocalVideoPreview/LocalVideoPreview'; -import SettingsMenu from './SettingsMenu/SettingsMenu'; -import { Steps } from '../PreJoinScreens'; -import ToggleAudioButton from '../../Buttons/ToggleAudioButton/ToggleAudioButton'; -import ToggleVideoButton from '../../Buttons/ToggleVideoButton/ToggleVideoButton'; -import { useAppState } from '../../../state'; -import useChatContext from '../../../hooks/useChatContext/useChatContext'; -import useVideoContext from '../../../hooks/useVideoContext/useVideoContext'; import FormControlLabel from '@material-ui/core/FormControlLabel'; +import useChatContext from '../../../hooks/useChatContext/useChatContext'; import { useKrispToggle } from '../../../hooks/useKrispToggle/useKrispToggle'; -import SmallCheckIcon from '../../../icons/SmallCheckIcon'; +import useVideoContext from '../../../hooks/useVideoContext/useVideoContext'; import InfoIconOutlined from '../../../icons/InfoIconOutlined'; +import SmallCheckIcon from '../../../icons/SmallCheckIcon'; +import { useAppState } from '../../../state'; +import ToggleAudioButton from '../../Buttons/ToggleAudioButton/ToggleAudioButton'; +import ToggleVideoButton from '../../Buttons/ToggleVideoButton/ToggleVideoButton'; +import { Steps } from '../PreJoinScreens'; +import LocalVideoPreview from './LocalVideoPreview/LocalVideoPreview'; +import SettingsMenu from './SettingsMenu/SettingsMenu'; const useStyles = makeStyles((theme: Theme) => ({ gutterBottom: { @@ -91,18 +90,7 @@ export default function DeviceSelectionScreen({ name, roomName, setStep }: Devic }; if (isFetching || isConnecting) { - return ( - -
- -
-
- - Joining Meeting - -
-
- ); + return ; } return ( @@ -209,3 +197,18 @@ export default function DeviceSelectionScreen({ name, roomName, setStep }: Devic ); } + +export const JoiningMeetingAlert = () => { + return ( + +
+ +
+
+ + Joining Meeting + +
+
+ ); +}; diff --git a/src/components/PreJoinScreens/PreJoinScreens.tsx b/src/components/PreJoinScreens/PreJoinScreens.tsx index e2638b38e..8c0323d41 100644 --- a/src/components/PreJoinScreens/PreJoinScreens.tsx +++ b/src/components/PreJoinScreens/PreJoinScreens.tsx @@ -1,15 +1,15 @@ -import React, { useState, useEffect, FormEvent } from 'react'; -import DeviceSelectionScreen from './DeviceSelectionScreen/DeviceSelectionScreen'; +import { FormEvent, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import useVideoContext from '../../hooks/useVideoContext/useVideoContext'; +import { useAppState } from '../../state'; import IntroContainer from '../IntroContainer/IntroContainer'; +import DeviceSelectionScreen from './DeviceSelectionScreen/DeviceSelectionScreen'; import MediaErrorSnackbar from './MediaErrorSnackbar/MediaErrorSnackbar'; import RoomNameScreen from './RoomNameScreen/RoomNameScreen'; -import { useAppState } from '../../state'; -import { useParams } from 'react-router-dom'; -import useVideoContext from '../../hooks/useVideoContext/useVideoContext'; export enum Steps { - roomNameStep, - deviceSelectionStep, + roomNameStep = 0, + deviceSelectionStep = 1, } export default function PreJoinScreens() { diff --git a/src/components/RecordingNotifications/RecordingNotifications.tsx b/src/components/RecordingNotifications/RecordingNotifications.tsx index 0c752f484..99a94e9ab 100644 --- a/src/components/RecordingNotifications/RecordingNotifications.tsx +++ b/src/components/RecordingNotifications/RecordingNotifications.tsx @@ -1,13 +1,13 @@ -import React, { useEffect, useRef, useState } from 'react'; import { Link } from '@material-ui/core'; -import Snackbar from '../Snackbar/Snackbar'; +import { useEffect, useRef, useState } from 'react'; import useIsRecording from '../../hooks/useIsRecording/useIsRecording'; +import Snackbar from '../Snackbar/Snackbar'; enum Snackbars { - none, - recordingStarted, - recordingInProgress, - recordingFinished, + none = 0, + recordingStarted = 1, + recordingInProgress = 2, + recordingFinished = 3, } export default function RecordingNotifications() {