From a4aa54c7d28721a3f58b47158085467d3056f87d Mon Sep 17 00:00:00 2001 From: "D. Ror." Date: Tue, 5 Nov 2024 16:11:42 -0500 Subject: [PATCH] Prevent rapid-click audio recorder bugs (#3414) --- .../Pronunciations/AudioRecorder.tsx | 26 +++++++++++++++---- .../Pronunciations/RecorderIcon.tsx | 9 ++++--- .../tests/RecorderIcon.test.tsx | 2 +- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/components/Pronunciations/AudioRecorder.tsx b/src/components/Pronunciations/AudioRecorder.tsx index 981e949d27..2e8af49740 100644 --- a/src/components/Pronunciations/AudioRecorder.tsx +++ b/src/components/Pronunciations/AudioRecorder.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useContext } from "react"; +import { ReactElement, useContext, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "react-toastify"; @@ -22,15 +22,28 @@ export default function AudioRecorder(props: RecorderProps): ReactElement { (state: StoreState) => state.currentProjectState.speaker?.id ); const recorder = useContext(RecorderContext); + const [clicked, setClicked] = useState(false); const { t } = useTranslation(); - async function startRecording(): Promise { + useEffect(() => { + // Re-enable clicking when the word id has changed + setClicked(false); + }, [props.id]); + + async function startRecording(): Promise { + if (clicked) { + // Prevent recording again before this word has updated. + return false; + } + const recordingId = recorder.getRecordingId(); if (recordingId && recordingId !== props.id) { // Prevent interfering with an active recording on a different entry. - return; + return false; } + setClicked(true); + // Prevent starting a recording before a previous one is finished. await stopRecording(); @@ -40,10 +53,12 @@ export default function AudioRecorder(props: RecorderProps): ReactElement { errorMessage += ` ${t("pronunciations.recordingPermission")}`; } toast.error(errorMessage); + return false; } + return true; } - async function stopRecording(): Promise { + async function stopRecording(): Promise { // Prevent triggering this function if no recording is active. if (recorder.getRecordingId() === undefined) { return; @@ -53,8 +68,9 @@ export default function AudioRecorder(props: RecorderProps): ReactElement { props.onClick(); } const file = await recorder.stopRecording(); - if (!file) { + if (!file || !file.size) { toast.error(t("pronunciations.recordingError")); + setClicked(false); return; } if (!props.noSpeaker) { diff --git a/src/components/Pronunciations/RecorderIcon.tsx b/src/components/Pronunciations/RecorderIcon.tsx index 552c3e71d1..22899f1cd0 100644 --- a/src/components/Pronunciations/RecorderIcon.tsx +++ b/src/components/Pronunciations/RecorderIcon.tsx @@ -19,7 +19,7 @@ export const recordIconId = "recordingIcon"; interface RecorderIconProps { disabled?: boolean; id: string; - startRecording: () => void; + startRecording: () => Promise; stopRecording: () => void; } @@ -41,11 +41,12 @@ export default function RecorderIcon(props: RecorderIconProps): ReactElement { checkMicPermission().then(setHasMic); }, []); - function toggleIsRecordingToTrue(): void { + async function toggleIsRecordingToTrue(): Promise { if (!isRecording) { // Only start a recording if there's not another on in progress. - dispatch(recording(props.id)); - props.startRecording(); + if (await props.startRecording()) { + dispatch(recording(props.id)); + } } else { // This triggers if user clicks-and-holds on one entry's record icon, // drags the mouse outside that icon before releasing, diff --git a/src/components/Pronunciations/tests/RecorderIcon.test.tsx b/src/components/Pronunciations/tests/RecorderIcon.test.tsx index 6fda104dac..03834390a4 100644 --- a/src/components/Pronunciations/tests/RecorderIcon.test.tsx +++ b/src/components/Pronunciations/tests/RecorderIcon.test.tsx @@ -31,7 +31,7 @@ function mockRecordingState(wordId: string): Partial { const mockWordId = "1234567890"; -const mockStartRecording = jest.fn(); +const mockStartRecording = jest.fn(() => Promise.resolve(true)); const mockStopRecording = jest.fn(); const renderRecorderIcon = async (wordId = ""): Promise => {