feat(web): voice messages (record, send, playback)
Click-to-record (README §3.3's explicit 取消/发送 buttons), not the old
app's press-and-hold-with-swipe-to-cancel gesture -- the new design replaces
that touch gesture with plain buttons, a reasonable desktop-first
adaptation; the underlying MediaRecorder mechanics (audio/webm or
audio/mp4 depending on browser support, 24kbps, 2s minimum/10min maximum
duration) are ported as-is from the old app's audio-recorder.jsx.
- VoiceRecorder.tsx: recording UI + MediaRecorder lifecycle. Silently
discards (no send) recordings under MIN_DURATION, matching the old app.
Deliberately skips the `webm-duration-fix` package the old app pulled in
for Chrome's well-known missing-webm-duration-metadata bug -- this app's
own displayed duration comes from its own recording timer, not the file's
embedded metadata, so only a browser's native <audio> scrubber would be
affected, not anything this app actually shows.
- attachments.ts: sendVoice() -- inline base64 or upload-then-reference,
same size-based branch as images/files, Drafty.insertAudio's confirmed
{mime,val/ref,duration,size} shape.
- AttachmentPreview.tsx: AudioAttachment play/pause button + static
waveform (CSS repeating-gradient, not real waveform analysis) + duration.
Verified the failure path live (this sandbox blocks getUserMedia): the
recorder's error state shows a clear message and recovers cleanly back to
the normal composer with no crash. The actual record -> encode -> send ->
playback round trip could not be exercised in this environment and needs
a manual check with real microphone access.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
aa3e464b35
commit
03b8098b3c
@@ -100,3 +100,50 @@ export function FileAttachment({ data }: { data: FileData }) {
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
interface AudioData {
|
||||
val?: string;
|
||||
ref?: string;
|
||||
mime?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
function formatAudioDuration(ms: number | undefined): string {
|
||||
const totalSeconds = Math.round((ms || 0) / 1000);
|
||||
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function AudioAttachment({ data }: { data: AudioData }) {
|
||||
const inline = typeof data.val === 'string' ? `data:${data.mime || 'audio/webm'};base64,${data.val}` : null;
|
||||
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
|
||||
const src = inline || remote.url;
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [audioEl] = useState(() => (typeof Audio !== 'undefined' ? new Audio() : null));
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioEl) return undefined;
|
||||
const onEnded = () => setPlaying(false);
|
||||
audioEl.addEventListener('ended', onEnded);
|
||||
return () => audioEl.removeEventListener('ended', onEnded);
|
||||
}, [audioEl]);
|
||||
|
||||
const toggle = () => {
|
||||
if (!audioEl || !src) return;
|
||||
if (playing) {
|
||||
audioEl.pause();
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
if (audioEl.src !== src) audioEl.src = src;
|
||||
audioEl.play().catch(() => {});
|
||||
setPlaying(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<button type="button" onClick={toggle} disabled={!src} className="flex w-[200px] items-center gap-2">
|
||||
<Icon name={playing ? 'pause_circle' : 'play_circle'} className="text-[24px] text-accent" />
|
||||
<span className="h-4 flex-1 rounded bg-[repeating-linear-gradient(90deg,#b7c3d4_0px,#b7c3d4_2px,transparent_2px,transparent_5px)]" />
|
||||
<span className="flex-none text-xs text-text-secondary">{remote.loading ? '…' : formatAudioDuration(data.duration)}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@ import { EmojiPanel } from '@/features/messages/EmojiPanel';
|
||||
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
|
||||
import { MessageContextMenu } from '@/features/messages/MessageContextMenu';
|
||||
import { MessageRow } from '@/features/messages/MessageBubble';
|
||||
import { VoiceRecorder } from '@/features/messages/VoiceRecorder';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { favoriteMessages, recallMessage } from '@/lib/api/messages';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
||||
import { availableActions, type MessageAction } from '@/lib/messages/actions';
|
||||
import { renderDrafty } from '@/lib/messages/drafty';
|
||||
import { sendFile, sendImage } from '@/lib/tinode/attachments';
|
||||
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
|
||||
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
|
||||
import { forwardMessage, sendReply, sendText } from '@/lib/tinode/send';
|
||||
import { closeConversation, openConversation } from '@/lib/tinode/topics';
|
||||
@@ -45,6 +46,7 @@ export function ConversationView() {
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedSeqs, setSelectedSeqs] = useState<Set<number>>(new Set());
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [replyTo, setReplyTo] = useState<StoredMessage | null>(null);
|
||||
const [forwardMsg, setForwardMsg] = useState<StoredMessage | null>(null);
|
||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||
@@ -317,26 +319,44 @@ export function ConversationView() {
|
||||
<input type="file" onChange={handlePickFile} className="hidden" />
|
||||
</label>
|
||||
<Icon name="contact_page" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||
<Icon name="mic_none" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||
<Icon name="history" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||
</div>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
|
||||
className="min-h-[72px] w-full resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim()}
|
||||
className="h-9 rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||
onClick={() => setRecording(true)}
|
||||
className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default"
|
||||
>
|
||||
发送
|
||||
<Icon name="mic_none" />
|
||||
</button>
|
||||
<Icon name="history" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
|
||||
</div>
|
||||
{recording ? (
|
||||
<VoiceRecorder
|
||||
onCancel={() => setRecording(false)}
|
||||
onSend={(blob, durationMs) => {
|
||||
setRecording(false);
|
||||
sendVoice(topic, blob, durationMs).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
|
||||
className="min-h-[72px] w-full resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim()}
|
||||
className="h-9 rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { MAX_DURATION, MIN_DURATION } from '@/lib/config';
|
||||
|
||||
// Chrome/Firefox vs Safari support different codecs for MediaRecorder -- ported from the old app's
|
||||
// audio-recorder.jsx, first-supported-wins probe.
|
||||
const AUDIO_MIME_TYPES = ['audio/webm', 'audio/mp4', ''];
|
||||
|
||||
function formatSeconds(s: number): string {
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface VoiceRecorderProps {
|
||||
onSend: (blob: Blob, durationMs: number) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §3.3's recording state: click-to-start (from the composer's mic icon), explicit 取消/发送
|
||||
* buttons -- not the old app's press-and-hold-with-swipe-to-cancel gesture, which the new design
|
||||
* replaces with plain buttons (a reasonable desktop-first adaptation; the underlying recording
|
||||
* mechanics are the same). Silently cancels (no message sent) if the recording is shorter than
|
||||
* MIN_DURATION, matching the old app's "too short, discard" behavior.
|
||||
*/
|
||||
export function VoiceRecorder({ onSend, onCancel }: VoiceRecorderProps) {
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const startedAtRef = useRef(0);
|
||||
const finishedRef = useRef(false);
|
||||
|
||||
const stopStream = () => {
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
};
|
||||
|
||||
const finish = (send: boolean) => {
|
||||
if (finishedRef.current) return;
|
||||
finishedRef.current = true;
|
||||
const recorder = recorderRef.current;
|
||||
if (!recorder || recorder.state === 'inactive') {
|
||||
stopStream();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
const durationMs = Date.now() - startedAtRef.current;
|
||||
recorder.onstop = () => {
|
||||
stopStream();
|
||||
if (send && durationMs >= MIN_DURATION) {
|
||||
onSend(new Blob(chunksRef.current, { type: recorder.mimeType }), durationMs);
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
recorder.stop();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ audio: true })
|
||||
.then((stream) => {
|
||||
if (cancelled) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
streamRef.current = stream;
|
||||
const mimeType = AUDIO_MIME_TYPES.find((t) => MediaRecorder.isTypeSupported(t));
|
||||
const recorder = new MediaRecorder(stream, mimeType ? { mimeType, audioBitsPerSecond: 24_000 } : undefined);
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
recorder.start();
|
||||
recorderRef.current = recorder;
|
||||
startedAtRef.current = Date.now();
|
||||
setReady(true);
|
||||
})
|
||||
.catch(() => setError('无法访问麦克风,请检查权限设置'));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopStream();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
setSeconds((s) => {
|
||||
const next = s + 1;
|
||||
if (next * 1000 >= MAX_DURATION) finish(true);
|
||||
return next;
|
||||
});
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-14 items-center justify-between rounded-lg bg-error-strip-bg px-4 text-sm text-error-strip-text">
|
||||
{error}
|
||||
<button type="button" onClick={onCancel} className="text-accent">
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-14 items-center justify-between rounded-lg bg-[#fff0f0] px-4">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-danger-text">
|
||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-danger-text" />
|
||||
录音中 {formatSeconds(seconds)}
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<button type="button" onClick={() => finish(false)} className="text-sm text-text-secondary">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => finish(true)}
|
||||
disabled={!ready}
|
||||
className="flex h-8 items-center gap-1 rounded-lg bg-btn px-4 text-sm font-medium text-white shadow-btn disabled:bg-btn-disabled"
|
||||
>
|
||||
<Icon name="send" className="text-[16px]" />
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user