Skip to content

Commit 1dff940

Browse files
Improve recording button state management (#4180)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Danny Rorabaugh <imnasnainaec@gmail.com>
1 parent bd576b9 commit 1dff940

File tree

8 files changed

+260
-131
lines changed

8 files changed

+260
-131
lines changed

src/components/DataEntry/DataEntryTable/NewEntry/index.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ export default function NewEntry(props: NewEntryProps): ReactElement {
117117
[glossInput, vernInput]
118118
);
119119

120+
const focusGloss = useCallback((): void => {
121+
focus(FocusTarget.Gloss);
122+
}, [focus]);
123+
120124
const resetState = useCallback((): void => {
121125
resetNewEntry();
122126
setSubmitting(false);
@@ -277,7 +281,7 @@ export default function NewEntry(props: NewEntryProps): ReactElement {
277281
<NoteButton
278282
buttonId={NewEntryId.ButtonNote}
279283
noteText={submitting ? "" : newNote}
280-
onExited={() => focus(FocusTarget.Gloss)}
284+
onExited={focusGloss}
281285
updateNote={setNewNote}
282286
/>
283287
)}
@@ -289,7 +293,7 @@ export default function NewEntry(props: NewEntryProps): ReactElement {
289293
deleteAudio={delNewAudio}
290294
replaceAudio={repNewAudio}
291295
uploadAudio={addNewAudio}
292-
onClick={() => focus(FocusTarget.Gloss)}
296+
onClick={focusGloss}
293297
/>
294298
</Grid2>
295299

src/components/ProjectSettings/tests/ProjectName.test.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
import "@testing-library/jest-dom";
22
import { act, render, screen } from "@testing-library/react";
33
import userEvent from "@testing-library/user-event";
4+
import { toast } from "react-toastify"; // mocked in setupTests.js
45

56
import ProjectName from "components/ProjectSettings/ProjectName";
67
import { randomProject } from "types/project";
78

8-
jest.mock("react-toastify", () => ({
9-
toast: { error: () => mockToastError() },
10-
}));
11-
12-
const mockToastError = jest.fn();
139
const mockSetProject = jest.fn();
1410

1511
const mockProject = randomProject();
@@ -31,7 +27,7 @@ describe("ProjectName", () => {
3127
mockSetProject.mockResolvedValueOnce({});
3228
await userEvent.click(saveButton);
3329
expect(mockSetProject).toHaveBeenCalledWith({ ...mockProject, name });
34-
expect(mockToastError).not.toHaveBeenCalled();
30+
expect(toast.error).not.toHaveBeenCalled();
3531
});
3632

3733
it("toasts on error", async () => {
@@ -41,8 +37,8 @@ describe("ProjectName", () => {
4137
await userEvent.clear(textField);
4238
await userEvent.type(textField, "whatever");
4339
mockSetProject.mockRejectedValueOnce({});
44-
expect(mockToastError).not.toHaveBeenCalled();
40+
expect(toast.error).not.toHaveBeenCalled();
4541
await userEvent.click(saveButton);
46-
expect(mockToastError).toHaveBeenCalledTimes(1);
42+
expect(toast.error).toHaveBeenCalledTimes(1);
4743
});
4844
});

src/components/Pronunciations/AudioRecorder.tsx

Lines changed: 73 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { ReactElement, useContext, useEffect, useState } from "react";
1+
import {
2+
ReactElement,
3+
useCallback,
4+
useContext,
5+
useEffect,
6+
useRef,
7+
} from "react";
28
import { useTranslation } from "react-i18next";
39
import { toast } from "react-toastify";
410

@@ -23,16 +29,57 @@ export default function AudioRecorder(props: RecorderProps): ReactElement {
2329
(state: StoreState) => state.currentProjectState.speaker?.id
2430
);
2531
const recorder = useContext(RecorderContext);
26-
const [clicked, setClicked] = useState(false);
32+
const clickedRef = useRef(false);
2733
const { t } = useTranslation();
2834

2935
useEffect(() => {
3036
// Re-enable clicking when the word id has changed
31-
setClicked(false);
37+
clickedRef.current = false;
3238
}, [props.id]);
3339

34-
async function startRecording(): Promise<boolean> {
35-
if (clicked) {
40+
const uploadAudio = useCallback(
41+
(file?: File): boolean => {
42+
if (!file?.size) {
43+
console.error("No audio file to upload");
44+
toast.error(t("pronunciations.recordingError"));
45+
return false;
46+
}
47+
48+
try {
49+
if (!props.noSpeaker) {
50+
(file as FileWithSpeakerId).speakerId = speakerId;
51+
}
52+
props.uploadAudio(file);
53+
return true;
54+
} catch (err) {
55+
console.error("Error uploading audio:", err);
56+
toast.error(t("pronunciations.recordingError"));
57+
return false;
58+
}
59+
},
60+
[props.noSpeaker, props.uploadAudio, speakerId, t]
61+
);
62+
63+
const stopRecording = useCallback(async (): Promise<void> => {
64+
// Prevent triggering this function if no recording is active.
65+
if (recorder.getRecordingId() === undefined) {
66+
return;
67+
}
68+
69+
props.onClick?.();
70+
71+
const uploadSuccess = uploadAudio(await recorder.stopRecording());
72+
73+
if (uploadSuccess && props.id) {
74+
// The id will change when the frontend updates with the new audio,
75+
// so we rely on a useEffect above to reset the clickedRef.
76+
} else {
77+
clickedRef.current = false;
78+
}
79+
}, [props.id, props.onClick, recorder, uploadAudio]);
80+
81+
const startRecording = useCallback(async (): Promise<boolean> => {
82+
if (clickedRef.current) {
3683
// Prevent recording again before this word has updated.
3784
return false;
3885
}
@@ -43,48 +90,33 @@ export default function AudioRecorder(props: RecorderProps): ReactElement {
4390
return false;
4491
}
4592

46-
setClicked(true);
93+
clickedRef.current = true;
4794

48-
// Prevent starting a recording before a previous one is finished.
49-
await stopRecording();
95+
try {
96+
// Prevent starting a recording before a previous one is finished.
97+
await stopRecording();
5098

51-
if (!recorder.startRecording(props.id)) {
52-
let errorMessage = t("pronunciations.recordingError");
53-
if (isBrowserFirefox()) {
54-
errorMessage += ` ${t("pronunciations.recordingPermission")}`;
99+
if (!recorder.startRecording(props.id)) {
100+
let errorMessage = t("pronunciations.recordingError");
101+
if (isBrowserFirefox()) {
102+
errorMessage += ` ${t("pronunciations.recordingPermission")}`;
103+
}
104+
toast.error(errorMessage);
105+
clickedRef.current = false;
106+
return false;
55107
}
56-
toast.error(errorMessage);
108+
} catch (err) {
109+
console.error("Error starting recording:", err);
110+
toast.error(t("pronunciations.recordingError"));
111+
clickedRef.current = false;
57112
return false;
58113
}
59-
return true;
60-
}
61-
62-
async function stopRecording(): Promise<void> {
63-
// Prevent triggering this function if no recording is active.
64-
if (recorder.getRecordingId() === undefined) {
65-
return;
66-
}
67114

68-
if (props.onClick) {
69-
props.onClick();
70-
}
71-
const file = await recorder.stopRecording();
72-
if (!file || !file.size) {
73-
toast.error(t("pronunciations.recordingError"));
74-
setClicked(false);
75-
return;
76-
}
77-
if (!props.noSpeaker) {
78-
(file as FileWithSpeakerId).speakerId = speakerId;
79-
}
80-
props.uploadAudio(file);
81-
if (!props.id) {
82-
// If recorder is on something with an id,
83-
// that id will update after the upload is complete,
84-
// so rely on the useEffect above to do this.
85-
setClicked(false);
86-
}
87-
}
115+
// Don't set clickedRef to false here;
116+
// It will be reset when the id changes after a successful upload,
117+
// or in the catch block if there's an error.
118+
return true;
119+
}, [props.id, recorder, stopRecording, t]);
88120

89121
return (
90122
<RecorderIcon
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { enqueueSnackbar } from "notistack";
22
import { createContext } from "react";
3+
import { getI18n } from "react-i18next";
34

45
import Recorder from "components/Pronunciations/Recorder";
5-
import i18n from "i18n";
66

77
const RecorderContext = createContext(
8-
new Recorder((textId: string) => enqueueSnackbar(i18n.t(textId)))
8+
new Recorder((textId: string) =>
9+
enqueueSnackbar(getI18n()?.t(textId) ?? textId)
10+
)
911
);
1012

1113
export default RecorderContext;

src/components/Pronunciations/RecorderIcon.tsx

Lines changed: 37 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { FiberManualRecord } from "@mui/icons-material";
22
import { IconButton, Tooltip } from "@mui/material";
3-
import { ReactElement, useEffect, useState } from "react";
3+
import { ReactElement, useEffect, useRef, useState } from "react";
44
import { useTranslation } from "react-i18next";
55

66
import {
@@ -30,56 +30,46 @@ export default function RecorderIcon(props: RecorderIconProps): ReactElement {
3030
const recordingId = useAppSelector(
3131
(state: StoreState) => state.pronunciationsState.wordId
3232
);
33-
const isRecordingThis = isRecording && recordingId === props.id;
3433

3534
const dispatch = useAppDispatch();
3635
const [hasMic, setHasMic] = useState(false);
3736
const { t } = useTranslation();
3837

39-
useEffect(() => {
40-
checkMicPermission().then(setHasMic);
41-
}, []);
38+
// Use refs to for the useEffect cleanup.
39+
const stopRef = useRef<(() => void) | undefined>();
4240

43-
async function toggleIsRecordingToTrue(): Promise<void> {
44-
if (!isRecording) {
45-
// Only start a recording if there's not another on in progress.
46-
if (await props.startRecording()) {
47-
dispatch(recording(props.id));
48-
}
49-
} else {
50-
// This triggers if user clicks-and-holds on one entry's record icon,
51-
// drags the mouse outside that icon before releasing,
52-
// then clicks-and-holds a different entry's record icon.
53-
if (recordingId !== props.id) {
54-
console.error(
55-
"Tried to record for an entry before finishing a recording on another entry."
56-
);
57-
}
58-
}
59-
}
60-
function toggleIsRecordingToFalse(): void {
41+
const isRecordingThis = isRecording && recordingId === props.id;
42+
const disabled =
43+
props.disabled || !hasMic || (isRecording && !isRecordingThis);
44+
45+
const stop = (): void => {
6146
if (isRecordingThis) {
6247
props.stopRecording();
6348
dispatch(resetPronunciations());
6449
}
65-
}
50+
};
51+
stopRef.current = stop;
6652

67-
function handleTouchStart(): void {
68-
// Temporarily disable context menu since some browsers
69-
// interpret a long-press touch as a right-click.
70-
document.addEventListener("contextmenu", disableContextMenu, false);
71-
}
72-
function handleTouchEnd(): void {
73-
enableContextMenu();
74-
}
53+
useEffect(() => {
54+
checkMicPermission().then(setHasMic);
55+
56+
return () => {
57+
// Reset recording state if this component unmounts while recording
58+
// (e.g., navigating away from the page mid-recording).
59+
stopRef.current?.();
60+
};
61+
}, []);
7562

76-
function disableContextMenu(event: any): void {
77-
event.preventDefault();
78-
enableContextMenu();
79-
}
80-
function enableContextMenu(): void {
81-
document.removeEventListener("contextmenu", disableContextMenu, false);
82-
}
63+
const start = async (): Promise<void> => {
64+
if (isRecording) {
65+
return;
66+
}
67+
68+
// Only start a recording if there's not another one in progress.
69+
if (await props.startRecording()) {
70+
dispatch(recording(props.id));
71+
}
72+
};
8373

8474
const tooltipId = hasMic
8575
? "pronunciations.recordTooltip"
@@ -89,26 +79,26 @@ export default function RecorderIcon(props: RecorderIconProps): ReactElement {
8979
<Tooltip
9080
disableTouchListener // Distracting when already recording with a long-press.
9181
placement="top"
92-
title={!props.disabled && t(tooltipId)}
82+
title={disabled ? undefined : t(tooltipId)}
9383
>
9484
<span>
9585
<IconButton
9686
aria-label="record"
9787
data-testid={recordButtonId}
98-
disabled={props.disabled || !hasMic}
88+
disabled={disabled}
9989
id={recordButtonId}
100-
onBlur={toggleIsRecordingToFalse}
101-
onPointerDown={toggleIsRecordingToTrue}
102-
onPointerUp={toggleIsRecordingToFalse}
103-
onTouchEnd={handleTouchEnd}
104-
onTouchStart={handleTouchStart}
90+
onBlur={stop}
91+
onContextMenu={(e) => e.preventDefault()}
92+
onPointerCancel={stop}
93+
onPointerDown={start}
94+
onPointerUp={stop}
10595
size="large"
10696
tabIndex={-1}
10797
>
10898
<FiberManualRecord
10999
sx={{
110100
color: (t) =>
111-
props.disabled || !hasMic
101+
disabled
112102
? t.palette.grey[400]
113103
: isRecordingThis
114104
? themeColors.recordActive

0 commit comments

Comments
 (0)