Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 8 additions & 21 deletions src/app/components/upload-board/UploadBoard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { MutableRefObject, ReactNode } from 'react';
import { useEffect, useImperativeHandle, useRef } from 'react';
import { useEffect, useImperativeHandle } from 'react';
import { Badge, Box, Chip, Header, Spinner, Text, as, percent } from 'folds';
import { CaretRight, CaretUp, X, sizedIcon } from '$components/icons/phosphor';
import classNames from 'classnames';
Expand Down Expand Up @@ -27,14 +27,15 @@ export const UploadBoard = as<'div', UploadBoardProps>(({ header, children, ...p
</Box>
));

export type UploadBoardImperativeHandlers = { handleSend: () => Promise<void> };
// Progress ticks re-render this header, so the caller reads uploads on demand here
// instead of subscribing to them itself.
export type UploadBoardImperativeHandlers = { getSendableUploads: () => Upload[] };

type UploadBoardHeaderProps = {
open: boolean;
onToggle: () => void;
uploadFamilyObserverAtom: TUploadFamilyObserverAtom;
onCancel: (uploads: Upload[]) => void;
onSend: (uploads: Upload[]) => Promise<void>;
onBusyChange?: (busy: boolean) => void;
imperativeHandlerRef: MutableRefObject<UploadBoardImperativeHandlers | undefined>;
};
Expand All @@ -44,11 +45,9 @@ export function UploadBoardHeader({
onToggle,
uploadFamilyObserverAtom,
onCancel,
onSend,
onBusyChange,
imperativeHandlerRef,
}: UploadBoardHeaderProps) {
const sendingRef = useRef(false);
const uploads = useAtomValue(uploadFamilyObserverAtom);

const isSuccess = uploads.every((upload) => upload.status === UploadStatus.Success);
Expand All @@ -72,23 +71,11 @@ export function UploadBoardHeader({
{ loaded: 0, total: 0 }
);

const handleSend = async () => {
if (sendingRef.current) return;
sendingRef.current = true;
try {
await onSend(
uploads.filter(
(upload) =>
upload.status === UploadStatus.Success || upload.status === UploadStatus.Loading
)
);
} finally {
sendingRef.current = false;
}
};

useImperativeHandle(imperativeHandlerRef, () => ({
handleSend,
getSendableUploads: () =>
uploads.filter(
(upload) => upload.status === UploadStatus.Success || upload.status === UploadStatus.Loading
),
}));
const handleCancel = () => onCancel(uploads);

Expand Down
107 changes: 107 additions & 0 deletions src/app/features/room/AudioMessageRecorder.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/* oxlint-disable typescript/no-explicit-any, vitest/require-mock-type-parameters */

import { act, render } from '@testing-library/react';
import { createRef } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AudioMessageRecorder, type AudioMessageRecorderHandle } from './AudioMessageRecorder';

const recorderState = vi.hoisted(() => ({
handleStop: vi.fn(),
handleDelete: vi.fn(),
onStop: undefined as ((payload: any) => void) | undefined,
onDelete: undefined as (() => void) | undefined,
}));

vi.mock('$plugins/voice-recorder-kit', () => ({
useVoiceRecorder: ({ onStop, onDelete }: any) => {
recorderState.onStop = onStop;
recorderState.onDelete = onDelete;
return {
levels: [],
seconds: 0,
error: undefined,
handleStop: recorderState.handleStop,
handleDelete: recorderState.handleDelete,
};
},
}));
vi.mock('$hooks/useElementSizeObserver', () => ({ useElementSizeObserver: () => {} }));
vi.mock('folds', () => ({
Box: ({ children }: any) => <div>{children}</div>,
Text: ({ children }: any) => <span>{children}</span>,
}));

const payload = {
audioFile: new Blob(['audio'], { type: 'audio/ogg' }),
waveform: [0.5],
audioLength: 1,
audioCodec: 'audio/ogg',
};

function renderRecorder() {
const ref = createRef<AudioMessageRecorderHandle>();
const onRecordingComplete = vi.fn();
const result = render(
<AudioMessageRecorder
ref={ref}
onRecordingComplete={onRecordingComplete}
onRequestClose={vi.fn()}
onWaveformUpdate={vi.fn()}
onAudioLengthUpdate={vi.fn()}
/>
);
return { ref, onRecordingComplete, result };
}

beforeEach(() => {
vi.useFakeTimers();
recorderState.handleStop.mockReset();
recorderState.handleDelete.mockReset();
recorderState.onStop = undefined;
recorderState.onDelete = undefined;
});

afterEach(() => {
vi.useRealTimers();
});

describe('AudioMessageRecorder lifecycle', () => {
it('makes stop idempotent and surfaces one completion', () => {
const { ref, onRecordingComplete } = renderRecorder();

act(() => {
ref.current?.stop();
ref.current?.stop();
recorderState.onStop?.(payload);
recorderState.onStop?.(payload);
});

expect(recorderState.handleStop).toHaveBeenCalledOnce();
expect(onRecordingComplete).toHaveBeenCalledOnce();
});

it('makes delayed cancel idempotent and cancels its timer on unmount', () => {
const { ref, result } = renderRecorder();

act(() => {
ref.current?.cancel();
ref.current?.cancel();
vi.advanceTimersByTime(180);
});
expect(recorderState.handleDelete).toHaveBeenCalledOnce();

result.unmount();
act(() => vi.runOnlyPendingTimers());
expect(recorderState.handleDelete).toHaveBeenCalledOnce();
});

it('does not delete after cancel is followed by unmount before the delay', () => {
const { ref, result } = renderRecorder();

act(() => ref.current?.cancel());
result.unmount();
act(() => vi.advanceTimersByTime(180));

expect(recorderState.handleDelete).not.toHaveBeenCalled();
});
});
34 changes: 26 additions & 8 deletions src/app/features/room/AudioMessageRecorder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,9 @@ export const AudioMessageRecorder = forwardRef<
AudioMessageRecorderHandle,
AudioMessageRecorderProps
>(({ onRecordingComplete, onRequestClose, onWaveformUpdate, onAudioLengthUpdate }, ref) => {
const isDismissedRef = useRef(false);
const userRequestedStopRef = useRef(false);
const actionRef = useRef<'active' | 'stopping' | 'canceling' | 'dismissed'>('active');
const cancelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isCanceling, setIsCanceling] = useState(false);
const [announcedTime, setAnnouncedTime] = useState(0);
Expand All @@ -67,8 +68,8 @@ export const AudioMessageRecorder = forwardRef<
const stableOnStop = useCallback((payload: VoiceRecorderStopPayload) => {
// useVoiceRecorder also stops during cancel/teardown paths, so only surface a completed
// recording after an explicit user stop.
if (!userRequestedStopRef.current) return;
if (isDismissedRef.current) return;
if (!userRequestedStopRef.current || actionRef.current !== 'stopping') return;
actionRef.current = 'dismissed';
onRecordingCompleteRef.current({
audioBlob: payload.audioFile,
waveform: payload.waveform,
Expand All @@ -80,7 +81,11 @@ export const AudioMessageRecorder = forwardRef<
}, []);

const stableOnDelete = useCallback(() => {
isDismissedRef.current = true;
if (cancelTimerRef.current !== null) {
clearTimeout(cancelTimerRef.current);
cancelTimerRef.current = null;
}
actionRef.current = 'dismissed';
onRequestCloseRef.current();
}, []);

Expand All @@ -91,22 +96,35 @@ export const AudioMessageRecorder = forwardRef<
});

const doStop = useCallback(() => {
if (isDismissedRef.current) return;
if (actionRef.current !== 'active') return;
actionRef.current = 'stopping';
userRequestedStopRef.current = true;
handleStop();
}, [handleStop]);

const doCancel = useCallback(() => {
if (isDismissedRef.current) return;
if (actionRef.current !== 'active') return;
actionRef.current = 'canceling';
setIsCanceling(true);
setTimeout(() => {
isDismissedRef.current = true;
cancelTimerRef.current = setTimeout(() => {
cancelTimerRef.current = null;
if (actionRef.current !== 'canceling') return;
actionRef.current = 'dismissed';
handleDelete();
}, 180);
}, [handleDelete]);

useImperativeHandle(ref, () => ({ stop: doStop, cancel: doCancel }), [doStop, doCancel]);

useEffect(
() => () => {
if (cancelTimerRef.current !== null) clearTimeout(cancelTimerRef.current);
cancelTimerRef.current = null;
actionRef.current = 'dismissed';
},
[]
);

useEffect(() => {
if (seconds > 0 && seconds % 30 === 0 && seconds !== announcedTime) {
setAnnouncedTime(seconds);
Expand Down
Loading
Loading