feat(soundboard): shared room/space packs (like emoji/stickers), grid picker, management
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:
@@ -0,0 +1,58 @@
|
||||
import { SoundboardClip, SoundboardClipInfo } from './types';
|
||||
|
||||
/** Parallels custom-emoji/PackImageReader, for a soundboard clip. */
|
||||
export class SoundboardClipReader {
|
||||
public readonly shortcode: string;
|
||||
|
||||
public readonly url: string;
|
||||
|
||||
private readonly clip: Omit<SoundboardClip, 'url'>;
|
||||
|
||||
constructor(shortcode: string, url: string, clip: Omit<SoundboardClip, 'url'>) {
|
||||
this.shortcode = shortcode;
|
||||
this.url = url;
|
||||
this.clip = clip;
|
||||
}
|
||||
|
||||
static fromClip(shortcode: string, clip: SoundboardClip): SoundboardClipReader | undefined {
|
||||
const { url } = clip;
|
||||
if (typeof url !== 'string' || !url.startsWith('mxc://')) return undefined;
|
||||
return new SoundboardClipReader(shortcode, url, clip);
|
||||
}
|
||||
|
||||
get body(): string | undefined {
|
||||
const { body } = this.clip;
|
||||
return typeof body === 'string' ? body : undefined;
|
||||
}
|
||||
|
||||
/** Display name — the clip body, falling back to the shortcode. */
|
||||
get name(): string {
|
||||
return this.body ?? this.shortcode;
|
||||
}
|
||||
|
||||
get emoji(): string | undefined {
|
||||
const { emoji } = this.clip;
|
||||
return typeof emoji === 'string' && emoji.length > 0 ? emoji : undefined;
|
||||
}
|
||||
|
||||
/** Per-clip volume 0–100; defaults to 100 when unset/invalid. */
|
||||
get volume(): number {
|
||||
const v = this.clip.volume;
|
||||
if (typeof v !== 'number' || Number.isNaN(v)) return 100;
|
||||
return Math.min(100, Math.max(0, v));
|
||||
}
|
||||
|
||||
get info(): SoundboardClipInfo | undefined {
|
||||
return this.clip.info;
|
||||
}
|
||||
|
||||
get content(): SoundboardClip {
|
||||
return {
|
||||
url: this.url,
|
||||
body: this.clip.body,
|
||||
emoji: this.clip.emoji,
|
||||
volume: this.clip.volume,
|
||||
info: this.clip.info,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SoundboardClipReader } from './SoundboardClipReader';
|
||||
import { SoundboardClips } from './types';
|
||||
|
||||
/** Parallels custom-emoji/PackImagesReader. */
|
||||
export class SoundboardClipsReader {
|
||||
private readonly rawClips: SoundboardClips;
|
||||
|
||||
private shortcodeToClips: Map<string, SoundboardClipReader> | undefined;
|
||||
|
||||
constructor(clips: SoundboardClips) {
|
||||
this.rawClips = clips;
|
||||
}
|
||||
|
||||
get collection(): Map<string, SoundboardClipReader> {
|
||||
if (this.shortcodeToClips) return this.shortcodeToClips;
|
||||
|
||||
const shortcodeToClips: Map<string, SoundboardClipReader> = new Map();
|
||||
Object.entries(this.rawClips).forEach(([shortcode, clip]) => {
|
||||
const reader = SoundboardClipReader.fromClip(shortcode, clip);
|
||||
if (reader) shortcodeToClips.set(shortcode, reader);
|
||||
});
|
||||
|
||||
this.shortcodeToClips = shortcodeToClips;
|
||||
return this.shortcodeToClips;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { SoundboardMeta } from './types';
|
||||
|
||||
/** Parallels custom-emoji/PackMetaReader (no usage tiers for soundboard). */
|
||||
export class SoundboardMetaReader {
|
||||
private readonly meta: SoundboardMeta;
|
||||
|
||||
constructor(meta: SoundboardMeta) {
|
||||
this.meta = meta;
|
||||
}
|
||||
|
||||
get name(): string | undefined {
|
||||
const displayName = this.meta.display_name;
|
||||
return typeof displayName === 'string' ? displayName : undefined;
|
||||
}
|
||||
|
||||
get avatar(): string | undefined {
|
||||
const avatarURL = this.meta.avatar_url;
|
||||
return typeof avatarURL === 'string' ? avatarURL : undefined;
|
||||
}
|
||||
|
||||
get attribution(): string | undefined {
|
||||
const { attribution } = this.meta;
|
||||
return typeof attribution === 'string' ? attribution : undefined;
|
||||
}
|
||||
|
||||
get content(): SoundboardMeta {
|
||||
return this.meta;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MatrixEvent } from 'matrix-js-sdk';
|
||||
import { PackAddress } from '../custom-emoji/PackAddress';
|
||||
import { SoundboardClipReader } from './SoundboardClipReader';
|
||||
import { SoundboardClipsReader } from './SoundboardClipsReader';
|
||||
import { SoundboardMetaReader } from './SoundboardMetaReader';
|
||||
import { SoundboardContent } from './types';
|
||||
|
||||
/** Parallels custom-emoji/ImagePack. Holds a soundboard pack's meta + clips. */
|
||||
export class SoundboardPack {
|
||||
public readonly id: string;
|
||||
|
||||
public readonly deleted: boolean;
|
||||
|
||||
public readonly address: PackAddress | undefined;
|
||||
|
||||
public readonly meta: SoundboardMetaReader;
|
||||
|
||||
public readonly clips: SoundboardClipsReader;
|
||||
|
||||
private clipsMemo: SoundboardClipReader[] | undefined;
|
||||
|
||||
constructor(id: string, content: SoundboardContent, address: PackAddress | undefined) {
|
||||
this.id = id;
|
||||
this.address = address;
|
||||
this.deleted = content.pack === undefined && content.clips === undefined;
|
||||
this.meta = new SoundboardMetaReader(content.pack ?? {});
|
||||
this.clips = new SoundboardClipsReader(content.clips ?? {});
|
||||
}
|
||||
|
||||
static fromMatrixEvent(id: string, matrixEvent: MatrixEvent): SoundboardPack {
|
||||
const roomId = matrixEvent.getRoomId();
|
||||
const stateKey = matrixEvent.getStateKey();
|
||||
const address =
|
||||
roomId && typeof stateKey === 'string' ? new PackAddress(roomId, stateKey) : undefined;
|
||||
return new SoundboardPack(id, matrixEvent.getContent<SoundboardContent>(), address);
|
||||
}
|
||||
|
||||
getClips(): SoundboardClipReader[] {
|
||||
if (this.clipsMemo) return this.clipsMemo;
|
||||
this.clipsMemo = Array.from(this.clips.collection.values());
|
||||
return this.clipsMemo;
|
||||
}
|
||||
|
||||
getAvatarUrl(): string | undefined {
|
||||
if (this.meta.avatar) return this.meta.avatar;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './types';
|
||||
export * from './SoundboardClipReader';
|
||||
export * from './SoundboardClipsReader';
|
||||
export * from './SoundboardMetaReader';
|
||||
export * from './SoundboardPack';
|
||||
export * from './utils';
|
||||
@@ -0,0 +1,52 @@
|
||||
// [P5-15 v2] Soundboard packs — a near-parallel of the MSC2545 custom-emoji
|
||||
// image packs (see ../custom-emoji/types.ts), for shareable in-call audio clips.
|
||||
|
||||
/** io.lotus.soundboard_rooms content (global refs) — mirrors EmoteRoomsContent. */
|
||||
export type SoundboardPackStateKeyToObject = Record<string, object>;
|
||||
export type SoundboardRoomIdToStateKey = Record<string, SoundboardPackStateKeyToObject>;
|
||||
export type SoundboardRoomsContent = {
|
||||
rooms?: SoundboardRoomIdToStateKey;
|
||||
};
|
||||
|
||||
/** Per-clip media info (audio, so no width/height — unlike IImageInfo). */
|
||||
export type SoundboardClipInfo = {
|
||||
mimetype?: string;
|
||||
size?: number;
|
||||
/** Clip duration in milliseconds, if known. */
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
/** A single soundboard clip (parallels PackImage). Keyed by shortcode in the pack. */
|
||||
export type SoundboardClip = {
|
||||
url: string; // mxc://
|
||||
body?: string; // display name
|
||||
emoji?: string; // emoji tag (like a Discord soundboard emoji)
|
||||
volume?: number; // 0–100, per-clip gain
|
||||
info?: SoundboardClipInfo;
|
||||
};
|
||||
|
||||
export type SoundboardClips = Record<string, SoundboardClip>;
|
||||
|
||||
export type SoundboardMeta = {
|
||||
display_name?: string;
|
||||
avatar_url?: string;
|
||||
attribution?: string;
|
||||
};
|
||||
|
||||
/** io.lotus.soundboard (user account data) + io.lotus.soundboard (room state). */
|
||||
export type SoundboardContent = {
|
||||
pack?: SoundboardMeta;
|
||||
clips?: SoundboardClips;
|
||||
};
|
||||
|
||||
/** Legacy v1 personal soundboard shape (flat list), migrated to a pack on read. */
|
||||
export type LegacySoundboardClip = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
mimetype?: string;
|
||||
size?: number;
|
||||
};
|
||||
export type LegacySoundboardContent = {
|
||||
clips?: LegacySoundboardClip[];
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { migrateUserSoundboardContent, slugifyClipName, uniqueShortcode } from './utils';
|
||||
|
||||
describe('slugifyClipName', () => {
|
||||
test('lowercases, replaces spaces, strips punctuation', () => {
|
||||
assert.equal(slugifyClipName(' Air Horn!! '), 'air_horn');
|
||||
assert.equal(slugifyClipName('Ba-Dum Tss'), 'ba-dum_tss');
|
||||
});
|
||||
test('falls back to "clip" when empty', () => {
|
||||
assert.equal(slugifyClipName(' '), 'clip');
|
||||
assert.equal(slugifyClipName('!!!'), 'clip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uniqueShortcode', () => {
|
||||
test('returns the slug when free', () => {
|
||||
assert.equal(uniqueShortcode('Airhorn', new Set()), 'airhorn');
|
||||
});
|
||||
test('suffixes on collision', () => {
|
||||
assert.equal(uniqueShortcode('Airhorn', new Set(['airhorn'])), 'airhorn-2');
|
||||
assert.equal(uniqueShortcode('Airhorn', new Set(['airhorn', 'airhorn-2'])), 'airhorn-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('migrateUserSoundboardContent', () => {
|
||||
test('migrates the v1 flat list into a v2 pack keyed by slug', () => {
|
||||
const v1 = {
|
||||
clips: [
|
||||
{ id: 'a', name: 'Air Horn', url: 'mxc://x/1', mimetype: 'audio/mpeg', size: 100 },
|
||||
{ id: 'b', name: 'Applause', url: 'mxc://x/2' },
|
||||
],
|
||||
};
|
||||
const out = migrateUserSoundboardContent(v1);
|
||||
assert.deepEqual(Object.keys(out.clips ?? {}).sort(), ['air_horn', 'applause']);
|
||||
assert.equal(out.clips?.air_horn.url, 'mxc://x/1');
|
||||
assert.equal(out.clips?.air_horn.body, 'Air Horn');
|
||||
assert.equal(out.clips?.air_horn.info?.mimetype, 'audio/mpeg');
|
||||
assert.ok(out.pack?.display_name);
|
||||
});
|
||||
|
||||
test('dedupes colliding v1 names', () => {
|
||||
const v1 = {
|
||||
clips: [
|
||||
{ id: 'a', name: 'Horn', url: 'mxc://x/1' },
|
||||
{ id: 'b', name: 'Horn', url: 'mxc://x/2' },
|
||||
],
|
||||
};
|
||||
const out = migrateUserSoundboardContent(v1);
|
||||
assert.deepEqual(Object.keys(out.clips ?? {}).sort(), ['horn', 'horn-2']);
|
||||
});
|
||||
|
||||
test('skips v1 entries without a url', () => {
|
||||
const out = migrateUserSoundboardContent({ clips: [{ id: 'a', name: 'Bad' } as never] });
|
||||
assert.deepEqual(out.clips, {});
|
||||
});
|
||||
|
||||
test('passes a v2 pack through unchanged', () => {
|
||||
const v2 = { pack: { display_name: 'P' }, clips: { horn: { url: 'mxc://x/1', volume: 50 } } };
|
||||
assert.deepEqual(migrateUserSoundboardContent(v2), v2);
|
||||
});
|
||||
|
||||
test('handles empty / non-object input', () => {
|
||||
assert.deepEqual(migrateUserSoundboardContent({}), {});
|
||||
assert.deepEqual(migrateUserSoundboardContent(null), {});
|
||||
assert.deepEqual(migrateUserSoundboardContent(undefined), {});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user