From 492cfa58ceb3ea73111ea69564234c2f1b4b6e67 Mon Sep 17 00:00:00 2001 From: Flaykz Date: Tue, 27 Sep 2022 00:10:31 +1100 Subject: [PATCH] feat: [#7] Play a sound on new message (#25) * feat: [#7] Play a sound on new message * fix: [#7] Since this mock is a no-op, I think we can omit the argument to mockImplementation Co-authored-by: Jeremy Kahn * fix: [#7] lazy initialization of this state Co-authored-by: Jeremy Kahn * fix: [#7] More accurate error message Co-authored-by: Jeremy Kahn * fix: [#7] Replace then with await * [closes #24] Settings UI (#26) * feat: [#24] wire up settings page * feat: [#24] stand up settings UI * feat: [#24] implement storage deletion * feat: [#24] confirm deletion of settings data * feat: [#7] Add play sound switch in settings * feat: [#7] avoid typescript warning Co-authored-by: Jeremy Kahn * feat: [#7] more straighforward wording Co-authored-by: Jeremy Kahn * feat: [#7] remove useless usestate * feat: [#7] avoid new settings to be undefined in persisted storage * feat: [#7] creating a chat section in settings Co-authored-by: Jeremy Kahn --- public/sounds/new-message.aac | Bin 0 -> 4361 bytes src/Bootstrap.test.tsx | 1 + src/Bootstrap.tsx | 3 ++- src/components/Room/Room.test.tsx | 1 + src/components/Room/Room.tsx | 37 +++++++++++++++++++++++++++++- src/components/Shell/Shell.tsx | 26 ++++++++++++++++++++- src/contexts/SettingsContext.ts | 1 + src/contexts/ShellContext.ts | 2 ++ src/models/settings.ts | 1 + src/pages/Settings/Settings.tsx | 29 ++++++++++++++++++++++- 10 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 public/sounds/new-message.aac diff --git a/public/sounds/new-message.aac b/public/sounds/new-message.aac new file mode 100644 index 0000000000000000000000000000000000000000..baea11418694925f1c05737488216f0958f2b240 GIT binary patch literal 4361 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 { expect(mockSetItem).toHaveBeenCalledWith(PersistedStorageKeys.USER_SETTINGS, { colorMode: 'dark', userId: 'abc123', + playSoundOnNewMessage: true, }) }) diff --git a/src/Bootstrap.tsx b/src/Bootstrap.tsx index e30dcd9..1cfba9c 100644 --- a/src/Bootstrap.tsx +++ b/src/Bootstrap.tsx @@ -33,6 +33,7 @@ function Bootstrap({ const [userSettings, setUserSettings] = useState({ userId: getUuid(), colorMode: 'dark', + playSoundOnNewMessage: true, }) const { userId } = userSettings @@ -54,7 +55,7 @@ function Bootstrap({ ) if (persistedUserSettings) { - setUserSettings(persistedUserSettings) + setUserSettings({ ...userSettings, ...persistedUserSettings }) } else { await persistedStorageProp.setItem( PersistedStorageKeys.USER_SETTINGS, diff --git a/src/components/Room/Room.test.tsx b/src/components/Room/Room.test.tsx index 2bc8f9d..3446b61 100644 --- a/src/components/Room/Room.test.tsx +++ b/src/components/Room/Room.test.tsx @@ -8,6 +8,7 @@ import { Room } from './' const mockUserId = 'user-id' const mockRoomId = 'room-123' +window.AudioContext = jest.fn().mockImplementation() const mockGetUuid = jest.fn() const mockMessagedSender = jest .fn() diff --git a/src/components/Room/Room.tsx b/src/components/Room/Room.tsx index 3a3d5bb..0253378 100644 --- a/src/components/Room/Room.tsx +++ b/src/components/Room/Room.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect, useState } from 'react' +import { useContext, useEffect, useRef, useState } from 'react' import { v4 as uuid } from 'uuid' import Box from '@mui/material/Box' import Divider from '@mui/material/Divider' @@ -6,6 +6,7 @@ import Divider from '@mui/material/Divider' import { rtcConfig } from 'config/rtcConfig' import { trackerUrls } from 'config/trackerUrls' import { ShellContext } from 'contexts/ShellContext' +import { SettingsContext } from 'contexts/SettingsContext' import { usePeerRoom, usePeerRoomAction } from 'hooks/usePeerRoom' import { PeerActions } from 'models/network' import { UnsentMessage, ReceivedMessage } from 'models/chat' @@ -27,10 +28,13 @@ export function Room({ }: RoomProps) { const [numberOfPeers, setNumberOfPeers] = useState(1) // Includes this peer const shellContext = useContext(ShellContext) + const settingsContext = useContext(SettingsContext) const [isMessageSending, setIsMessageSending] = useState(false) const [messageLog, setMessageLog] = useState< Array >([]) + const [audioContext] = useState(() => new AudioContext()) + const audioBufferContainer = useRef(null) const peerRoom = usePeerRoom( { @@ -41,6 +45,22 @@ export function Room({ roomId ) + useEffect(() => { + ;(async () => { + try { + const response = await fetch( + process.env.PUBLIC_URL + '/sounds/new-message.aac' + ) + const arrayBuffer = await response.arrayBuffer() + audioBufferContainer.current = await audioContext.decodeAudioData( + arrayBuffer + ) + } catch (e) { + console.error(e) + } + })() + }, [audioBufferContainer, audioContext]) + useEffect(() => { shellContext.setDoShowPeers(true) @@ -96,9 +116,24 @@ export function Room({ } receiveMessage(message => { + const userSettings = settingsContext.getUserSettings() + !shellContext.tabHasFocus && + userSettings.playSoundOnNewMessage && + playNewMessageSound() setMessageLog([...messageLog, { ...message, timeReceived: Date.now() }]) }) + const playNewMessageSound = () => { + if (!audioBufferContainer.current) { + console.error('Audio buffer not available') + return + } + const audioSource = audioContext.createBufferSource() + audioSource.buffer = audioBufferContainer.current + audioSource.connect(audioContext.destination) + audioSource.start() + } + const handleMessageSubmit = async (message: string) => { await performMessageSend(message) } diff --git a/src/components/Shell/Shell.tsx b/src/components/Shell/Shell.tsx index c2f4c0c..c1f28e1 100644 --- a/src/components/Shell/Shell.tsx +++ b/src/components/Shell/Shell.tsx @@ -36,6 +36,7 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { const [title, setTitle] = useState('') const [alertText, setAlertText] = useState('') const [numberOfPeers, setNumberOfPeers] = useState(1) + const [tabHasFocus, setTabHasFocus] = useState(true) const showAlert = useCallback< (message: string, options?: AlertOptions) => void @@ -48,12 +49,20 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { const shellContextValue = useMemo( () => ({ numberOfPeers, + tabHasFocus, setDoShowPeers, setNumberOfPeers, setTitle, showAlert, }), - [numberOfPeers, setDoShowPeers, setNumberOfPeers, setTitle, showAlert] + [ + numberOfPeers, + tabHasFocus, + setDoShowPeers, + setNumberOfPeers, + setTitle, + showAlert, + ] ) const colorMode = settingsContext.getUserSettings().colorMode @@ -83,6 +92,21 @@ export const Shell = ({ appNeedsUpdate, children, userPeerId }: ShellProps) => { document.title = title }, [title]) + useEffect(() => { + const handleFocus = () => { + setTabHasFocus(true) + } + const handleBlur = () => { + setTabHasFocus(false) + } + window.addEventListener('focus', handleFocus) + window.addEventListener('blur', handleBlur) + return () => { + window.removeEventListener('focus', handleFocus) + window.removeEventListener('blur', handleBlur) + } + }, []) + const handleDrawerOpen = () => { setIsDrawerOpen(true) } diff --git a/src/contexts/SettingsContext.ts b/src/contexts/SettingsContext.ts index 1afa9c9..0cda8a0 100644 --- a/src/contexts/SettingsContext.ts +++ b/src/contexts/SettingsContext.ts @@ -12,5 +12,6 @@ export const SettingsContext = createContext({ getUserSettings: () => ({ userId: '', colorMode: 'dark', + playSoundOnNewMessage: true, }), }) diff --git a/src/contexts/ShellContext.ts b/src/contexts/ShellContext.ts index 682369a..beedb52 100644 --- a/src/contexts/ShellContext.ts +++ b/src/contexts/ShellContext.ts @@ -4,6 +4,7 @@ import { AlertOptions } from 'models/shell' interface ShellContextProps { numberOfPeers: number + tabHasFocus: boolean setDoShowPeers: Dispatch> setNumberOfPeers: Dispatch> setTitle: Dispatch> @@ -12,6 +13,7 @@ interface ShellContextProps { export const ShellContext = createContext({ numberOfPeers: 1, + tabHasFocus: true, setDoShowPeers: () => {}, setNumberOfPeers: () => {}, setTitle: () => {}, diff --git a/src/models/settings.ts b/src/models/settings.ts index f30d084..ae32eb6 100644 --- a/src/models/settings.ts +++ b/src/models/settings.ts @@ -1,4 +1,5 @@ export interface UserSettings { colorMode: 'dark' | 'light' userId: string + playSoundOnNewMessage: boolean } diff --git a/src/pages/Settings/Settings.tsx b/src/pages/Settings/Settings.tsx index 1741ba4..588fa38 100644 --- a/src/pages/Settings/Settings.tsx +++ b/src/pages/Settings/Settings.tsx @@ -1,14 +1,16 @@ -import { useContext, useEffect, useState } from 'react' +import { ChangeEvent, useContext, useEffect, useState } from 'react' import Box from '@mui/material/Box' import Button from '@mui/material/Button' import Typography from '@mui/material/Typography' import Divider from '@mui/material/Divider' +import { Switch } from '@mui/material' import { ShellContext } from 'contexts/ShellContext' import { StorageContext } from 'contexts/StorageContext' import { PeerNameDisplay } from 'components/PeerNameDisplay' import { ConfirmDialog } from '../../components/ConfirmDialog' +import { SettingsContext } from '../../contexts/SettingsContext' interface SettingsProps { userId: string @@ -16,11 +18,13 @@ interface SettingsProps { export const Settings = ({ userId }: SettingsProps) => { const { setTitle } = useContext(ShellContext) + const { updateUserSettings, getUserSettings } = useContext(SettingsContext) const { getPersistedStorage } = useContext(StorageContext) const [ isDeleteSettingsConfirmDiaglogOpen, setIsDeleteSettingsConfirmDiaglogOpen, ] = useState(false) + const { playSoundOnNewMessage } = getUserSettings() const persistedStorage = getPersistedStorage() @@ -28,6 +32,13 @@ export const Settings = ({ userId }: SettingsProps) => { setTitle('Settings') }, [setTitle]) + const handlePlaySoundOnNewMessageChange = ( + _event: ChangeEvent, + value: boolean + ) => { + updateUserSettings({ playSoundOnNewMessage: value }) + } + const handleDeleteSettingsClick = () => { setIsDeleteSettingsConfirmDiaglogOpen(true) } @@ -43,6 +54,22 @@ export const Settings = ({ userId }: SettingsProps) => { return ( + ({ + fontSize: theme.typography.h3.fontSize, + fontWeight: theme.typography.fontWeightMedium, + mb: 2, + })} + > + Chat + + {' '} + Play a sound when a new message is received + ({