composer: voice messages (record + inline player)

Record a voice message in the composer (MediaRecorder → opus/webm) and
share it through the same /FILEHOST upload flow as images; received audio
URLs render as an inline player. Works in channels and PMs.
This commit is contained in:
Jean Chevronnet 2026-06-22 22:00:58 +00:00
parent 6f9df4cd01
commit 39e7e403d7
7 changed files with 166 additions and 1 deletions

View file

@ -1,7 +1,7 @@
// Tchatou service worker — installable PWA + offline app shell.
// Scope: /app/. Only handles same-origin /app/ GETs; the IRC websocket and all
// API calls (cloudflare, change_password, upload) pass straight through.
const CACHE = 'tchatou-v73';
const CACHE = 'tchatou-v74';
const SHELL = ['/app/', '/app/index.html', '/app/favicon.svg', '/app/orbit-icon.svg', '/app/manifest.webmanifest'];
self.addEventListener('install', (e) => {

View file

@ -56,6 +56,7 @@ export function Composer() {
const send = useChat((s) => s.sendInput);
const notifyTyping = useChat((s) => s.notifyTyping);
const uploadImage = useChat((s) => s.uploadImage);
const uploadAudio = useChat((s) => s.uploadAudio);
const setDraft = useChat((s) => s.setDraft);
const [picker, setPicker] = useState(false);
@ -66,6 +67,15 @@ export function Composer() {
const fgRef = useRef(''); // active text colour, kept sticky across sends
const ed = useRef<HTMLDivElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
// Voice recording
const [recording, setRecording] = useState(false);
const [recSecs, setRecSecs] = useState(0);
const recRef = useRef<MediaRecorder | null>(null);
const recChunks = useRef<Blob[]>([]);
const recStream = useRef<MediaStream | null>(null);
const recTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const recCancel = useRef(false);
const recExt = useRef('webm');
const cyc = useRef<{ start: number; len: number; cands: string[]; idx: number } | null>(null);
const prevActive = useRef(active);
// mIRC-style sent-message history (global to the session). idx -1 = live draft.
@ -251,6 +261,49 @@ export function Composer() {
return false;
}
// ── voice recording (MediaRecorder → opus/webm → uploadAudio) ───────────────
const canRecord = canUpload && !isConsole && typeof navigator !== 'undefined'
&& !!navigator.mediaDevices?.getUserMedia && typeof MediaRecorder !== 'undefined';
function bestAudioMime(): string {
const cands = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg', 'audio/mp4'];
for (const m of cands) if (MediaRecorder.isTypeSupported?.(m)) return m;
return '';
}
function teardownRec() {
recStream.current?.getTracks().forEach((tr) => tr.stop());
recStream.current = null;
if (recTimer.current) { clearInterval(recTimer.current); recTimer.current = null; }
}
async function startRec() {
if (recording || !canRecord) return;
let stream: MediaStream;
try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); }
catch { useChat.getState().pushSystem?.(active, `${t('composer.micDenied')}`); return; }
const mime = bestAudioMime();
recExt.current = mime.includes('ogg') ? 'ogg' : mime.includes('mp4') ? 'm4a' : 'webm';
const mr = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
recRef.current = mr; recStream.current = stream; recChunks.current = []; recCancel.current = false;
mr.ondataavailable = (e) => { if (e.data && e.data.size) recChunks.current.push(e.data); };
mr.onstop = () => {
const cancelled = recCancel.current;
teardownRec(); setRecording(false); setRecSecs(0);
const blob = new Blob(recChunks.current, { type: mime || 'audio/webm' });
recChunks.current = [];
if (!cancelled && blob.size > 0) uploadAudio(blob, recExt.current);
};
mr.start();
setRecording(true); setRecSecs(0);
recTimer.current = setInterval(() => setRecSecs((s) => {
if (s + 1 >= 300) { stopRec(); return 300; } // 5-minute hard cap
return s + 1;
}), 1000);
}
function stopRec() { if (recRef.current && recRef.current.state !== 'inactive') recRef.current.stop(); }
function cancelRec() { recCancel.current = true; stopRec(); }
// Make sure the mic is released if the composer unmounts mid-recording.
useEffect(() => () => { recCancel.current = true; try { recRef.current?.stop(); } catch { /* ignore */ } teardownRec(); }, []);
const placeholder = isConsole
? t('composer.consolePlaceholder')
: t('composer.placeholder', { chan: active || '…' });
@ -286,6 +339,15 @@ export function Composer() {
onDragOver={(e) => { if (!isConsole) { e.preventDefault(); setDragOver(true); } }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => { setDragOver(false); if (uploadFrom(e.dataTransfer.files)) e.preventDefault(); }}>
{recording && (
<div className="composer__rec">
<span className="composer__rec-dot" aria-hidden="true" />
<span className="composer__rec-time">{Math.floor(recSecs / 60)}:{String(recSecs % 60).padStart(2, '0')}</span>
<span className="composer__rec-label">{t('composer.recording')}</span>
<button className="composer__rec-btn composer__rec-cancel" onClick={cancelRec} aria-label={t('composer.cancel')} title={t('composer.cancel')}></button>
<button className="composer__rec-btn composer__rec-send" onClick={stopRec} aria-label={t('composer.send')} title={t('composer.send')}></button>
</div>
)}
{canUpload && !isConsole && (
<button className="composer__add" title={t('composer.sendImage')} aria-label={t('composer.sendImage')} onClick={() => fileRef.current?.click()}>
<svg className="composer__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
@ -296,6 +358,16 @@ export function Composer() {
</svg>
</button>
)}
{canRecord && (
<button className="composer__add composer__mic" title={t('composer.recordVoice')} aria-label={t('composer.recordVoice')} onClick={startRec}>
<svg className="composer__icon" viewBox="0 0 24 24" width="20" height="20" fill="none"
stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="9" y="2" width="6" height="12" rx="3" />
<path d="M5 11a7 7 0 0 0 14 0" />
<line x1="12" y1="18" x2="12" y2="22" />
</svg>
</button>
)}
<div
ref={ed}
className={`composer__rich ${isConsole ? 'composer__rich--console' : ''} ${empty ? 'is-empty' : ''}`}

View file

@ -370,10 +370,12 @@
"onlyImages": "Only images are accepted.",
"imageTooLarge": "Image too large (max 16 MB).",
"sendingImage": "Sending “{{name}}”…",
"sendingVoice": "Sending voice message…",
"uploadNeedAccount": "You must be logged into an account to send images (/msg NickServ IDENTIFY).",
"uploadTimeout": "The file server is not responding.",
"uploadFailed": "Upload failed ({{msg}}).",
"shareImage": "shares an image: {{url}}",
"shareVoice": "voice message: {{url}}",
"yourModes": "Your modes: +{{modes}} ({{change}})",
"yourModesNamed": "Your modes: +{{modes}} ({{change}} — {{names}})",
"pmNotif": "{{nick}} (private)"
@ -533,6 +535,10 @@
"color": "Color {{i}}",
"clearFormat": "Clear formatting",
"sendImage": "Send an image",
"recordVoice": "Voice message",
"recording": "Recording…",
"cancel": "Cancel",
"micDenied": "Microphone unavailable (permission denied?).",
"bold": "Bold",
"italic": "Italic",
"underline": "Underline",

View file

@ -370,10 +370,12 @@
"onlyImages": "Seules les images sont acceptées.",
"imageTooLarge": "Image trop volumineuse (max 16 Mo).",
"sendingImage": "Envoi de « {{name}} »…",
"sendingVoice": "Envoi du message vocal…",
"uploadNeedAccount": "Tu dois être connecté à un compte pour envoyer des images (/msg NickServ IDENTIFY).",
"uploadTimeout": "Le serveur de fichiers ne répond pas.",
"uploadFailed": "Échec de lenvoi ({{msg}}).",
"shareImage": "partage une image : {{url}}",
"shareVoice": "message vocal : {{url}}",
"yourModes": "Tes modes : +{{modes}} ({{change}})",
"yourModesNamed": "Tes modes : +{{modes}} ({{change}} — {{names}})",
"pmNotif": "{{nick}} (privé)"
@ -533,6 +535,10 @@
"color": "Couleur {{i}}",
"clearFormat": "Effacer le format",
"sendImage": "Envoyer une image",
"recordVoice": "Message vocal",
"recording": "Enregistrement…",
"cancel": "Annuler",
"micDenied": "Micro indisponible (autorisation refusée ?).",
"bold": "Gras",
"italic": "Italique",
"underline": "Souligné",

View file

@ -467,6 +467,7 @@ input, textarea { font-family: inherit; }
/* ============ COMPOSER ============ */
.composer { padding: .35rem 1.3rem 1rem; }
.composer__box {
position: relative;
display: flex; align-items: flex-end; gap: .4rem; background: var(--bg); border: 1px solid var(--border-2); border-radius: 14px; padding: .35rem .4rem .35rem .5rem;
transition: border-color .15s, box-shadow .15s;
}
@ -514,7 +515,30 @@ input, textarea { font-family: inherit; }
.composer__add { display: inline-flex; align-items: center; justify-content: center; }
.composer__add:hover, .composer__emoji:hover { background: var(--bg-soft-2); }
.composer__add:hover { color: var(--green-d); }
.composer__mic:hover { color: #e0577a; }
.composer__icon { display: block; }
/* Voice recording overlay (covers the composer box while recording). */
.composer__rec { position: absolute; inset: 0; z-index: 3; display: flex; align-items: center; gap: .6rem;
padding: 0 .7rem; background: var(--bg-soft); border-radius: inherit; }
.composer__rec-dot { width: 11px; height: 11px; border-radius: 50%; background: #e0577a; flex: none;
animation: recpulse 1.2s ease-in-out infinite; }
@keyframes recpulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
.composer__rec-time { font-variant-numeric: tabular-nums; font-weight: 700; }
.composer__rec-label { color: var(--faint); font-size: .9rem; }
.composer__rec-btn { margin-left: auto; width: 32px; height: 32px; border-radius: 50%; display: inline-flex;
align-items: center; justify-content: center; border: 0; cursor: pointer; font-size: 1rem; }
.composer__rec-cancel { margin-left: auto; background: var(--bg-soft-2); color: var(--ink); }
.composer__rec-send { margin-left: 0; background: var(--green, #2ec27e); color: #fff; }
.composer__rec-btn:hover { filter: brightness(1.08); }
/* Voice message inline player. */
.audcard { margin-top: .4rem; display: flex; align-items: center; gap: .55rem; max-width: min(420px, 100%);
padding: .4rem .6rem; background: var(--bg-soft); border: 1px solid var(--border); border-radius: 12px; }
.audcard__ic { font-size: 1.05rem; flex: none; }
.audcard__player { height: 36px; flex: 1; min-width: 0; }
.audcard__act { flex: none; font-size: .82rem; color: var(--accent, #7aa2ff); text-decoration: none; }
.audcard__act:hover { text-decoration: underline; }
.composer textarea { flex: 1; border: 0; background: none; color: var(--ink); font: inherit; font-size: 1rem; resize: none; outline: none; max-height: 140px; line-height: 1.45; padding: .5rem .2rem; }
.composer textarea::placeholder { color: var(--faint); }
/* Rich (WYSIWYG) composer — shows real bold/italic/colour, never the codes */

View file

@ -25,6 +25,20 @@ export function nickColor(nick: string): string {
}
const isImageUrl = (u: string) => /\.(png|jpe?g|gif|webp)(\?|#|$)/i.test(u);
const isAudioUrl = (u: string) => /\.(opus|ogg|mp3|m4a|wav|weba)(\?|#|$)/i.test(u)
|| /\/files\/[^/?#]+\.webm(\?|#|$)/i.test(u); // filehost voice messages use a .webm audio container
/* Voice message / audio attachment: a compact inline player. */
function AudioAttachment({ url }: { url: string }) {
const { t } = useTranslation();
return (
<div className="audcard">
<span className="audcard__ic" aria-hidden="true">🎤</span>
<audio className="audcard__player" src={url} controls preload="metadata" />
<a className="audcard__act" href={url} target="_blank" rel="noopener noreferrer">{t('media.open')}</a>
</div>
);
}
/* Element-style image attachment: friendly caption bar + collapsible thumbnail + lightbox. */
function ImageAttachment({ url, defaultShown = false }: { url: string; defaultShown?: boolean }) {
@ -105,6 +119,7 @@ function linkify(text: string, selfMsg = false): ReactNode {
return parts.map((p, i) => {
if (!/^https?:\/\//.test(p)) return <span key={i}>{p}</span>;
if (isImageUrl(p)) return <ImageAttachment key={i} url={p} defaultShown={selfMsg} />;
if (isAudioUrl(p)) return <AudioAttachment key={i} url={p} />;
const yt = youtubeId(p);
if (yt) return <YouTubeEmbed key={i} id={yt} url={p} />;
return <a key={i} href={p} target="_blank" rel="noopener noreferrer">{p}</a>;

View file

@ -145,6 +145,8 @@ interface ChatState {
toggleReaction: (msgid: string, emoji: string) => void;
redact: (msgid: string) => void;
uploadImage: (file: File) => Promise<void>;
uploadAudio: (blob: Blob, ext: string) => Promise<void>;
pushSystem: (buffer: string, text: string) => void;
accountRegister: (account: string, email: string, password: string) => void;
accountVerify: (code: string) => void;
accountResend: () => void;
@ -1627,6 +1629,46 @@ export const useChat = create<ChatState>((set, get) => {
}
},
// Surface a one-off system line in a buffer (used by UI for local hints).
pushSystem(buffer, text) { sysLine(buffer || get().active, text, 'system'); },
// Voice message: a recorded audio blob, uploaded via the same /FILEHOST flow
// as images and shared as an action; other clients render an inline player.
async uploadAudio(blob, ext) {
const { client, active } = get();
if (!client || !active || active === SERVER) return;
if (blob.size > 16 * 1024 * 1024) { sysLine(active, `${i18n.t('system.imageTooLarge')}`, 'system'); return; }
sysLine(active, `📤 ${i18n.t('system.sendingVoice')}`, 'system');
try {
const token = await new Promise<string>((resolve, reject) => {
filehostResolve = resolve; filehostReject = reject;
filehostTimer = setTimeout(() => { filehostResolve = null; filehostReject = null; reject(new Error('timeout')); }, 10000);
client.send('FILEHOST');
});
const fd = new FormData();
fd.append('file', new File([blob], `voice.${ext}`, { type: blob.type || 'audio/webm' }));
const res = await fetch(`/upload?token=${encodeURIComponent(token)}`, { method: 'POST', body: fd });
if (!res.ok) {
let detail = `http_${res.status}`;
try { const j = await res.json(); if (j?.detail) detail = `${res.status}:${j.detail}`; } catch { /* ignore */ }
throw new Error(detail);
}
const data = await res.json() as { url: string };
const caption = `🎤 ${i18n.t('system.shareVoice', { url: data.url })}`;
client.action(active, caption);
if (!client.hasCap('echo-message')) {
addMessage(active, { id: newId(), bufferName: active, from: get().nick, text: caption, ts: Date.now(), kind: 'action', self: true });
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const human = msg === 'not_identified'
? i18n.t('system.uploadNeedAccount')
: msg === 'timeout' ? i18n.t('system.uploadTimeout') : i18n.t('system.uploadFailed', { msg });
sysLine(active, `${human}`, 'system');
}
},
// ---- account management (draft/account-registration) ----
accountRegister(account, email, password) {
const { client } = get();