diff --git a/public/sounds/new-message.aac b/public/sounds/new-message.aac new file mode 100644 index 0000000..baea114 Binary files /dev/null and b/public/sounds/new-message.aac differ diff --git a/src/Bootstrap.test.tsx b/src/Bootstrap.test.tsx index f1c5a1e..effc0dc 100644 --- a/src/Bootstrap.test.tsx +++ b/src/Bootstrap.test.tsx @@ -54,6 +54,7 @@ test('persists user settings if none were already persisted', async () => { 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 + ({