Files
cinny/src/app/features/space-settings/SpaceSettings.tsx
T
jared a9505ca5b2
CI / Build & Quality Checks (push) Successful in 10m56s
CI / Trigger Desktop Build (push) Successful in 8s
feat(soundboard): shared room/space packs (like emoji/stickers), grid picker, management
Soundboard v2 — a near-parallel of the custom-emoji image-pack system for
in-call audio clips.

- Data model: 3-tier packs mirroring MSC2545 — room/space pack (state event
  io.lotus.soundboard, inherited by child rooms via parent-space aggregation),
  global refs (io.lotus.soundboard_rooms), and the personal pack
  (io.lotus.soundboard account data; the v1 flat-list content is migrated to the
  pack shape on read). New plugins/soundboard/ (readers, SoundboardPack, utils) +
  hooks/useSoundboardPacks (useRelevantSoundboardPacks = user U global U room,
  deduped). Unit-tested (migration + slug).
- Management: reusable SoundboardPackEditor (name + emoji + per-clip volume +
  delete + upload + batched save), power-level-gated for room packs like emoji
  packs; a Soundboard page wired into Room + Space settings.
- In-call: CallSoundboard rewritten as a Discord-style grid grouped by pack
  (emoji + name tiles), sourcing room+parent-space U personal clips; a Manage
  toggle embeds the editors; per-clip volume x master volume on playback.
- Spam guard: host gates on a playing key (fork enforces one clip at a time).
- Control bar: Mute-Screenshare moved next to the Screenshare button.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:21:50 -04:00

211 lines
7.3 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import { useAtomValue } from 'jotai';
import { Avatar, Box, config, Icon, IconButton, Icons, IconSrc, MenuItem, Text } from 'folds';
import { JoinRule } from 'matrix-js-sdk';
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomAvatar, useRoomJoinRule, useRoomName } from '../../hooks/useRoomMeta';
import { mDirectAtom } from '../../state/mDirectList';
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';
import { Permissions } from './permissions';
import { PolicyListViewer } from '../room-settings/PolicyListViewer';
import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
import { useRoomCreators } from '../../hooks/useRoomCreators';
type SpaceSettingsMenuItem = {
page: SpaceSettingsPage;
name: string;
icon: IconSrc;
};
const BASE_SPACE_MENU_ITEMS: SpaceSettingsMenuItem[] = [
{
page: SpaceSettingsPage.GeneralPage,
name: 'General',
icon: Icons.Setting,
},
{
page: SpaceSettingsPage.MembersPage,
name: 'Members',
icon: Icons.User,
},
{
page: SpaceSettingsPage.PermissionsPage,
name: 'Permissions',
icon: Icons.Lock,
},
{
page: SpaceSettingsPage.EmojisStickersPage,
name: 'Emojis & Stickers',
icon: Icons.Smile,
},
{
page: SpaceSettingsPage.SoundboardPage,
name: 'Soundboard',
icon: Icons.Bell,
},
{
page: SpaceSettingsPage.DeveloperToolsPage,
name: 'Developer Tools',
icon: Icons.Terminal,
},
];
const SPACE_POLICY_LISTS_ITEM: SpaceSettingsMenuItem = {
page: SpaceSettingsPage.PolicyListsPage,
name: 'Policy Lists',
icon: Icons.NoEntry,
};
const useSpaceSettingsMenuItems = (canSeePolicyLists: boolean): SpaceSettingsMenuItem[] =>
useMemo(
() =>
canSeePolicyLists
? [...BASE_SPACE_MENU_ITEMS, SPACE_POLICY_LISTS_ITEM]
: BASE_SPACE_MENU_ITEMS,
[canSeePolicyLists],
);
type SpaceSettingsProps = {
initialPage?: SpaceSettingsPage;
requestClose: () => void;
};
export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps) {
const room = useRoom();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const mDirects = useAtomValue(mDirectAtom);
const roomAvatar = useRoomAvatar(room, mDirects.has(room.roomId));
const roomName = useRoomName(room);
const joinRuleContent = useRoomJoinRule(room);
const avatarUrl = roomAvatar
? (mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room);
const myUserId = mx.getSafeUserId();
const myPL = readPowerLevel.user(powerLevels, myUserId);
// Show Policy Lists to admins (power level 50+) or creators.
const canSeePolicyLists = myPL >= 50 || creators.has(myUserId);
const screenSize = useScreenSizeContext();
const [activePage, setActivePage] = useState<SpaceSettingsPage | undefined>(() => {
if (initialPage) return initialPage;
return screenSize === ScreenSize.Mobile ? undefined : SpaceSettingsPage.GeneralPage;
});
const menuItems = useSpaceSettingsMenuItems(canSeePolicyLists);
const handlePageRequestClose = () => {
if (screenSize === ScreenSize.Mobile) {
setActivePage(undefined);
return;
}
requestClose();
};
return (
<PageRoot
nav={
screenSize === ScreenSize.Mobile && activePage !== undefined ? undefined : (
<PageNav size="300">
<PageNavHeader outlined={false}>
<Box grow="Yes" gap="200">
<Avatar size="200" radii="300">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={roomName}
renderFallback={() => (
<RoomIcon
roomType={room.getType()}
size="50"
joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite}
filled
/>
)}
/>
</Avatar>
<Text size="H4" truncate>
{roomName}
</Text>
</Box>
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<IconButton
onClick={requestClose}
variant="Background"
aria-label="Close settings"
>
<Icon src={Icons.Cross} />
</IconButton>
)}
</Box>
</PageNavHeader>
<Box grow="Yes" direction="Column">
<PageNavContent>
<div style={{ flexGrow: 1 }}>
{menuItems.map((item) => (
<MenuItem
key={item.name}
variant="Background"
radii="400"
aria-pressed={activePage === item.page}
before={<Icon src={item.icon} size="100" filled={activePage === item.page} />}
onClick={() => setActivePage(item.page)}
>
<Text
style={{
fontWeight: activePage === item.page ? config.fontWeight.W600 : undefined,
}}
size="T300"
truncate
>
{item.name}
</Text>
</MenuItem>
))}
</div>
</PageNavContent>
</Box>
</PageNav>
)
}
>
{activePage === SpaceSettingsPage.GeneralPage && (
<General requestClose={handlePageRequestClose} />
)}
{activePage === SpaceSettingsPage.MembersPage && (
<Members requestClose={handlePageRequestClose} />
)}
{activePage === SpaceSettingsPage.PermissionsPage && (
<Permissions requestClose={handlePageRequestClose} />
)}
{activePage === SpaceSettingsPage.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} />
)}
{activePage === SpaceSettingsPage.SoundboardPage && (
<Soundboard requestClose={handlePageRequestClose} />
)}
{activePage === SpaceSettingsPage.DeveloperToolsPage && (
<DeveloperTools requestClose={handlePageRequestClose} />
)}
{activePage === SpaceSettingsPage.PolicyListsPage && (
<PolicyListViewer requestClose={handlePageRequestClose} />
)}
</PageRoot>
);
}