Files
cinny/src/app/components/soundboard-pack-view/SoundboardPackEditor.tsx
T
jaredandClaude Opus 4.8 09415f95c0 fix(mobile): native settings controls + soundboard/gif/call polish (M5)
Mobile-audit batch 5. Desktop provably unchanged (two review passes).

- Translate-language control: raw <select> (crowded narrow tiles + broke under
  non-default themes) -> the folds-native SettingsSelect used by every other
  dropdown in the settings screen (native-cinny; keeps aria-label).
- Ringtone/Soundboard volume sliders: moved from the fixed-width tile `after`
  slot (which squeezed the title on phones) to a full-width slider in the tile
  body, matching the night-light slider pattern.
- Screenshare-confirm popover: clamp maxWidth to the viewport so it can't run
  past the screen edges on a phone (inert on desktop).
- In-call soundboard editor rows wrap on a narrow popout instead of crushing the
  clip-name field.
- GifPicker: feed the giphy Grid the measured container width (useElementSize
  Observer) instead of a fixed 296px, so it doesn't overflow a <312px phone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:38:01 -04:00

529 lines
17 KiB
TypeScript

import React, { useCallback, useEffect, 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 {
getAudioDurationMs,
playClipLocally,
resolveClipObjectUrl,
SOUNDBOARD_ACCEPT,
SOUNDBOARD_MAX_CLIP_BYTES,
SOUNDBOARD_MAX_CLIPS,
} from '../../utils/soundboardClips';
import { stopPropagation } from '../../utils/keyboard';
// Injected once: the little "now playing" equalizer bars animation.
const EQ_STYLE_ID = 'lotus-soundboard-eq-keyframes';
function ensureEqKeyframes() {
if (typeof document === 'undefined' || document.getElementById(EQ_STYLE_ID)) return;
const style = document.createElement('style');
style.id = EQ_STYLE_ID;
style.textContent = `
@keyframes lotusSbEq { 0%,100% { transform: scaleY(0.3); } 50% { transform: scaleY(1); } }
@media (prefers-reduced-motion: reduce) { @keyframes lotusSbEq { 0%,100% { transform: scaleY(0.6); } } }
`;
document.head.appendChild(style);
}
function PlayingBars() {
return (
<Box alignItems="Center" gap="100" style={{ height: toRem(14) }} aria-hidden>
{[0, 1, 2].map((i) => (
<span
key={i}
style={{
display: 'inline-block',
width: toRem(3),
height: toRem(14),
borderRadius: toRem(2),
background: color.Primary.Main,
transformOrigin: 'center bottom',
animation: `lotusSbEq 0.7s ease-in-out ${i * 0.15}s infinite`,
}}
/>
))}
</Box>
);
}
// Short clip length shown while adjusting a sound: "3.2s", or "1:04" if ≥ 60s.
const formatClipSeconds = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds < 0) return '';
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
};
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 [playingKey, setPlayingKey] = useState<string>();
const [durations, setDurations] = useState<Map<string, number>>(new Map()); // shortcode -> seconds
const audioElRef = useRef<HTMLAudioElement | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const emojiAnchorRef = useRef<HTMLElement | null>(null);
useEffect(() => {
ensureEqKeyframes();
return () => {
audioElRef.current?.pause();
audioElRef.current = 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 stopPlayback = useCallback(() => {
audioElRef.current?.pause();
audioElRef.current = null;
setPlayingKey(undefined);
}, []);
const preview = useCallback(
async (id: string, mxc: string, volume: number) => {
// Clicking the clip that's already playing stops it (toggle).
if (audioElRef.current && playingKey === id) {
stopPlayback();
return;
}
stopPlayback(); // stop any other clip first
setBusyPreview(id);
try {
const url = await resolveClipObjectUrl(mx, mxc);
const audio = playClipLocally(url, volume / 100);
if (audio) {
audioElRef.current = audio;
setPlayingKey(id);
audio.addEventListener('loadedmetadata', () => {
if (Number.isFinite(audio.duration)) {
setDurations((prev) => new Map(prev).set(id, audio.duration));
}
});
const clear = () => {
if (audioElRef.current === audio) audioElRef.current = null;
setPlayingKey((k) => (k === id ? undefined : k));
};
audio.addEventListener('ended', clear);
audio.addEventListener('pause', clear);
audio.addEventListener('error', clear);
}
} catch {
/* ignore preview errors */
} finally {
setBusyPreview(undefined);
}
},
[mx, playingKey, stopPlayback],
);
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 durationMs = await getAudioDurationMs(file);
// 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, duration: durationMs },
},
]);
}
} 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);
}
};
const isPlaying = playingKey === key;
const clipSeconds =
durations.get(key) ?? (base.info?.duration != null ? base.info.duration / 1000 : undefined);
return (
<Box
key={key}
alignItems="Center"
gap="200"
wrap="Wrap"
style={{
padding: config.space.S200,
borderRadius: config.radii.R400,
background: color.SurfaceVariant.Container,
opacity: markedDeleted ? 0.5 : 1,
}}
>
<IconButton
size="300"
radii="300"
variant={isPlaying ? 'Primary' : 'Secondary'}
disabled={busyPreview === key}
onClick={() => preview(key, base.url, rowVolume)}
aria-label={isPlaying ? `Stop ${rowBody}` : `Preview ${rowBody}`}
>
{busyPreview === key ? (
<Spinner size="100" />
) : (
<Icon size="100" src={isPlaying ? Icons.Pause : Icons.Play} filled={isPlaying} />
)}
</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"
style={{ width: '100%' }}
/>
</Box>
<Box
alignItems="Center"
justifyContent="End"
gap="100"
shrink="No"
style={{ width: toRem(52) }}
>
{isPlaying ? (
<PlayingBars />
) : (
clipSeconds !== undefined && (
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap' }}>
{formatClipSeconds(clipSeconds)}
</Text>
)
)}
</Box>
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(148) }}>
<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, minWidth: 0 }}
aria-label="Clip volume"
/>
<Text size="T200" priority="300" style={{ width: toRem(30), textAlign: 'right' }}>
{rowVolume}%
</Text>
</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}
aria-label="Upload soundboard clip"
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,
info: c.info,
},
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>
);
}