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
+3 -1
View File
@@ -2,7 +2,7 @@ import { Fragment, type ReactNode } from 'react';
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
@@ -61,6 +61,8 @@ function formatNode(
return <ImageAttachment key={key} data={data || {}} />;
case 'EX':
return <FileAttachment key={key} data={data || {}} />;
case 'AU':
return <AudioAttachment key={key} data={data || {}} />;
case 'QQ':
// 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`.
+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' });
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());
}