Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[AudioPlayer] Fix the touch-screen long-press behavior #3065

Merged
merged 10 commits into from
Apr 25, 2024
40 changes: 34 additions & 6 deletions src/components/Pronunciations/AudioPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -55,6 +62,9 @@ export default function AudioPlayer(props: PlayerProps): ReactElement {
);
const [anchor, setAnchor] = useState<HTMLElement | undefined>();
const [deleteConf, setDeleteConf] = useState(false);
const [longPressTarget, setLongPressTarget] = useState<
(EventTarget & HTMLButtonElement) | undefined
>();
const [speaker, setSpeaker] = useState<Speaker | undefined>();
const [speakerDialog, setSpeakerDialog] = useState(false);

Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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<HTMLButtonElement>): void {
function handleTouchStart(e: TouchEvent<HTMLButtonElement>): 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<void> {
if (canChangeSpeaker) {
await props.updateAudioSpeaker!(speaker?.id);
Expand Down Expand Up @@ -194,6 +221,7 @@ export default function AudioPlayer(props: PlayerProps): ReactElement {
return (
<>
<Tooltip
disableTouchListener // Conflicts with our long-press menu.
title={<MultilineTooltipTitle lines={tooltipTexts} />}
placement="top"
>
Expand All @@ -202,19 +230,19 @@ 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}
</IconButton>
</Tooltip>
<Menu
TransitionComponent={Fade}
id="play-menu"
id={playMenuId}
anchorEl={anchor}
open={Boolean(anchor)}
onClose={handleMenuOnClose}
Expand Down
6 changes: 5 additions & 1 deletion src/components/Pronunciations/RecorderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ export default function RecorderIcon(props: RecorderIconProps): ReactElement {
}

return (
<Tooltip title={t("pronunciations.recordTooltip")} placement="top">
<Tooltip
disableTouchListener // Distracting when already recording with a long-press.
placement="top"
title={t("pronunciations.recordTooltip")}
>
<IconButton
aria-label="record"
disabled={props.disabled}
Expand Down
153 changes: 153 additions & 0 deletions src/components/Pronunciations/tests/AudioPlayer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { type TouchEvent } from "react";
import { Provider } from "react-redux";
import { type ReactTestRenderer, act, create } from "react-test-renderer";
import configureMockStore from "redux-mock-store";

import { defaultState } from "components/App/DefaultState";
import AudioPlayer, {
longPressDelay,
playButtonId,
playMenuId,
} from "components/Pronunciations/AudioPlayer";
import { PronunciationsStatus } from "components/Pronunciations/Redux/PronunciationsReduxTypes";
import { type StoreState } from "types";
import { newPronunciation } from "types/word";

// Mock out Menu to avoid issues with setting its anchor.
jest.mock("@mui/material", () => {
return {
...jest.requireActual("@mui/material"),
Menu: (props: any) => <div {...props} />,
};
});

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<TouchEvent<HTMLButtonElement>> = {
currentTarget: {} as HTMLButtonElement,
};

function mockPlayingState(fileName = ""): Partial<StoreState> {
return {
...defaultState,
pronunciationsState: {
fileName,
status: PronunciationsStatus.Inactive,
wordId: "",
},
};
}

function renderAudioPlayer(canDelete = false): void {
act(() => {
testRenderer = create(
<Provider store={mockStore}>
<AudioPlayer
audio={mockPronunciation}
deleteAudio={canDelete ? mockCanDeleteAudio : undefined}
/>
</Provider>
);
});
}

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();
});
});
Loading