On phones the composer's 7-8 secondary action buttons wrapped into a tall
multi-row stack ("massive height"). Mobile now shows a single compact row —
[ + | input | emoji | send ] — where "+" toggles a collapsible row (above the
formatting toolbar) holding attach, GIF, location, poll, voice, formatting and
schedule. Desktop is unchanged (isMobile === false; the mobile branches are
never entered and composerOverflow stays null).
The after-builder stashes the collapsed buttons in a render-local `let` that
the bottom slot reads; safe because JSX props evaluate in source order within
one render (verified by review). Emoji/Send stay inline; the emoji and GIF
PopOut anchors still resolve wherever their button renders.
Review fixes folded in: the "+" toggle uses aria-expanded + aria-controls
(dropped the redundant aria-pressed) pointing at the labelled role="group"
overflow row; the voice recorder's idle mic button gets the @media-gated
MobileTouchTarget 44px target so the overflow row is uniformly tappable.
Two review agents (correctness + UX/a11y); gate-green (tsc, eslint, prettier,
914 tests, build). Visual confirmation still wants a real device per
LOTUS_TESTING.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { Box, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
|
import { useSetting } from '../state/hooks/settings';
|
|
import { settingsAtom } from '../state/settings';
|
|
import { MobileTouchTarget } from '../styles/mobile.css';
|
|
|
|
type RecorderState = 'idle' | 'recording' | 'paused' | 'preview';
|
|
|
|
interface VoiceRecorderProps {
|
|
onSend: (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => void;
|
|
onError?: (err: string) => void;
|
|
}
|
|
|
|
function formatDuration(ms: number): string {
|
|
const totalSec = Math.floor(ms / 1000);
|
|
const m = Math.floor(totalSec / 60);
|
|
const s = totalSec % 60;
|
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function normalizeWaveform(samples: number[]): number[] {
|
|
if (samples.length === 0) return Array(20).fill(0);
|
|
const max = Math.max(...samples, 1);
|
|
const count = Math.min(samples.length, 100);
|
|
const step = samples.length / count;
|
|
const result: number[] = [];
|
|
for (let i = 0; i < count; i += 1) {
|
|
const idx = Math.floor(i * step);
|
|
result.push(Math.round((samples[idx] / max) * 1024));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const WAVEFORM_BARS = 40;
|
|
|
|
export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
|
const [state, setState] = useState<RecorderState>('idle');
|
|
const [durationMs, setDurationMs] = useState(0);
|
|
const [waveformBars, setWaveformBars] = useState<number[]>(Array(WAVEFORM_BARS).fill(0));
|
|
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null);
|
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
|
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
const streamRef = useRef<MediaStream | null>(null);
|
|
const chunksRef = useRef<Blob[]>([]);
|
|
const analyserRef = useRef<AnalyserNode | null>(null);
|
|
const audioCtxRef = useRef<AudioContext | null>(null);
|
|
const rawSamplesRef = useRef<number[]>([]);
|
|
// Active-recording duration excluding paused time: accumulated ms from prior
|
|
// segments + (now - segmentStart) for the current segment.
|
|
const accumulatedMsRef = useRef<number>(0);
|
|
const segmentStartRef = useRef<number>(0);
|
|
const animFrameRef = useRef<number>(0);
|
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const previewMimeRef = useRef('audio/ogg;codecs=opus');
|
|
const previewDurationRef = useRef(0);
|
|
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
|
|
const [previewPlaying, setPreviewPlaying] = useState(false);
|
|
|
|
// Start the waveform (rAF) + duration (interval) meters against the live
|
|
// analyser. Reused by startRecording and resumeRecording.
|
|
const startMeters = useCallback(() => {
|
|
const analyser = analyserRef.current;
|
|
if (!analyser) return;
|
|
// Guard against ever running two loops.
|
|
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
const buf = new Uint8Array(analyser.frequencyBinCount);
|
|
const tick = () => {
|
|
if (!analyserRef.current) return;
|
|
analyserRef.current.getByteFrequencyData(buf);
|
|
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
|
|
rawSamplesRef.current.push(avg);
|
|
setWaveformBars((prev) => [...prev.slice(1), Math.round((avg / 255) * 100)]);
|
|
animFrameRef.current = requestAnimationFrame(tick);
|
|
};
|
|
animFrameRef.current = requestAnimationFrame(tick);
|
|
timerRef.current = setInterval(() => {
|
|
setDurationMs(accumulatedMsRef.current + (Date.now() - segmentStartRef.current));
|
|
}, 100);
|
|
}, []);
|
|
|
|
// Stop the meters (rAF + interval) without tearing down the audio graph, so a
|
|
// paused recording can resume.
|
|
const stopMeters = useCallback(() => {
|
|
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
|
if (timerRef.current) {
|
|
clearInterval(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
// Release the microphone. The mic tracks are independent of the MediaRecorder
|
|
// and the AudioContext — neither mr.stop() nor audioCtx.close() releases them —
|
|
// so they must be stopped explicitly (else the OS mic indicator stays on).
|
|
const stopStream = useCallback(() => {
|
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
|
streamRef.current = null;
|
|
}, []);
|
|
|
|
const stopAll = useCallback(() => {
|
|
stopMeters();
|
|
if (audioCtxRef.current) {
|
|
audioCtxRef.current.close();
|
|
audioCtxRef.current = null;
|
|
}
|
|
analyserRef.current = null;
|
|
}, [stopMeters]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
// Unmounting mid-recording/pause must release the mic — stopAll() only
|
|
// closes the AudioContext and cancels the meters, not the mic tracks.
|
|
const mr = mediaRecorderRef.current;
|
|
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
|
|
mr.ondataavailable = null;
|
|
mr.onstop = null;
|
|
mr.stop();
|
|
}
|
|
stopStream();
|
|
stopAll();
|
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[],
|
|
);
|
|
|
|
const startRecording = useCallback(async () => {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
const mimeType = MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
|
|
? 'audio/ogg;codecs=opus'
|
|
: 'audio/webm;codecs=opus';
|
|
previewMimeRef.current = mimeType;
|
|
|
|
const mr = new MediaRecorder(stream, { mimeType });
|
|
mediaRecorderRef.current = mr;
|
|
streamRef.current = stream;
|
|
chunksRef.current = [];
|
|
rawSamplesRef.current = [];
|
|
accumulatedMsRef.current = 0;
|
|
segmentStartRef.current = Date.now();
|
|
|
|
const audioCtx = new AudioContext();
|
|
audioCtxRef.current = audioCtx;
|
|
const source = audioCtx.createMediaStreamSource(stream);
|
|
const analyser = audioCtx.createAnalyser();
|
|
analyser.fftSize = 256;
|
|
source.connect(analyser);
|
|
analyserRef.current = analyser;
|
|
|
|
startMeters();
|
|
|
|
mr.ondataavailable = (e) => {
|
|
if (e.data.size > 0) chunksRef.current.push(e.data);
|
|
};
|
|
|
|
mr.onstop = () => {
|
|
stream.getTracks().forEach((t) => t.stop());
|
|
streamRef.current = null;
|
|
const blob = new Blob(chunksRef.current, { type: mimeType });
|
|
setPreviewBlob(blob);
|
|
setPreviewUrl((prev) => {
|
|
if (prev) URL.revokeObjectURL(prev);
|
|
return URL.createObjectURL(blob);
|
|
});
|
|
setState('preview');
|
|
};
|
|
|
|
mr.start(250);
|
|
setState('recording');
|
|
} catch {
|
|
onError?.('Microphone access denied');
|
|
}
|
|
}, [onError, startMeters]);
|
|
|
|
const pauseRecording = useCallback(() => {
|
|
const mr = mediaRecorderRef.current;
|
|
if (mr?.state !== 'recording') return;
|
|
accumulatedMsRef.current += Date.now() - segmentStartRef.current;
|
|
setDurationMs(accumulatedMsRef.current);
|
|
stopMeters();
|
|
mr.pause();
|
|
setState('paused');
|
|
}, [stopMeters]);
|
|
|
|
const resumeRecording = useCallback(() => {
|
|
const mr = mediaRecorderRef.current;
|
|
if (mr?.state !== 'paused') return;
|
|
segmentStartRef.current = Date.now();
|
|
mr.resume();
|
|
startMeters();
|
|
setState('recording');
|
|
}, [startMeters]);
|
|
|
|
const stopRecording = useCallback(() => {
|
|
const mr = mediaRecorderRef.current;
|
|
const activeMs = mr?.state === 'recording' ? Date.now() - segmentStartRef.current : 0;
|
|
previewDurationRef.current = accumulatedMsRef.current + activeMs;
|
|
stopAll();
|
|
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
|
|
mr.stop();
|
|
}
|
|
}, [stopAll]);
|
|
|
|
const cancelRecording = useCallback(() => {
|
|
stopAll();
|
|
const mr = mediaRecorderRef.current;
|
|
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
|
|
mr.ondataavailable = null;
|
|
// onstop (which would release the mic) is cleared, so release it here.
|
|
mr.onstop = null;
|
|
mr.stop();
|
|
}
|
|
stopStream();
|
|
setPreviewBlob(null);
|
|
setPreviewUrl((prev) => {
|
|
if (prev) URL.revokeObjectURL(prev);
|
|
return null;
|
|
});
|
|
rawSamplesRef.current = [];
|
|
accumulatedMsRef.current = 0;
|
|
segmentStartRef.current = 0;
|
|
setWaveformBars(Array(WAVEFORM_BARS).fill(0));
|
|
setDurationMs(0);
|
|
setState('idle');
|
|
}, [stopAll, stopStream]);
|
|
|
|
const sendVoice = useCallback(() => {
|
|
if (!previewBlob) return;
|
|
const waveform = normalizeWaveform(rawSamplesRef.current);
|
|
onSend(previewBlob, previewMimeRef.current, previewDurationRef.current, waveform);
|
|
cancelRecording();
|
|
}, [previewBlob, onSend, cancelRecording]);
|
|
|
|
const barMax = Math.max(...waveformBars, 1);
|
|
|
|
if (state === 'idle') {
|
|
return (
|
|
<IconButton
|
|
className={MobileTouchTarget}
|
|
onClick={startRecording}
|
|
aria-label="Record voice message"
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="300"
|
|
title="Record voice message"
|
|
>
|
|
<Icon src={Icons.Mic} size="100" />
|
|
</IconButton>
|
|
);
|
|
}
|
|
|
|
if (state === 'recording' || state === 'paused') {
|
|
const paused = state === 'paused';
|
|
return (
|
|
<Box
|
|
data-voice-recorder={paused ? 'paused' : 'recording'}
|
|
alignItems="Center"
|
|
gap="200"
|
|
style={{
|
|
background: color.SurfaceVariant.Container,
|
|
borderRadius: config.radii.R300,
|
|
padding: `${toRem(4)} ${toRem(8)}`,
|
|
maxWidth: '100%',
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<Box
|
|
data-voice-rec-dot
|
|
style={{
|
|
width: toRem(8),
|
|
height: toRem(8),
|
|
borderRadius: '50%',
|
|
background: lotusTerminal ? 'var(--lt-accent-orange)' : color.Critical.Main,
|
|
flexShrink: 0,
|
|
// Pulse only while actively recording; hold steady (dimmed) when paused.
|
|
animation: paused ? 'none' : 'pttLivePulse 900ms ease-in-out infinite',
|
|
opacity: paused ? 0.5 : 1,
|
|
}}
|
|
/>
|
|
<Text
|
|
size="T200"
|
|
role="timer"
|
|
aria-label={`${paused ? 'Paused' : 'Recording'}, duration ${formatDuration(durationMs)}`}
|
|
style={{
|
|
minWidth: toRem(32),
|
|
fontVariantNumeric: 'tabular-nums',
|
|
...(lotusTerminal
|
|
? {
|
|
fontFamily: 'JetBrains Mono, monospace',
|
|
color: 'var(--lt-accent-green)',
|
|
fontWeight: 700,
|
|
}
|
|
: {}),
|
|
}}
|
|
>
|
|
{formatDuration(durationMs)}
|
|
</Text>
|
|
<Box
|
|
data-voice-waveform
|
|
alignItems="Center"
|
|
gap="100"
|
|
style={{ height: toRem(20), overflow: 'hidden', flexShrink: 1, minWidth: 0 }}
|
|
>
|
|
{waveformBars.map((h, i) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
width: toRem(2),
|
|
height: toRem(2 + (h / barMax) * 16),
|
|
borderRadius: toRem(1),
|
|
background: lotusTerminal ? 'var(--lt-accent-green)' : color.Primary.Main,
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
))}
|
|
</Box>
|
|
<IconButton
|
|
onClick={paused ? resumeRecording : pauseRecording}
|
|
aria-label={paused ? 'Resume recording' : 'Pause recording'}
|
|
variant="SurfaceVariant"
|
|
fill="Soft"
|
|
size="300"
|
|
radii="300"
|
|
title={paused ? 'Resume' : 'Pause'}
|
|
style={{ flexShrink: 0 }}
|
|
>
|
|
<Icon src={paused ? Icons.Play : Icons.Pause} size="100" />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={stopRecording}
|
|
aria-label="Finish recording"
|
|
variant="Primary"
|
|
fill="Soft"
|
|
size="300"
|
|
radii="300"
|
|
title="Finish"
|
|
style={{ flexShrink: 0 }}
|
|
>
|
|
<Icon src={Icons.Check} size="100" />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={cancelRecording}
|
|
aria-label="Cancel recording"
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="300"
|
|
title="Cancel"
|
|
style={{ flexShrink: 0 }}
|
|
>
|
|
<Icon src={Icons.Cross} size="100" />
|
|
</IconButton>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
alignItems="Center"
|
|
gap="200"
|
|
style={{
|
|
background: color.SurfaceVariant.Container,
|
|
borderRadius: config.radii.R300,
|
|
padding: `${toRem(4)} ${toRem(8)}`,
|
|
}}
|
|
>
|
|
{previewUrl && (
|
|
<>
|
|
<audio
|
|
ref={previewAudioRef}
|
|
src={previewUrl}
|
|
onEnded={() => setPreviewPlaying(false)}
|
|
aria-hidden="true"
|
|
/>
|
|
<IconButton
|
|
onClick={() => {
|
|
const audio = previewAudioRef.current;
|
|
if (!audio) return;
|
|
if (previewPlaying) {
|
|
audio.pause();
|
|
setPreviewPlaying(false);
|
|
} else {
|
|
audio.play();
|
|
setPreviewPlaying(true);
|
|
}
|
|
}}
|
|
aria-label={previewPlaying ? 'Pause preview' : 'Play preview'}
|
|
variant="Secondary"
|
|
fill="Soft"
|
|
size="300"
|
|
radii="300"
|
|
title={previewPlaying ? 'Pause' : 'Play'}
|
|
>
|
|
<Icon src={previewPlaying ? Icons.Pause : Icons.Play} size="100" />
|
|
</IconButton>
|
|
</>
|
|
)}
|
|
<Text size="T200" style={{ fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>
|
|
{formatDuration(previewDurationRef.current)}
|
|
</Text>
|
|
<IconButton
|
|
onClick={sendVoice}
|
|
aria-label="Send voice message"
|
|
variant="Primary"
|
|
fill="Soft"
|
|
size="300"
|
|
radii="300"
|
|
title="Send voice message"
|
|
>
|
|
<Icon src={Icons.Send} size="100" />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={cancelRecording}
|
|
aria-label="Discard voice message"
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="300"
|
|
title="Discard"
|
|
>
|
|
<Icon src={Icons.Delete} size="100" />
|
|
</IconButton>
|
|
</Box>
|
|
);
|
|
}
|