A button to say it instead of typing it

The microphone, the encoder and the upload are all in the daemon (nobilis
f9b5be5), so what is here is the part a person touches: press to record, a
clock, a level meter, then send it or throw it away.

The meter is not decoration. A voice message recorded from a muted or
misrouted microphone is two seconds of silence, and nothing about sending one
would say so - the sender finds out when somebody tells them. A bar that moves
while they talk is the whole of that check, and it costs one number the daemon
already computes for calls.

Moving to another conversation abandons the recording rather than quietly
carrying it somewhere else: it was started in a conversation and belongs to
it.

Discord only, and the button says so by not being there elsewhere. Matrix has
voice messages too and shapes them differently enough - milliseconds and an
array of 0-1024 samples against seconds and base64 bytes - to be its own
piece of work.

Closes #186.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-13 00:44:43 -04:00
co-authored by Claude Opus 5
parent a39ee5f633
commit e5196526bd
4 changed files with 189 additions and 1 deletions
+1 -1
Submodule nobilis updated: 54ac74e4fd...f9b5be5f1d
+47
View File
@@ -3547,6 +3547,53 @@ img.smilie {
height: 32px;
}
/* --- recording one --------------------------------------------------- */
.voice-recorder {
display: flex;
align-items: center;
gap: var(--spacing-xs);
padding-left: var(--spacing-xxs);
}
/* The thing that says this is live. Slow, because a fast blink beside a
timer reads as an alarm. */
.voice-recording-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--error);
animation: voice-pulse 1.6s ease-in-out infinite;
}
@keyframes voice-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
@media (prefers-reduced-motion: reduce) {
.voice-recording-dot {
animation: none;
}
}
/* What the microphone is hearing, which is how somebody knows it is the one
they think it is before they send silence to a friend. */
.voice-meter {
width: 56px;
height: 4px;
border-radius: 2px;
background: var(--surface-container-highest);
overflow: hidden;
}
.voice-meter-fill {
display: block;
height: 100%;
background: var(--primary);
transition: width 120ms linear;
}
/* --- a voice message ------------------------------------------------- */
/* Sized like the audio row it replaces rather than like a media embed: this
+6
View File
@@ -3,6 +3,7 @@ import { Icon, IconButton } from './Icon'
import { Avatar } from './Avatar'
import { EmojiPicker, type StickerEntry } from './EmojiPicker'
import { PlaceField } from './PlaceField'
import { VoiceRecorder } from './VoiceRecorder'
import { useActiveBuffer, useChat, useStore } from '../state/hooks'
import { emojiPreview } from '../lib/format'
import { bufferDisplayName, classes, fileNameOf, isImageFile, resolveMediaUrl } from '../lib/util'
@@ -822,6 +823,11 @@ export function Composer(): JSX.Element | null {
</button>
)}
{/* Saying it rather than typing it. Discord only, because it is the
one service here whose voice messages this speaks - Matrix shapes
them differently and is its own piece of work. */}
{service === 'discord' && <VoiceRecorder bufferId={buffer.id} />}
<IconButton
name="add"
title={
@@ -0,0 +1,135 @@
import { useEffect, useRef, useState } from 'react'
import { Icon } from './Icon'
import { useStore } from '../state/hooks'
/** What the daemon says about a recording in progress. */
interface Progress {
recording: boolean
bufferId?: string
seconds?: number
level?: number
}
/**
* Saying something instead of typing it.
*
* The microphone, the encoder and the upload all live in the daemon - it
* already captures audio for calls and links the same Opus library - so what
* is here is the part a person interacts with: press to start, watch a clock
* and a level meter that proves the microphone is actually hearing something,
* then send or throw it away.
*
* The meter matters more than it looks. A voice message recorded from a muted
* or misrouted microphone is silence, and there is nothing about sending one
* that would say so - the sender finds out when somebody tells them. A bar
* that moves while they talk is the whole of the check.
*/
export function VoiceRecorder({ bufferId }: { bufferId: string }): JSX.Element {
const store = useStore()
const [recording, setRecording] = useState(false)
const [seconds, setSeconds] = useState(0)
const [level, setLevel] = useState(0)
const [busy, setBusy] = useState(false)
const timer = useRef<ReturnType<typeof setInterval> | null>(null)
/** Polled rather than pushed: it is only wanted while somebody watches it. */
useEffect(() => {
if (!recording) return
timer.current = setInterval(() => {
void window.moho
.rpc<Progress>('voiceMessageProgress', {})
.then((p) => {
if (!p.recording) {
setRecording(false)
return
}
setSeconds(p.seconds ?? 0)
setLevel(p.level ?? 0)
})
.catch(() => {})
}, 200)
return () => {
if (timer.current) clearInterval(timer.current)
timer.current = null
}
}, [recording])
// A recording belongs to the conversation it was started in. Moving to
// another one abandons it rather than quietly sending it somewhere else.
useEffect(() => {
return () => {
if (recording) void window.moho.rpc('cancelVoiceMessage', {}).catch(() => {})
}
}, [bufferId])
const start = (): void => {
setBusy(true)
void window.moho
.rpc('startVoiceMessage', { bufferId })
.then(() => {
setSeconds(0)
setLevel(0)
setRecording(true)
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(false))
}
const send = (): void => {
setBusy(true)
setRecording(false)
void window.moho
.rpc('sendVoiceMessage', {})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(false))
}
const cancel = (): void => {
setRecording(false)
void window.moho.rpc('cancelVoiceMessage', {}).catch(() => {})
}
if (!recording) {
return (
<button
type="button"
className="icon-button"
title="Record a voice message"
disabled={busy}
onClick={start}
>
<Icon name="mic" size={18} />
</button>
)
}
return (
<div className="voice-recorder">
<button type="button" className="icon-button" title="Throw it away" onClick={cancel}>
<Icon name="delete" size={18} />
</button>
<span className="voice-recording-dot" />
<span className="small tabular">{clock(seconds)}</span>
{/* Not a decoration: this is how somebody knows the microphone is the
one they think it is, before they send silence to a friend. */}
<span className="voice-meter" title="What the microphone is hearing">
<span className="voice-meter-fill" style={{ width: `${Math.min(100, level * 140)}%` }} />
</span>
<button
type="button"
className="icon-button"
title="Send it"
disabled={busy}
onClick={send}
>
<Icon name="send" size={18} />
</button>
</div>
)
}
/** Seconds as a person reads them. */
function clock(seconds: number): string {
const whole = Math.max(0, Math.floor(seconds))
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`
}