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
+160
View File
@@ -0,0 +1,160 @@
import { Room } from 'matrix-js-sdk';
import { useCallback, useMemo, useState } from 'react';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { StateEvent } from '../../types/matrix/room';
import {
getGlobalSoundboardPacks,
getRoomSoundboardPack,
getRoomSoundboardPacks,
getUserSoundboardPack,
SoundboardPack,
} from '../plugins/soundboard';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
import { useStateEventCallback } from './useStateEventCallback';
// Parallels hooks/useImagePacks.ts (custom emoji). Same aggregation shape.
export const useUserSoundboardPack = (): SoundboardPack | undefined => {
const mx = useMatrixClient();
const [userPack, setUserPack] = useState(() => getUserSoundboardPack(mx));
useAccountDataCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === AccountDataEvent.LotusSoundboard) {
setUserPack(getUserSoundboardPack(mx));
}
},
[mx],
),
);
return userPack;
};
export const useGlobalSoundboardPacks = (): SoundboardPack[] => {
const mx = useMatrixClient();
const [globalPacks, setGlobalPacks] = useState(() => getGlobalSoundboardPacks(mx));
useAccountDataCallback(
mx,
useCallback(
(mEvent) => {
if (mEvent.getType() === AccountDataEvent.LotusSoundboardRooms) {
setGlobalPacks(getGlobalSoundboardPacks(mx));
}
},
[mx],
),
);
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
const roomId = mEvent.getRoomId();
const stateKey = mEvent.getStateKey();
if (
mEvent.getType() === StateEvent.LotusSoundboardRoom &&
roomId &&
typeof stateKey === 'string'
) {
const isGlobal = !!globalPacks.find(
(pack) =>
pack.address && pack.address.roomId === roomId && pack.address.stateKey === stateKey,
);
if (isGlobal) setGlobalPacks(getGlobalSoundboardPacks(mx));
}
},
[mx, globalPacks],
),
);
return globalPacks;
};
export const useRoomSoundboardPack = (room: Room, stateKey: string): SoundboardPack | undefined => {
const mx = useMatrixClient();
const [roomPack, setRoomPack] = useState(() => getRoomSoundboardPack(room, stateKey));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
mEvent.getRoomId() === room.roomId &&
mEvent.getType() === StateEvent.LotusSoundboardRoom &&
mEvent.getStateKey() === stateKey
) {
setRoomPack(getRoomSoundboardPack(room, stateKey));
}
},
[room, stateKey],
),
);
return roomPack;
};
export const useRoomSoundboardPacks = (room: Room): SoundboardPack[] => {
const mx = useMatrixClient();
const [roomPacks, setRoomPacks] = useState(() => getRoomSoundboardPacks(room));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
mEvent.getRoomId() === room.roomId &&
mEvent.getType() === StateEvent.LotusSoundboardRoom
) {
setRoomPacks(getRoomSoundboardPacks(room));
}
},
[room],
),
);
return roomPacks;
};
export const useRoomsSoundboardPacks = (rooms: Room[]): SoundboardPack[] => {
const mx = useMatrixClient();
const [roomPacks, setRoomPacks] = useState(() => rooms.flatMap(getRoomSoundboardPacks));
useStateEventCallback(
mx,
useCallback(
(mEvent) => {
if (
rooms.find((room) => room.roomId === mEvent.getRoomId()) &&
mEvent.getType() === StateEvent.LotusSoundboardRoom
) {
setRoomPacks(rooms.flatMap(getRoomSoundboardPacks));
}
},
[rooms],
),
);
return roomPacks;
};
/** User global room packs, deduped by id, keeping only packs with clips. */
export const useRelevantSoundboardPacks = (rooms: Room[]): SoundboardPack[] => {
const userPack = useUserSoundboardPack();
const globalPacks = useGlobalSoundboardPacks();
const roomsPacks = useRoomsSoundboardPacks(rooms);
return useMemo(() => {
const packs = userPack ? [userPack] : [];
const globalPackIds = new Set(globalPacks.map((pack) => pack.id));
const relPacks = packs.concat(
globalPacks,
roomsPacks.filter((pack) => !globalPackIds.has(pack.id)),
);
return relPacks.filter((pack) => pack.getClips().length > 0);
}, [userPack, globalPacks, roomsPacks]);
};