Skip to content

Commit

Permalink
feat: mute audio notifications
Browse files Browse the repository at this point in the history
Refs: #331
  • Loading branch information
GentlemanHal committed Nov 28, 2024
1 parent 56b3812 commit c367d12
Show file tree
Hide file tree
Showing 9 changed files with 157 additions and 18 deletions.
6 changes: 3 additions & 3 deletions src/client/common/AudioPlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ export function playAudio(
return audio.play()
}

export function stopAudio(src: string) {
export function stopAudio(src: string): void {
const audio = cachedAudio.get(src)
stop(audio)
}

export function stopAnyPlayingAudio() {
export function stopAnyPlayingAudio(): void {
for (const audio of cachedAudio.values()) {
stop(audio)
}
Expand All @@ -43,6 +43,6 @@ export function anyAudioPlaying(): boolean {
return false
}

export function deleteAudio(src: string) {
export function deleteAudio(src: string): void {
cachedAudio.delete(src)
}
10 changes: 10 additions & 0 deletions src/client/common/icons/Mute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { ReactElement, SVGProps } from 'react'
import { Icon } from './Icon'

export function Mute(props: SVGProps<SVGSVGElement>): ReactElement {
return (
<Icon {...props}>
<path d="M30 19.348V22h-2.652L24 18.652 20.652 22H18v-2.652L21.348 16 18 12.652V10h2.652L24 13.348 27.348 10H30v2.652L26.652 16 30 19.348zM13 30a1 1 0 0 1-.707-.293L4.586 22H1a1 1 0 0 1-1-1V11a1 1 0 0 1 1-1h3.586l7.707-7.707A1 1 0 0 1 14 3v26a1.002 1.002 0 0 1-1 1z" />
</Icon>
)
}
17 changes: 10 additions & 7 deletions src/client/help/KeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,19 +86,22 @@ export function KeyboardShortcuts({ searchQuery }: HelpProps): ReactElement {
</Shortcut>
</ul>

{screenfull.isEnabled && (
<>
<h3 className={styles.header}>Monitor page</h3>
<ul className={styles.shortcuts}>
<h3 className={styles.header}>Monitor page</h3>
<ul className={styles.shortcuts}>
<Shortcut label="Toggle mute audio notifications">
<Binding>space</Binding>
</Shortcut>
{screenfull.isEnabled && (
<>
<Shortcut label="Toggle fullscreen">
<Binding>f</Binding>
</Shortcut>
<Shortcut label="Exit fullscreen">
<Binding>esc</Binding>
</Shortcut>
</ul>
</>
)}
</>
)}
</ul>

<h3 className={styles.header}>Tracking page</h3>
<ul className={styles.shortcuts}>
Expand Down
76 changes: 73 additions & 3 deletions src/client/monitor/Monitor.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { screen, waitFor } from '@testing-library/react'
import { act, screen, waitFor } from '@testing-library/react'
import noop from 'lodash/noop'
import { Monitor } from './Monitor'
import { render, waitForLoadingToFinish } from '../testUtils/testHelpers'
Expand All @@ -17,6 +17,9 @@ import {
import { Prognosis } from '../domain/Project'
import * as NotificationsHook from './notifications/NotificationsHook'
import * as AudioPlayer from '../common/AudioPlayer'
import { notificationsRoot } from '../settings/notifications/NotificationsReducer'
import { personalSettingsRoot } from '../settings/PersonalSettingsReducer'
import { triggerShortcut } from '../common/Keyboard'

const outletContext = {
menusHidden: false,
Expand Down Expand Up @@ -56,6 +59,7 @@ it('should show a success message if there are no interesting projects', async (
prognosis: Prognosis.healthy,
}),
])
jest.spyOn(AudioPlayer, 'playAudio')
const state = {
[feedsRoot]: {
[feedId]: buildFeed({ trayId: feedId }),
Expand All @@ -69,6 +73,7 @@ it('should show a success message if there are no interesting projects', async (
await waitFor(() => {
expect(screen.getByText('some-success-message')).toBeInTheDocument()
})
expect(AudioPlayer.playAudio).not.toHaveBeenCalled()
})

it('should display an error if the Nevergreen server is having issues', async () => {
Expand Down Expand Up @@ -98,8 +103,6 @@ it('should display an error if the Nevergreen server is having issues', async ()

expect(screen.getByText('some-project')).toBeInTheDocument()

jest.advanceTimersToNextTimer()

await waitFor(
() => {
expect(screen.getByText('some-error')).toBeInTheDocument()
Expand Down Expand Up @@ -187,3 +190,70 @@ it('should trigger notifications and stop any audio notifications if user leaves

expect(AudioPlayer.stopAnyPlayingAudio).toHaveBeenCalled()
})

it('should allow muting audio notifications', async () => {
const feedId = 'some-tray-id'
jest
.spyOn(Gateway, 'post')
.mockResolvedValueOnce([
buildProjectApi({
trayId: feedId,
prognosis: Prognosis.sick,
description: 'some-project-sick',
}),
])
.mockResolvedValueOnce([
buildProjectApi({
trayId: feedId,
prognosis: Prognosis.healthy,
description: 'some-project-healthy',
}),
])
jest.spyOn(AudioPlayer, 'playAudio').mockResolvedValue()
jest.spyOn(AudioPlayer, 'stopAnyPlayingAudio')
const state = {
[feedsRoot]: {
[feedId]: buildFeed({ trayId: feedId }),
},
[personalSettingsRoot]: {
allowAudioNotifications: true,
},
[notificationsRoot]: {
notifications: {
[Prognosis.sick]: { sfx: 'sick-sfx', systemNotification: false },
[Prognosis.healthy]: { sfx: 'healthy-sfx', systemNotification: false },
},
},
[displaySettingsRoot]: {
refreshTime: 1,
showPrognosis: [Prognosis.sick, Prognosis.healthy],
},
}

render(<Monitor />, { state, outletContext })

await waitForLoadingToFinish()

expect(AudioPlayer.playAudio).toHaveBeenCalledWith(
'sick-sfx',
expect.any(Number),
)

// Trigger shortcut manually as I couldn't figure out how to get it working by firing key events :'(
act(() => {
triggerShortcut('space')
})

expect(AudioPlayer.stopAnyPlayingAudio).toHaveBeenCalled()

await waitFor(
() => {
expect(screen.getByText('some-project-healthy')).toBeInTheDocument()
},
{ timeout: 4000 },
)
expect(AudioPlayer.playAudio).not.toHaveBeenCalledWith(
'healthy-sfx',
expect.any(Number),
)
})
11 changes: 9 additions & 2 deletions src/client/monitor/Monitor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ReactElement } from 'react'
import { ReactElement, useState } from 'react'
import { useEffect, useRef } from 'react'
import cn from 'classnames'
import isEmpty from 'lodash/isEmpty'
Expand All @@ -15,12 +15,14 @@ import { useInterestingProjects } from './InterestingProjectsHook'
import { useNotifications } from './notifications/NotificationsHook'
import { stopAnyPlayingAudio } from '../common/AudioPlayer'
import { useAppSelector } from '../configuration/Hooks'
import { Mute } from '../common/icons/Mute'
import styles from './monitor.scss'

export function Monitor(): ReactElement {
const { menusHidden, toggleMenusHidden } = useNevergreenContext()
const feeds = useAppSelector(getFeeds)
const ref = useRef<HTMLDivElement>(null)
const [muted, setMuted] = useState(false)

useEffect(() => {
toggleMenusHidden(true)
Expand All @@ -32,7 +34,7 @@ export function Monitor(): ReactElement {
const feedsAdded = !isEmpty(feeds)

const { isLoading, projects, feedErrors } = useInterestingProjects()
useNotifications(projects, feedErrors)
useNotifications(projects, feedErrors, muted)

useEffect(() => {
return () => {
Expand All @@ -45,6 +47,10 @@ export function Monitor(): ReactElement {
void screenfull.toggle(ref.current)
}
})
useShortcut('space', () => {
stopAnyPlayingAudio()
setMuted((m) => !m)
})

const monitorClassNames = cn(styles.monitor, {
[styles.menusHidden]: menusHidden,
Expand All @@ -64,6 +70,7 @@ export function Monitor(): ReactElement {
<InterestingProjects projects={projects} feedErrors={feedErrors} />
</Loading>
)}
{muted && <Mute className={styles.mute} />}
</div>
)
}
Expand Down
13 changes: 13 additions & 0 deletions src/client/monitor/monitor.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
@use 'sass:color';
@use '../common/colours';
@use '../common/typography';

Expand All @@ -14,3 +15,15 @@
.menusHidden {
cursor: none;
}

.mute {
background: color.change(colours.$off-black, $alpha: 0.5);
border-radius: 1em;
bottom: 1em;
color: color.change(colours.$off-white, $alpha: 0.75);
font-size: 2em;
margin: 0;
outline: 0.3em solid color.change(colours.$off-black, $alpha: 0.5);
position: absolute;
right: 1em;
}
4 changes: 3 additions & 1 deletion src/client/monitor/notifications/AudioNotificationsHook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ import { useAppSelector } from '../../configuration/Hooks'
export function useAudioNotifications(
projects: Projects,
feedErrors: FeedErrors,
muted: boolean,
): void {
const notifications = useAppSelector(getNotifications)
const allowAudioNotifications = useAppSelector(getAllowAudioNotifications)
const audioNotificationVolume = useAppSelector(getAudioNotificationVolume)

useEffect(() => {
if (!allowAudioNotifications || anyAudioPlaying()) {
if (!allowAudioNotifications || anyAudioPlaying() || muted) {
return
}

Expand Down Expand Up @@ -53,5 +54,6 @@ export function useAudioNotifications(
notifications,
allowAudioNotifications,
audioNotificationVolume,
muted,
])
}
35 changes: 34 additions & 1 deletion src/client/monitor/notifications/NotificationsHook.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ interface PrognosisTest {
function HookWrapper({
projects,
errors,
mute = false,
}: {
projects: Projects
errors: FeedErrors
mute?: boolean
}) {
useNotifications(projects, errors)
useNotifications(projects, errors, mute)
return <div />
}

Expand Down Expand Up @@ -278,6 +280,37 @@ describe('audio notifications', () => {
},
)

it('should not play notification if muted', () => {
const state = {
[notificationsRoot]: {
notifications: {
[Prognosis.healthyBuilding]: {
systemNotification: false,
sfx: 'some-sfx.mp3',
},
},
},
[personalSettingsRoot]: {
allowAudioNotifications: true,
},
}
const projects = [
buildProject({
projectId: 'some-id',
description: 'some-name',
previousPrognosis: Prognosis.healthy,
prognosis: Prognosis.healthyBuilding,
}),
]
const errors: FeedErrors = []

render(<HookWrapper projects={projects} errors={errors} mute={true} />, {
state,
})

expect(AudioPlayer.playAudio).not.toHaveBeenCalled()
})

it('should only play one notification at a time even if multiple projects transition to valid prognosis', () => {
const state = {
[notificationsRoot]: {
Expand Down
3 changes: 2 additions & 1 deletion src/client/monitor/notifications/NotificationsHook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ export function recentlyTransitioned(
export function useNotifications(
projects: Projects,
feedErrors: FeedErrors,
muted: boolean,
): void {
useBrowserTitleSummary(projects, feedErrors)
useAudioNotifications(projects, feedErrors)
useAudioNotifications(projects, feedErrors, muted)
useSystemNotifications(projects, feedErrors)
}

0 comments on commit c367d12

Please sign in to comment.