feat(soundboard): shared room/space packs (like emoji/stickers), grid picker, management
CI / Build & Quality Checks (push) Successful in 10m56s
CI / Trigger Desktop Build (push) Successful in 8s

Soundboard v2 — a near-parallel of the custom-emoji image-pack system for
in-call audio clips.

- Data model: 3-tier packs mirroring MSC2545 — room/space pack (state event
  io.lotus.soundboard, inherited by child rooms via parent-space aggregation),
  global refs (io.lotus.soundboard_rooms), and the personal pack
  (io.lotus.soundboard account data; the v1 flat-list content is migrated to the
  pack shape on read). New plugins/soundboard/ (readers, SoundboardPack, utils) +
  hooks/useSoundboardPacks (useRelevantSoundboardPacks = user U global U room,
  deduped). Unit-tested (migration + slug).
- Management: reusable SoundboardPackEditor (name + emoji + per-clip volume +
  delete + upload + batched save), power-level-gated for room packs like emoji
  packs; a Soundboard page wired into Room + Space settings.
- In-call: CallSoundboard rewritten as a Discord-style grid grouped by pack
  (emoji + name tiles), sourcing room+parent-space U personal clips; a Manage
  toggle embeds the editors; per-clip volume x master volume on playback.
- Spam guard: host gates on a playing key (fork enforces one clip at a time).
- Control bar: Mute-Screenshare moved next to the Screenshare button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 23:21:50 -04:00
parent dca51a41ef
commit a9505ca5b2
25 changed files with 1353 additions and 304 deletions
+103
View File
@@ -0,0 +1,103 @@
import { MatrixClient, MatrixEvent, Room } from 'matrix-js-sdk';
import { SoundboardPack } from './SoundboardPack';
import {
LegacySoundboardContent,
SoundboardClips,
SoundboardContent,
SoundboardRoomsContent,
} from './types';
import { StateEvent } from '../../../types/matrix/room';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { getAccountData, getStateEvent, getStateEvents } from '../../utils/room';
/** Normalize a display name into a pack shortcode key (parallels emoji shortcodes). */
export function slugifyClipName(name: string): string {
const s = name
.trim()
.toLowerCase()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_-]/g, '');
return s || 'clip';
}
/** Pick a shortcode not already present in `taken`, suffixing on collision. */
export function uniqueShortcode(base: string, taken: Set<string>): string {
const code = slugifyClipName(base);
if (!taken.has(code)) return code;
let i = 2;
while (taken.has(`${code}-${i}`)) i += 1;
return `${code}-${i}`;
}
export function makeSoundboardPacks(packEvents: MatrixEvent[]): SoundboardPack[] {
return packEvents.reduce<SoundboardPack[]>((packs, packEvent) => {
const packId = packEvent.getId();
if (!packId) return packs;
packs.push(SoundboardPack.fromMatrixEvent(packId, packEvent));
return packs;
}, []);
}
export function getRoomSoundboardPack(room: Room, stateKey: string): SoundboardPack | undefined {
const packEvent = getStateEvent(room, StateEvent.LotusSoundboardRoom, stateKey);
if (!packEvent) return undefined;
const packId = packEvent.getId();
if (!packId) return undefined;
return SoundboardPack.fromMatrixEvent(packId, packEvent);
}
export function getRoomSoundboardPacks(room: Room): SoundboardPack[] {
return makeSoundboardPacks(getStateEvents(room, StateEvent.LotusSoundboardRoom));
}
export function getGlobalSoundboardPacks(mx: MatrixClient): SoundboardPack[] {
const content = getAccountData(mx, AccountDataEvent.LotusSoundboardRooms)?.getContent() as
| SoundboardRoomsContent
| undefined;
const roomIdToPackInfo = content?.rooms;
if (typeof roomIdToPackInfo !== 'object' || !roomIdToPackInfo) return [];
return Object.keys(roomIdToPackInfo).flatMap((roomId) => {
if (typeof roomIdToPackInfo[roomId] !== 'object') return [];
const room = mx.getRoom(roomId);
if (!room) return [];
const stateKeys = roomIdToPackInfo[roomId];
const globalEvents = getStateEvents(room, StateEvent.LotusSoundboardRoom).filter((mE) => {
const stateKey = mE.getStateKey();
return typeof stateKey === 'string' ? !!stateKeys[stateKey] : false;
});
return makeSoundboardPacks(globalEvents);
});
}
/**
* Convert a personal soundboard account-data content to the v2 pack shape,
* migrating the v1 flat-list form (`{clips: [{id,name,url}]}`) on the fly.
*/
export function migrateUserSoundboardContent(raw: unknown): SoundboardContent {
if (typeof raw !== 'object' || raw === null) return {};
const legacy = raw as LegacySoundboardContent;
if (!Array.isArray(legacy.clips)) return raw as SoundboardContent; // already v2 (or empty)
const clips: SoundboardClips = {};
const taken = new Set<string>();
legacy.clips.forEach((c) => {
if (!c || typeof c.url !== 'string') return;
const shortcode = uniqueShortcode(c.name || 'clip', taken);
taken.add(shortcode);
clips[shortcode] = {
url: c.url,
body: c.name,
info: { mimetype: c.mimetype, size: c.size },
};
});
return { pack: { display_name: 'My Soundboard' }, clips };
}
export function getUserSoundboardPack(mx: MatrixClient): SoundboardPack | undefined {
const packEvent = getAccountData(mx, AccountDataEvent.LotusSoundboard);
const userId = mx.getUserId();
if (!packEvent || !userId) return undefined;
const content = migrateUserSoundboardContent(packEvent.getContent());
return new SoundboardPack(userId, content, undefined);
}