import { useEffect, useRef, useState } from 'react'; import { api, type SoundboardSound } from '../../api/client'; import { wsSend } from '../../hooks/useWebSocket'; import { useTransferStore } from '../../stores/transferStore'; import { waitForTransferAttachment } from '../../utils/waitForTransfer'; import { useT } from '../../i18n'; interface SoundboardPopoverProps { spaceId: string; canManage: boolean; onClose: () => void; } /** Clips are short gags; anything larger is a music file in disguise. */ const MAX_SOUND_BYTES = 2 * 1024 * 1024; export function SoundboardPopover({ spaceId, canManage, onClose }: SoundboardPopoverProps) { const t = useT(); const [sounds, setSounds] = useState([]); const [uploading, setUploading] = useState(false); const [error, setError] = useState(''); // Two-step add: pick the file, then name it in a field right here. The first // version asked with window.prompt, which Electron does not implement — it // returned nothing and the flow aborted in silence, so adding a sound worked // in the browser and did nothing at all in the desktop app. const [pendingFile, setPendingFile] = useState(null); const [pendingName, setPendingName] = useState(''); const fileRef = useRef(null); const panelRef = useRef(null); useEffect(() => { let cancelled = false; api.soundboard.list(spaceId) .then(({ sounds: list }) => { if (!cancelled) setSounds(list); }) .catch(() => { /* an empty board is the honest fallback */ }); return () => { cancelled = true; }; }, [spaceId]); useEffect(() => { const handlePointer = (e: MouseEvent | TouchEvent) => { if (!panelRef.current?.contains(e.target as Node)) onClose(); }; const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('mousedown', handlePointer); document.addEventListener('touchstart', handlePointer); document.addEventListener('keydown', handleKey); return () => { document.removeEventListener('mousedown', handlePointer); document.removeEventListener('touchstart', handlePointer); document.removeEventListener('keydown', handleKey); }; }, [onClose]); // Fire and forget: the server echoes the clip back to everyone in the call, // this client included, so the presser hears exactly what the others hear — // including the server's refusal when the cooldown is still running. const play = (soundId: string) => wsSend({ type: 'soundboard_play', soundId }); const pickFile = (file: File) => { setError(''); if (file.size > MAX_SOUND_BYTES) { setError(t('soundboard.tooLarge')); return; } setPendingFile(file); setPendingName(file.name.replace(/\.[^.]+$/, '').slice(0, 32)); }; const cancelPending = () => { setPendingFile(null); setPendingName(''); if (fileRef.current) fileRef.current.value = ''; }; const confirmPending = async () => { const file = pendingFile; const name = pendingName.trim(); if (!file || !name) return; setUploading(true); setError(''); try { const tid = await useTransferStore.getState().startUpload(file, { tray: false }); const { filename } = await waitForTransferAttachment(tid); const created = await api.soundboard.add(spaceId, name, filename); setSounds((prev) => [...prev, created]); cancelPending(); } catch { // Distinct from the size check above: reporting every failure as "too // large" sends people to shrink a file that was never the problem. setError(t('soundboard.uploadFailed')); } finally { setUploading(false); } }; const handleRemove = async (soundId: string) => { const previous = sounds; setSounds((prev) => prev.filter((s) => s.id !== soundId)); try { await api.soundboard.remove(soundId); } catch { setSounds(previous); } }; return (
{t('soundboard.title')} {canManage && ( <> { const file = e.target.files?.[0]; if (file) pickFile(file); }} /> )}
{error &&
{error}
} {pendingFile && (
setPendingName(e.target.value)} onKeyDown={(e) => { // Scoped here so Enter does not reach the composer behind the popover. e.stopPropagation(); if (e.key === 'Enter' && pendingName.trim()) void confirmPending(); if (e.key === 'Escape') cancelPending(); }} className="input-search w-full mb-2" />
)} {sounds.length === 0 ? (

{t('soundboard.empty')}

) : (
{sounds.map((sound) => (
{canManage && ( )}
))}
)}
); }