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">
|
||||
<MicrophoneButton enabled={microphone} onToggle={handleMicrophoneToggle} />
|
||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||
<ScreenshareAudioButton
|
||||
muted={screenshareAudioMuted}
|
||||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||||
/>
|
||||
</Box>
|
||||
{!compact && showVideoGroup && <ControlDivider />}
|
||||
{showVideoGroup && (
|
||||
@@ -363,12 +359,20 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
user can stop it; once stopped it hides and can't be restarted. */}
|
||||
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||
{showScreenshare && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() =>
|
||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() =>
|
||||
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 && (
|
||||
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
||||
|
||||
@@ -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<RectCords>();
|
||||
const [busyId, setBusyId] = useState<string>();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [manage, setManage] = useState(false);
|
||||
const [playingKey, setPlayingKey] = useState<string>(); // host-side spam guard
|
||||
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) => {
|
||||
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 (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={SOUNDBOARD_ACCEPT}
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
handleFile(e.target.files?.[0]);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position="Top"
|
||||
align="Center"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ maxWidth: '320px' }}>
|
||||
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="L400">Soundboard</Text>
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
disabled={uploading || clips.length >= 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>
|
||||
|
||||
{clips.length === 0 ? (
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position="Top"
|
||||
align="Center"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ maxWidth: manage ? toRem(420) : toRem(340), maxHeight: '70vh' }}>
|
||||
<Box direction="Column" style={{ maxHeight: '70vh' }}>
|
||||
<Box
|
||||
shrink="No"
|
||||
alignItems="Center"
|
||||
justifyContent="SpaceBetween"
|
||||
gap="200"
|
||||
style={{
|
||||
padding: config.space.S200,
|
||||
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
<Text size="L400">Soundboard</Text>
|
||||
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||
<Text size="T200" priority="300">
|
||||
No clips yet. Upload a short audio clip (max 1 MB) to play it into the call.
|
||||
Clips sync across your devices.
|
||||
Manage
|
||||
</Text>
|
||||
) : (
|
||||
<Box wrap="Wrap" gap="200">
|
||||
{clips.map((clip) => (
|
||||
<Box
|
||||
key={clip.id}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="300"
|
||||
disabled={busyId === clip.id}
|
||||
onClick={() => handlePlay(clip.id, clip.url)}
|
||||
before={
|
||||
busyId === clip.id ? (
|
||||
<Spinner size="100" />
|
||||
) : (
|
||||
<Icon size="100" src={Icons.Play} />
|
||||
)
|
||||
}
|
||||
after={
|
||||
<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' }}>
|
||||
{clip.name}
|
||||
</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<Switch variant="Primary" value={manage} onChange={setManage} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
|
||||
<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">
|
||||
No soundboard clips here yet. Turn on <b>Manage</b> to upload some, or add
|
||||
a pack in Space settings.
|
||||
</Text>
|
||||
)}
|
||||
{groups.map((g) => (
|
||||
<Box key={g.id} direction="Column" gap="100">
|
||||
<Text size="L400">{g.name}</Text>
|
||||
<Box wrap="Wrap" gap="200">
|
||||
{g.clips.map((clip) => {
|
||||
const key = `${g.id}|${clip.shortcode}`;
|
||||
const flat: FlatClip = {
|
||||
key,
|
||||
packId: g.id,
|
||||
packName: g.name,
|
||||
clip,
|
||||
};
|
||||
return (
|
||||
<Box
|
||||
key={key}
|
||||
as="button"
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="100"
|
||||
disabled={!!playingKey}
|
||||
onClick={() => 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,
|
||||
}}
|
||||
>
|
||||
<Text size="H4">
|
||||
{playingKey === key ? (
|
||||
<Spinner size="200" />
|
||||
) : (
|
||||
clip.emoji || '🔊'
|
||||
)}
|
||||
</Text>
|
||||
<Text size="T200" truncate style={{ maxWidth: '100%' }}>
|
||||
{clip.name}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<TooltipProvider
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Soundboard</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<TooltipProvider
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Soundboard</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
variant="Surface"
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
onClick={handleOpen}
|
||||
outlined
|
||||
aria-label="Soundboard"
|
||||
aria-expanded={!!cords}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Icon size="400" src={Icons.BellRing} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</PopOut>
|
||||
</>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
variant="Surface"
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
onClick={handleOpen}
|
||||
outlined
|
||||
aria-label="Soundboard"
|
||||
aria-expanded={!!cords}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Icon size="400" src={Icons.BellRing} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</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 { 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 && (
|
||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === RoomSettingsPage.SoundboardPage && (
|
||||
<Soundboard requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === RoomSettingsPage.DeveloperToolsPage && (
|
||||
<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 {
|
||||
Avatar,
|
||||
Box,
|
||||
color,
|
||||
config,
|
||||
Header,
|
||||
Icon,
|
||||
@@ -28,6 +29,7 @@ import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||
|
||||
type RoomRowProps = {
|
||||
room: Room;
|
||||
@@ -86,35 +88,83 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
const modalStyle = useModalStyle(400);
|
||||
const directs = useAtomValue(mDirectAtom);
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const allRooms = mx
|
||||
.getRooms()
|
||||
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
|
||||
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
|
||||
const allRooms = useMemo(
|
||||
() =>
|
||||
mx
|
||||
.getRooms()
|
||||
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
|
||||
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0)),
|
||||
[mx],
|
||||
);
|
||||
|
||||
const filtered = query
|
||||
? allRooms.filter((r) => r.name.toLowerCase().includes(query.toLowerCase()))
|
||||
: allRooms;
|
||||
const filtered = useMemo(() => {
|
||||
if (!query) return 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(
|
||||
async (room: Room) => {
|
||||
if (sending) return;
|
||||
const fwdContent = buildForwardContent();
|
||||
if (!fwdContent) {
|
||||
setError('This message could not be decrypted, so it cannot be forwarded.');
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
const fwdContent: Record<string, unknown> = { ...mEvent.getContent() };
|
||||
delete fwdContent['m.relates_to'];
|
||||
setError(null);
|
||||
try {
|
||||
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||
// 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);
|
||||
setTimeout(onClose, 1400);
|
||||
} catch {
|
||||
setSending(false);
|
||||
setError(`Failed to forward to ${room.name}. Try again.`);
|
||||
}
|
||||
},
|
||||
[mx, mEvent, onClose, sending],
|
||||
[mx, mEvent, onClose, sending, buildForwardContent],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -122,7 +172,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
initialFocus: () => searchInputRef.current ?? false,
|
||||
onDeactivate: onClose,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
@@ -153,8 +203,13 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
</IconButton>
|
||||
</Header>
|
||||
{!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
|
||||
ref={searchInputRef}
|
||||
variant="Background"
|
||||
size="400"
|
||||
radii="400"
|
||||
@@ -163,6 +218,14 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
value={query}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||
>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Line size="300" />
|
||||
|
||||
@@ -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 && (
|
||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SpaceSettingsPage.SoundboardPage && (
|
||||
<Soundboard requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SpaceSettingsPage.DeveloperToolsPage && (
|
||||
<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,
|
||||
PermissionsPage,
|
||||
EmojisStickersPage,
|
||||
SoundboardPage,
|
||||
DeveloperToolsPage,
|
||||
ExportPage,
|
||||
ActivityLogPage,
|
||||
|
||||
@@ -5,6 +5,7 @@ export enum SpaceSettingsPage {
|
||||
MembersPage,
|
||||
PermissionsPage,
|
||||
EmojisStickersPage,
|
||||
SoundboardPage,
|
||||
DeveloperToolsPage,
|
||||
PolicyListsPage,
|
||||
}
|
||||
|
||||
@@ -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 : [];
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user