2026-07-01 23:21:50 -04:00
|
|
|
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<void>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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<Map<string, ClipDraft>>(new Map()); // shortcode -> edits
|
|
|
|
|
const [deleted, setDeleted] = useState<Set<string>>(new Set());
|
|
|
|
|
const [uploads, setUploads] = useState<Array<{ shortcode: string } & ClipDraft>>([]);
|
|
|
|
|
const [uploading, setUploading] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string>();
|
|
|
|
|
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
|
|
|
|
|
const [busyPreview, setBusyPreview] = useState<string>();
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
const emojiAnchorRef = useRef<HTMLElement | null>(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<ClipDraft>, 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<string>([
|
|
|
|
|
...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<string, SoundboardClip> = {};
|
|
|
|
|
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<ClipDraft>) => {
|
|
|
|
|
if (isUpload) {
|
|
|
|
|
setUploads((prev) => prev.map((u) => (u.shortcode === key ? { ...u, ...patch } : u)));
|
|
|
|
|
} else {
|
|
|
|
|
setDraft(key, patch, base);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
return (
|
|
|
|
|
<Box
|
|
|
|
|
key={key}
|
|
|
|
|
alignItems="Center"
|
|
|
|
|
gap="200"
|
|
|
|
|
style={{
|
|
|
|
|
padding: config.space.S200,
|
|
|
|
|
borderRadius: config.radii.R400,
|
|
|
|
|
background: color.SurfaceVariant.Container,
|
|
|
|
|
opacity: markedDeleted ? 0.5 : 1,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<IconButton
|
|
|
|
|
size="300"
|
|
|
|
|
radii="300"
|
|
|
|
|
variant="Secondary"
|
|
|
|
|
disabled={busyPreview === key}
|
|
|
|
|
onClick={() => preview(key, base.url, rowVolume)}
|
|
|
|
|
aria-label={`Preview ${rowBody}`}
|
|
|
|
|
>
|
|
|
|
|
{busyPreview === key ? <Spinner size="100" /> : <Icon size="100" src={Icons.Play} />}
|
|
|
|
|
</IconButton>
|
|
|
|
|
<IconButton
|
|
|
|
|
size="300"
|
|
|
|
|
radii="300"
|
|
|
|
|
variant="Secondary"
|
|
|
|
|
disabled={!canEdit || markedDeleted}
|
|
|
|
|
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
|
|
|
|
emojiAnchorRef.current = evt.currentTarget;
|
|
|
|
|
setEmojiFor(key);
|
|
|
|
|
}}
|
|
|
|
|
aria-label="Pick emoji"
|
|
|
|
|
>
|
|
|
|
|
<Text size="T400">{rowEmoji || '🔊'}</Text>
|
|
|
|
|
</IconButton>
|
|
|
|
|
<Box grow="Yes">
|
|
|
|
|
<Input
|
|
|
|
|
variant="Surface"
|
|
|
|
|
size="300"
|
|
|
|
|
defaultValue={rowBody}
|
|
|
|
|
readOnly={!canEdit || markedDeleted}
|
|
|
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => commit({ body: e.target.value })}
|
|
|
|
|
aria-label="Clip name"
|
|
|
|
|
/>
|
|
|
|
|
</Box>
|
|
|
|
|
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(120) }}>
|
|
|
|
|
<Icon size="50" src={Icons.VolumeHigh} />
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min={0}
|
|
|
|
|
max={100}
|
|
|
|
|
step={5}
|
|
|
|
|
defaultValue={rowVolume}
|
|
|
|
|
disabled={!canEdit || markedDeleted}
|
|
|
|
|
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
|
|
|
|
|
style={{ flexGrow: 1 }}
|
|
|
|
|
aria-label="Clip volume"
|
|
|
|
|
/>
|
|
|
|
|
</Box>
|
|
|
|
|
{canEdit && !isUpload && (
|
|
|
|
|
<IconButton
|
|
|
|
|
size="300"
|
|
|
|
|
radii="300"
|
|
|
|
|
variant={markedDeleted ? 'Success' : 'Critical'}
|
|
|
|
|
onClick={() =>
|
|
|
|
|
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'}
|
|
|
|
|
>
|
|
|
|
|
<Icon size="100" src={markedDeleted ? Icons.Plus : Icons.Delete} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
)}
|
|
|
|
|
{canEdit && isUpload && (
|
|
|
|
|
<IconButton
|
|
|
|
|
size="300"
|
|
|
|
|
radii="300"
|
|
|
|
|
variant="Critical"
|
|
|
|
|
onClick={() => setUploads((prev) => prev.filter((u) => u.shortcode !== key))}
|
|
|
|
|
aria-label="Remove upload"
|
|
|
|
|
>
|
|
|
|
|
<Icon size="100" src={Icons.Cross} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
)}
|
|
|
|
|
</Box>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Box direction="Column" gap="300">
|
|
|
|
|
<input
|
|
|
|
|
ref={fileInputRef}
|
2026-07-02 11:45:21 -04:00
|
|
|
aria-label="Upload soundboard clip"
|
2026-07-01 23:21:50 -04:00
|
|
|
type="file"
|
|
|
|
|
accept={SOUNDBOARD_ACCEPT}
|
|
|
|
|
multiple
|
|
|
|
|
hidden
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
handleFiles(e.target.files);
|
|
|
|
|
e.target.value = '';
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
|
|
|
|
<Text size="H4">{pack.meta.name ?? 'Soundboard'}</Text>
|
|
|
|
|
{canEdit && (
|
|
|
|
|
<Chip
|
|
|
|
|
variant="Secondary"
|
|
|
|
|
radii="Pill"
|
|
|
|
|
disabled={uploading || clipCount >= SOUNDBOARD_MAX_CLIPS}
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
before={uploading ? <Spinner size="100" /> : <Icon size="100" src={Icons.Plus} />}
|
|
|
|
|
>
|
|
|
|
|
<Text size="B300">Upload</Text>
|
|
|
|
|
</Chip>
|
|
|
|
|
)}
|
|
|
|
|
</Box>
|
|
|
|
|
|
|
|
|
|
<Box direction="Column" gap="100">
|
|
|
|
|
{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 && (
|
|
|
|
|
<Text size="T200" priority="300">
|
|
|
|
|
No clips yet. Upload a short audio clip (max 1 MB){canEdit ? '' : ' — ask an admin'}.
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
</Box>
|
|
|
|
|
|
|
|
|
|
{error && (
|
|
|
|
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
|
|
|
|
{error}
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{canEdit && dirty && (
|
|
|
|
|
<Box gap="200">
|
|
|
|
|
<Button
|
|
|
|
|
size="300"
|
|
|
|
|
variant="Success"
|
|
|
|
|
radii="300"
|
|
|
|
|
disabled={saving}
|
|
|
|
|
onClick={() => save()}
|
|
|
|
|
before={saving ? <Spinner size="100" fill="Solid" /> : undefined}
|
|
|
|
|
>
|
|
|
|
|
<Text size="B300">Save changes</Text>
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="300"
|
|
|
|
|
variant="Secondary"
|
|
|
|
|
fill="Soft"
|
|
|
|
|
radii="300"
|
|
|
|
|
disabled={saving}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setDrafts(new Map());
|
|
|
|
|
setDeleted(new Set());
|
|
|
|
|
setUploads([]);
|
|
|
|
|
setError(undefined);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Text size="B300">Reset</Text>
|
|
|
|
|
</Button>
|
|
|
|
|
</Box>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<PopOut
|
|
|
|
|
anchor={emojiFor ? emojiAnchorRef.current?.getBoundingClientRect() : undefined}
|
|
|
|
|
position="Bottom"
|
|
|
|
|
content={
|
|
|
|
|
<FocusTrap
|
|
|
|
|
focusTrapOptions={{
|
|
|
|
|
initialFocus: false,
|
|
|
|
|
onDeactivate: () => setEmojiFor(undefined),
|
|
|
|
|
clickOutsideDeactivates: true,
|
|
|
|
|
escapeDeactivates: stopPropagation,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<EmojiBoard
|
|
|
|
|
imagePackRooms={[]}
|
|
|
|
|
returnFocusOnDeactivate={false}
|
|
|
|
|
onEmojiSelect={(unicode: string) => {
|
|
|
|
|
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)}
|
|
|
|
|
/>
|
|
|
|
|
</FocusTrap>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<span />
|
|
|
|
|
</PopOut>
|
|
|
|
|
</Box>
|
|
|
|
|
);
|
|
|
|
|
}
|