import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Box, Button, Chip, Icon, IconButton, Icons, Input, PopOut, Spinner, Text, color, config, toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; import { EmojiBoard } from '../emoji-board'; import { SoundboardClip, SoundboardContent, SoundboardPack } from '../../plugins/soundboard'; import { uniqueShortcode } from '../../plugins/soundboard/utils'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { playClipLocally, resolveClipObjectUrl, SOUNDBOARD_ACCEPT, SOUNDBOARD_MAX_CLIP_BYTES, SOUNDBOARD_MAX_CLIPS, } from '../../utils/soundboardClips'; import { stopPropagation } from '../../utils/keyboard'; type ClipDraft = { url: string; body: string; emoji: string; volume: number; info?: SoundboardClip['info']; }; type SoundboardPackEditorProps = { pack: SoundboardPack; canEdit?: boolean; onUpdate: (content: SoundboardContent) => Promise; }; /** * Reusable single-pack soundboard manager (used by the settings page and the * in-call management mode). Mirrors image-pack-view/ImagePackContent's staged- * edit + batched-save pattern, but per-clip fields are name + emoji + volume. */ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPackEditorProps) { const mx = useMatrixClient(); // Staged, unsaved state: const [drafts, setDrafts] = useState>(new Map()); // shortcode -> edits const [deleted, setDeleted] = useState>(new Set()); const [uploads, setUploads] = useState>([]); const [uploading, setUploading] = useState(false); const [error, setError] = useState(); const [emojiFor, setEmojiFor] = useState(); // shortcode currently picking an emoji const [busyPreview, setBusyPreview] = useState(); const fileInputRef = useRef(null); const emojiAnchorRef = useRef(null); const existing = useMemo(() => pack.getClips(), [pack]); const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length; const dirty = drafts.size > 0 || deleted.size > 0 || uploads.length > 0; const draftFor = (shortcode: string, base: { body: string; emoji: string; volume: number }) => drafts.get(shortcode) ?? { url: '', ...base }; const setDraft = (shortcode: string, patch: Partial, base: ClipDraft) => { setDrafts((prev) => { const next = new Map(prev); next.set(shortcode, { ...base, ...(next.get(shortcode) ?? {}), ...patch }); return next; }); }; const preview = useCallback( async (id: string, mxc: string, volume: number) => { setBusyPreview(id); try { const url = await resolveClipObjectUrl(mx, mxc); playClipLocally(url, volume / 100); } catch { /* ignore preview errors */ } finally { setBusyPreview(undefined); } }, [mx], ); const handleFiles = useCallback( async (files: FileList | null) => { if (!files || files.length === 0) return; setUploading(true); setError(undefined); try { const taken = new Set([ ...existing.map((c) => c.shortcode), ...uploads.map((u) => u.shortcode), ]); for (let i = 0; i < files.length; i += 1) { const file = files[i]; if (clipCount + uploads.length >= SOUNDBOARD_MAX_CLIPS) { throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`); } if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) { throw new Error(`"${file.name}" is too large (max 1 MB).`); } // eslint-disable-next-line no-await-in-loop const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' }); const mxc = res.content_uri; if (!mxc) throw new Error('Upload failed.'); const name = file.name.replace(/\.[^/.]+$/, ''); const shortcode = uniqueShortcode(name, taken); taken.add(shortcode); setUploads((prev) => [ ...prev, { shortcode, url: mxc, body: name, emoji: '', volume: 100, info: { mimetype: file.type || undefined, size: file.size }, }, ]); } } catch (e) { setError(e instanceof Error ? e.message : 'Upload failed.'); } finally { setUploading(false); } }, [mx, existing, uploads, clipCount], ); const [saveState, save] = useAsyncCallback( useCallback(async () => { const clips: Record = {}; existing.forEach((c) => { if (deleted.has(c.shortcode)) return; const d = drafts.get(c.shortcode); clips[c.shortcode] = { url: c.url, body: d ? d.body : c.body, emoji: d ? d.emoji || undefined : c.emoji, volume: d ? d.volume : c.volume, info: c.info, }; }); uploads.forEach((u) => { clips[u.shortcode] = { url: u.url, body: u.body, emoji: u.emoji || undefined, volume: u.volume, info: u.info, }; }); await onUpdate({ pack: pack.meta.content, clips }); setDrafts(new Map()); setDeleted(new Set()); setUploads([]); }, [existing, deleted, drafts, uploads, onUpdate, pack]), ); const saving = saveState.status === AsyncStatus.Loading; const renderRow = (key: string, base: ClipDraft, isUpload: boolean, markedDeleted: boolean) => { const d = isUpload ? base : draftFor(key, base); const rowVolume = isUpload ? base.volume : d.volume; const rowBody = isUpload ? base.body : d.body; const rowEmoji = isUpload ? base.emoji : d.emoji; const commit = (patch: Partial) => { if (isUpload) { setUploads((prev) => prev.map((u) => (u.shortcode === key ? { ...u, ...patch } : u))); } else { setDraft(key, patch, base); } }; return ( preview(key, base.url, rowVolume)} aria-label={`Preview ${rowBody}`} > {busyPreview === key ? : } ) => { emojiAnchorRef.current = evt.currentTarget; setEmojiFor(key); }} aria-label="Pick emoji" > {rowEmoji || '🔊'} ) => commit({ body: e.target.value })} aria-label="Clip name" /> commit({ volume: parseInt(e.target.value, 10) })} style={{ flexGrow: 1 }} aria-label="Clip volume" /> {canEdit && !isUpload && ( setDeleted((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }) } aria-label={markedDeleted ? 'Undo delete' : 'Delete clip'} > )} {canEdit && isUpload && ( setUploads((prev) => prev.filter((u) => u.shortcode !== key))} aria-label="Remove upload" > )} ); }; return ( { handleFiles(e.target.files); e.target.value = ''; }} /> {pack.meta.name ?? 'Soundboard'} {canEdit && ( = SOUNDBOARD_MAX_CLIPS} onClick={() => fileInputRef.current?.click()} before={uploading ? : } > Upload )} {existing.map((c) => renderRow( c.shortcode, { url: c.url, body: c.body ?? c.shortcode, emoji: c.emoji ?? '', volume: c.volume }, false, deleted.has(c.shortcode), ), )} {uploads.map((u) => renderRow(u.shortcode, u, true, false))} {existing.length === 0 && uploads.length === 0 && ( No clips yet. Upload a short audio clip (max 1 MB){canEdit ? '' : ' — ask an admin'}. )} {error && ( {error} )} {canEdit && dirty && ( )} setEmojiFor(undefined), clickOutsideDeactivates: true, escapeDeactivates: stopPropagation, }} > { const key = emojiFor; setEmojiFor(undefined); if (!key) return; const up = uploads.find((u) => u.shortcode === key); if (up) { setUploads((prev) => prev.map((u) => (u.shortcode === key ? { ...u, emoji: unicode } : u)), ); } else { const c = existing.find((x) => x.shortcode === key); if (c) setDraft( key, { emoji: unicode }, { url: c.url, body: c.body ?? c.shortcode, emoji: c.emoji ?? '', volume: c.volume, }, ); } }} requestClose={() => setEmojiFor(undefined)} /> } > ); }