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:
Your Name
2026-09-08 02:49:17 +08:00
co-authored by Claude Sonnet 5
parent aa3e464b35
commit 03b8098b3c
5 changed files with 241 additions and 17 deletions
@@ -100,3 +100,50 @@ export function FileAttachment({ data }: { data: FileData }) {
</a> </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>
);
}
+36 -16
View File
@@ -9,13 +9,14 @@ import { EmojiPanel } from '@/features/messages/EmojiPanel';
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal'; import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
import { MessageContextMenu } from '@/features/messages/MessageContextMenu'; import { MessageContextMenu } from '@/features/messages/MessageContextMenu';
import { MessageRow } from '@/features/messages/MessageBubble'; import { MessageRow } from '@/features/messages/MessageBubble';
import { VoiceRecorder } from '@/features/messages/VoiceRecorder';
import { useBootstrap } from '@/lib/api/hooks'; import { useBootstrap } from '@/lib/api/hooks';
import { favoriteMessages, recallMessage } from '@/lib/api/messages'; import { favoriteMessages, recallMessage } from '@/lib/api/messages';
import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config'; import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
import { availableActions, type MessageAction } from '@/lib/messages/actions'; import { availableActions, type MessageAction } from '@/lib/messages/actions';
import { renderDrafty } from '@/lib/messages/drafty'; 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 { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
import { forwardMessage, sendReply, sendText } from '@/lib/tinode/send'; import { forwardMessage, sendReply, sendText } from '@/lib/tinode/send';
import { closeConversation, openConversation } from '@/lib/tinode/topics'; import { closeConversation, openConversation } from '@/lib/tinode/topics';
@@ -45,6 +46,7 @@ export function ConversationView() {
const [selectMode, setSelectMode] = useState(false); const [selectMode, setSelectMode] = useState(false);
const [selectedSeqs, setSelectedSeqs] = useState<Set<number>>(new Set()); const [selectedSeqs, setSelectedSeqs] = useState<Set<number>>(new Set());
const [loadingOlder, setLoadingOlder] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false);
const [recording, setRecording] = useState(false);
const [replyTo, setReplyTo] = useState<StoredMessage | null>(null); const [replyTo, setReplyTo] = useState<StoredMessage | null>(null);
const [forwardMsg, setForwardMsg] = useState<StoredMessage | null>(null); const [forwardMsg, setForwardMsg] = useState<StoredMessage | null>(null);
const [emojiOpen, setEmojiOpen] = useState(false); const [emojiOpen, setEmojiOpen] = useState(false);
@@ -317,26 +319,44 @@ export function ConversationView() {
<input type="file" onChange={handlePickFile} className="hidden" /> <input type="file" onChange={handlePickFile} className="hidden" />
</label> </label>
<Icon name="contact_page" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" /> <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 <button
type="button" type="button"
onClick={submit} onClick={() => setRecording(true)}
disabled={!draft.trim()} className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default"
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"
> >
发送 <Icon name="mic_none" />
</button> </button>
<Icon name="history" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
</div> </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> </div>
)} )}
+136
View File
@@ -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>
);
}
+3 -1
View File
@@ -2,7 +2,7 @@ import { Fragment, type ReactNode } from 'react';
import { Drafty, type DraftyContent } from 'tinode-sdk'; import { Drafty, type DraftyContent } from 'tinode-sdk';
import { FileAttachment, ImageAttachment } from '@/features/messages/AttachmentPreview'; import { AudioAttachment, FileAttachment, ImageAttachment } from '@/features/messages/AttachmentPreview';
/** /**
* Bubble content renderer for the Drafty style tags this app currently sends/receives. Grown * Bubble content renderer for the Drafty style tags this app currently sends/receives. Grown
@@ -61,6 +61,8 @@ function formatNode(
return <ImageAttachment key={key} data={data || {}} />; return <ImageAttachment key={key} data={data || {}} />;
case 'EX': case 'EX':
return <FileAttachment key={key} data={data || {}} />; return <FileAttachment key={key} data={data || {}} />;
case 'AU':
return <AudioAttachment key={key} data={data || {}} />;
case 'QQ': case 'QQ':
// Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain style // Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain style
// span wrapping mention(header,uid) + linebreak + body, all already rendered into `children`. // span wrapping mention(header,uid) + linebreak + body, all already rendered into `children`.
+19
View File
@@ -62,3 +62,22 @@ export async function sendFile(topic: Topic, file: File): Promise<unknown> {
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'file' }); msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'file' });
return topic.publishDraft(msg, Promise.resolve()); return topic.publishDraft(msg, Promise.resolve());
} }
/**
* `durationMs` is measured by this app's own recording timer, independent of whatever duration
* metadata (if any) ends up embedded in the audio file itself -- Chrome's MediaRecorder webm output
* is well known to omit duration metadata entirely (the old app worked around this with the
* `webm-duration-fix` package; skipped here as a deliberate scope cut since the Drafty `duration`
* field this app actually displays from doesn't depend on it, only a browser's native <audio>
* scrubber would).
*/
export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): Promise<unknown> {
const file = new File([blob], `voice.${blob.type.includes('mp4') ? 'm4a' : 'webm'}`, { type: blob.type });
const content =
file.size <= MAX_INBAND_ATTACHMENT_SIZE
? Drafty.insertAudio(undefined, 0, { mime: file.type, bits: await fileToBase64(file), duration: durationMs, size: file.size })
: Drafty.insertAudio(undefined, 0, { mime: file.type, refurl: (await uploadFile(file, topic.name)).url, duration: durationMs, size: file.size });
const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'voice' });
return topic.publishDraft(msg, Promise.resolve());
}