Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9505ca5b2 | |||
| dca51a41ef |
@@ -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 <SoundboardPackEditor pack={pack} canEdit={canEdit} onUpdate={handleUpdate} />;
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Map<string, ClipDraft>>(new Map()); // shortcode -> edits
|
||||||
|
const [deleted, setDeleted] = useState<Set<string>>(new Set());
|
||||||
|
const [uploads, setUploads] = useState<Array<{ shortcode: string } & ClipDraft>>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
|
||||||
|
const [busyPreview, setBusyPreview] = useState<string>();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const emojiAnchorRef = useRef<HTMLElement | null>(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<ClipDraft>, 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<string>([
|
||||||
|
...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<string, SoundboardClip> = {};
|
||||||
|
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<ClipDraft>) => {
|
||||||
|
if (isUpload) {
|
||||||
|
setUploads((prev) => prev.map((u) => (u.shortcode === key ? { ...u, ...patch } : u)));
|
||||||
|
} else {
|
||||||
|
setDraft(key, patch, base);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={key}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: config.space.S200,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
background: color.SurfaceVariant.Container,
|
||||||
|
opacity: markedDeleted ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
disabled={busyPreview === key}
|
||||||
|
onClick={() => preview(key, base.url, rowVolume)}
|
||||||
|
aria-label={`Preview ${rowBody}`}
|
||||||
|
>
|
||||||
|
{busyPreview === key ? <Spinner size="100" /> : <Icon size="100" src={Icons.Play} />}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
disabled={!canEdit || markedDeleted}
|
||||||
|
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
emojiAnchorRef.current = evt.currentTarget;
|
||||||
|
setEmojiFor(key);
|
||||||
|
}}
|
||||||
|
aria-label="Pick emoji"
|
||||||
|
>
|
||||||
|
<Text size="T400">{rowEmoji || '🔊'}</Text>
|
||||||
|
</IconButton>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Input
|
||||||
|
variant="Surface"
|
||||||
|
size="300"
|
||||||
|
defaultValue={rowBody}
|
||||||
|
readOnly={!canEdit || markedDeleted}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => commit({ body: e.target.value })}
|
||||||
|
aria-label="Clip name"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(120) }}>
|
||||||
|
<Icon size="50" src={Icons.VolumeHigh} />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
defaultValue={rowVolume}
|
||||||
|
disabled={!canEdit || markedDeleted}
|
||||||
|
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
aria-label="Clip volume"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{canEdit && !isUpload && (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant={markedDeleted ? 'Success' : 'Critical'}
|
||||||
|
onClick={() =>
|
||||||
|
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'}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={markedDeleted ? Icons.Plus : Icons.Delete} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{canEdit && isUpload && (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Critical"
|
||||||
|
onClick={() => setUploads((prev) => prev.filter((u) => u.shortcode !== key))}
|
||||||
|
aria-label="Remove upload"
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={SOUNDBOARD_ACCEPT}
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
handleFiles(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||||
|
<Text size="H4">{pack.meta.name ?? 'Soundboard'}</Text>
|
||||||
|
{canEdit && (
|
||||||
|
<Chip
|
||||||
|
variant="Secondary"
|
||||||
|
radii="Pill"
|
||||||
|
disabled={uploading || clipCount >= SOUNDBOARD_MAX_CLIPS}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
before={uploading ? <Spinner size="100" /> : <Icon size="100" src={Icons.Plus} />}
|
||||||
|
>
|
||||||
|
<Text size="B300">Upload</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
{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 && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
No clips yet. Upload a short audio clip (max 1 MB){canEdit ? '' : ' — ask an admin'}.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEdit && dirty && (
|
||||||
|
<Box gap="200">
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Success"
|
||||||
|
radii="300"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => save()}
|
||||||
|
before={saving ? <Spinner size="100" fill="Solid" /> : undefined}
|
||||||
|
>
|
||||||
|
<Text size="B300">Save changes</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
setDrafts(new Map());
|
||||||
|
setDeleted(new Set());
|
||||||
|
setUploads([]);
|
||||||
|
setError(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">Reset</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PopOut
|
||||||
|
anchor={emojiFor ? emojiAnchorRef.current?.getBoundingClientRect() : undefined}
|
||||||
|
position="Bottom"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setEmojiFor(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EmojiBoard
|
||||||
|
imagePackRooms={[]}
|
||||||
|
returnFocusOnDeactivate={false}
|
||||||
|
onEmojiSelect={(unicode: string) => {
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
</PopOut>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <SoundboardPackEditor pack={pack} canEdit onUpdate={handleUpdate} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './SoundboardPackEditor';
|
||||||
|
export * from './RoomSoundboardPack';
|
||||||
|
export * from './UserSoundboardPack';
|
||||||
@@ -351,10 +351,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
|
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
|
||||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||||
<ScreenshareAudioButton
|
|
||||||
muted={screenshareAudioMuted}
|
|
||||||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
{!compact && showVideoGroup && <ControlDivider />}
|
{!compact && showVideoGroup && <ControlDivider />}
|
||||||
{showVideoGroup && (
|
{showVideoGroup && (
|
||||||
@@ -363,12 +359,20 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
user can stop it; once stopped it hides and can't be restarted. */}
|
user can stop it; once stopped it hides and can't be restarted. */}
|
||||||
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||||
{showScreenshare && (
|
{showScreenshare && (
|
||||||
|
<>
|
||||||
<ScreenShareButton
|
<ScreenShareButton
|
||||||
enabled={screenshare}
|
enabled={screenshare}
|
||||||
onToggle={() =>
|
onToggle={() =>
|
||||||
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. */}
|
||||||
|
<ScreenshareAudioButton
|
||||||
|
muted={screenshareAudioMuted}
|
||||||
|
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{!!document.fullscreenEnabled && (
|
{!!document.fullscreenEnabled && (
|
||||||
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
||||||
|
|||||||
@@ -1,108 +1,114 @@
|
|||||||
import React, { MouseEventHandler, useCallback, useRef, useState } from 'react';
|
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Chip,
|
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
Menu,
|
Menu,
|
||||||
PopOut,
|
PopOut,
|
||||||
RectCords,
|
RectCords,
|
||||||
|
Scroll,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
color,
|
color,
|
||||||
config,
|
config,
|
||||||
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import { CallEmbed } from '../../plugins/call';
|
import { CallEmbed } from '../../plugins/call';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useSoundboard } from '../../hooks/useSoundboard';
|
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/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 { stopPropagation } from '../../utils/keyboard';
|
||||||
import {
|
import { playClipLocally, resolveClipObjectUrl } from '../../utils/soundboardClips';
|
||||||
SOUNDBOARD_ACCEPT,
|
|
||||||
SOUNDBOARD_MAX_CLIPS,
|
|
||||||
playClipLocally,
|
|
||||||
resolveClipObjectUrl,
|
|
||||||
} from '../../utils/soundboardClips';
|
|
||||||
|
|
||||||
type CallSoundboardProps = {
|
type CallSoundboardProps = {
|
||||||
callEmbed: CallEmbed;
|
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
|
* [P5-15 v2] In-call soundboard. Clips come from the aggregated soundboard packs
|
||||||
* clip is published to peers as a separate track by the EC fork
|
* relevant to the call room (the room + parent spaces ∪ the user's personal
|
||||||
* (`io.lotus.inject_audio`) and also played locally for the presser's feedback.
|
* pack), just like custom emoji. Playing a clip publishes it into the call via
|
||||||
* Clips are uploadable/managed here and synced across devices via the
|
* the EC fork (`io.lotus.inject_audio`, max one at a time) and plays it locally.
|
||||||
* `io.lotus.soundboard` account data (like custom emoji/sticker packs).
|
* 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) {
|
export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||||
const mx = useMatrixClient();
|
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 [soundboardVolume] = useSetting(settingsAtom, 'soundboardVolume');
|
||||||
|
const master = Math.max(0, Math.min(1, soundboardVolume / 100));
|
||||||
|
|
||||||
const [cords, setCords] = useState<RectCords>();
|
const [cords, setCords] = useState<RectCords>();
|
||||||
const [busyId, setBusyId] = useState<string>();
|
const [manage, setManage] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(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<HTMLButtonElement> = (evt) => {
|
const handleOpen: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setCords(evt.currentTarget.getBoundingClientRect());
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePlay = useCallback(
|
const play = useCallback(
|
||||||
async (id: string, mxc: string) => {
|
async (flat: FlatClip) => {
|
||||||
setBusyId(id);
|
if (playingKey) return; // one at a time (fork also enforces this)
|
||||||
|
setPlayingKey(flat.key);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
const done = () => setPlayingKey((k) => (k === flat.key ? undefined : k));
|
||||||
try {
|
try {
|
||||||
const objectUrl = await resolveClipObjectUrl(mx, mxc);
|
const url = await resolveClipObjectUrl(mx, flat.clip.url);
|
||||||
callEmbed.control.injectAudio(objectUrl, volume);
|
const vol = (flat.clip.volume / 100) * master;
|
||||||
playClipLocally(objectUrl, volume);
|
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 {
|
} catch {
|
||||||
setError('Could not play that clip.');
|
setError('Could not play that clip.');
|
||||||
} finally {
|
done();
|
||||||
setBusyId(undefined);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx, callEmbed, volume],
|
[mx, callEmbed, master, playingKey],
|
||||||
);
|
|
||||||
|
|
||||||
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],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept={SOUNDBOARD_ACCEPT}
|
|
||||||
hidden
|
|
||||||
onChange={(e) => {
|
|
||||||
handleFile(e.target.files?.[0]);
|
|
||||||
e.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PopOut
|
<PopOut
|
||||||
anchor={cords}
|
anchor={cords}
|
||||||
position="Top"
|
position="Top"
|
||||||
@@ -116,76 +122,105 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu style={{ maxWidth: '320px' }}>
|
<Menu style={{ maxWidth: manage ? toRem(420) : toRem(340), maxHeight: '70vh' }}>
|
||||||
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
|
<Box direction="Column" style={{ maxHeight: '70vh' }}>
|
||||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
<Box
|
||||||
<Text size="L400">Soundboard</Text>
|
shrink="No"
|
||||||
<Chip
|
alignItems="Center"
|
||||||
variant="Secondary"
|
justifyContent="SpaceBetween"
|
||||||
radii="Pill"
|
gap="200"
|
||||||
disabled={uploading || clips.length >= SOUNDBOARD_MAX_CLIPS}
|
style={{
|
||||||
onClick={() => fileInputRef.current?.click()}
|
padding: config.space.S200,
|
||||||
before={
|
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
uploading ? <Spinner size="100" /> : <Icon size="100" src={Icons.Plus} />
|
}}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Text size="B300">Upload</Text>
|
<Text size="L400">Soundboard</Text>
|
||||||
</Chip>
|
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Manage
|
||||||
|
</Text>
|
||||||
|
<Switch variant="Primary" value={manage} onChange={setManage} />
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{clips.length === 0 ? (
|
<Scroll size="300" hideTrack visibility="Hover">
|
||||||
|
<Box direction="Column" gap="300" style={{ padding: config.space.S200 }}>
|
||||||
|
{manage ? (
|
||||||
|
<>
|
||||||
|
<RoomSoundboardPack room={room} stateKey="" />
|
||||||
|
<UserSoundboardPack />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{groups.length === 0 && (
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
No clips yet. Upload a short audio clip (max 1 MB) to play it into the call.
|
No soundboard clips here yet. Turn on <b>Manage</b> to upload some, or add
|
||||||
Clips sync across your devices.
|
a pack in Space settings.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
)}
|
||||||
|
{groups.map((g) => (
|
||||||
|
<Box key={g.id} direction="Column" gap="100">
|
||||||
|
<Text size="L400">{g.name}</Text>
|
||||||
<Box wrap="Wrap" gap="200">
|
<Box wrap="Wrap" gap="200">
|
||||||
{clips.map((clip) => (
|
{g.clips.map((clip) => {
|
||||||
|
const key = `${g.id}|${clip.shortcode}`;
|
||||||
|
const flat: FlatClip = {
|
||||||
|
key,
|
||||||
|
packId: g.id,
|
||||||
|
packName: g.name,
|
||||||
|
clip,
|
||||||
|
};
|
||||||
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={clip.id}
|
key={key}
|
||||||
|
as="button"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
gap="100"
|
gap="100"
|
||||||
style={{ position: 'relative' }}
|
disabled={!!playingKey}
|
||||||
>
|
onClick={() => play(flat)}
|
||||||
<Chip
|
aria-label={`Play ${clip.name}`}
|
||||||
variant="SurfaceVariant"
|
style={{
|
||||||
radii="300"
|
width: toRem(76),
|
||||||
disabled={busyId === clip.id}
|
height: toRem(76),
|
||||||
onClick={() => handlePlay(clip.id, clip.url)}
|
padding: config.space.S100,
|
||||||
before={
|
borderRadius: config.radii.R400,
|
||||||
busyId === clip.id ? (
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
<Spinner size="100" />
|
background:
|
||||||
) : (
|
playingKey === key
|
||||||
<Icon size="100" src={Icons.Play} />
|
? color.Primary.Container
|
||||||
)
|
: color.SurfaceVariant.Container,
|
||||||
}
|
cursor: playingKey ? 'default' : 'pointer',
|
||||||
after={
|
opacity: playingKey && playingKey !== key ? 0.5 : 1,
|
||||||
<Icon
|
|
||||||
size="50"
|
|
||||||
src={Icons.Cross}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={(e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
removeClip(clip.id);
|
|
||||||
}}
|
}}
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Text size="B300" truncate style={{ maxWidth: '120px' }}>
|
<Text size="H4">
|
||||||
|
{playingKey === key ? (
|
||||||
|
<Spinner size="200" />
|
||||||
|
) : (
|
||||||
|
clip.emoji || '🔊'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" truncate style={{ maxWidth: '100%' }}>
|
||||||
{clip.name}
|
{clip.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Chip>
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
{error}
|
{error}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
</Menu>
|
</Menu>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
}
|
}
|
||||||
@@ -215,6 +250,5 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</PopOut>
|
</PopOut>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Page>
|
||||||
|
<PageHeader outlined={false}>
|
||||||
|
<Box grow="Yes" gap="200">
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Text as="h2" size="H3" truncate>
|
||||||
|
Soundboard
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No">
|
||||||
|
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Scroll hideTrack visibility="Hover">
|
||||||
|
<PageContent>
|
||||||
|
<Box direction="Column" gap="700">
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text size="L400">This room / space (shared)</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
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.
|
||||||
|
</Text>
|
||||||
|
{room && <RoomSoundboardPack room={room} stateKey="" />}
|
||||||
|
</Box>
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text size="L400">Personal</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Your own clips, available in every call and synced across your devices.
|
||||||
|
</Text>
|
||||||
|
<UserSoundboardPack />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PageContent>
|
||||||
|
</Scroll>
|
||||||
|
</Box>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './Soundboard';
|
||||||
@@ -13,6 +13,7 @@ import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
|||||||
import { General } from './general';
|
import { General } from './general';
|
||||||
import { Members } from '../common-settings/members';
|
import { Members } from '../common-settings/members';
|
||||||
import { EmojisStickers } from '../common-settings/emojis-stickers';
|
import { EmojisStickers } from '../common-settings/emojis-stickers';
|
||||||
|
import { Soundboard } from '../common-settings/soundboard';
|
||||||
import { Permissions } from './permissions';
|
import { Permissions } from './permissions';
|
||||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
@@ -53,6 +54,11 @@ const BASE_MENU_ITEMS: RoomSettingsMenuItem[] = [
|
|||||||
name: 'Emojis & Stickers',
|
name: 'Emojis & Stickers',
|
||||||
icon: Icons.Smile,
|
icon: Icons.Smile,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
page: RoomSettingsPage.SoundboardPage,
|
||||||
|
name: 'Soundboard',
|
||||||
|
icon: Icons.Bell,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
page: RoomSettingsPage.DeveloperToolsPage,
|
page: RoomSettingsPage.DeveloperToolsPage,
|
||||||
name: 'Developer Tools',
|
name: 'Developer Tools',
|
||||||
@@ -226,6 +232,9 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
|||||||
{activePage === RoomSettingsPage.EmojisStickersPage && (
|
{activePage === RoomSettingsPage.EmojisStickersPage && (
|
||||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
|
{activePage === RoomSettingsPage.SoundboardPage && (
|
||||||
|
<Soundboard requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
{activePage === RoomSettingsPage.DeveloperToolsPage && (
|
{activePage === RoomSettingsPage.DeveloperToolsPage && (
|
||||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
<DeveloperTools requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { ChangeEvent, useCallback, useState } from 'react';
|
import React, { ChangeEvent, useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
|
color,
|
||||||
config,
|
config,
|
||||||
Header,
|
Header,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -28,6 +29,7 @@ import { mDirectAtom } from '../../../state/mDirectList';
|
|||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||||
|
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||||
|
|
||||||
type RoomRowProps = {
|
type RoomRowProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -86,35 +88,83 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
const modalStyle = useModalStyle(400);
|
const modalStyle = useModalStyle(400);
|
||||||
const directs = useAtomValue(mDirectAtom);
|
const directs = useAtomValue(mDirectAtom);
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const allRooms = mx
|
const allRooms = useMemo(
|
||||||
|
() =>
|
||||||
|
mx
|
||||||
.getRooms()
|
.getRooms()
|
||||||
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
|
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
|
||||||
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
|
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0)),
|
||||||
|
[mx],
|
||||||
|
);
|
||||||
|
|
||||||
const filtered = query
|
const filtered = useMemo(() => {
|
||||||
? allRooms.filter((r) => r.name.toLowerCase().includes(query.toLowerCase()))
|
if (!query) return allRooms;
|
||||||
: allRooms;
|
const q = query.toLowerCase();
|
||||||
|
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||||
|
}, [allRooms, query]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the content to forward:
|
||||||
|
* - undecryptable events are refused (would forward `m.bad.encrypted` junk)
|
||||||
|
* - edited messages forward the LATEST edit (`m.new_content`), not the
|
||||||
|
* original pre-edit body
|
||||||
|
* - reply fallbacks (`> <@user> …` quote + `<mx-reply>` block) are stripped
|
||||||
|
* along with the `m.relates_to` reply/thread relation, so the forwarded
|
||||||
|
* message stands alone in the target room
|
||||||
|
*/
|
||||||
|
const buildForwardContent = useCallback((): Record<string, unknown> | undefined => {
|
||||||
|
if (mEvent.isDecryptionFailure()) return undefined;
|
||||||
|
|
||||||
|
let content = { ...mEvent.getContent() };
|
||||||
|
|
||||||
|
const eventId = mEvent.getId();
|
||||||
|
const room = mx.getRoom(mEvent.getRoomId());
|
||||||
|
if (eventId && room) {
|
||||||
|
const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet());
|
||||||
|
const newContent = editedEvent?.getContent()['m.new_content'];
|
||||||
|
if (newContent && typeof newContent === 'object') {
|
||||||
|
content = { ...(newContent as Record<string, unknown>) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete content['m.relates_to'];
|
||||||
|
if (typeof content.body === 'string') {
|
||||||
|
content.body = trimReplyFromBody(content.body);
|
||||||
|
}
|
||||||
|
if (typeof content.formatted_body === 'string') {
|
||||||
|
content.formatted_body = trimReplyFromFormattedBody(content.formatted_body);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}, [mx, mEvent]);
|
||||||
|
|
||||||
const forward = useCallback(
|
const forward = useCallback(
|
||||||
async (room: Room) => {
|
async (room: Room) => {
|
||||||
if (sending) return;
|
if (sending) return;
|
||||||
|
const fwdContent = buildForwardContent();
|
||||||
|
if (!fwdContent) {
|
||||||
|
setError('This message could not be decrypted, so it cannot be forwarded.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSending(true);
|
setSending(true);
|
||||||
const fwdContent: Record<string, unknown> = { ...mEvent.getContent() };
|
setError(null);
|
||||||
delete fwdContent['m.relates_to'];
|
|
||||||
try {
|
try {
|
||||||
|
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
await (mx as any).sendEvent(room.roomId, mEvent.getType(), fwdContent);
|
await mx.sendEvent(room.roomId, null, mEvent.getType() as any, fwdContent);
|
||||||
setSentTo(room.name);
|
setSentTo(room.name);
|
||||||
setTimeout(onClose, 1400);
|
setTimeout(onClose, 1400);
|
||||||
} catch {
|
} catch {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
|
setError(`Failed to forward to ${room.name}. Try again.`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[mx, mEvent, onClose, sending],
|
[mx, mEvent, onClose, sending, buildForwardContent],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -122,7 +172,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: () => searchInputRef.current ?? false,
|
||||||
onDeactivate: onClose,
|
onDeactivate: onClose,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
@@ -153,8 +203,13 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
{!sentTo && (
|
{!sentTo && (
|
||||||
<Box shrink="No" style={{ padding: `${config.space.S200} ${config.space.S400}` }}>
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
direction="Column"
|
||||||
|
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
variant="Background"
|
variant="Background"
|
||||||
size="400"
|
size="400"
|
||||||
radii="400"
|
radii="400"
|
||||||
@@ -163,6 +218,14 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
|
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{error && (
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Line size="300" />
|
<Line size="300" />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
|||||||
import { SpaceSettingsPage } from '../../state/spaceSettings';
|
import { SpaceSettingsPage } from '../../state/spaceSettings';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { EmojisStickers } from '../common-settings/emojis-stickers';
|
import { EmojisStickers } from '../common-settings/emojis-stickers';
|
||||||
|
import { Soundboard } from '../common-settings/soundboard';
|
||||||
import { Members } from '../common-settings/members';
|
import { Members } from '../common-settings/members';
|
||||||
import { DeveloperTools } from '../common-settings/developer-tools';
|
import { DeveloperTools } from '../common-settings/developer-tools';
|
||||||
import { General } from './general';
|
import { General } from './general';
|
||||||
@@ -48,6 +49,11 @@ const BASE_SPACE_MENU_ITEMS: SpaceSettingsMenuItem[] = [
|
|||||||
name: 'Emojis & Stickers',
|
name: 'Emojis & Stickers',
|
||||||
icon: Icons.Smile,
|
icon: Icons.Smile,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
page: SpaceSettingsPage.SoundboardPage,
|
||||||
|
name: 'Soundboard',
|
||||||
|
icon: Icons.Bell,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
page: SpaceSettingsPage.DeveloperToolsPage,
|
page: SpaceSettingsPage.DeveloperToolsPage,
|
||||||
name: 'Developer Tools',
|
name: 'Developer Tools',
|
||||||
@@ -190,6 +196,9 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
|
|||||||
{activePage === SpaceSettingsPage.EmojisStickersPage && (
|
{activePage === SpaceSettingsPage.EmojisStickersPage && (
|
||||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
|
{activePage === SpaceSettingsPage.SoundboardPage && (
|
||||||
|
<Soundboard requestClose={handlePageRequestClose} />
|
||||||
|
)}
|
||||||
{activePage === SpaceSettingsPage.DeveloperToolsPage && (
|
{activePage === SpaceSettingsPage.DeveloperToolsPage && (
|
||||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
<DeveloperTools requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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<void>;
|
|
||||||
removeClip: (id: string) => Promise<void>;
|
|
||||||
renameClip: (id: string, name: string) => Promise<void>;
|
|
||||||
} {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const [clips, setClips] = useState<SoundboardClip[]>(() => readSoundboardClips(mx));
|
|
||||||
|
|
||||||
useAccountDataCallback(
|
|
||||||
mx,
|
|
||||||
useCallback((evt) => {
|
|
||||||
if (evt.getType() === KEY) {
|
|
||||||
const content = evt.getContent<SoundboardContent>();
|
|
||||||
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<void> }
|
|
||||||
).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 };
|
|
||||||
}
|
|
||||||
@@ -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]);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export enum RoomSettingsPage {
|
|||||||
MembersPage,
|
MembersPage,
|
||||||
PermissionsPage,
|
PermissionsPage,
|
||||||
EmojisStickersPage,
|
EmojisStickersPage,
|
||||||
|
SoundboardPage,
|
||||||
DeveloperToolsPage,
|
DeveloperToolsPage,
|
||||||
ExportPage,
|
ExportPage,
|
||||||
ActivityLogPage,
|
ActivityLogPage,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export enum SpaceSettingsPage {
|
|||||||
MembersPage,
|
MembersPage,
|
||||||
PermissionsPage,
|
PermissionsPage,
|
||||||
EmojisStickersPage,
|
EmojisStickersPage,
|
||||||
|
SoundboardPage,
|
||||||
DeveloperToolsPage,
|
DeveloperToolsPage,
|
||||||
PolicyListsPage,
|
PolicyListsPage,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,9 @@
|
|||||||
import { MatrixClient } from 'matrix-js-sdk';
|
import { MatrixClient } from 'matrix-js-sdk';
|
||||||
import { downloadMedia, mxcUrlToHttp } from './matrix';
|
import { downloadMedia, mxcUrlToHttp } from './matrix';
|
||||||
|
|
||||||
/**
|
// [P5-15 v2] Shared media helpers for the soundboard. Clip storage/metadata now
|
||||||
* [P5-15] A user-uploaded soundboard clip. Stored (as a list) in the
|
// lives in the soundboard pack plugin (plugins/soundboard); this module only
|
||||||
* `io.lotus.soundboard` account data event, so clips sync across a user's
|
// handles resolving an mxc clip for playback + local preview.
|
||||||
* 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[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SOUNDBOARD_NAME_MAX = 24;
|
export const SOUNDBOARD_NAME_MAX = 24;
|
||||||
/** Keep clips short: they publish to every peer and hold a track open. */
|
/** 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
|
* 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
|
* 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.
|
* 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 {
|
try {
|
||||||
const audio = new Audio(objectUrl);
|
const audio = new Audio(objectUrl);
|
||||||
audio.volume = Math.max(0, Math.min(1, volume));
|
audio.volume = Math.max(0, Math.min(1, volume));
|
||||||
audio.play().catch(() => undefined);
|
audio.play().catch(() => undefined);
|
||||||
|
return audio;
|
||||||
} catch {
|
} 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 : [];
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ export enum AccountDataEvent {
|
|||||||
PoniesUserEmotes = 'im.ponies.user_emotes',
|
PoniesUserEmotes = 'im.ponies.user_emotes',
|
||||||
PoniesEmoteRooms = 'im.ponies.emote_rooms',
|
PoniesEmoteRooms = 'im.ponies.emote_rooms',
|
||||||
|
|
||||||
// [P5-15] Personal, uploadable in-call soundboard clips (synced across
|
// [P5-15] Personal soundboard pack (synced across devices). v2 content is a
|
||||||
// devices like custom emoji/sticker packs).
|
// SoundboardContent pack ({pack, clips}); v1 was {clips: [...]} (migrated on read).
|
||||||
LotusSoundboard = 'io.lotus.soundboard',
|
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
|
// [P4-1] Per-thread notification mode overrides (All/Mentions/Mute) plus the
|
||||||
// global default behavior for threads.
|
// global default behavior for threads.
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export enum StateEvent {
|
|||||||
PowerLevelTags = 'in.cinny.room.power_level_tags',
|
PowerLevelTags = 'in.cinny.room.power_level_tags',
|
||||||
LotusVoiceLimit = 'io.lotus.voice_limit',
|
LotusVoiceLimit = 'io.lotus.voice_limit',
|
||||||
LotusRoomQuality = 'io.lotus.room_quality',
|
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 {
|
export enum MessageEvent {
|
||||||
|
|||||||
Reference in New Issue
Block a user