diff --git a/src/app/components/soundboard-pack-view/RoomSoundboardPack.tsx b/src/app/components/soundboard-pack-view/RoomSoundboardPack.tsx new file mode 100644 index 000000000..e60a7f418 --- /dev/null +++ b/src/app/components/soundboard-pack-view/RoomSoundboardPack.tsx @@ -0,0 +1,49 @@ +import React, { useCallback, useMemo } from 'react'; +import { Room } from 'matrix-js-sdk'; +import { usePowerLevels } from '../../hooks/usePowerLevels'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { SoundboardPackEditor } from './SoundboardPackEditor'; +import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard'; +import { StateEvent } from '../../../types/matrix/room'; +import { useRoomSoundboardPack } from '../../hooks/useSoundboardPacks'; +import { PackAddress } from '../../plugins/custom-emoji/PackAddress'; +import { randomStr } from '../../utils/common'; +import { useRoomPermissions } from '../../hooks/useRoomPermissions'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; + +type RoomSoundboardPackProps = { + room: Room; + stateKey: string; +}; + +export function RoomSoundboardPack({ room, stateKey }: RoomSoundboardPackProps) { + const mx = useMatrixClient(); + const userId = mx.getUserId()!; + const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); + const permissions = useRoomPermissions(creators, powerLevels); + const canEdit = permissions.stateEvent( + StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents, + userId, + ); + + const fallbackPack = useMemo( + () => new SoundboardPack(randomStr(4), {}, new PackAddress(room.roomId, stateKey)), + [room.roomId, stateKey], + ); + const pack = useRoomSoundboardPack(room, stateKey) ?? fallbackPack; + + const handleUpdate = useCallback( + async (content: SoundboardContent) => { + await mx.sendStateEvent( + room.roomId, + StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents, + content as never, + stateKey, + ); + }, + [mx, room.roomId, stateKey], + ); + + return ; +} diff --git a/src/app/components/soundboard-pack-view/SoundboardPackEditor.tsx b/src/app/components/soundboard-pack-view/SoundboardPackEditor.tsx new file mode 100644 index 000000000..2188c0252 --- /dev/null +++ b/src/app/components/soundboard-pack-view/SoundboardPackEditor.tsx @@ -0,0 +1,407 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { + Box, + Button, + Chip, + Icon, + IconButton, + Icons, + Input, + PopOut, + Spinner, + Text, + color, + config, + toRem, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { EmojiBoard } from '../emoji-board'; +import { SoundboardClip, SoundboardContent, SoundboardPack } from '../../plugins/soundboard'; +import { uniqueShortcode } from '../../plugins/soundboard/utils'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { + playClipLocally, + resolveClipObjectUrl, + SOUNDBOARD_ACCEPT, + SOUNDBOARD_MAX_CLIP_BYTES, + SOUNDBOARD_MAX_CLIPS, +} from '../../utils/soundboardClips'; +import { stopPropagation } from '../../utils/keyboard'; + +type ClipDraft = { + url: string; + body: string; + emoji: string; + volume: number; + info?: SoundboardClip['info']; +}; + +type SoundboardPackEditorProps = { + pack: SoundboardPack; + canEdit?: boolean; + onUpdate: (content: SoundboardContent) => Promise; +}; + +/** + * Reusable single-pack soundboard manager (used by the settings page and the + * in-call management mode). Mirrors image-pack-view/ImagePackContent's staged- + * edit + batched-save pattern, but per-clip fields are name + emoji + volume. + */ +export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPackEditorProps) { + const mx = useMatrixClient(); + + // Staged, unsaved state: + const [drafts, setDrafts] = useState>(new Map()); // shortcode -> edits + const [deleted, setDeleted] = useState>(new Set()); + const [uploads, setUploads] = useState>([]); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(); + const [emojiFor, setEmojiFor] = useState(); // shortcode currently picking an emoji + const [busyPreview, setBusyPreview] = useState(); + const fileInputRef = useRef(null); + const emojiAnchorRef = useRef(null); + + const existing = useMemo(() => pack.getClips(), [pack]); + const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length; + + const dirty = drafts.size > 0 || deleted.size > 0 || uploads.length > 0; + + const draftFor = (shortcode: string, base: { body: string; emoji: string; volume: number }) => + drafts.get(shortcode) ?? { url: '', ...base }; + + const setDraft = (shortcode: string, patch: Partial, base: ClipDraft) => { + setDrafts((prev) => { + const next = new Map(prev); + next.set(shortcode, { ...base, ...(next.get(shortcode) ?? {}), ...patch }); + return next; + }); + }; + + const preview = useCallback( + async (id: string, mxc: string, volume: number) => { + setBusyPreview(id); + try { + const url = await resolveClipObjectUrl(mx, mxc); + playClipLocally(url, volume / 100); + } catch { + /* ignore preview errors */ + } finally { + setBusyPreview(undefined); + } + }, + [mx], + ); + + const handleFiles = useCallback( + async (files: FileList | null) => { + if (!files || files.length === 0) return; + setUploading(true); + setError(undefined); + try { + const taken = new Set([ + ...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).`); + } + if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) { + throw new Error(`"${file.name}" is too large (max 1 MB).`); + } + // eslint-disable-next-line no-await-in-loop + const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' }); + const mxc = res.content_uri; + if (!mxc) throw new Error('Upload failed.'); + const name = file.name.replace(/\.[^/.]+$/, ''); + const shortcode = uniqueShortcode(name, taken); + taken.add(shortcode); + setUploads((prev) => [ + ...prev, + { + shortcode, + url: mxc, + body: name, + emoji: '', + volume: 100, + info: { mimetype: file.type || undefined, size: file.size }, + }, + ]); + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Upload failed.'); + } finally { + setUploading(false); + } + }, + [mx, existing, uploads, clipCount], + ); + + const [saveState, save] = useAsyncCallback( + useCallback(async () => { + const clips: Record = {}; + existing.forEach((c) => { + if (deleted.has(c.shortcode)) return; + const d = drafts.get(c.shortcode); + clips[c.shortcode] = { + url: c.url, + body: d ? d.body : c.body, + emoji: d ? d.emoji || undefined : c.emoji, + volume: d ? d.volume : c.volume, + info: c.info, + }; + }); + uploads.forEach((u) => { + clips[u.shortcode] = { + url: u.url, + body: u.body, + emoji: u.emoji || undefined, + volume: u.volume, + info: u.info, + }; + }); + await onUpdate({ pack: pack.meta.content, clips }); + setDrafts(new Map()); + setDeleted(new Set()); + setUploads([]); + }, [existing, deleted, drafts, uploads, onUpdate, pack]), + ); + const saving = saveState.status === AsyncStatus.Loading; + + const renderRow = (key: string, base: ClipDraft, isUpload: boolean, markedDeleted: boolean) => { + const d = isUpload ? base : draftFor(key, base); + const rowVolume = isUpload ? base.volume : d.volume; + const rowBody = isUpload ? base.body : d.body; + const rowEmoji = isUpload ? base.emoji : d.emoji; + const commit = (patch: Partial) => { + if (isUpload) { + setUploads((prev) => prev.map((u) => (u.shortcode === key ? { ...u, ...patch } : u))); + } else { + setDraft(key, patch, base); + } + }; + return ( + + preview(key, base.url, rowVolume)} + aria-label={`Preview ${rowBody}`} + > + {busyPreview === key ? : } + + ) => { + emojiAnchorRef.current = evt.currentTarget; + setEmojiFor(key); + }} + aria-label="Pick emoji" + > + {rowEmoji || '🔊'} + + + ) => commit({ body: e.target.value })} + aria-label="Clip name" + /> + + + + commit({ volume: parseInt(e.target.value, 10) })} + style={{ flexGrow: 1 }} + aria-label="Clip volume" + /> + + {canEdit && !isUpload && ( + + setDeleted((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }) + } + aria-label={markedDeleted ? 'Undo delete' : 'Delete clip'} + > + + + )} + {canEdit && isUpload && ( + setUploads((prev) => prev.filter((u) => u.shortcode !== key))} + aria-label="Remove upload" + > + + + )} + + ); + }; + + return ( + + { + handleFiles(e.target.files); + e.target.value = ''; + }} + /> + + + {pack.meta.name ?? 'Soundboard'} + {canEdit && ( + = SOUNDBOARD_MAX_CLIPS} + onClick={() => fileInputRef.current?.click()} + before={uploading ? : } + > + Upload + + )} + + + + {existing.map((c) => + renderRow( + c.shortcode, + { url: c.url, body: c.body ?? c.shortcode, emoji: c.emoji ?? '', volume: c.volume }, + false, + deleted.has(c.shortcode), + ), + )} + {uploads.map((u) => renderRow(u.shortcode, u, true, false))} + {existing.length === 0 && uploads.length === 0 && ( + + No clips yet. Upload a short audio clip (max 1 MB){canEdit ? '' : ' — ask an admin'}. + + )} + + + {error && ( + + {error} + + )} + + {canEdit && dirty && ( + + + + + )} + + setEmojiFor(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + { + const key = emojiFor; + setEmojiFor(undefined); + if (!key) return; + const up = uploads.find((u) => u.shortcode === key); + if (up) { + setUploads((prev) => + prev.map((u) => (u.shortcode === key ? { ...u, emoji: unicode } : u)), + ); + } else { + const c = existing.find((x) => x.shortcode === key); + if (c) + setDraft( + key, + { emoji: unicode }, + { + url: c.url, + body: c.body ?? c.shortcode, + emoji: c.emoji ?? '', + volume: c.volume, + }, + ); + } + }} + requestClose={() => setEmojiFor(undefined)} + /> + + } + > + + + + ); +} diff --git a/src/app/components/soundboard-pack-view/UserSoundboardPack.tsx b/src/app/components/soundboard-pack-view/UserSoundboardPack.tsx new file mode 100644 index 000000000..57eaebbe1 --- /dev/null +++ b/src/app/components/soundboard-pack-view/UserSoundboardPack.tsx @@ -0,0 +1,32 @@ +import React, { useCallback, useMemo } from 'react'; +import { SoundboardPackEditor } from './SoundboardPackEditor'; +import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { AccountDataEvent } from '../../../types/matrix/accountData'; +import { useUserSoundboardPack } from '../../hooks/useSoundboardPacks'; + +export function UserSoundboardPack() { + const mx = useMatrixClient(); + const defaultPack = useMemo( + () => + new SoundboardPack( + mx.getUserId() ?? '', + { pack: { display_name: 'My Soundboard' } }, + undefined, + ), + [mx], + ); + const pack = useUserSoundboardPack() ?? defaultPack; + + const handleUpdate = useCallback( + async (content: SoundboardContent) => { + await mx.setAccountData( + AccountDataEvent.LotusSoundboard as unknown as keyof import('matrix-js-sdk').AccountDataEvents, + content as never, + ); + }, + [mx], + ); + + return ; +} diff --git a/src/app/components/soundboard-pack-view/index.ts b/src/app/components/soundboard-pack-view/index.ts new file mode 100644 index 000000000..c9ebf311b --- /dev/null +++ b/src/app/components/soundboard-pack-view/index.ts @@ -0,0 +1,3 @@ +export * from './SoundboardPackEditor'; +export * from './RoomSoundboardPack'; +export * from './UserSoundboardPack'; diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx index a27ab30ef..882754993 100644 --- a/src/app/features/call/CallControls.tsx +++ b/src/app/features/call/CallControls.tsx @@ -351,10 +351,6 @@ export function CallControls({ callEmbed }: CallControlsProps) { callEmbed.control.toggleSound()} /> - callEmbed.control.toggleScreenshareAudio()} - /> {!compact && showVideoGroup && } {showVideoGroup && ( @@ -363,12 +359,20 @@ export function CallControls({ callEmbed }: CallControlsProps) { user can stop it; once stopped it hides and can't be restarted. */} {showCamera && } {showScreenshare && ( - - screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true) - } - /> + <> + + screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true) + } + /> + {/* Mute-screenshare-audio sits directly next to the screenshare + control since they're the same concern. */} + callEmbed.control.toggleScreenshareAudio()} + /> + )} {!!document.fullscreenEnabled && ( diff --git a/src/app/features/call/CallSoundboard.tsx b/src/app/features/call/CallSoundboard.tsx index 05f711435..c69e41d8a 100644 --- a/src/app/features/call/CallSoundboard.tsx +++ b/src/app/features/call/CallSoundboard.tsx @@ -1,220 +1,254 @@ -import React, { MouseEventHandler, useCallback, useRef, useState } from 'react'; +import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; import { Box, - Chip, Icon, IconButton, Icons, Menu, PopOut, RectCords, + Scroll, Spinner, + Switch, Text, Tooltip, TooltipProvider, color, config, + toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; +import { useAtomValue } from 'jotai'; import { CallEmbed } from '../../plugins/call'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { useSoundboard } from '../../hooks/useSoundboard'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; +import { roomToParentsAtom } from '../../state/room/roomToParents'; +import { useImagePackRooms } from '../../hooks/useImagePackRooms'; +import { useRelevantSoundboardPacks } from '../../hooks/useSoundboardPacks'; +import { SoundboardClipReader } from '../../plugins/soundboard'; +import { UserSoundboardPack, RoomSoundboardPack } from '../../components/soundboard-pack-view'; import { stopPropagation } from '../../utils/keyboard'; -import { - SOUNDBOARD_ACCEPT, - SOUNDBOARD_MAX_CLIPS, - playClipLocally, - resolveClipObjectUrl, -} from '../../utils/soundboardClips'; +import { playClipLocally, resolveClipObjectUrl } from '../../utils/soundboardClips'; type CallSoundboardProps = { callEmbed: CallEmbed; }; +type FlatClip = { + key: string; // packId|shortcode + packId: string; + packName: string; + clip: SoundboardClipReader; +}; + /** - * [P5-15] In-call soundboard: trigger user-uploaded clips into the call. Each - * clip is published to peers as a separate track by the EC fork - * (`io.lotus.inject_audio`) and also played locally for the presser's feedback. - * Clips are uploadable/managed here and synced across devices via the - * `io.lotus.soundboard` account data (like custom emoji/sticker packs). + * [P5-15 v2] In-call soundboard. Clips come from the aggregated soundboard packs + * relevant to the call room (the room + parent spaces ∪ the user's personal + * pack), just like custom emoji. Playing a clip publishes it into the call via + * the EC fork (`io.lotus.inject_audio`, max one at a time) and plays it locally. + * A management toggle reveals the pack editors (personal + this room, if + * permitted). Space-wide packs are managed from Space settings. */ export function CallSoundboard({ callEmbed }: CallSoundboardProps) { const mx = useMatrixClient(); - const { clips, addClip, removeClip } = useSoundboard(); + const { room } = callEmbed; + const roomToParents = useAtomValue(roomToParentsAtom); + const packRooms = useImagePackRooms(room.roomId, roomToParents); + const packs = useRelevantSoundboardPacks(packRooms); const [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume'); + const master = Math.max(0, Math.min(1, soundboardVolume / 100)); const [cords, setCords] = useState(); - const [busyId, setBusyId] = useState(); - const [uploading, setUploading] = useState(false); + const [manage, setManage] = useState(false); + const [playingKey, setPlayingKey] = useState(); // host-side spam guard const [error, setError] = useState(); - const fileInputRef = useRef(null); - const volume = Math.max(0, Math.min(1, soundboardVolume / 100)); + const groups = useMemo( + () => + packs + .map((pack) => ({ + id: pack.id, + name: pack.meta.name ?? 'Soundboard', + clips: pack.getClips(), + })) + .filter((g) => g.clips.length > 0), + [packs], + ); const handleOpen: MouseEventHandler = (evt) => { setError(undefined); setCords(evt.currentTarget.getBoundingClientRect()); }; - const handlePlay = useCallback( - async (id: string, mxc: string) => { - setBusyId(id); + const play = useCallback( + async (flat: FlatClip) => { + if (playingKey) return; // one at a time (fork also enforces this) + setPlayingKey(flat.key); setError(undefined); + const done = () => setPlayingKey((k) => (k === flat.key ? undefined : k)); try { - const objectUrl = await resolveClipObjectUrl(mx, mxc); - callEmbed.control.injectAudio(objectUrl, volume); - playClipLocally(objectUrl, volume); + const url = await resolveClipObjectUrl(mx, flat.clip.url); + const vol = (flat.clip.volume / 100) * master; + callEmbed.control.injectAudio(url, vol); + const audio = playClipLocally(url, vol); + if (audio) { + audio.addEventListener('ended', done, { once: true }); + audio.addEventListener('error', done, { once: true }); + } else { + done(); + } + // Safety: clear the guard even if the audio never signals end. + window.setTimeout(done, 30_000); } catch { setError('Could not play that clip.'); - } finally { - setBusyId(undefined); + done(); } }, - [mx, callEmbed, volume], - ); - - const handleFile = useCallback( - async (file: File | undefined) => { - if (!file) return; - setUploading(true); - setError(undefined); - try { - await addClip(file); - } catch (e) { - setError(e instanceof Error ? e.message : 'Upload failed.'); - } finally { - setUploading(false); - } - }, - [addClip], + [mx, callEmbed, master, playingKey], ); return ( - <> - { - handleFile(e.target.files?.[0]); - e.target.value = ''; - }} - /> - setCords(undefined), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - - - - Soundboard - = SOUNDBOARD_MAX_CLIPS} - onClick={() => fileInputRef.current?.click()} - before={ - uploading ? : - } - > - Upload - - - - {clips.length === 0 ? ( + setCords(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + + Soundboard + - No clips yet. Upload a short audio clip (max 1 MB) to play it into the call. - Clips sync across your devices. + Manage - ) : ( - - {clips.map((clip) => ( - - handlePlay(clip.id, clip.url)} - before={ - busyId === clip.id ? ( - - ) : ( - - ) - } - after={ - { - e.stopPropagation(); - removeClip(clip.id); - }} - /> - } - > - - {clip.name} - - - - ))} - - )} - - {error && ( - - {error} - - )} + + - - + + + + {manage ? ( + <> + + + + ) : ( + <> + {groups.length === 0 && ( + + No soundboard clips here yet. Turn on Manage to upload some, or add + a pack in Space settings. + + )} + {groups.map((g) => ( + + {g.name} + + {g.clips.map((clip) => { + const key = `${g.id}|${clip.shortcode}`; + const flat: FlatClip = { + key, + packId: g.id, + packName: g.name, + clip, + }; + return ( + play(flat)} + aria-label={`Play ${clip.name}`} + style={{ + width: toRem(76), + height: toRem(76), + padding: config.space.S100, + borderRadius: config.radii.R400, + border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + background: + playingKey === key + ? color.Primary.Container + : color.SurfaceVariant.Container, + cursor: playingKey ? 'default' : 'pointer', + opacity: playingKey && playingKey !== key ? 0.5 : 1, + }} + > + + {playingKey === key ? ( + + ) : ( + clip.emoji || '🔊' + )} + + + {clip.name} + + + ); + })} + + + ))} + + )} + {error && ( + + {error} + + )} + + + + + + } + > + + Soundboard + } > - - Soundboard - - } - > - {(triggerRef) => ( - - - - )} - - - + {(triggerRef) => ( + + + + )} + + ); } diff --git a/src/app/features/common-settings/soundboard/Soundboard.tsx b/src/app/features/common-settings/soundboard/Soundboard.tsx new file mode 100644 index 000000000..201090768 --- /dev/null +++ b/src/app/features/common-settings/soundboard/Soundboard.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; +import { Page, PageContent, PageHeader } from '../../../components/page'; +import { useRoom } from '../../../hooks/useRoom'; +import { RoomSoundboardPack, UserSoundboardPack } from '../../../components/soundboard-pack-view'; + +type SoundboardProps = { + requestClose: () => void; +}; + +/** + * Soundboard management page (Room/Space settings). Mirrors the Emojis & + * Stickers page: a shared room/space pack (admin-editable, inherited by child + * rooms like emoji packs) plus the user's personal pack. A single default room + * pack (state key "") is used per room/space. + */ +export function Soundboard({ requestClose }: SoundboardProps) { + const room = useRoom(); + + return ( + + + + + + Soundboard + + + + + + + + + + + + + + + This room / space (shared) + + Clips here are shared with everyone, and inherited by every room under this space + — just like emoji/sticker packs. Only members with permission can edit. + + {room && } + + + Personal + + Your own clips, available in every call and synced across your devices. + + + + + + + + + ); +} diff --git a/src/app/features/common-settings/soundboard/index.ts b/src/app/features/common-settings/soundboard/index.ts new file mode 100644 index 000000000..35adb41fc --- /dev/null +++ b/src/app/features/common-settings/soundboard/index.ts @@ -0,0 +1 @@ +export * from './Soundboard'; diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx index e19139cbf..70367251d 100644 --- a/src/app/features/room-settings/RoomSettings.tsx +++ b/src/app/features/room-settings/RoomSettings.tsx @@ -13,6 +13,7 @@ import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { General } from './general'; import { Members } from '../common-settings/members'; import { EmojisStickers } from '../common-settings/emojis-stickers'; +import { Soundboard } from '../common-settings/soundboard'; import { Permissions } from './permissions'; import { RoomSettingsPage } from '../../state/roomSettings'; import { useRoom } from '../../hooks/useRoom'; @@ -53,6 +54,11 @@ const BASE_MENU_ITEMS: RoomSettingsMenuItem[] = [ name: 'Emojis & Stickers', icon: Icons.Smile, }, + { + page: RoomSettingsPage.SoundboardPage, + name: 'Soundboard', + icon: Icons.Bell, + }, { page: RoomSettingsPage.DeveloperToolsPage, name: 'Developer Tools', @@ -226,6 +232,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) { {activePage === RoomSettingsPage.EmojisStickersPage && ( )} + {activePage === RoomSettingsPage.SoundboardPage && ( + + )} {activePage === RoomSettingsPage.DeveloperToolsPage && ( )} diff --git a/src/app/features/space-settings/SpaceSettings.tsx b/src/app/features/space-settings/SpaceSettings.tsx index 937201e08..b53908d15 100644 --- a/src/app/features/space-settings/SpaceSettings.tsx +++ b/src/app/features/space-settings/SpaceSettings.tsx @@ -13,6 +13,7 @@ import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { SpaceSettingsPage } from '../../state/spaceSettings'; import { useRoom } from '../../hooks/useRoom'; import { EmojisStickers } from '../common-settings/emojis-stickers'; +import { Soundboard } from '../common-settings/soundboard'; import { Members } from '../common-settings/members'; import { DeveloperTools } from '../common-settings/developer-tools'; import { General } from './general'; @@ -48,6 +49,11 @@ const BASE_SPACE_MENU_ITEMS: SpaceSettingsMenuItem[] = [ name: 'Emojis & Stickers', icon: Icons.Smile, }, + { + page: SpaceSettingsPage.SoundboardPage, + name: 'Soundboard', + icon: Icons.Bell, + }, { page: SpaceSettingsPage.DeveloperToolsPage, name: 'Developer Tools', @@ -190,6 +196,9 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps) {activePage === SpaceSettingsPage.EmojisStickersPage && ( )} + {activePage === SpaceSettingsPage.SoundboardPage && ( + + )} {activePage === SpaceSettingsPage.DeveloperToolsPage && ( )} diff --git a/src/app/hooks/useSoundboard.ts b/src/app/hooks/useSoundboard.ts deleted file mode 100644 index e76d3fa45..000000000 --- a/src/app/hooks/useSoundboard.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useMatrixClient } from './useMatrixClient'; -import { useAccountDataCallback } from './useAccountDataCallback'; -import { AccountDataEvent } from '../../types/matrix/accountData'; -import { - SoundboardClip, - SoundboardContent, - SOUNDBOARD_MAX_CLIP_BYTES, - SOUNDBOARD_MAX_CLIPS, - SOUNDBOARD_NAME_MAX, - readSoundboardClips, -} from '../utils/soundboardClips'; - -const KEY = AccountDataEvent.LotusSoundboard; - -/** - * [P5-15] Read/write the user's personal soundboard, stored in the - * `io.lotus.soundboard` account data event (synced across devices like custom - * emoji/sticker packs). Uploading writes the audio to the media repo and - * appends an mxc reference. - */ -export function useSoundboard(): { - clips: SoundboardClip[]; - addClip: (file: File, name?: string) => Promise; - removeClip: (id: string) => Promise; - renameClip: (id: string, name: string) => Promise; -} { - const mx = useMatrixClient(); - const [clips, setClips] = useState(() => readSoundboardClips(mx)); - - useAccountDataCallback( - mx, - useCallback((evt) => { - if (evt.getType() === KEY) { - const content = evt.getContent(); - setClips(Array.isArray(content?.clips) ? content.clips : []); - } - }, []), - ); - - useEffect(() => { - setClips(readSoundboardClips(mx)); - }, [mx]); - - const persist = useCallback( - async (next: SoundboardClip[]) => { - const content: SoundboardContent = { clips: next }; - await ( - mx as unknown as { setAccountData: (t: string, c: unknown) => Promise } - ).setAccountData(KEY, content); - }, - [mx], - ); - - const addClip = useCallback( - async (file: File, name?: string) => { - const current = readSoundboardClips(mx); - if (current.length >= SOUNDBOARD_MAX_CLIPS) { - throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`); - } - if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) { - throw new Error('Clip is too large (max 1 MB).'); - } - const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' }); - const mxc = res.content_uri; - if (!mxc) throw new Error('Upload failed.'); - const label = (name ?? file.name.replace(/\.[^/.]+$/, '')) - .trim() - .slice(0, SOUNDBOARD_NAME_MAX); - const clip: SoundboardClip = { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - name: label || 'Clip', - url: mxc, - mimetype: file.type || undefined, - size: file.size, - }; - await persist([...current, clip]); - }, - [mx, persist], - ); - - const removeClip = useCallback( - async (id: string) => { - const next = readSoundboardClips(mx).filter((c) => c.id !== id); - await persist(next); - }, - [mx, persist], - ); - - const renameClip = useCallback( - async (id: string, name: string) => { - const trimmed = name.trim().slice(0, SOUNDBOARD_NAME_MAX); - if (!trimmed) return; - const next = readSoundboardClips(mx).map((c) => (c.id === id ? { ...c, name: trimmed } : c)); - await persist(next); - }, - [mx, persist], - ); - - return { clips, addClip, removeClip, renameClip }; -} diff --git a/src/app/hooks/useSoundboardPacks.ts b/src/app/hooks/useSoundboardPacks.ts new file mode 100644 index 000000000..2370241f4 --- /dev/null +++ b/src/app/hooks/useSoundboardPacks.ts @@ -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]); +}; diff --git a/src/app/plugins/soundboard/SoundboardClipReader.ts b/src/app/plugins/soundboard/SoundboardClipReader.ts new file mode 100644 index 000000000..947058343 --- /dev/null +++ b/src/app/plugins/soundboard/SoundboardClipReader.ts @@ -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; + + constructor(shortcode: string, url: string, clip: Omit) { + 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, + }; + } +} diff --git a/src/app/plugins/soundboard/SoundboardClipsReader.ts b/src/app/plugins/soundboard/SoundboardClipsReader.ts new file mode 100644 index 000000000..ef7147155 --- /dev/null +++ b/src/app/plugins/soundboard/SoundboardClipsReader.ts @@ -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 | undefined; + + constructor(clips: SoundboardClips) { + this.rawClips = clips; + } + + get collection(): Map { + if (this.shortcodeToClips) return this.shortcodeToClips; + + const shortcodeToClips: Map = 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; + } +} diff --git a/src/app/plugins/soundboard/SoundboardMetaReader.ts b/src/app/plugins/soundboard/SoundboardMetaReader.ts new file mode 100644 index 000000000..8ef8ecb9b --- /dev/null +++ b/src/app/plugins/soundboard/SoundboardMetaReader.ts @@ -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; + } +} diff --git a/src/app/plugins/soundboard/SoundboardPack.ts b/src/app/plugins/soundboard/SoundboardPack.ts new file mode 100644 index 000000000..ad0eb0de3 --- /dev/null +++ b/src/app/plugins/soundboard/SoundboardPack.ts @@ -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(), 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; + } +} diff --git a/src/app/plugins/soundboard/index.ts b/src/app/plugins/soundboard/index.ts new file mode 100644 index 000000000..7b29ba07b --- /dev/null +++ b/src/app/plugins/soundboard/index.ts @@ -0,0 +1,6 @@ +export * from './types'; +export * from './SoundboardClipReader'; +export * from './SoundboardClipsReader'; +export * from './SoundboardMetaReader'; +export * from './SoundboardPack'; +export * from './utils'; diff --git a/src/app/plugins/soundboard/types.ts b/src/app/plugins/soundboard/types.ts new file mode 100644 index 000000000..b46bdc613 --- /dev/null +++ b/src/app/plugins/soundboard/types.ts @@ -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; +export type SoundboardRoomIdToStateKey = Record; +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; + +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[]; +}; diff --git a/src/app/plugins/soundboard/utils.test.ts b/src/app/plugins/soundboard/utils.test.ts new file mode 100644 index 000000000..df9fddfa0 --- /dev/null +++ b/src/app/plugins/soundboard/utils.test.ts @@ -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), {}); + }); +}); diff --git a/src/app/plugins/soundboard/utils.ts b/src/app/plugins/soundboard/utils.ts new file mode 100644 index 000000000..1d401789d --- /dev/null +++ b/src/app/plugins/soundboard/utils.ts @@ -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 { + 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((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(); + 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); +} diff --git a/src/app/state/roomSettings.ts b/src/app/state/roomSettings.ts index 08875b1e8..0ffa70bd6 100644 --- a/src/app/state/roomSettings.ts +++ b/src/app/state/roomSettings.ts @@ -5,6 +5,7 @@ export enum RoomSettingsPage { MembersPage, PermissionsPage, EmojisStickersPage, + SoundboardPage, DeveloperToolsPage, ExportPage, ActivityLogPage, diff --git a/src/app/state/spaceSettings.ts b/src/app/state/spaceSettings.ts index c82a838ed..a81b1fdc0 100644 --- a/src/app/state/spaceSettings.ts +++ b/src/app/state/spaceSettings.ts @@ -5,6 +5,7 @@ export enum SpaceSettingsPage { MembersPage, PermissionsPage, EmojisStickersPage, + SoundboardPage, DeveloperToolsPage, PolicyListsPage, } diff --git a/src/app/utils/soundboardClips.ts b/src/app/utils/soundboardClips.ts index a41630428..d3c035531 100644 --- a/src/app/utils/soundboardClips.ts +++ b/src/app/utils/soundboardClips.ts @@ -1,25 +1,9 @@ import { MatrixClient } from 'matrix-js-sdk'; import { downloadMedia, mxcUrlToHttp } from './matrix'; -/** - * [P5-15] A user-uploaded soundboard clip. Stored (as a list) in the - * `io.lotus.soundboard` account data event, so clips sync across a user's - * devices exactly like custom emoji / sticker packs. - */ -export type SoundboardClip = { - /** Stable local id (not shared with peers). */ - id: string; - /** Display name / shortcode shown on the tile. */ - name: string; - /** mxc:// URI of the uploaded audio. */ - url: string; - mimetype?: string; - size?: number; -}; - -export type SoundboardContent = { - clips?: SoundboardClip[]; -}; +// [P5-15 v2] Shared media helpers for the soundboard. Clip storage/metadata now +// lives in the soundboard pack plugin (plugins/soundboard); this module only +// handles resolving an mxc clip for playback + local preview. export const SOUNDBOARD_NAME_MAX = 24; /** Keep clips short: they publish to every peer and hold a track open. */ @@ -53,20 +37,19 @@ export const resolveClipObjectUrl = async (mx: MatrixClient, mxcUrl: string): Pr * Play a resolved clip locally so the person who pressed it gets immediate * feedback — LiveKit doesn't loop a participant's own published track back to * them, so without this the presser would hear nothing. `volume` is 0–1. + * Returns the audio element so callers can track when it ends (or undefined if + * playback couldn't start). */ -export const playClipLocally = (objectUrl: string, volume: number): void => { +export const playClipLocally = ( + objectUrl: string, + volume: number, +): HTMLAudioElement | undefined => { try { const audio = new Audio(objectUrl); audio.volume = Math.max(0, Math.min(1, volume)); audio.play().catch(() => undefined); + return audio; } catch { - /* best effort */ + return undefined; } }; - -export const readSoundboardClips = (mx: MatrixClient): SoundboardClip[] => { - const content = mx.getAccountData('io.lotus.soundboard' as never)?.getContent() as - | SoundboardContent - | undefined; - return Array.isArray(content?.clips) ? content.clips : []; -}; diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index d1133baa6..32d501622 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -10,9 +10,12 @@ export enum AccountDataEvent { PoniesUserEmotes = 'im.ponies.user_emotes', PoniesEmoteRooms = 'im.ponies.emote_rooms', - // [P5-15] Personal, uploadable in-call soundboard clips (synced across - // devices like custom emoji/sticker packs). + // [P5-15] Personal soundboard pack (synced across devices). v2 content is a + // SoundboardContent pack ({pack, clips}); v1 was {clips: [...]} (migrated on read). LotusSoundboard = 'io.lotus.soundboard', + // [P5-15 v2] Global refs: room soundboard packs the user enabled everywhere + // (mirrors im.ponies.emote_rooms). + LotusSoundboardRooms = 'io.lotus.soundboard_rooms', // [P4-1] Per-thread notification mode overrides (All/Mentions/Mute) plus the // global default behavior for threads. diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index 914f550be..3bfd41246 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -42,6 +42,9 @@ export enum StateEvent { PowerLevelTags = 'in.cinny.room.power_level_tags', LotusVoiceLimit = 'io.lotus.voice_limit', LotusRoomQuality = 'io.lotus.room_quality', + // [P5-15 v2] Room/Space soundboard pack (mirrors PoniesRoomEmotes). Per + // state-key, aggregated with parent-space packs like custom emoji. + LotusSoundboardRoom = 'io.lotus.soundboard', } export enum MessageEvent {