a9505ca5b2
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>
104 lines
3.9 KiB
TypeScript
104 lines
3.9 KiB
TypeScript
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);
|
|
}
|