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:
parent
6f9df4cd01
commit
39e7e403d7
7 changed files with 166 additions and 1 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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' : ''}`}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 l’envoi ({{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é",
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
42
src/store.ts
42
src/store.ts
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue