fix(soundboard): upload cap counts staged clips once and per batch
CI / Build & Quality Checks (push) Successful in 1m26s
CI / Trigger Desktop Build (push) Successful in 28s

The guard double-counted staged uploads and read a stale count for every
file in a batch, so a 60-file drop bypassed the cap while a nearly-full
pack refused early. Partition the batch with a running count.

Fixes #31

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 19:46:06 -04:00
co-authored by Claude Opus 5
parent 26c70f5a1d
commit a29be7953b
@@ -73,6 +73,32 @@ const formatClipSeconds = (seconds: number): string => {
return `${m}:${s.toString().padStart(2, '0')}`;
};
/**
* [Gitea #31] Pure running-count cap check for `handleFiles`: given how many
* clips already exist (staged uploads included) before this batch starts,
* decide which of the batch's files fit under `max`. Kept pure/exported so the
* "running count, not a stale double-counted closure value" logic can be unit
* tested without a DOM/MatrixClient.
*/
export function acceptClips<T>(
currentCount: number,
files: readonly T[],
max: number,
): { accepted: T[]; rejected: T[] } {
const accepted: T[] = [];
const rejected: T[] = [];
let count = currentCount;
files.forEach((file) => {
if (count >= max) {
rejected.push(file);
} else {
accepted.push(file);
count += 1;
}
});
return { accepted, rejected };
}
type ClipDraft = {
url: string;
body: string;
@@ -186,11 +212,19 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
...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).`);
}
// [Gitea #31] `clipCount` already includes staged `uploads`, so don't
// add `uploads.length` again here (double-counting). And since
// `setUploads` inside the loop doesn't update this closure's
// `clipCount`, track the running total in a local variable that starts
// from the real current total instead of re-reading a stale value for
// every file in the batch.
const { accepted, rejected } = acceptClips(
clipCount,
Array.from(files),
SOUNDBOARD_MAX_CLIPS,
);
for (let i = 0; i < accepted.length; i += 1) {
const file = accepted[i];
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
throw new Error(`"${file.name}" is too large (max 1 MB).`);
}
@@ -215,6 +249,9 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
},
]);
}
if (rejected.length > 0) {
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed.');
} finally {