diff --git a/src/components/Pronunciations/AudioPlayer.tsx b/src/components/Pronunciations/AudioPlayer.tsx index 74a2ff3a9f..8c13def473 100644 --- a/src/components/Pronunciations/AudioPlayer.tsx +++ b/src/components/Pronunciations/AudioPlayer.tsx @@ -32,6 +32,13 @@ import { PronunciationsStatus } from "components/Pronunciations/Redux/Pronunciat import { StoreState } from "types"; import { useAppDispatch, useAppSelector } from "types/hooks"; +/** Number of ms for a touchscreen press to be considered a long-press. + * 600 ms is too short: it can still register as a click. */ +export const longPressDelay = 700; + +export const playButtonId = (fileName: string): string => `audio-${fileName}`; +export const playMenuId = "play-menu"; + interface PlayerProps { audio: Pronunciation; deleteAudio?: (fileName: string) => void; @@ -55,6 +62,9 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { ); const [anchor, setAnchor] = useState(); const [deleteConf, setDeleteConf] = useState(false); + const [longPressTarget, setLongPressTarget] = useState< + (EventTarget & HTMLButtonElement) | undefined + >(); const [speaker, setSpeaker] = useState(); const [speakerDialog, setSpeakerDialog] = useState(false); @@ -86,6 +96,17 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { } }, [audio, dispatchReset, isPlaying]); + // When pressed, set a timer for a long-press. + // https://stackoverflow.com/questions/48048957/add-a-long-press-event-in-react + useEffect(() => { + const timerId = longPressTarget + ? setTimeout(() => setAnchor(longPressTarget), longPressDelay) + : undefined; + return () => { + clearTimeout(timerId); + }; + }, [longPressTarget]); + function togglePlay(): void { if (!isPlaying) { dispatch(playing(props.audio.fileName)); @@ -125,15 +146,21 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { /** If audio can be deleted or speaker changed, a touchscreen press should open an * options menu instead of the context menu. */ - function handleTouch(e: TouchEvent): void { + function handleTouchStart(e: TouchEvent): void { if (canChangeSpeaker || canDeleteAudio) { // Temporarily disable context menu since some browsers // interpret a long-press touch as a right-click. disableContextMenu(); - setAnchor(e.currentTarget); + setLongPressTarget(e.currentTarget); } } + /** When a touch ends, restore the context menu and cancel the long-press timer. */ + function handleTouchEnd(): void { + enableContextMenu(); + setLongPressTarget(undefined); + } + async function handleOnSelect(speaker?: Speaker): Promise { if (canChangeSpeaker) { await props.updateAudioSpeaker!(speaker?.id); @@ -194,6 +221,7 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { return ( <> } placement="top" > @@ -202,11 +230,11 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { onAuxClick={handleOnAuxClick} onClick={deleteOrTogglePlay} onMouseDown={handleOnMouseDown} - onTouchStart={handleTouch} - onTouchEnd={enableContextMenu} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} aria-label="play" disabled={props.disabled} - id={`audio-${props.audio.fileName}`} + id={playButtonId(props.audio.fileName)} size={props.size || "large"} > {icon} @@ -214,7 +242,7 @@ export default function AudioPlayer(props: PlayerProps): ReactElement { + { + return { + ...jest.requireActual("@mui/material"), + Menu: (props: any) =>
, + }; +}); + +jest.mock("backend", () => ({ + getSpeaker: () => mockGetSpeaker(), +})); +jest.mock("types/hooks", () => { + return { + ...jest.requireActual("types/hooks"), + useAppDispatch: () => mockDispatch, + }; +}); + +const mockCanDeleteAudio = jest.fn(); +const mockDispatch = jest.fn((action: any) => action); +const mockGetSpeaker = jest.fn(); + +let testRenderer: ReactTestRenderer; + +const mockFileName = "speech.mp3"; +const mockId = playButtonId(mockFileName); +const mockPronunciation = newPronunciation(mockFileName); +const mockStore = configureMockStore()(mockPlayingState()); +const mockTouchEvent: Partial> = { + currentTarget: {} as HTMLButtonElement, +}; + +function mockPlayingState(fileName = ""): Partial { + return { + ...defaultState, + pronunciationsState: { + fileName, + status: PronunciationsStatus.Inactive, + wordId: "", + }, + }; +} + +function renderAudioPlayer(canDelete = false): void { + act(() => { + testRenderer = create( + + + + ); + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); +}); + +describe("Pronunciations", () => { + it("dispatches on play", () => { + renderAudioPlayer(); + expect(mockDispatch).not.toHaveBeenCalled(); + const playButton = testRenderer.root.findByProps({ id: mockId }); + act(() => { + playButton.props.onClick(); + }); + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + + it("opens the menu on long-press", () => { + // Provide deleteAudio prop so that menu is available + renderAudioPlayer(true); + + // Use a mock timer to control the length of the press + jest.useFakeTimers(); + + const playButton = testRenderer.root.findByProps({ id: mockId }); + const playMenu = testRenderer.root.findByProps({ id: playMenuId }); + + // Start a press and advance the timer just shy of the long-press time + expect(playMenu.props.open).toBeFalsy(); + act(() => { + playButton.props.onTouchStart(mockTouchEvent); + }); + expect(playMenu.props.open).toBeFalsy(); + act(() => { + jest.advanceTimersByTime(longPressDelay - 1); + }); + expect(playMenu.props.open).toBeFalsy(); + + // Advance the timer just past the long-press time + act(() => { + jest.advanceTimersByTime(2); + }); + expect(playMenu.props.open).toBeTruthy(); + + // Make sure the menu stays open and no play is dispatched + act(() => { + playButton.props.onTouchEnd(); + jest.runAllTimers(); + }); + expect(playMenu.props.open).toBeTruthy(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it("doesn't open the menu on short-press", () => { + // Provide deleteAudio prop so that menu is available + renderAudioPlayer(true); + + // Use a mock timer to control the length of the press + jest.useFakeTimers(); + + const playButton = testRenderer.root.findByProps({ id: mockId }); + const playMenu = testRenderer.root.findByProps({ id: playMenuId }); + + // Press the button and advance the timer, but end press before the long-press time + expect(playMenu.props.open).toBeFalsy(); + act(() => { + playButton.props.onTouchStart(mockTouchEvent); + }); + expect(playMenu.props.open).toBeFalsy(); + act(() => { + jest.advanceTimersByTime(longPressDelay - 1); + }); + expect(playMenu.props.open).toBeFalsy(); + act(() => { + playButton.props.onTouchEnd(); + }); + expect(playMenu.props.open).toBeFalsy(); + act(() => { + jest.advanceTimersByTime(2); + }); + expect(playMenu.props.open).toBeFalsy(); + }); +});