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:
+1
-1
Submodule nobilis updated: 54ac74e4fd...f9b5be5f1d
@@ -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
|
||||
|
||||
@@ -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')}`
|
||||
}
|
||||
Reference in New Issue
Block a user