feat: expand BOTSU creative rooms and apps

This commit is contained in:
2026-08-08 20:31:07 +02:00
parent eae9333307
commit ed3f934b61
98 changed files with 11197 additions and 635 deletions
+2 -1
View File
@@ -16,7 +16,8 @@
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
"test:botsu": "node --experimental-strip-types --test src/botsu/apps/catalog.test.ts src/botsu/apps/launcher.test.ts src/botsu/documents/model.test.ts src/botsu/documents/document-share.test.ts src/botsu/documents/document-sync-bridge.test.ts src/botsu/documents/editor.test.ts src/botsu/profile/summary.test.ts src/botsu/presence/compact-presence.test.ts src/botsu/presence/connection-generation.test.ts src/botsu/presence/cursor-publisher.test.ts src/botsu/presence/cursor-state.test.ts src/botsu/presence/location-publisher.test.ts src/botsu/presence/socket-message.test.ts src/botsu/presence/state.test.ts src/botsu/shell/layout.test.ts src/botsu/start/background-options.test.ts src/botsu/start/cookie-controller.test.ts src/botsu/start/model.test.ts src/botsu/start/pixel-canvas-bridge.test.ts src/botsu/start/pixel-canvas-publisher.test.ts src/botsu/voxel/model.test.ts src/botsu/wikipedia/model.test.ts src/botsu/wikipedia/wikipedia-scraper.test.ts src/botsu/openstreetmap/geocoder.test.ts src/botsu/watch/model.test.ts src/botsu/workspace/editor.test.ts src/botsu/workspace/permissions.test.ts src/botsu/workspace/state.test.ts",
"test": "npm run test:botsu",
"test:botsu": "node --experimental-strip-types --test src/botsu/storyboard/model.test.ts src/botsu/storyboard/storyboard-ui.test.ts src/botsu/apps/catalog.test.ts src/botsu/apps/launcher.test.ts src/botsu/moodboard/model.test.ts src/botsu/moodboard/controller.test.ts src/botsu/moodboard/moodboard-ui.test.ts src/botsu/documents/model.test.ts src/botsu/documents/document-share.test.ts src/botsu/documents/document-sync-bridge.test.ts src/botsu/documents/editor.test.ts src/botsu/drawing/drawing-bridge.test.ts src/botsu/drawing/drawing-export.test.ts src/botsu/drawing/drawing-model.test.ts src/botsu/drawing/drawing-ui.test.ts src/botsu/profile/summary.test.ts src/botsu/presence/compact-presence.test.ts src/botsu/presence/connection-generation.test.ts src/botsu/presence/cursor-publisher.test.ts src/botsu/presence/cursor-state.test.ts src/botsu/presence/location-publisher.test.ts src/botsu/presence/socket-message.test.ts src/botsu/presence/state.test.ts src/botsu/shell/layout.test.ts src/botsu/start/background-options.test.ts src/botsu/start/cookie-controller.test.ts src/botsu/start/model.test.ts src/botsu/start/pixel-canvas-bridge.test.ts src/botsu/start/pixel-canvas-publisher.test.ts src/botsu/voxel/model.test.ts src/botsu/voxel/voxel-color.test.ts src/botsu/voxel/voxel-ui.test.ts src/botsu/wikipedia/model.test.ts src/botsu/wikipedia/wikipedia-scraper.test.ts src/botsu/openstreetmap/geocoder.test.ts src/botsu/watch/jellyfin.test.ts src/botsu/watch/model.test.ts src/botsu/watch/providers.test.ts src/botsu/watch/watch-ui.test.ts src/botsu/workspace/editor.test.ts src/botsu/workspace/permissions.test.ts src/botsu/workspace/state.test.ts",
"typecheck:botsu": "../../node_modules/.bin/tsc -p tsconfig.botsu.json && ../../node_modules/.bin/tsc -p tsconfig.botsu-ui.json",
"prepare": "husky install",
"commit": "git-cz",
@@ -170,6 +170,31 @@ export function CreateRoomTypeSelector({
</Box>
</SettingTile>
</SequenceCard>
<SequenceCard
style={{ padding: config.space.S300 }}
variant={value === CreateRoomType.StoryboardRoom ? 'Primary' : 'SurfaceVariant'}
direction="Column"
gap="100"
as="button"
type="button"
aria-pressed={value === CreateRoomType.StoryboardRoom}
onClick={() => onSelect(CreateRoomType.StoryboardRoom)}
disabled={disabled}
>
<SettingTile
before={<Icon size="400" src={getIcon(CreateRoomType.StoryboardRoom)} />}
after={value === CreateRoomType.StoryboardRoom && <Icon src={Icons.Check} />}
>
<Box gap="200" alignItems="Baseline">
<Text size="H6" style={{ flexShrink: 0 }}>
Story Room
</Text>
<Text size="T300" priority="300" truncate>
- Scènes, dialogues, images et sons à réarranger ensemble.
</Text>
</Box>
</SettingTile>
</SequenceCard>
</Box>
);
}
@@ -5,6 +5,7 @@ export enum CreateRoomType {
DrawingRoom = 'drawing',
VoxelRoom = 'voxel',
WatchRoom = 'watch',
StoryboardRoom = 'storyboard',
}
export enum CreateRoomAccess {
@@ -105,6 +105,12 @@ export const createWatchRoomPowerLevelsOverride = () => ({
},
});
export const createStoryboardRoomPowerLevelsOverride = () => ({
events: {
[StateEvent.BotsuStoryboardCard]: 0,
},
});
export const createWatchRoomState = () => ({
type: StateEvent.BotsuWatchSession,
state_key: '',
@@ -123,6 +129,7 @@ export type CreateRoomData = {
knock: boolean;
allowFederation: boolean;
additionalCreators?: string[];
drawingResourceId?: string;
};
export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promise<string> => {
const initialState: ICreateRoomStateEvent[] = [];
@@ -131,6 +138,14 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
initialState.push(createRoomEncryptionState());
}
if (data.type === RoomType.Drawing && data.drawingResourceId) {
initialState.push({
type: StateEvent.BotsuDrawing,
state_key: '',
content: { version: 1, resourceId: data.drawingResourceId },
});
}
if (data.parent) {
initialState.push(createRoomParentState(data.parent));
}
@@ -160,7 +175,9 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
? createVoiceRoomPowerLevelsOverride()
: data.type === RoomType.Watch
? createWatchRoomPowerLevelsOverride()
: undefined,
: data.type === RoomType.Storyboard
? createStoryboardRoomPowerLevelsOverride()
: undefined,
initial_state: initialState,
};
@@ -14,7 +14,7 @@ import {
Text,
TextArea,
} from 'folds';
import { parseBotsuDocumentObject } from '@botsu/protocol';
import { createBotsuDrawingResourceId, parseBotsuDocumentObject } from '@botsu/protocol';
import { SettingTile } from '../../components/setting-tile';
import { SequenceCard } from '../../components/sequence-card';
import {
@@ -50,6 +50,7 @@ const getCreateRoomAccessToIcon = (access: CreateRoomAccess, type?: CreateRoomTy
const isDrawingRoom = type === CreateRoomType.DrawingRoom;
const isVoxelRoom = type === CreateRoomType.VoxelRoom;
const isWatchRoom = type === CreateRoomType.WatchRoom;
const isStoryboardRoom = type === CreateRoomType.StoryboardRoom;
let joinRule: JoinRule = JoinRule.Public;
if (access === CreateRoomAccess.Restricted) joinRule = JoinRule.Restricted;
@@ -61,6 +62,7 @@ const getCreateRoomAccessToIcon = (access: CreateRoomAccess, type?: CreateRoomTy
if (isDrawingRoom) roomType = RoomType.Drawing;
if (isVoxelRoom) roomType = RoomType.Voxel;
if (isWatchRoom) roomType = RoomType.Watch;
if (isStoryboardRoom) roomType = RoomType.Storyboard;
return getRoomIconSrc(Icons, roomType, joinRule);
};
@@ -70,6 +72,7 @@ const getCreateRoomTypeToIcon = (type: CreateRoomType) => {
if (type === CreateRoomType.DrawingRoom) return Icons.Pencil;
if (type === CreateRoomType.VoxelRoom) return Icons.Hash;
if (type === CreateRoomType.WatchRoom) return Icons.Play;
if (type === CreateRoomType.StoryboardRoom) return Icons.Play;
return Icons.Hash;
};
@@ -157,6 +160,7 @@ export function CreateRoomForm({
if (type === CreateRoomType.DrawingRoom) roomType = RoomType.Drawing;
if (type === CreateRoomType.VoxelRoom) roomType = RoomType.Voxel;
if (type === CreateRoomType.WatchRoom) roomType = RoomType.Watch;
if (type === CreateRoomType.StoryboardRoom) roomType = RoomType.Storyboard;
create({
version: selectedRoomVersion,
@@ -170,6 +174,10 @@ export function CreateRoomForm({
knock: roomKnock,
allowFederation: federation,
additionalCreators: allowAdditionalCreators ? additionalCreators : undefined,
drawingResourceId:
type === CreateRoomType.DrawingRoom
? createBotsuDrawingResourceId(crypto.randomUUID())
: undefined,
}).then(async (roomId) => {
if (type === CreateRoomType.DocumentRoom || type === CreateRoomType.VoxelRoom) {
try {
@@ -69,6 +69,8 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
? 'New Voxel room'
: type === CreateRoomType.WatchRoom
? 'New Watch room'
: type === CreateRoomType.StoryboardRoom
? 'New Story Room'
: 'New Chat room'}
</Text>
</Box>
@@ -7,12 +7,12 @@ import { callChatAtom } from '../../state/callEmbed';
import { RoomView } from './RoomView';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
export function CallChatView() {
export function CallChatView({ onClose }: { onClose?: () => void }) {
const { eventId } = useParams();
const setChat = useSetAtom(callChatAtom);
const screenSize = useScreenSizeContext();
const handleClose = () => setChat(false);
const handleClose = () => (onClose ? onClose() : setChat(false));
return (
<Page
+114 -16
View File
@@ -1,8 +1,9 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import type { Room as MatrixRoom } from 'matrix-js-sdk/lib/models/room';
import { Box, Line } from 'folds';
import { useParams } from 'react-router-dom';
import { isKeyHotkey } from 'is-hotkey';
import { useAtomValue } from 'jotai';
import { useAtom, useAtomValue } from 'jotai';
import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
@@ -16,7 +17,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomMembers } from '../../hooks/useRoomMembers';
import { CallView } from '../call/CallView';
import { RoomViewHeader } from './RoomViewHeader';
import { callChatAtom } from '../../state/callEmbed';
import { callChatAtom, drawingChatAtom, watchChatAtom } from '../../state/callEmbed';
import { CallChatView } from './CallChatView';
import { useCallEmbed } from '../../hooks/useCallEmbed';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
@@ -25,11 +26,57 @@ import { BotsuDocumentTransport } from '../../../botsu/documents/BotsuDocumentTr
import { DocumentEditorProvider } from '../../../botsu/documents/DocumentEditorContext';
import { BotsuDrawingRoomView } from '../../../botsu/drawing/BotsuDrawingRoomView';
import { BotsuDrawingTransport } from '../../../botsu/drawing/BotsuDrawingTransport';
import { DrawingProvider } from '../../../botsu/drawing/DrawingContext';
import { DrawingToolProvider } from '../../../botsu/drawing/DrawingToolContext';
import { PixelCanvasProvider } from '../../../botsu/start/PixelCanvasContext';
import { BotsuWatchRoomView } from '../../../botsu/watch';
import { BotsuVoxelRoomView } from '../../../botsu/voxel';
import { isDocumentRoom, isDrawingRoom, isVoxelRoom, isWatchRoom } from '../../utils/room';
import { BotsuStoryboardRoomView } from '../../../botsu/storyboard';
import {
BotsuVoxelRoomView,
VoxelEditorProvider,
VoxelMaterialsDrawer,
useVoxelEditor,
} from '../../../botsu/voxel';
import {
isDocumentRoom,
isDrawingRoom,
isStoryboardRoom,
isVoxelRoom,
isWatchRoom,
} from '../../utils/room';
function VoxelMaterialsPanel() {
const { materialsOpen } = useVoxelEditor();
if (!materialsOpen) return null;
return (
<>
<Line variant="Background" direction="Vertical" size="300" />
<VoxelMaterialsDrawer />
</>
);
}
function VoxelRoomContent({ room }: { room: MatrixRoom }) {
const screenSize = useScreenSizeContext();
const { materialsOpen } = useVoxelEditor();
return (
<>
<Box
grow="Yes"
direction="Column"
style={
screenSize === ScreenSize.Desktop || !materialsOpen ? undefined : { display: 'none' }
}
>
<BotsuDocumentTransport />
<RoomViewHeader />
<Box grow="Yes" style={{ minHeight: 0 }}>
<BotsuVoxelRoomView room={room} />
</Box>
</Box>
<VoxelMaterialsPanel />
</>
);
}
export function Room() {
const { eventId } = useParams();
@@ -46,6 +93,16 @@ export function Room() {
const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId);
const chat = useAtomValue(callChatAtom);
const [drawingChat, setDrawingChat] = useAtom(drawingChatAtom);
const [watchChat, setWatchChat] = useAtom(watchChatAtom);
useEffect(() => {
setDrawingChat(false);
}, [room.roomId, setDrawingChat]);
useEffect(() => {
setWatchChat(false);
}, [room.roomId, setWatchChat]);
useKeyDown(
window,
@@ -64,6 +121,7 @@ export function Room() {
const drawingView = isDrawingRoom(room);
const voxelView = isVoxelRoom(room);
const watchView = isWatchRoom(room);
const storyboardView = isStoryboardRoom(room);
return (
<PowerLevelsContextProvider value={powerLevels}>
@@ -76,7 +134,12 @@ export function Room() {
</Box>
</Box>
)}
{!callView && !documentView && !drawingView && !voxelView && !watchView && (
{!callView &&
!documentView &&
!drawingView &&
!voxelView &&
!watchView &&
!storyboardView && (
<Box grow="Yes" direction="Column">
<RoomViewHeader />
<Box grow="Yes">
@@ -96,27 +159,53 @@ export function Room() {
</DocumentEditorProvider>
)}
{!callView && watchView && (
<Box grow="Yes" direction="Column">
<Box
grow="Yes"
direction="Column"
style={
screenSize === ScreenSize.Desktop || !watchChat ? undefined : { display: 'none' }
}
>
<RoomViewHeader />
<Box grow="Yes">
<Box grow="Yes" style={{ minHeight: 0 }}>
<BotsuWatchRoomView room={room} />
</Box>
</Box>
)}
{!callView && voxelView && (
{!callView && watchView && watchChat && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<CallChatView onClose={() => setWatchChat(false)} />
</>
)}
{!callView && storyboardView && (
<Box grow="Yes" direction="Column">
<BotsuDocumentTransport />
<RoomViewHeader />
<Box grow="Yes">
<BotsuVoxelRoomView room={room} />
<Box grow="Yes" style={{ minHeight: 0 }}>
<BotsuStoryboardRoomView room={room} />
</Box>
</Box>
)}
{!callView && voxelView && (
<VoxelEditorProvider key={room.roomId}>
<VoxelRoomContent room={room} />
</VoxelEditorProvider>
)}
{!callView && drawingView && (
<PixelCanvasProvider>
<DrawingProvider key={room.roomId}>
<DrawingToolProvider>
<Box grow="Yes" direction="Column">
<Box
grow="Yes"
direction="Column"
style={
screenSize === ScreenSize.Desktop || !drawingChat
? undefined
: { display: 'none' }
}
>
<BotsuDrawingTransport />
<RoomViewHeader />
<Box grow="Yes" style={{ minHeight: 0 }}>
@@ -124,7 +213,16 @@ export function Room() {
</Box>
</Box>
</DrawingToolProvider>
</PixelCanvasProvider>
</DrawingProvider>
)}
{!callView && drawingView && drawingChat && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<CallChatView onClose={() => setDrawingChat(false)} />
</>
)}
{callView && chat && (
@@ -25,6 +25,7 @@ import {
} from 'folds';
import { useNavigate } from 'react-router-dom';
import { Room } from 'matrix-js-sdk';
import { useAtom } from 'jotai';
import { useStateEvent } from '../../hooks/useStateEvent';
import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
@@ -38,7 +39,7 @@ import { settingsAtom } from '../../state/settings';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix';
import { isDocumentRoom, isDrawingRoom } from '../../utils/room';
import { isDocumentRoom, isDrawingRoom, isVoxelRoom, isWatchRoom } from '../../utils/room';
import { _SearchPathSearchParams } from '../../pages/paths';
import * as css from './RoomViewHeader.css';
import { useRoomUnread } from '../../state/hooks/unread';
@@ -60,6 +61,7 @@ import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { DocumentRoomToolbar } from '../../../botsu/documents/DocumentRoomToolbar';
import { DrawingRoomToolbar } from '../../../botsu/drawing/DrawingRoomToolbar';
import { VoxelRoomToolbar } from '../../../botsu/voxel';
import {
getRoomNotificationMode,
getRoomNotificationModeIcon,
@@ -75,6 +77,7 @@ import { RoomSettingsPage } from '../../state/roomSettings';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
import { watchChatAtom } from '../../state/callEmbed';
type RoomMenuProps = {
room: Room;
@@ -386,6 +389,34 @@ function CallButton() {
);
}
function WatchChatButton() {
const [watchChat, setWatchChat] = useAtom(watchChatAtom);
return (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{watchChat ? 'Fermer la discussion' : 'Ouvrir la discussion'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
aria-label={watchChat ? 'Fermer la discussion' : 'Ouvrir la discussion'}
aria-pressed={watchChat}
onClick={() => setWatchChat(!watchChat)}
>
<Icon size="400" src={Icons.Message} filled={watchChat} />
</IconButton>
)}
</TooltipProvider>
);
}
export function RoomViewHeader({ callView }: { callView?: boolean }) {
const navigate = useNavigate();
const mx = useMatrixClient();
@@ -544,6 +575,8 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
<DocumentRoomToolbar />
) : isDrawingRoom(room) ? (
<DrawingRoomToolbar />
) : isVoxelRoom(room) ? (
<VoxelRoomToolbar />
) : (
<>
<TooltipProvider
@@ -605,9 +638,14 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
/>
</>
)}
{!room.isCallRoom() && !isDocumentRoom(room) && !isDrawingRoom(room) && livekitSupported && rtcSupported && hasCallPermission && (
<CallButton />
)}
{!room.isCallRoom() &&
!isDocumentRoom(room) &&
!isDrawingRoom(room) &&
!isVoxelRoom(room) &&
livekitSupported &&
rtcSupported &&
hasCallPermission && <CallButton />}
{isWatchRoom(room) && <WatchChatButton />}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
+3
View File
@@ -40,6 +40,7 @@ import {
_BOTSU_WIKIPEDIA_PATH,
_BOTSU_WIKIPEDIA_ARTICLE_PATH,
_BOTSU_OPENSTREETMAP_PATH,
_BOTSU_MOODBOARD_PATH,
} from './paths';
import {
getAppPathFromHref,
@@ -88,6 +89,7 @@ import { BotsuCommunityCanvasHome, BotsuStartPage } from '../../botsu/start';
import { BotsuTcgPage } from '../../botsu/tcg/BotsuTcgPage';
import { BotsuWikipediaHome, BotsuWikipediaArticle } from '../../botsu/wikipedia';
import { BotsuOpenStreetMap } from '../../botsu/openstreetmap/BotsuOpenStreetMap';
import { BotsuMoodboard } from '../../botsu/moodboard';
export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) => {
const { hashRouter } = clientConfig;
@@ -196,6 +198,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
<Route path={_BOTSU_WIKIPEDIA_PATH} element={<BotsuWikipediaHome />} />
<Route path={_BOTSU_WIKIPEDIA_ARTICLE_PATH} element={<BotsuWikipediaArticle />} />
<Route path={_BOTSU_OPENSTREETMAP_PATH} element={<BotsuOpenStreetMap />} />
<Route path={_BOTSU_MOODBOARD_PATH} element={<BotsuMoodboard />} />
<Route path={_BOTSU_SERVICES_PATH} element={<BotsuServices />} />
<Route path={_BOTSU_EMBED_PATH} element={<BotsuEmbed />} />
<Route path={_BOTSU_GODOT_PATH} element={<BotsuGodot />} />
+1
View File
@@ -86,6 +86,7 @@ export const _BOTSU_TCG_PATH = 'tcg/';
export const _BOTSU_WIKIPEDIA_PATH = 'wikipedia/';
export const _BOTSU_WIKIPEDIA_ARTICLE_PATH = 'wikipedia/:slug/';
export const _BOTSU_OPENSTREETMAP_PATH = 'openstreetmap/';
export const _BOTSU_MOODBOARD_PATH = 'moodboard/';
export const BOTSU_PATH = '/botsu/';
export const BOTSU_GODOT_PATH = `/botsu/${_BOTSU_GODOT_PATH}`;
export const BOTSU_APPS_PATH = `/botsu/${_BOTSU_APPS_PATH}`;
+2
View File
@@ -18,3 +18,5 @@ export const callEmbedAtom = atom<CallEmbed | undefined, [CallEmbed | undefined]
);
export const callChatAtom = atom<boolean>(false);
export const drawingChatAtom = atom<boolean>(false);
export const watchChatAtom = atom<boolean>(false);
+11 -1
View File
@@ -97,7 +97,8 @@ export const isUnsupportedRoom = (room: Room | null): boolean => {
type !== RoomType.Document &&
type !== RoomType.Drawing &&
type !== RoomType.Voxel &&
type !== RoomType.Watch
type !== RoomType.Watch &&
type !== RoomType.Storyboard
);
};
@@ -129,6 +130,13 @@ export const isWatchRoom = (room: Room | null): boolean => {
return event.getContent().type === RoomType.Watch;
};
export const isStoryboardRoom = (room: Room | null): boolean => {
if (!room) return false;
const event = getStateEvent(room, StateEvent.RoomCreate);
if (!event) return false;
return event.getContent().type === RoomType.Storyboard;
};
export function isValidChild(mEvent: MatrixEvent): boolean {
return (
mEvent.getType() === StateEvent.SpaceChild &&
@@ -351,6 +359,8 @@ export const getRoomIconSrc = (
if (roomType === RoomType.Watch) return icons.Play;
if (roomType === RoomType.Storyboard) return icons.Play;
if (joinRule === JoinRule.Public) return icons.HashGlobe;
if (
joinRule === JoinRule.Invite ||
+32 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botsuApps, getApp, getVisibleApps } from './catalog.ts';
import { botsuApps, getApp, getBotsuApplicationApps, getVisibleApps } from './catalog.ts';
test('member catalogue exposes the collaborative suite without administration', () => {
const apps = getVisibleApps(new Set(['member']));
@@ -10,6 +10,7 @@ test('member catalogue exposes the collaborative suite without administration',
apps.map((app) => app.id),
[
'godot',
'moodboard',
'discussions',
'documents',
'tables',
@@ -48,6 +49,32 @@ test('administrator catalogue includes administration', () => {
);
});
test('BOTSU applications page hides room/stream experiments and keeps Pinterest', () => {
const apps = getBotsuApplicationApps(new Set(['member']));
const ids = apps.map((app) => app.id);
assert.ok(ids.includes('moodboard'));
assert.equal(getApp('moodboard')?.label, 'Pinterest');
assert.equal(ids.includes('dessin'), false);
assert.equal(ids.includes('voxel'), false);
assert.equal(ids.includes('cinema'), false);
assert.equal(ids.includes('radio'), false);
assert.equal(getApp('dessin')?.label, 'Dessin');
assert.equal(getApp('voxel')?.label, 'Voxel Room');
assert.equal(getApp('cinema')?.label, 'Watch Room');
assert.equal(getApp('radio')?.label, 'Radio');
});
test('BOTSU applications page keeps administration hidden from members', () => {
const apps = getBotsuApplicationApps(new Set(['member']));
assert.equal(
apps.some((app) => app.id === 'administration'),
false
);
});
test('catalogue identifiers and native paths are unique', () => {
const ids = botsuApps.map((app) => app.id);
const nativePaths = botsuApps.flatMap((app) =>
@@ -60,6 +87,10 @@ test('catalogue identifiers and native paths are unique', () => {
test('catalogue records live and planned applications explicitly', () => {
assert.equal(getApp('discussions')?.availability, 'available');
assert.deepEqual(getApp('moodboard')?.launch, {
mode: 'native',
path: '/botsu/moodboard/',
});
assert.equal(getApp('documents')?.availability, 'planned');
assert.equal(getApp('tables')?.launch.mode, 'iframe');
assert.equal(getApp('transfers')?.launch.mode, 'external');
+16
View File
@@ -1,6 +1,7 @@
export type BotsuRole = 'member' | 'admin';
export type BotsuAppId =
| 'godot'
| 'moodboard'
| 'discussions'
| 'documents'
| 'tables'
@@ -47,6 +48,14 @@ export const botsuApps: readonly BotsuApp[] = [
availability: 'available',
launch: { mode: 'native', path: '/botsu/godot/' },
},
{
id: 'moodboard',
label: 'Pinterest',
description: 'Moodboard partagé — images, couleurs, textes, fichiers et liens.',
roles: ['member', 'admin'],
availability: 'available',
launch: { mode: 'native', path: '/botsu/moodboard/' },
},
{
id: 'discussions',
label: 'Discussions',
@@ -225,10 +234,17 @@ export const botsuApps: readonly BotsuApp[] = [
},
];
const HIDDEN_APPLICATION_IDS = new Set<string>(['dessin', 'voxel', 'cinema', 'radio']);
export const getVisibleApps = (
roles: ReadonlySet<BotsuRole>,
apps: readonly BotsuApp[] = botsuApps
): readonly BotsuApp[] => apps.filter((app) => app.roles.some((role) => roles.has(role)));
export const getBotsuApplicationApps = (
roles: ReadonlySet<BotsuRole>,
apps: readonly BotsuApp[] = botsuApps
): readonly BotsuApp[] => getVisibleApps(roles, apps).filter((app) => !HIDDEN_APPLICATION_IDS.has(app.id));
export const getApp = (id: string, apps: readonly BotsuApp[] = botsuApps): BotsuApp | undefined =>
apps.find((app) => app.id === id);
@@ -0,0 +1,404 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
BOTSU_DRAWING_SIZE,
type DrawingElement,
type DrawingPoint,
type DrawingShape,
} from '@botsu/protocol';
import { useDrawing } from './DrawingContext';
import { useDrawingTool } from './DrawingToolContext';
import {
applyDrawingElement,
createDrawingFillRuns,
findDrawingElementAt,
getDrawingElementBounds,
moveDrawingElement,
sampleDrawingPoint,
} from './drawing-model';
import { downloadDrawingBlob, encodeDrawingGif } from './drawing-export';
const createElementId = (): string => `draw_${crypto.randomUUID()}`;
const clamp = (value: number): number => Math.max(0, Math.min(BOTSU_DRAWING_SIZE - 1, Math.round(value)));
const getPoint = (canvas: HTMLCanvasElement, clientX: number, clientY: number): DrawingPoint => {
const bounds = canvas.getBoundingClientRect();
return {
x: clamp(((clientX - bounds.left) / bounds.width) * BOTSU_DRAWING_SIZE),
y: clamp(((clientY - bounds.top) / bounds.height) * BOTSU_DRAWING_SIZE),
};
};
const fontFamily = (font: string, siteFont: string): string => {
if (font === 'inter') return 'Inter, ui-sans-serif, sans-serif';
if (font === 'velvelyne') return '"Botsu Velvelyne", ui-sans-serif, sans-serif';
if (font === 'minecraft') return '"Botsu Minecraft", monospace';
if (font === 'monospace') return 'ui-monospace, SFMono-Regular, Consolas, monospace';
return siteFont || 'Inter, ui-sans-serif, sans-serif';
};
const pathPoints = (context: CanvasRenderingContext2D, points: readonly DrawingPoint[]): void => {
const first = points[0];
if (!first) return;
context.beginPath();
context.moveTo(first.x, first.y);
points.slice(1).forEach((point) => context.lineTo(point.x, point.y));
};
const drawElement = (
context: CanvasRenderingContext2D,
element: DrawingElement,
siteFont: string
): void => {
context.save();
if (element.kind === 'fill') {
context.fillStyle = element.color;
element.runs.forEach((run) =>
context.fillRect(run.startX, run.y, run.endX - run.startX + 1, 1)
);
context.restore();
return;
}
if (element.kind === 'text') {
context.fillStyle = element.color;
context.font = `${element.size}px ${fontFamily(element.font, siteFont)}`;
context.textBaseline = 'alphabetic';
context.fillText(element.text, element.x, element.y, BOTSU_DRAWING_SIZE - element.x);
context.restore();
return;
}
context.lineCap = 'round';
context.lineJoin = 'round';
context.lineWidth = element.size;
context.strokeStyle = element.kind === 'stroke' && element.mode === 'erase' ? '#ffffff' : element.color;
context.fillStyle = element.color;
if (element.kind === 'stroke') {
pathPoints(context, element.points);
if (element.points.length === 1) {
const point = element.points[0]!;
context.beginPath();
context.arc(point.x, point.y, element.size / 2, 0, Math.PI * 2);
context.fillStyle = element.mode === 'erase' ? '#ffffff' : element.color;
context.fill();
} else {
context.stroke();
}
context.restore();
return;
}
const [start, end] = element.points;
if (!start || !end) { context.restore(); return; }
if (element.shape === 'line') {
pathPoints(context, element.points);
context.stroke();
} else if (element.shape === 'polygon') {
pathPoints(context, element.points);
context.closePath();
context.stroke();
} else {
const left = Math.min(start.x, end.x);
const top = Math.min(start.y, end.y);
const width = Math.abs(end.x - start.x);
const height = Math.abs(end.y - start.y);
if (element.shape === 'rectangle' || element.shape === 'rectangle-filled') {
if (element.shape === 'rectangle-filled') context.fillRect(left, top, width, height);
else context.strokeRect(left, top, width, height);
} else {
context.beginPath();
context.ellipse(left + width / 2, top + height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
if (element.shape === 'circle-filled') context.fill();
else context.stroke();
}
}
context.restore();
};
const renderDrawing = (
canvas: HTMLCanvasElement,
elements: readonly DrawingElement[],
selectedId?: string
): void => {
const context = canvas.getContext('2d', { willReadFrequently: true });
if (!context) return;
context.clearRect(0, 0, BOTSU_DRAWING_SIZE, BOTSU_DRAWING_SIZE);
context.fillStyle = '#ffffff';
context.fillRect(0, 0, BOTSU_DRAWING_SIZE, BOTSU_DRAWING_SIZE);
const siteFont = getComputedStyle(canvas).fontFamily;
elements.forEach((element) => drawElement(context, element, siteFont));
const selected = elements.find((element) => element.id === selectedId);
if (selected) {
const bounds = getDrawingElementBounds(selected);
context.save();
context.strokeStyle = '#3578e5';
context.lineWidth = 2;
context.setLineDash([8, 6]);
context.strokeRect(bounds.left, bounds.top, bounds.right - bounds.left, bounds.bottom - bounds.top);
context.restore();
}
};
const shapeForTool: Partial<Record<string, DrawingShape>> = {
rectangle: 'rectangle',
'rectangle-filled': 'rectangle-filled',
circle: 'circle',
'circle-filled': 'circle-filled',
line: 'line',
};
export function BotsuDrawingCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const activeRef = useRef<DrawingElement>();
const dragRef = useRef<{ element: DrawingElement; start: DrawingPoint }>();
const polygonRef = useRef<DrawingPoint[]>([]);
const lastPublishRef = useRef(0);
const [preview, setPreview] = useState<DrawingElement>();
const [selectedId, setSelectedId] = useState<string>();
const { state, upsert, remove } = useDrawing();
const {
tool,
setTool,
color,
setColor,
size,
font,
fontSize,
registerExporters,
} = useDrawingTool();
const elements = useMemo(
() => (preview ? applyDrawingElement(state.elements, preview) : state.elements),
[preview, state.elements]
);
useEffect(() => {
const canvas = canvasRef.current;
if (canvas) renderDrawing(canvas, elements, selectedId);
}, [elements, selectedId]);
useEffect(() => {
const exportPng = () => {
canvasRef.current?.toBlob((blob) => {
if (blob) downloadDrawingBlob(blob, 'dessin-botsu.png');
}, 'image/png');
};
const exportGif = () => {
const canvas = canvasRef.current;
const context = canvas?.getContext('2d', { willReadFrequently: true });
if (!canvas || !context) return;
const image = context.getImageData(0, 0, canvas.width, canvas.height);
const bytes = encodeDrawingGif(canvas.width, canvas.height, image.data);
const buffer = Uint8Array.from(bytes).buffer;
downloadDrawingBlob(new Blob([buffer], { type: 'image/gif' }), 'dessin-botsu.gif');
};
return registerExporters(exportPng, exportGif);
}, [registerExporters]);
const publishActive = (force = false): boolean => {
const active = activeRef.current;
if (!active) return false;
const now = performance.now();
if (!force && now - lastPublishRef.current < 125) return true;
lastPublishRef.current = now;
return upsert(active);
};
const commitPolygon = (): void => {
if (polygonRef.current.length < 2) return;
const element: DrawingElement = {
id: createElementId(),
kind: 'shape',
shape: 'polygon',
color,
size,
points: polygonRef.current.slice(0, 64),
};
polygonRef.current = [];
setPreview(undefined);
upsert(element);
};
const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>): void => {
if (state.status !== 'online') return;
const point = getPoint(event.currentTarget, event.clientX, event.clientY);
if (tool === 'select') {
const selected = findDrawingElementAt(state.elements, point);
setSelectedId(selected?.id);
if (selected) {
dragRef.current = { element: selected, start: point };
event.currentTarget.setPointerCapture(event.pointerId);
}
return;
}
if (tool === 'eyedropper') {
const context = event.currentTarget.getContext('2d', { willReadFrequently: true });
const pixel = context?.getImageData(point.x, point.y, 1, 1).data;
if (pixel) {
setColor(`#${[pixel[0], pixel[1], pixel[2]].map((value) => (value ?? 0).toString(16).padStart(2, '0')).join('')}`);
setTool('pencil');
}
return;
}
if (tool === 'bucket') {
const context = event.currentTarget.getContext('2d', { willReadFrequently: true });
const image = context?.getImageData(0, 0, BOTSU_DRAWING_SIZE, BOTSU_DRAWING_SIZE);
const runs = image
? createDrawingFillRuns(
image.data,
BOTSU_DRAWING_SIZE,
BOTSU_DRAWING_SIZE,
point,
color
)
: undefined;
if (runs?.length) upsert({ id: createElementId(), kind: 'fill', color, runs });
return;
}
if (tool === 'text') {
const text = window.prompt('Texte');
if (text?.trim()) {
upsert({
id: createElementId(),
kind: 'text',
color,
font,
size: fontSize,
x: point.x,
y: point.y,
text: text.slice(0, 512),
});
}
return;
}
if (tool === 'polygon') {
polygonRef.current = [...polygonRef.current, point].slice(0, 64);
if (event.detail >= 2) {
polygonRef.current = polygonRef.current.slice(0, -1);
commitPolygon();
} else if (polygonRef.current.length >= 2) {
setPreview({
id: 'draw_00000000-0000-4000-8000-000000000000',
kind: 'shape',
shape: 'polygon',
color,
size,
points: polygonRef.current,
});
}
return;
}
const shape = shapeForTool[tool];
const element: DrawingElement = shape
? { id: createElementId(), kind: 'shape', shape, color, size, points: [point, point] }
: {
id: createElementId(),
kind: 'stroke',
mode: tool === 'eraser' ? 'erase' : 'paint',
color,
size,
points: [point],
};
activeRef.current = element;
setPreview(element);
event.currentTarget.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: React.PointerEvent<HTMLCanvasElement>): void => {
const point = getPoint(event.currentTarget, event.clientX, event.clientY);
if (dragRef.current) {
const moved = moveDrawingElement(
dragRef.current.element,
point.x - dragRef.current.start.x,
point.y - dragRef.current.start.y
);
setPreview(moved);
return;
}
const active = activeRef.current;
if (!active) return;
if (active.kind === 'stroke') {
if (active.points.length >= 128) {
if (!publishActive(true)) {
setPreview(active);
return;
}
const last = active.points.at(-1)!;
activeRef.current = { ...active, id: createElementId(), points: [last, point] };
setPreview(activeRef.current);
return;
}
const sampled = sampleDrawingPoint(active.points, point, Math.max(2, active.size / 3));
if (sampled === active.points) return;
if (sampled.length >= 128) {
activeRef.current = { ...active, points: [...sampled] };
if (!publishActive(true)) {
setPreview(activeRef.current);
return;
}
const last = sampled.at(-1)!;
activeRef.current = { ...active, id: createElementId(), points: [last] };
} else {
activeRef.current = { ...active, points: [...sampled] };
}
} else if (active.kind === 'shape') {
activeRef.current = { ...active, points: [active.points[0]!, point] };
}
setPreview(activeRef.current);
publishActive();
};
const handlePointerEnd = (event: React.PointerEvent<HTMLCanvasElement>): void => {
if (dragRef.current && preview) upsert(preview);
else publishActive(true);
dragRef.current = undefined;
activeRef.current = undefined;
setPreview(undefined);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLCanvasElement>): void => {
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedId) {
event.preventDefault();
if (remove(selectedId)) setSelectedId(undefined);
} else if (event.key === 'Enter' && tool === 'polygon') {
event.preventDefault();
commitPolygon();
} else if (event.key === 'Escape') {
polygonRef.current = [];
activeRef.current = undefined;
dragRef.current = undefined;
setPreview(undefined);
setSelectedId(undefined);
}
};
const status = {
idle: 'Inactif',
syncing: 'Synchronisation…',
online: `Temps réel · révision ${state.revision}`,
offline: 'Hors ligne',
}[state.status];
return (
<div className="botsu-drawing-canvas-frame">
<div className="botsu-drawing-status" role="status" aria-live="polite">
<span className={`botsu-presence-dot is-${state.status === 'online' ? 'online' : 'offline'}`} />
{status}
</div>
<canvas
aria-label="Page de dessin collaborative 1024 par 1024"
className="botsu-drawing-canvas"
data-tool={tool}
height={BOTSU_DRAWING_SIZE}
onKeyDown={handleKeyDown}
onPointerCancel={handlePointerEnd}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
ref={canvasRef}
tabIndex={0}
width={BOTSU_DRAWING_SIZE}
/>
</div>
);
}
@@ -1,15 +1,12 @@
import React from 'react';
import { BotsuPixelCanvas } from '../start/BotsuPixelCanvas';
import { useDrawingTool } from './DrawingToolContext';
import '../start/start.css';
import { BotsuDrawingCanvas } from './BotsuDrawingCanvas';
import './drawing.css';
export function BotsuDrawingRoomView() {
const { tool } = useDrawingTool();
return (
<section className="botsu-drawing-room" aria-label="Canevas 1024 × 1024">
<section className="botsu-drawing-room" aria-label="Salle de dessin">
<div className="botsu-drawing-room__canvas">
<BotsuPixelCanvas interactive tool={tool} variant="inline" />
<BotsuDrawingCanvas />
</div>
</section>
);
@@ -1,30 +1,63 @@
import React, { useEffect, useMemo } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import { createBotsuDrawingResourceId, parseDrawingReference } from '@botsu/protocol';
import { useRoom } from '../../app/hooks/useRoom';
import { useMatrixClient } from '../../app/hooks/useMatrixClient';
import { useStateEvent } from '../../app/hooks/useStateEvent';
import { readPowerLevel, usePowerLevels } from '../../app/hooks/usePowerLevels';
import { StateEvent } from '../../types/matrix/room';
import { createPresenceWebSocketUrl, parsePresenceSocketMessage } from '../presence/socket-message';
import { usePixelCanvasBridge } from '../start/PixelCanvasContext';
const toDrawingResourceId = (roomId: string): string => {
const encoded = window
.btoa(roomId)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
return `drawing_${encoded}`.slice(0, 128);
};
import { useDrawingBridge } from './DrawingContext';
export function BotsuDrawingTransport() {
const mx = useMatrixClient();
const room = useRoom();
const pixelCanvasBridge = usePixelCanvasBridge();
const resourceId = useMemo(() => toDrawingResourceId(room.roomId), [room.roomId]);
const drawingBridge = useDrawingBridge();
const drawingEvent = useStateEvent(room, StateEvent.BotsuDrawing);
const powerLevels = usePowerLevels(room);
const provisioningRoomRef = useRef<string>();
const canProvision =
readPowerLevel.user(powerLevels, mx.getSafeUserId()) >=
readPowerLevel.state(powerLevels, StateEvent.BotsuDrawing);
const resourceId = useMemo(() => {
try {
return parseDrawingReference(drawingEvent?.getContent()).resourceId;
} catch {
return undefined;
}
}, [drawingEvent]);
useEffect(() => {
if (drawingEvent || !canProvision || provisioningRoomRef.current === room.roomId) {
return undefined;
}
provisioningRoomRef.current = room.roomId;
let active = true;
const resourceId = createBotsuDrawingResourceId(crypto.randomUUID());
void mx
.sendStateEvent(
room.roomId,
StateEvent.BotsuDrawing as any,
{ version: 1, resourceId },
''
)
.catch(() => {
if (active && provisioningRoomRef.current === room.roomId) {
provisioningRoomRef.current = undefined;
}
});
return () => {
active = false;
};
}, [canProvision, drawingEvent, mx, room.roomId]);
useEffect(() => {
drawingBridge.reset();
if (!resourceId) return undefined;
let stopped = false;
let socket: WebSocket | undefined;
let reconnectTimer: number | undefined;
let heartbeatTimer: number | undefined;
let detachPixelCanvasSender: (() => void) | undefined;
let detachDrawingSender: (() => void) | undefined;
const clearHeartbeat = () => {
if (heartbeatTimer !== undefined) window.clearInterval(heartbeatTimer);
@@ -39,7 +72,7 @@ export function BotsuDrawingTransport() {
currentSocket.addEventListener('open', async () => {
try {
const token = await mx.getOpenIdToken();
if (stopped || currentSocket.readyState !== WebSocket.OPEN) return;
if (stopped || currentSocket !== socket || currentSocket.readyState !== WebSocket.OPEN) return;
currentSocket.send(
JSON.stringify({
type: 'presence.hello',
@@ -58,6 +91,7 @@ export function BotsuDrawingTransport() {
});
currentSocket.addEventListener('message', (event) => {
if (stopped || currentSocket !== socket) return;
try {
const message = parsePresenceSocketMessage(event.data, (code, reason) =>
currentSocket.close(code, reason)
@@ -72,12 +106,19 @@ export function BotsuDrawingTransport() {
resourceId,
})
);
detachPixelCanvasSender?.();
detachPixelCanvasSender = pixelCanvasBridge.attach((canvasMessage) => {
if (currentSocket.readyState === WebSocket.OPEN) {
currentSocket.send(JSON.stringify(canvasMessage));
}
});
detachDrawingSender?.();
detachDrawingSender = drawingBridge.attach(
(drawingMessage) => {
if (
!stopped &&
currentSocket === socket &&
currentSocket.readyState === WebSocket.OPEN
) {
currentSocket.send(JSON.stringify(drawingMessage));
}
},
room.roomId
);
clearHeartbeat();
heartbeatTimer = window.setInterval(() => {
if (currentSocket.readyState === WebSocket.OPEN) {
@@ -88,8 +129,13 @@ export function BotsuDrawingTransport() {
}, message.heartbeatIntervalMs);
return;
}
if (message.type === 'canvas.snapshot' || message.type === 'canvas.patch') {
pixelCanvasBridge.receive(message);
if (
message.type === 'drawing.snapshot' ||
message.type === 'drawing.upsert' ||
message.type === 'drawing.remove' ||
message.type === 'drawing.error'
) {
drawingBridge.receive(message);
}
} catch {
currentSocket.close();
@@ -97,8 +143,9 @@ export function BotsuDrawingTransport() {
});
currentSocket.addEventListener('close', () => {
detachPixelCanvasSender?.();
detachPixelCanvasSender = undefined;
if (stopped || currentSocket !== socket) return;
detachDrawingSender?.();
detachDrawingSender = undefined;
clearHeartbeat();
if (!stopped) reconnectTimer = window.setTimeout(connect, 5_000);
});
@@ -107,12 +154,12 @@ export function BotsuDrawingTransport() {
connect();
return () => {
stopped = true;
detachPixelCanvasSender?.();
detachDrawingSender?.();
clearHeartbeat();
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
socket?.close();
};
}, [mx, pixelCanvasBridge, resourceId]);
}, [drawingBridge, mx, resourceId]);
return null;
}
@@ -0,0 +1,32 @@
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import type { DrawingElement } from '@botsu/protocol';
import {
createDrawingBridge,
type DrawingBridge,
type DrawingClientState,
} from './drawing-bridge';
const DrawingContext = createContext<DrawingBridge | undefined>(undefined);
export function DrawingProvider({ children }: React.PropsWithChildren) {
const bridgeRef = useRef<DrawingBridge>();
if (!bridgeRef.current) bridgeRef.current = createDrawingBridge();
return <DrawingContext.Provider value={bridgeRef.current}>{children}</DrawingContext.Provider>;
}
export const useDrawingBridge = (): DrawingBridge => {
const bridge = useContext(DrawingContext);
if (!bridge) throw new Error('Drawing provider is missing');
return bridge;
};
export const useDrawing = (): {
state: DrawingClientState;
upsert: (element: DrawingElement) => boolean;
remove: (id: string) => boolean;
} => {
const bridge = useDrawingBridge();
const [state, setState] = useState<DrawingClientState>(bridge.getState());
useEffect(() => bridge.activate(setState), [bridge]);
return { state, upsert: bridge.upsert, remove: bridge.remove };
};
@@ -1,32 +1,150 @@
import React from 'react';
import { Box, Button, Icon, Icons, Text } from 'folds';
import {
ChatCircleIcon,
CircleIcon,
CursorIcon,
EraserIcon,
EyedropperIcon,
FilePngIcon,
GifIcon,
LineSegmentIcon,
PaintBucketIcon,
PencilSimpleIcon,
PolygonIcon,
RectangleIcon,
TextTIcon,
} from '@phosphor-icons/react';
import { useAtom } from 'jotai';
import { drawingChatAtom } from '../../app/state/callEmbed';
import { useDrawingTool, type DrawingTool } from './DrawingToolContext';
const tools: Array<{ id: DrawingTool; label: string; icon: typeof Icons.Pencil }> = [
{ id: 'draw', label: 'Crayon', icon: Icons.Pencil },
{ id: 'erase', label: 'Gomme', icon: Icons.Delete },
const tools: Array<{ id: DrawingTool; label: string; icon: React.ReactNode }> = [
{ id: 'select', label: 'Déplacer', icon: <CursorIcon aria-hidden="true" size={18} /> },
{ id: 'eyedropper', label: 'Pipette', icon: <EyedropperIcon aria-hidden="true" size={18} /> },
{ id: 'pencil', label: 'Crayon', icon: <PencilSimpleIcon aria-hidden="true" size={18} /> },
{ id: 'eraser', label: 'Gomme', icon: <EraserIcon aria-hidden="true" size={18} /> },
{ id: 'bucket', label: 'Remplir', icon: <PaintBucketIcon aria-hidden="true" size={18} /> },
{ id: 'rectangle', label: 'Rectangle', icon: <RectangleIcon aria-hidden="true" size={18} /> },
{
id: 'rectangle-filled',
label: 'Rectangle plein',
icon: <RectangleIcon aria-hidden="true" size={18} weight="fill" />,
},
{ id: 'circle', label: 'Cercle', icon: <CircleIcon aria-hidden="true" size={18} /> },
{
id: 'circle-filled',
label: 'Cercle plein',
icon: <CircleIcon aria-hidden="true" size={18} weight="fill" />,
},
{ id: 'text', label: 'Texte', icon: <TextTIcon aria-hidden="true" size={18} /> },
{ id: 'line', label: 'Ligne', icon: <LineSegmentIcon aria-hidden="true" size={18} /> },
{ id: 'polygon', label: 'Polygone', icon: <PolygonIcon aria-hidden="true" size={18} /> },
];
export function DrawingRoomToolbar() {
const { tool, setTool } = useDrawingTool();
const {
tool,
setTool,
color,
setColor,
size,
setSize,
font,
setFont,
fontSize,
setFontSize,
exportPng,
exportGif,
} = useDrawingTool();
const [chat, setChat] = useAtom(drawingChatAtom);
return (
<Box className="botsu-drawing-toolbar" alignItems="Center" gap="100">
<div className="botsu-drawing-toolbar" aria-label="Outils de dessin">
{tools.map((item) => (
<Button
key={item.id}
size="300"
radii="300"
variant={tool === item.id ? 'Primary' : 'Surface'}
fill={tool === item.id ? 'Soft' : 'None'}
<button
aria-label={item.label}
aria-pressed={tool === item.id}
className="botsu-drawing-toolbar__button botsu-drawing-toolbar__button--icon"
key={item.id}
onClick={() => setTool(item.id)}
before={<Icon size="100" src={item.icon} />}
title={item.label}
type="button"
>
<Text as="span" size="B300">
{item.label}
</Text>
</Button>
{item.icon}
</button>
))}
</Box>
<span className="botsu-drawing-toolbar__separator" aria-hidden="true" />
<label className="botsu-drawing-toolbar__field" title="Couleur">
<span className="sr-only">Couleur</span>
<input
aria-label="Couleur"
type="color"
value={color}
onChange={(event) => setColor(event.target.value)}
/>
</label>
<label className="botsu-drawing-toolbar__field">
<span>Trait</span>
<select
aria-label="Épaisseur du trait"
value={size}
onChange={(event) => setSize(Number(event.target.value))}
>
{[1, 2, 4, 5, 8, 12, 18, 24, 32, 48, 64].map((value) => (
<option key={value} value={value}>{value}</option>
))}
</select>
</label>
{tool === 'text' && (
<>
<label className="botsu-drawing-toolbar__field">
<span>Police</span>
<select value={font} onChange={(event) => setFont(event.target.value as typeof font)}>
<option value="site">Site</option>
<option value="inter">Inter</option>
<option value="velvelyne">Velvelyne</option>
<option value="minecraft">Minecraft</option>
<option value="monospace">Mono</option>
</select>
</label>
<label className="botsu-drawing-toolbar__field">
<span>Taille</span>
<select value={fontSize} onChange={(event) => setFontSize(Number(event.target.value))}>
{[16, 24, 32, 48, 64, 96, 128].map((value) => (
<option key={value} value={value}>{value}</option>
))}
</select>
</label>
</>
)}
<span className="botsu-drawing-toolbar__separator" aria-hidden="true" />
<button
aria-label="Exporter PNG"
className="botsu-drawing-toolbar__button botsu-drawing-toolbar__button--icon"
onClick={exportPng}
title="Exporter PNG"
type="button"
>
<FilePngIcon aria-hidden="true" size={18} />
</button>
<button
aria-label="Exporter GIF"
className="botsu-drawing-toolbar__button botsu-drawing-toolbar__button--icon"
onClick={exportGif}
title="Exporter GIF"
type="button"
>
<GifIcon aria-hidden="true" size={18} />
</button>
<button
aria-label="Chat"
aria-pressed={chat}
className="botsu-drawing-toolbar__button botsu-drawing-toolbar__button--icon"
onClick={() => setChat((value) => !value)}
title="Chat"
type="button"
>
<ChatCircleIcon aria-hidden="true" size={18} weight={chat ? 'fill' : 'regular'} />
</button>
</div>
);
}
@@ -1,17 +1,74 @@
import React, { createContext, useContext, useMemo, useState } from 'react';
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import type { DrawingFont } from '@botsu/protocol';
export type DrawingTool = 'draw' | 'erase';
export type DrawingTool =
| 'select'
| 'eyedropper'
| 'pencil'
| 'eraser'
| 'bucket'
| 'rectangle'
| 'rectangle-filled'
| 'circle'
| 'circle-filled'
| 'text'
| 'line'
| 'polygon';
type DrawingToolContextValue = {
tool: DrawingTool;
setTool: (tool: DrawingTool) => void;
color: string;
setColor: (color: string) => void;
size: number;
setSize: (size: number) => void;
font: DrawingFont;
setFont: (font: DrawingFont) => void;
fontSize: number;
setFontSize: (size: number) => void;
exportPng: () => void;
exportGif: () => void;
registerExporters: (png: () => void, gif: () => void) => () => void;
};
const DrawingToolContext = createContext<DrawingToolContextValue | undefined>(undefined);
export function DrawingToolProvider({ children }: React.PropsWithChildren) {
const [tool, setTool] = useState<DrawingTool>('draw');
const value = useMemo(() => ({ tool, setTool }), [tool]);
const [tool, setTool] = useState<DrawingTool>('pencil');
const [color, setColor] = useState('#111111');
const [size, setSize] = useState(5);
const [font, setFont] = useState<DrawingFont>('site');
const [fontSize, setFontSize] = useState(32);
const pngRef = useRef<() => void>();
const gifRef = useRef<() => void>();
const exportPng = useCallback(() => pngRef.current?.(), []);
const exportGif = useCallback(() => gifRef.current?.(), []);
const registerExporters = useCallback((png: () => void, gif: () => void) => {
pngRef.current = png;
gifRef.current = gif;
return () => {
if (pngRef.current === png) pngRef.current = undefined;
if (gifRef.current === gif) gifRef.current = undefined;
};
}, []);
const value = useMemo(
() => ({
tool,
setTool,
color,
setColor,
size,
setSize,
font,
setFont,
fontSize,
setFontSize,
exportPng,
exportGif,
registerExporters,
}),
[color, exportGif, exportPng, font, fontSize, registerExporters, size, tool]
);
return <DrawingToolContext.Provider value={value}>{children}</DrawingToolContext.Provider>;
}
@@ -0,0 +1,236 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DrawingClientMessage, DrawingElement } from '@botsu/protocol';
import { createDrawingBridge } from './drawing-bridge.ts';
const roomId = '!zlpDmLJtzRUYjIctHZ:botsu.net';
const stroke: DrawingElement = {
id: 'draw_123e4567-e89b-42d3-a456-426614174000',
kind: 'stroke',
mode: 'paint',
color: '#112233',
size: 5,
points: [{ x: 10, y: 20 }],
};
const mutationOne = 'mut_123e4567-e89b-42d3-a456-426614174040';
const mutationTwo = 'mut_123e4567-e89b-42d3-a456-426614174041';
const remoteMutation = 'mut_123e4567-e89b-42d3-a456-426614174042';
test('subscribes, applies local drawing optimistically, and accepts server revisions', () => {
const sent: unknown[] = [];
const states: Array<{ status: string; revision: number; elements: DrawingElement[] }> = [];
const bridge = createDrawingBridge({ createMutationId: () => mutationOne });
const deactivate = bridge.activate((state) => states.push(state));
const detach = bridge.attach((message) => sent.push(message), roomId);
assert.deepEqual(sent, [{ type: 'drawing.subscribe', protocolVersion: 1, roomId }]);
bridge.receive({
type: 'drawing.snapshot',
protocolVersion: 1,
width: 1024,
height: 1024,
revision: 0,
elements: [],
});
assert.equal(bridge.upsert(stroke), true);
assert.deepEqual(states.at(-1)?.elements, [stroke]);
assert.deepEqual(sent.at(-1), {
type: 'drawing.upsert',
protocolVersion: 1,
mutationId: mutationOne,
expectedRevision: 0,
element: stroke,
});
bridge.receive({
type: 'drawing.upsert',
protocolVersion: 1,
revision: 1,
mutationId: mutationOne,
element: stroke,
});
assert.equal(states.at(-1)?.revision, 1);
assert.equal(states.at(-1)?.status, 'online');
deactivate();
assert.deepEqual(sent.at(-1), { type: 'drawing.unsubscribe', protocolVersion: 1 });
detach();
});
test('resubscribes on revision gaps and ignores stale transport cleanup', () => {
const first: unknown[] = [];
const second: unknown[] = [];
const bridge = createDrawingBridge();
const deactivate = bridge.activate(() => undefined);
const detachFirst = bridge.attach((message) => first.push(message), roomId);
bridge.attach((message) => second.push(message), roomId);
bridge.receive({
type: 'drawing.snapshot',
protocolVersion: 1,
width: 1024,
height: 1024,
revision: 1,
elements: [stroke],
});
detachFirst();
bridge.receive({
type: 'drawing.remove',
protocolVersion: 1,
revision: 3,
mutationId: remoteMutation,
id: stroke.id,
});
assert.deepEqual(second.slice(-2), [
{ type: 'drawing.unsubscribe', protocolVersion: 1 },
{ type: 'drawing.subscribe', protocolVersion: 1, roomId },
]);
assert.equal(bridge.remove(stroke.id), false);
deactivate();
});
test('keeps newer optimistic edits over old echoes and rejects stale snapshots', () => {
const mutations = [mutationOne, mutationTwo];
const bridge = createDrawingBridge({ createMutationId: () => mutations.shift()! });
bridge.activate(() => undefined);
bridge.attach(() => undefined, roomId);
bridge.receive({
type: 'drawing.snapshot',
protocolVersion: 1,
width: 1024,
height: 1024,
revision: 0,
elements: [],
});
const red = { ...stroke, color: '#ff0000' };
const blue = { ...stroke, color: '#0000ff' };
assert.equal(bridge.upsert(red), true);
assert.equal(bridge.upsert(blue), true);
bridge.receive({
type: 'drawing.upsert',
protocolVersion: 1,
revision: 1,
mutationId: mutationOne,
element: red,
});
assert.equal(bridge.getState().elements[0]?.color, '#0000ff');
bridge.receive({
type: 'drawing.upsert',
protocolVersion: 1,
revision: 2,
mutationId: mutationTwo,
element: blue,
});
bridge.receive({
type: 'drawing.snapshot',
protocolVersion: 1,
width: 1024,
height: 1024,
revision: 1,
elements: [red],
});
assert.equal(bridge.getState().revision, 2);
assert.equal(bridge.getState().elements[0]?.color, '#0000ff');
});
test('queues an offline mutation and resends it after a reconnect snapshot', () => {
const first: unknown[] = [];
const second: unknown[] = [];
const bridge = createDrawingBridge({ createMutationId: () => mutationOne });
bridge.activate(() => undefined);
const detach = bridge.attach((message) => first.push(message), roomId);
bridge.receive({
type: 'drawing.snapshot',
protocolVersion: 1,
width: 1024,
height: 1024,
revision: 0,
elements: [],
});
detach();
assert.equal(bridge.upsert(stroke), true);
bridge.attach((message) => second.push(message), roomId);
bridge.receive({
type: 'drawing.snapshot',
protocolVersion: 1,
width: 1024,
height: 1024,
revision: 0,
elements: [],
});
assert.deepEqual(second.at(-1), {
type: 'drawing.upsert',
protocolVersion: 1,
mutationId: mutationOne,
expectedRevision: 0,
element: stroke,
});
});
test('re-sequences offline coalesced edits before reconnecting', () => {
const sent: DrawingClientMessage[] = [];
const mutationIds = [mutationOne, mutationTwo];
const bridge = createDrawingBridge({ createMutationId: () => mutationIds.shift() ?? mutationTwo });
bridge.activate(() => undefined);
const detach = bridge.attach(() => undefined, roomId);
bridge.receive({
type: 'drawing.snapshot',
protocolVersion: 1,
width: 1024,
height: 1024,
revision: 0,
elements: [],
});
detach();
const red = { ...stroke, color: '#ff0000' };
const blue = { ...stroke, color: '#0000ff' };
assert.equal(bridge.upsert(red), true);
assert.equal(bridge.upsert(blue), true);
bridge.attach((message) => sent.push(message), roomId);
bridge.receive({
type: 'drawing.snapshot',
protocolVersion: 1,
width: 1024,
height: 1024,
revision: 0,
elements: [],
});
assert.deepEqual(sent.filter((message) => message.type === 'drawing.upsert'), [
{
type: 'drawing.upsert',
protocolVersion: 1,
mutationId: mutationTwo,
expectedRevision: 0,
element: blue,
},
]);
});
test('rolls back a rejected optimistic mutation and requests a fresh snapshot', () => {
const sent: unknown[] = [];
const bridge = createDrawingBridge({ createMutationId: () => mutationOne });
bridge.activate(() => undefined);
bridge.attach((message) => sent.push(message), roomId);
bridge.receive({
type: 'drawing.snapshot',
protocolVersion: 1,
width: 1024,
height: 1024,
revision: 0,
elements: [],
});
assert.equal(bridge.upsert(stroke), true);
bridge.receive({
type: 'drawing.error',
protocolVersion: 1,
mutationId: mutationOne,
code: 'rejected',
});
assert.deepEqual(bridge.getState().elements, []);
assert.equal(bridge.getState().status, 'syncing');
assert.deepEqual(sent.slice(-2), [
{ type: 'drawing.unsubscribe', protocolVersion: 1 },
{ type: 'drawing.subscribe', protocolVersion: 1, roomId },
]);
});
@@ -0,0 +1,230 @@
import type {
DrawingClientMessage,
DrawingElement,
DrawingRemove,
DrawingServerMessage,
DrawingUpsert,
} from '@botsu/protocol';
const applyDrawingElement = (
elements: readonly DrawingElement[],
element: DrawingElement
): DrawingElement[] => {
const index = elements.findIndex((candidate) => candidate.id === element.id);
if (index < 0) return [...elements, element];
return elements.map((candidate, candidateIndex) =>
candidateIndex === index ? element : candidate
);
};
const removeDrawingElement = (
elements: readonly DrawingElement[],
id: string
): DrawingElement[] => elements.filter((element) => element.id !== id);
export type DrawingClientState = {
status: 'idle' | 'syncing' | 'online' | 'offline';
revision: number;
elements: DrawingElement[];
};
type Sender = (message: DrawingClientMessage) => void;
type Listener = (state: DrawingClientState) => void;
type PendingMutation = { message: DrawingUpsert | DrawingRemove; sent: boolean };
type DrawingBridgeOptions = { createMutationId?: () => string };
export const createDrawingBridge = (options: DrawingBridgeOptions = {}) => {
const createMutationId = options.createMutationId ?? (() => `mut_${crypto.randomUUID()}`);
let state: DrawingClientState = { status: 'idle', revision: 0, elements: [] };
let authoritativeRevision = 0;
let authoritativeElements: DrawingElement[] = [];
let hasSnapshot = false;
const pending: PendingMutation[] = [];
let sender: Sender | undefined;
let roomId: string | undefined;
let senderGeneration: object | undefined;
const listeners = new Set<Listener>();
const notify = (): void => listeners.forEach((listener) => listener(state));
const composeElements = (): DrawingElement[] =>
pending.reduce(
(elements, entry) =>
entry.message.type === 'drawing.upsert'
? applyDrawingElement(elements, entry.message.element)
: removeDrawingElement(elements, entry.message.id),
authoritativeElements
);
const emit = (status: DrawingClientState['status']): void => {
state = { status, revision: authoritativeRevision, elements: composeElements() };
notify();
};
const resequencePending = (): void => {
pending.forEach((entry, index) => {
entry.message.expectedRevision = authoritativeRevision + index;
});
};
const resendPending = (): void => {
if (!sender) return;
resequencePending();
pending.forEach((entry) => {
sender?.(entry.message);
entry.sent = true;
});
};
const requestSnapshot = (reset = false): void => {
if (!sender || !roomId || listeners.size === 0) return;
emit('syncing');
if (reset) sender({ type: 'drawing.unsubscribe', protocolVersion: 1 });
sender({ type: 'drawing.subscribe', protocolVersion: 1, roomId });
};
return {
reset(): void {
sender = undefined;
roomId = undefined;
senderGeneration = undefined;
pending.length = 0;
authoritativeRevision = 0;
authoritativeElements = [];
hasSnapshot = false;
emit(listeners.size > 0 ? 'offline' : 'idle');
},
activate(listener: Listener): () => void {
listeners.add(listener);
listener(state);
if (listeners.size === 1) requestSnapshot();
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
sender?.({ type: 'drawing.unsubscribe', protocolVersion: 1 });
emit('idle');
}
};
},
attach(nextSender: Sender, nextRoomId: string): () => void {
const generation = {};
sender = nextSender;
roomId = nextRoomId;
senderGeneration = generation;
requestSnapshot();
return () => {
if (senderGeneration !== generation) return;
sender = undefined;
roomId = undefined;
senderGeneration = undefined;
if (listeners.size > 0) {
emit('offline');
}
};
},
receive(message: DrawingServerMessage): void {
if (message.type === 'drawing.error') {
const pendingIndex = pending.findIndex(
(entry) => entry.message.mutationId === message.mutationId
);
if (pendingIndex >= 0) {
pending.splice(pendingIndex, 1);
resequencePending();
}
emit(state.status);
requestSnapshot(true);
return;
}
if (message.type === 'drawing.snapshot') {
if (hasSnapshot && message.revision < authoritativeRevision) return;
authoritativeRevision = message.revision;
authoritativeElements = message.elements;
hasSnapshot = true;
emit('online');
resendPending();
return;
}
const pendingIndex = pending.findIndex(
(entry) => entry.message.mutationId === message.mutationId
);
if (message.revision <= authoritativeRevision) {
if (pendingIndex >= 0) {
pending.splice(pendingIndex, 1);
resequencePending();
}
emit(state.status);
return;
}
if (state.status !== 'online' || message.revision !== authoritativeRevision + 1) {
requestSnapshot(true);
return;
}
authoritativeRevision = message.revision;
authoritativeElements =
message.type === 'drawing.upsert'
? applyDrawingElement(authoritativeElements, message.element)
: removeDrawingElement(authoritativeElements, message.id);
if (pendingIndex >= 0) {
pending.splice(pendingIndex, 1);
resequencePending();
}
emit('online');
},
upsert(element: DrawingElement): boolean {
if (!hasSnapshot || pending.length >= 1_024) return false;
if (!sender || state.status !== 'online') {
const unsentIndex = pending.findIndex(
(entry) =>
!entry.sent &&
entry.message.type === 'drawing.upsert' &&
entry.message.element.id === element.id
);
if (unsentIndex >= 0) {
pending.splice(unsentIndex, 1);
resequencePending();
}
}
const message: DrawingUpsert = {
type: 'drawing.upsert',
protocolVersion: 1,
mutationId: createMutationId(),
expectedRevision: authoritativeRevision + pending.length,
element,
};
const entry: PendingMutation = { message, sent: false };
pending.push(entry);
emit(state.status);
if (sender && state.status === 'online') {
sender(message);
entry.sent = true;
}
return true;
},
remove(id: string): boolean {
if (
!hasSnapshot ||
state.status === 'idle' ||
state.status === 'syncing' ||
!state.elements.some((element) => element.id === id) ||
pending.length >= 1_024
) {
return false;
}
const message: DrawingRemove = {
type: 'drawing.remove',
protocolVersion: 1,
mutationId: createMutationId(),
expectedRevision: authoritativeRevision + pending.length,
id,
};
const entry: PendingMutation = { message, sent: false };
pending.push(entry);
emit(state.status);
if (sender && state.status === 'online') {
sender(message);
entry.sent = true;
}
return true;
},
getState(): DrawingClientState {
return state;
},
};
};
export type DrawingBridge = ReturnType<typeof createDrawingBridge>;
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { encodeDrawingGif } from './drawing-export.ts';
test('encodes a valid compact GIF89a frame from RGBA pixels', () => {
const rgba = new Uint8ClampedArray([
255, 255, 255, 255,
255, 0, 0, 255,
0, 255, 0, 255,
0, 0, 255, 255,
]);
const gif = encodeDrawingGif(2, 2, rgba);
assert.equal(new TextDecoder().decode(gif.slice(0, 6)), 'GIF89a');
assert.equal(gif[6], 2);
assert.equal(gif[7], 0);
assert.equal(gif[8], 2);
assert.equal(gif[9], 0);
assert.equal(gif.at(-1), 0x3b);
assert.ok(gif.byteLength < 1_024);
});
@@ -0,0 +1,97 @@
const pushWord = (output: number[], value: number): void => {
output.push(value & 0xff, (value >> 8) & 0xff);
};
const createPalette = (): number[] => {
const palette: number[] = [];
for (let index = 0; index < 256; index += 1) {
const red = ((index >> 5) & 0x07) * (255 / 7);
const green = ((index >> 2) & 0x07) * (255 / 7);
const blue = (index & 0x03) * (255 / 3);
palette.push(Math.round(red), Math.round(green), Math.round(blue));
}
return palette;
};
const rgbaToPaletteIndex = (rgba: Uint8ClampedArray, offset: number): number => {
if ((rgba[offset + 3] ?? 255) < 128) return 255;
const red = rgba[offset] ?? 255;
const green = rgba[offset + 1] ?? 255;
const blue = rgba[offset + 2] ?? 255;
return (red & 0xe0) | ((green & 0xe0) >> 3) | (blue >> 6);
};
const packNineBitCodes = (indices: Uint8Array): Uint8Array => {
const clearCode = 256;
const endCode = 257;
const bytes: number[] = [];
let accumulator = 0;
let bitCount = 0;
const writeCode = (code: number): void => {
accumulator |= code << bitCount;
bitCount += 9;
while (bitCount >= 8) {
bytes.push(accumulator & 0xff);
accumulator >>>= 8;
bitCount -= 8;
}
};
// A clear before every literal keeps the code width fixed at nine bits.
// It trades maximum compression for a tiny, deterministic encoder with
// bounded memory that works for the full 1024² artboard.
indices.forEach((index) => {
writeCode(clearCode);
writeCode(index);
});
writeCode(endCode);
if (bitCount > 0) bytes.push(accumulator & 0xff);
return Uint8Array.from(bytes);
};
export const encodeDrawingGif = (
width: number,
height: number,
rgba: Uint8ClampedArray
): Uint8Array => {
if (
!Number.isInteger(width) ||
!Number.isInteger(height) ||
width < 1 ||
height < 1 ||
width > 65_535 ||
height > 65_535 ||
rgba.length !== width * height * 4
) {
throw new TypeError('Invalid GIF dimensions or pixel data');
}
const indices = new Uint8Array(width * height);
for (let index = 0; index < indices.length; index += 1) {
indices[index] = rgbaToPaletteIndex(rgba, index * 4);
}
const compressed = packNineBitCodes(indices);
const output: number[] = [...new TextEncoder().encode('GIF89a')];
pushWord(output, width);
pushWord(output, height);
output.push(0xf7, 0x00, 0x00, ...createPalette());
output.push(0x21, 0xf9, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00);
output.push(0x2c, 0x00, 0x00, 0x00, 0x00);
pushWord(output, width);
pushWord(output, height);
output.push(0x00, 0x08);
for (let offset = 0; offset < compressed.length; offset += 255) {
const block = compressed.slice(offset, offset + 255);
output.push(block.length, ...block);
}
output.push(0x00, 0x3b);
return Uint8Array.from(output);
};
export const downloadDrawingBlob = (blob: Blob, filename: string): void => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
};
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DrawingElement } from '@botsu/protocol';
import {
applyDrawingElement,
createDrawingFillRuns,
hitTestDrawingElement,
moveDrawingElement,
sampleDrawingPoint,
} from './drawing-model.ts';
const rectangle: DrawingElement = {
id: 'draw_123e4567-e89b-42d3-a456-426614174000',
kind: 'shape',
shape: 'rectangle-filled',
color: '#ff0000',
size: 4,
points: [
{ x: 10, y: 20 },
{ x: 110, y: 120 },
],
};
test('upserts one semantic element without a per-pixel patch', () => {
const inserted = applyDrawingElement([], rectangle);
const moved = moveDrawingElement(rectangle, 15, -5);
const replaced = applyDrawingElement(inserted, moved);
assert.equal(replaced.length, 1);
const result = replaced[0];
assert.ok(result && result.kind === 'shape');
assert.deepEqual(result.points, [
{ x: 25, y: 15 },
{ x: 125, y: 115 },
]);
assert.equal('pixels' in result, false);
});
test('samples pointer movement by distance instead of calculating every pixel', () => {
const points = [{ x: 10, y: 10 }];
assert.equal(sampleDrawingPoint(points, { x: 11, y: 11 }, 3), points);
assert.deepEqual(sampleDrawingPoint(points, { x: 14, y: 10 }, 3), [
{ x: 10, y: 10 },
{ x: 14, y: 10 },
]);
});
test('hit-tests and moves Paint elements for the mouse selection tool', () => {
assert.equal(hitTestDrawingElement(rectangle, { x: 30, y: 40 }), true);
assert.equal(hitTestDrawingElement(rectangle, { x: 300, y: 400 }), false);
const text: DrawingElement = {
id: 'draw_123e4567-e89b-42d3-a456-426614174001',
kind: 'text',
color: '#000000',
font: 'site',
size: 32,
x: 50,
y: 60,
text: 'BOTSU',
};
assert.deepEqual(moveDrawingElement(text, 10, 20), { ...text, x: 60, y: 80 });
});
test('clamps a moved shape as one object without deforming it at the artboard edge', () => {
const moved = moveDrawingElement(rectangle, -50, 1_000);
assert.equal(moved.kind, 'shape');
if (moved.kind !== 'shape') return;
assert.deepEqual(moved.points, [
{ x: 0, y: 923 },
{ x: 100, y: 1023 },
]);
});
test('converts one bucket operation to bounded raster spans that replay without flood fill', () => {
const pixels = new Uint8ClampedArray([
255, 255, 255, 255, 0, 0, 0, 255, 255, 255, 255, 255,
255, 255, 255, 255, 0, 0, 0, 255, 255, 255, 255, 255,
]);
assert.deepEqual(createDrawingFillRuns(pixels, 3, 2, { x: 0, y: 0 }, '#ff0000'), [
{ y: 0, startX: 0, endX: 0 },
{ y: 1, startX: 0, endX: 0 },
]);
});
test('hit-tests and moves a bucket fill by its spans', () => {
const fill: DrawingElement = {
id: 'draw_123e4567-e89b-42d3-a456-426614174002',
kind: 'fill',
color: '#ff0000',
runs: [
{ y: 10, startX: 20, endX: 30 },
{ y: 11, startX: 22, endX: 28 },
],
};
assert.equal(hitTestDrawingElement(fill, { x: 25, y: 10 }), true);
assert.equal(hitTestDrawingElement(fill, { x: 20, y: 11 }), false);
assert.deepEqual(moveDrawingElement(fill, 5, 6), {
...fill,
runs: [
{ y: 16, startX: 25, endX: 35 },
{ y: 17, startX: 27, endX: 33 },
],
});
});
@@ -0,0 +1,214 @@
import {
BOTSU_DRAWING_SIZE,
MAXIMUM_DRAWING_FILL_RUNS,
type DrawingElement,
type DrawingFillRun,
type DrawingPoint,
} from '@botsu/protocol';
const clamp = (value: number): number => Math.max(0, Math.min(BOTSU_DRAWING_SIZE - 1, Math.round(value)));
const colorToRgba = (color: string): readonly [number, number, number, number] => [
Number.parseInt(color.slice(1, 3), 16),
Number.parseInt(color.slice(3, 5), 16),
Number.parseInt(color.slice(5, 7), 16),
255,
];
export const createDrawingFillRuns = (
pixels: Uint8ClampedArray,
width: number,
height: number,
point: DrawingPoint,
color: string
): DrawingFillRun[] | undefined => {
const startX = Math.round(point.x);
const startY = Math.round(point.y);
if (
width < 1 ||
height < 1 ||
pixels.length !== width * height * 4 ||
startX < 0 ||
startX >= width ||
startY < 0 ||
startY >= height
) {
return undefined;
}
const first = startY * width + startX;
const firstOffset = first * 4;
const target = [
pixels[firstOffset] ?? 0,
pixels[firstOffset + 1] ?? 0,
pixels[firstOffset + 2] ?? 0,
pixels[firstOffset + 3] ?? 0,
];
const replacement = colorToRgba(color);
if (target.every((value, index) => value === replacement[index])) return [];
const queue = new Int32Array(width * height);
const visited = new Uint8Array(width * height);
const filled = new Uint8Array(width * height);
let read = 0;
let write = 1;
queue[0] = first;
visited[first] = 1;
while (read < write) {
const index = queue[read++]!;
const offset = index * 4;
if (
pixels[offset] !== target[0] ||
pixels[offset + 1] !== target[1] ||
pixels[offset + 2] !== target[2] ||
pixels[offset + 3] !== target[3]
) {
continue;
}
filled[index] = 1;
const x = index % width;
const y = Math.floor(index / width);
const neighbors = [
x > 0 ? index - 1 : -1,
x + 1 < width ? index + 1 : -1,
y > 0 ? index - width : -1,
y + 1 < height ? index + width : -1,
];
neighbors.forEach((neighbor) => {
if (neighbor < 0 || visited[neighbor]) return;
visited[neighbor] = 1;
queue[write++] = neighbor;
});
}
const runs: DrawingFillRun[] = [];
for (let y = 0; y < height; y += 1) {
let x = 0;
while (x < width) {
while (x < width && !filled[y * width + x]) x += 1;
if (x >= width) break;
const runStart = x;
while (x + 1 < width && filled[y * width + x + 1]) x += 1;
runs.push({ y, startX: runStart, endX: x });
if (runs.length > MAXIMUM_DRAWING_FILL_RUNS) return undefined;
x += 1;
}
}
return runs;
};
export const applyDrawingElement = (
elements: readonly DrawingElement[],
element: DrawingElement
): DrawingElement[] => {
const index = elements.findIndex((candidate) => candidate.id === element.id);
if (index < 0) return [...elements, element];
return elements.map((candidate, candidateIndex) => (candidateIndex === index ? element : candidate));
};
export const removeDrawingElement = (
elements: readonly DrawingElement[],
id: string
): DrawingElement[] => elements.filter((element) => element.id !== id);
export const sampleDrawingPoint = (
points: readonly DrawingPoint[],
next: DrawingPoint,
minimumDistance = 2
): readonly DrawingPoint[] => {
const previous = points.at(-1);
if (!previous) return [{ x: clamp(next.x), y: clamp(next.y) }];
const distance = Math.hypot(next.x - previous.x, next.y - previous.y);
if (distance < minimumDistance) return points;
return [...points, { x: clamp(next.x), y: clamp(next.y) }];
};
export const moveDrawingElement = (
element: DrawingElement,
deltaX: number,
deltaY: number
): DrawingElement => {
if (element.kind === 'text') {
return { ...element, x: clamp(element.x + deltaX), y: clamp(element.y + deltaY) };
}
if (element.kind === 'fill') {
const bounds = getDrawingElementBounds(element);
const boundedDeltaX = Math.max(
-bounds.left,
Math.min(BOTSU_DRAWING_SIZE - 1 - bounds.right, Math.round(deltaX))
);
const boundedDeltaY = Math.max(
-bounds.top,
Math.min(BOTSU_DRAWING_SIZE - 1 - bounds.bottom, Math.round(deltaY))
);
return {
...element,
runs: element.runs.map((run) => ({
y: run.y + boundedDeltaY,
startX: run.startX + boundedDeltaX,
endX: run.endX + boundedDeltaX,
})),
};
}
const xs = element.points.map((point) => point.x);
const ys = element.points.map((point) => point.y);
const boundedDeltaX = Math.max(
-Math.min(...xs),
Math.min(BOTSU_DRAWING_SIZE - 1 - Math.max(...xs), Math.round(deltaX))
);
const boundedDeltaY = Math.max(
-Math.min(...ys),
Math.min(BOTSU_DRAWING_SIZE - 1 - Math.max(...ys), Math.round(deltaY))
);
return {
...element,
points: element.points.map((point) => ({
x: point.x + boundedDeltaX,
y: point.y + boundedDeltaY,
})),
};
};
type Bounds = { left: number; top: number; right: number; bottom: number };
export const getDrawingElementBounds = (element: DrawingElement): Bounds => {
if (element.kind === 'text') {
return {
left: element.x,
top: Math.max(0, element.y - element.size),
right: Math.min(BOTSU_DRAWING_SIZE - 1, element.x + element.text.length * element.size * 0.7),
bottom: Math.min(BOTSU_DRAWING_SIZE - 1, element.y + element.size * 0.25),
};
}
if (element.kind === 'fill') {
return {
left: Math.min(...element.runs.map((run) => run.startX)),
top: Math.min(...element.runs.map((run) => run.y)),
right: Math.max(...element.runs.map((run) => run.endX)),
bottom: Math.max(...element.runs.map((run) => run.y)),
};
}
const xs = element.points.map((point) => point.x);
const ys = element.points.map((point) => point.y);
const padding = element.size / 2 + 4;
return {
left: Math.min(...xs) - padding,
top: Math.min(...ys) - padding,
right: Math.max(...xs) + padding,
bottom: Math.max(...ys) + padding,
};
};
export const hitTestDrawingElement = (element: DrawingElement, point: DrawingPoint): boolean => {
if (element.kind === 'fill') {
const x = Math.round(point.x);
const y = Math.round(point.y);
return element.runs.some((run) => run.y === y && x >= run.startX && x <= run.endX);
}
const bounds = getDrawingElementBounds(element);
return point.x >= bounds.left && point.x <= bounds.right && point.y >= bounds.top && point.y <= bounds.bottom;
};
export const findDrawingElementAt = (
elements: readonly DrawingElement[],
point: DrawingPoint
): DrawingElement | undefined => [...elements].reverse().find((element) => hitTestDrawingElement(element, point));
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const read = (relativePath: string) =>
readFile(new URL(relativePath, import.meta.url), 'utf8');
test('Drawing Room exposes the complete Paint toolbar in the room navbar', async () => {
const toolbar = await read('./DrawingRoomToolbar.tsx');
assert.match(toolbar, /@phosphor-icons\/react/);
[
'CursorIcon',
'EyedropperIcon',
'PencilSimpleIcon',
'EraserIcon',
'PaintBucketIcon',
'RectangleIcon',
'CircleIcon',
'TextTIcon',
'LineSegmentIcon',
'PolygonIcon',
].forEach((icon) => assert.match(toolbar, new RegExp(icon)));
[
'Déplacer',
'Pipette',
'Crayon',
'Gomme',
'Remplir',
'Rectangle',
'Rectangle plein',
'Cercle',
'Cercle plein',
'Texte',
'Ligne',
'Polygone',
].forEach((label) => assert.match(toolbar, new RegExp(label)));
assert.match(toolbar, /Exporter PNG/);
assert.match(toolbar, /Exporter GIF/);
assert.match(toolbar, /Police/);
assert.match(toolbar, /\{item\.icon\}/);
assert.doesNotMatch(toolbar, />\s*\{item\.label\}\s*<\/button>/);
});
test('Drawing Room uses a white artboard on a themed page and a semantic realtime bridge', async () => {
const [view, css, room, transport] = await Promise.all([
read('./BotsuDrawingRoomView.tsx'),
read('./drawing.css'),
read('../../app/features/room/Room.tsx'),
read('./BotsuDrawingTransport.tsx'),
]);
assert.match(view, /BotsuDrawingCanvas/);
assert.doesNotMatch(view, /BotsuPixelCanvas/);
assert.match(css, /background:\s*var\(--bg-surface/);
assert.match(css, /background:\s*#fff/);
assert.match(transport, /drawing\.snapshot/);
assert.match(transport, /drawing\.upsert/);
assert.match(transport, /createBotsuDrawingResourceId/);
assert.match(transport, /sendStateEvent\(/);
assert.match(transport, /readPowerLevel\.state/);
assert.match(room, /drawingChatAtom/);
assert.match(room, /<CallChatView onClose=/);
});
test('bucket fills replay bounded spans and stroke segmentation does not discard a failed publish', async () => {
const canvas = await read('./BotsuDrawingCanvas.tsx');
assert.doesNotMatch(canvas, /const floodFill/);
assert.match(canvas, /createDrawingFillRuns/);
assert.match(canvas, /if \(!publishActive\(true\)\)/);
assert.match(canvas, /if \(active\.points\.length >= 128\)/);
});
test('mobile chat keeps the drawing provider mounted and resets when the room changes', async () => {
const room = await read('../../app/features/room/Room.tsx');
assert.doesNotMatch(room, /drawingView && \(screenSize === ScreenSize\.Desktop \|\| !drawingChat\)/);
assert.match(room, /<DrawingProvider key=\{room\.roomId\}>/);
assert.match(room, /setDrawingChat\(false\)/);
assert.match(room, /\[room\.roomId, setDrawingChat\]/);
});
test('drawing transport resets rotated capabilities and ignores stale socket callbacks', async () => {
const [transport, bridge] = await Promise.all([
read('./BotsuDrawingTransport.tsx'),
read('./drawing-bridge.ts'),
]);
assert.match(transport, /if \(stopped \|\| currentSocket !== socket\) return/);
assert.match(transport, /drawingBridge\.reset\(\)/);
assert.match(bridge, /pending\.length = 0/);
assert.match(bridge, /authoritativeRevision = 0/);
});
+119 -7
View File
@@ -1,5 +1,4 @@
.botsu-drawing-room {
--botsu-color-canvas: var(--bg-surface-low, #ffffff);
--botsu-color-text: var(--tc-surface-normal, #111111);
--botsu-color-text-muted: var(--tc-surface-low, #6f6f6f);
--botsu-color-accent: var(--tc-primary-normal, #5b6ee1);
@@ -8,8 +7,9 @@
display: grid;
grid-template-rows: 1fr;
gap: 1rem;
padding: 1rem;
background: var(--botsu-color-canvas);
padding: clamp(0.5rem, 2vw, 1.5rem);
overflow: auto;
background: var(--bg-surface, var(--bg-surface-low, #ececec));
color: var(--botsu-color-text);
}
@@ -19,18 +19,130 @@
place-items: center;
}
.botsu-drawing-room .botsu-pixel-canvas-frame {
width: min(1024px, 100%, calc(100vh - 8rem));
.botsu-drawing-canvas-frame {
width: min(1024px, 100%, calc(100vh - 7rem));
min-width: 0;
display: grid;
gap: 0.5rem;
}
.botsu-drawing-room .botsu-pixel-canvas {
width: min(1024px, 100%, calc(100vh - 12rem));
.botsu-drawing-canvas {
display: block;
width: 100%;
max-width: 1024px;
aspect-ratio: 1;
background: #fff;
border: 1px solid var(--bg-surface-border, rgba(127, 127, 127, 0.35));
box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.12);
touch-action: none;
cursor: crosshair;
}
.botsu-drawing-canvas[data-tool='select'] {
cursor: grab;
}
.botsu-drawing-canvas[data-tool='select']:active {
cursor: grabbing;
}
.botsu-drawing-canvas[data-tool='text'] {
cursor: text;
}
.botsu-drawing-status {
min-height: 1rem;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.35rem;
color: var(--botsu-color-text-muted);
font-size: 0.72rem;
}
.botsu-drawing-toolbar {
max-width: min(72vw, 1200px);
display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
.botsu-drawing-toolbar__button,
.botsu-drawing-toolbar__field,
.botsu-drawing-toolbar select {
flex: 0 0 auto;
min-height: 1.75rem;
border: 1px solid transparent;
border-radius: var(--botsu-radius, 4px);
background: transparent;
color: inherit;
font: inherit;
font-size: 0.72rem;
}
.botsu-drawing-toolbar__button {
padding: 0.2rem 0.45rem;
cursor: pointer;
}
.botsu-drawing-toolbar__button--icon {
width: 1.75rem;
display: inline-grid;
place-items: center;
padding: 0;
}
.botsu-drawing-toolbar__button--icon svg {
display: block;
}
.botsu-drawing-toolbar__button:hover,
.botsu-drawing-toolbar__button:focus-visible,
.botsu-drawing-toolbar__button[aria-pressed='true'] {
border-color: var(--bg-surface-border, currentColor);
background: var(--bg-surface-hover, rgba(127, 127, 127, 0.14));
}
.botsu-drawing-toolbar__field {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0 0.2rem;
white-space: nowrap;
}
.botsu-drawing-toolbar input[type='color'] {
width: 1.65rem;
height: 1.65rem;
padding: 0;
border: 0;
background: transparent;
}
.botsu-drawing-toolbar select {
max-width: 7rem;
padding: 0 0.25rem;
border-color: var(--bg-surface-border, rgba(127, 127, 127, 0.35));
background: var(--bg-surface, transparent);
}
.botsu-drawing-toolbar__separator {
width: 1px;
height: 1.25rem;
flex: 0 0 1px;
background: var(--bg-surface-border, rgba(127, 127, 127, 0.35));
}
@media (max-width: 48rem) {
.botsu-drawing-toolbar {
max-width: 65vw;
}
.botsu-drawing-room {
padding: 0.35rem;
}
}
@@ -0,0 +1,363 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
FileIcon,
LinkIcon,
MagnifyingGlassIcon,
PlusIcon,
PushPinIcon,
XIcon,
} from '@phosphor-icons/react';
import { useMatrixClient } from '../../app/hooks/useMatrixClient';
import { createMoodboardController } from './controller';
import type {
MoodboardCard,
MoodboardCardDraft,
MoodboardCardKind,
MoodboardSearchResult,
} from './model';
import './moodboard.css';
type ComposerState = {
kind: MoodboardCardKind;
title: string;
value: string;
color: string;
file?: File;
};
const EMPTY_COMPOSER: ComposerState = { kind: 'image', title: '', value: '', color: '#7C5CFF' };
const DEFAULT_PINTEREST_SOURCE = 'https://www.pinterest.com/pinterest/';
const PINTEREST_SOURCE_KEY = 'botsu.moodboard.pinterest-source';
const TYPE_LABELS: Record<MoodboardCardKind, string> = {
image: 'Image',
color: 'Couleur',
text: 'Texte',
file: 'Fichier',
link: 'Lien',
};
const readFileAsDataUrl = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
if (file.size > 2 * 1024 * 1024) {
reject(new Error('Le fichier dépasse 2 Mio.'));
return;
}
const reader = new FileReader();
reader.onerror = () => reject(new Error('Lecture du fichier impossible.'));
reader.onload = () => resolve(String(reader.result));
reader.readAsDataURL(file);
});
const toDraft = async (state: ComposerState): Promise<MoodboardCardDraft> => {
if (state.kind === 'color') return { kind: 'color', title: state.title, color: state.color };
if (state.kind === 'text') return { kind: 'text', title: state.title, text: state.value };
if (state.kind === 'link') return { kind: 'link', title: state.title, url: state.value };
if (state.kind === 'image') return { kind: 'image', title: state.title, url: state.value };
if (!state.file) throw new Error('Choisis un fichier.');
return {
kind: 'file',
title: state.title || state.file.name,
fileName: state.file.name,
mimeType: state.file.type || 'application/octet-stream',
dataUrl: await readFileAsDataUrl(state.file),
};
};
function SharedCard({ card }: { card: MoodboardCard }) {
return (
<article className={`botsu-moodboard-card is-${card.kind}`}>
{card.kind === 'image' && (
<a href={card.sourceUrl ?? card.url} target="_blank" rel="noopener noreferrer">
<img alt={card.title} loading="lazy" src={card.url} />
</a>
)}
{card.kind === 'color' && (
<div className="botsu-moodboard-color" style={{ background: card.color }}>
<span>{card.color}</span>
</div>
)}
{card.kind === 'text' && <p className="botsu-moodboard-note">{card.text}</p>}
{card.kind === 'link' && (
<a className="botsu-moodboard-link" href={card.url} target="_blank" rel="noopener noreferrer">
<LinkIcon aria-hidden="true" size={22} />
<span>{new URL(card.url).hostname}</span>
</a>
)}
{card.kind === 'file' && (
<a className="botsu-moodboard-file" download={card.fileName} href={card.dataUrl}>
<FileIcon aria-hidden="true" size={32} />
<span>{card.fileName}</span>
</a>
)}
<div className="botsu-moodboard-card-meta">
<strong>{card.title}</strong>
<small>{card.creatorName}</small>
</div>
</article>
);
}
function SearchCard({ result, onPin, pending }: { result: MoodboardSearchResult; onPin: () => void; pending: boolean }) {
return (
<article className="botsu-moodboard-card is-search">
<a href={result.sourceUrl} target="_blank" rel="noopener noreferrer">
<img alt={result.title} loading="lazy" src={result.thumbnailUrl ?? result.imageUrl} />
</a>
<div className="botsu-moodboard-card-meta">
<strong>{result.title}</strong>
<small>{new URL(result.sourceUrl).hostname}</small>
<button disabled={pending} type="button" onClick={onPin}>
<PushPinIcon aria-hidden="true" size={16} />
{pending ? 'Ajout…' : 'Épingler'}
</button>
</div>
</article>
);
}
export function BotsuMoodboard() {
const mx = useMatrixClient();
const controller = useMemo(
() => createMoodboardController({ getOpenIdToken: () => mx.getOpenIdToken() }),
[mx]
);
const [cards, setCards] = useState<MoodboardCard[]>([]);
const [searchResults, setSearchResults] = useState<MoodboardSearchResult[]>([]);
const [pinterestResults, setPinterestResults] = useState<MoodboardSearchResult[]>([]);
const [pinterestUrl, setPinterestUrl] = useState(() => {
try {
return window.localStorage.getItem(PINTEREST_SOURCE_KEY) ?? DEFAULT_PINTEREST_SOURCE;
} catch {
return DEFAULT_PINTEREST_SOURCE;
}
});
const [pinterestLoading, setPinterestLoading] = useState(false);
const [query, setQuery] = useState('');
const [activeQuery, setActiveQuery] = useState('');
const [nextCursor, setNextCursor] = useState<string>();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState('');
const [composerOpen, setComposerOpen] = useState(false);
const [composer, setComposer] = useState<ComposerState>(EMPTY_COMPOSER);
const [saving, setSaving] = useState(false);
const [pinningUrl, setPinningUrl] = useState('');
const sentinelRef = useRef<HTMLDivElement>(null);
const loadFirst = useCallback(async (nextQuery = '') => {
setLoading(true);
setError('');
try {
const [page, webResults] = await Promise.all([
controller.list(nextQuery ? { query: nextQuery } : {}),
nextQuery ? controller.search(nextQuery) : Promise.resolve([]),
]);
setCards(page.cards);
setNextCursor(page.nextCursor);
setSearchResults(webResults);
} catch {
setError('Le Moodboard partagé est momentanément indisponible.');
} finally {
setLoading(false);
}
}, [controller]);
const loadPinterest = useCallback(async (sourceUrl: string) => {
setPinterestLoading(true);
setError('');
try {
const pins = await controller.getPinterest(sourceUrl);
setPinterestResults(pins);
try {
window.localStorage.setItem(PINTEREST_SOURCE_KEY, sourceUrl.trim());
} catch {
// The direct feed remains usable without local persistence.
}
} catch {
setPinterestResults([]);
setError('Ce profil ou tableau Pinterest public est indisponible.');
} finally {
setPinterestLoading(false);
}
}, [controller]);
useEffect(() => {
void loadFirst();
void loadPinterest(pinterestUrl);
const poll = window.setInterval(() => {
if (!activeQuery && document.visibilityState === 'visible') void loadFirst();
}, 8_000);
return () => window.clearInterval(poll);
}, [activeQuery, loadFirst, loadPinterest]);
const loadMore = useCallback(async () => {
if (!nextCursor || loadingMore) return;
setLoadingMore(true);
try {
const page = await controller.list({ cursor: nextCursor, ...(activeQuery ? { query: activeQuery } : {}) });
setCards((current) => [...current, ...page.cards.filter((card) => !current.some(({ id }) => id === card.id))]);
setNextCursor(page.nextCursor);
} catch {
setError('Impossible de charger la suite du Moodboard.');
} finally {
setLoadingMore(false);
}
}, [activeQuery, controller, loadingMore, nextCursor]);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return undefined;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) void loadMore();
}, { rootMargin: '500px' });
observer.observe(sentinel);
return () => observer.disconnect();
}, [loadMore]);
const submitSearch = (event: React.FormEvent) => {
event.preventDefault();
const normalized = query.trim();
setActiveQuery(normalized);
void loadFirst(normalized);
};
const clearSearch = () => {
setQuery('');
setActiveQuery('');
void loadFirst();
};
const submitPinterest = (event: React.FormEvent) => {
event.preventDefault();
void loadPinterest(pinterestUrl);
};
const submitCard = async (event: React.FormEvent) => {
event.preventDefault();
setSaving(true);
setError('');
try {
const card = await controller.create(await toDraft(composer));
setCards((current) => [card, ...current]);
setComposer(EMPTY_COMPOSER);
setComposerOpen(false);
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Ajout impossible.');
} finally {
setSaving(false);
}
};
const pinResult = async (result: MoodboardSearchResult) => {
setPinningUrl(result.imageUrl);
try {
const card = await controller.create({
kind: 'image',
title: result.title,
url: result.imageUrl,
sourceUrl: result.sourceUrl,
});
setCards((current) => [card, ...current]);
setSearchResults((current) => current.filter(({ imageUrl }) => imageUrl !== result.imageUrl));
setPinterestResults((current) => current.filter(({ imageUrl }) => imageUrl !== result.imageUrl));
} catch {
setError('Cette image ne peut pas être épinglée.');
} finally {
setPinningUrl('');
}
};
return (
<section className="botsu-moodboard" aria-label="Moodboard partagé">
<header className="botsu-moodboard-toolbar">
<form className="botsu-moodboard-search" role="search" onSubmit={submitSearch}>
<MagnifyingGlassIcon aria-hidden="true" size={19} />
<input
aria-label="Rechercher dans le Moodboard et sur BOTSU Search"
maxLength={200}
placeholder="Rechercher"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
{activeQuery && (
<button aria-label="Effacer la recherche" type="button" onClick={clearSearch}>
<XIcon aria-hidden="true" size={17} />
</button>
)}
</form>
<button className="botsu-moodboard-add" type="button" onClick={() => setComposerOpen((open) => !open)}>
<PlusIcon aria-hidden="true" size={18} />
Ajouter
</button>
</header>
<form className="botsu-moodboard-pinterest-source" onSubmit={submitPinterest}>
<input
aria-label="Profil ou tableau Pinterest"
maxLength={240}
placeholder="https://www.pinterest.com/profil/tableau/"
required
type="url"
value={pinterestUrl}
onChange={(event) => setPinterestUrl(event.target.value)}
/>
<button disabled={pinterestLoading} type="submit">
{pinterestLoading ? 'Pinterest…' : 'Charger Pinterest'}
</button>
</form>
{composerOpen && (
<form className="botsu-moodboard-composer" onSubmit={submitCard}>
<div className="botsu-moodboard-kinds" role="group" aria-label="Type de contenu">
{(Object.keys(TYPE_LABELS) as MoodboardCardKind[]).map((kind) => (
<button
aria-pressed={composer.kind === kind}
key={kind}
type="button"
onClick={() => setComposer((current) => ({ ...current, kind }))}
>
{TYPE_LABELS[kind]}
</button>
))}
</div>
<input
aria-label="Titre"
maxLength={160}
placeholder="Titre"
required={composer.kind !== 'file'}
value={composer.title}
onChange={(event) => setComposer((current) => ({ ...current, title: event.target.value }))}
/>
{composer.kind === 'color' ? (
<input aria-label="Couleur" type="color" value={composer.color} onChange={(event) => setComposer((current) => ({ ...current, color: event.target.value }))} />
) : composer.kind === 'file' ? (
<input aria-label="Fichier" required type="file" onChange={(event) => setComposer((current) => ({ ...current, file: event.target.files?.[0] }))} />
) : composer.kind === 'text' ? (
<textarea aria-label="Texte" maxLength={5000} placeholder="Texte" required value={composer.value} onChange={(event) => setComposer((current) => ({ ...current, value: event.target.value }))} />
) : (
<input aria-label={composer.kind === 'image' ? "Lien de l'image" : 'Lien'} maxLength={2048} placeholder="https://" required type="url" value={composer.value} onChange={(event) => setComposer((current) => ({ ...current, value: event.target.value }))} />
)}
<button disabled={saving} type="submit">{saving ? 'Ajout…' : 'Ajouter au Moodboard'}</button>
</form>
)}
{error && <p className="botsu-moodboard-error" role="alert">{error}</p>}
{activeQuery && <p className="botsu-moodboard-context">BOTSU Search · {activeQuery}</p>}
{!loading && cards.length === 0 && searchResults.length === 0 && pinterestResults.length === 0 && (
<p className="botsu-moodboard-empty">Rien ici pour linstant. Ajoute la première inspiration.</p>
)}
<div className="botsu-moodboard-feed" aria-busy={loading || loadingMore || pinterestLoading}>
{pinterestResults.map((result) => (
<SearchCard key={`pinterest-${result.imageUrl}`} pending={pinningUrl === result.imageUrl} result={result} onPin={() => void pinResult(result)} />
))}
{searchResults.map((result) => (
<SearchCard key={`search-${result.imageUrl}`} pending={pinningUrl === result.imageUrl} result={result} onPin={() => void pinResult(result)} />
))}
{cards.map((card) => <SharedCard card={card} key={card.id} />)}
</div>
{(loading || loadingMore) && <p className="botsu-moodboard-loading" aria-live="polite">Chargement</p>}
<div ref={sentinelRef} className="botsu-moodboard-sentinel" aria-hidden="true" />
</section>
);
}
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMoodboardController } from './controller.ts';
const token = { access_token: 'openid-token-long-enough' };
test('loads paginated cards with Matrix OpenID authentication', async () => {
let requested = '';
const controller = createMoodboardController({
getOpenIdToken: async () => token,
fetchImpl: async (input, init) => {
requested = String(input);
assert.equal((init?.headers as Record<string, string>).authorization, `Bearer ${token.access_token}`);
return new Response(JSON.stringify({ cards: [], nextCursor: '24' }), { status: 200 });
},
});
const page = await controller.list({ cursor: '12', query: 'béton' });
const url = new URL(requested, 'https://test.botsu.net');
assert.equal(url.pathname, '/presence/moodboard/cards');
assert.equal(url.searchParams.get('cursor'), '12');
assert.equal(url.searchParams.get('q'), 'béton');
assert.equal(page.nextCursor, '24');
});
test('loads a public Pinterest profile directly through the authenticated BOTSU proxy', async () => {
let requested = '';
const controller = createMoodboardController({
getOpenIdToken: async () => token,
fetchImpl: async (input) => {
requested = String(input);
return new Response(JSON.stringify({
source: 'https://www.pinterest.com/pinterest/',
pins: [{
id: '424605071136961057',
title: 'Architecture',
sourceUrl: 'https://www.pinterest.com/pin/424605071136961057/',
imageUrl: 'https://i.pinimg.com/564x/a.jpg',
thumbnailUrl: 'https://i.pinimg.com/236x/a.jpg',
width: 564,
height: 846,
}],
}), { status: 200 });
},
});
const pins = await controller.getPinterest('https://www.pinterest.com/pinterest/');
const url = new URL(requested, 'https://test.botsu.net');
assert.equal(url.pathname, '/presence/moodboard/pinterest');
assert.equal(url.searchParams.get('url'), 'https://www.pinterest.com/pinterest/');
assert.equal(pins[0]?.title, 'Architecture');
});
test('pins a normalized search result as a shared image card', async () => {
let body: unknown;
const controller = createMoodboardController({
getOpenIdToken: async () => token,
fetchImpl: async (_input, init) => {
body = JSON.parse(String(init?.body));
return new Response(JSON.stringify({
card: {
version: 1,
id: 'mood_12345678',
creatorId: '@alice:botsu.net',
creatorName: 'Alice',
createdAt: 123,
...body as object,
},
}), { status: 201 });
},
});
const card = await controller.create({
kind: 'image',
title: 'Béton',
url: 'https://img.example.org/beton.jpg',
sourceUrl: 'https://example.org/article',
});
assert.equal((body as { kind: string }).kind, 'image');
assert.equal(card.title, 'Béton');
});
@@ -0,0 +1,112 @@
import {
buildMoodboardCardDraft,
parseMoodboardCard,
parseMoodboardPage,
parseSearxngImageResults,
type MoodboardCard,
type MoodboardCardDraft,
type MoodboardPage,
type MoodboardSearchResult,
} from './model.ts';
type OpenIdToken = { access_token: string };
type MoodboardControllerOptions = {
getOpenIdToken: () => Promise<OpenIdToken>;
fetchImpl?: typeof fetch;
};
export type MoodboardController = {
list: (options?: { cursor?: string; query?: string }) => Promise<MoodboardPage>;
getPinterest: (sourceUrl: string) => Promise<MoodboardSearchResult[]>;
search: (query: string) => Promise<MoodboardSearchResult[]>;
create: (draft: MoodboardCardDraft) => Promise<MoodboardCard>;
};
const BASE = '/presence/moodboard';
const parsePinterestPins = (input: unknown): MoodboardSearchResult[] => {
if (typeof input !== 'object' || input === null || Array.isArray(input)) return [];
const pins = (input as { pins?: unknown }).pins;
if (!Array.isArray(pins)) return [];
return pins.flatMap((pin): MoodboardSearchResult[] => {
if (typeof pin !== 'object' || pin === null || Array.isArray(pin)) return [];
const value = pin as Record<string, unknown>;
if (
typeof value.title !== 'string' ||
typeof value.sourceUrl !== 'string' ||
typeof value.imageUrl !== 'string' ||
typeof value.thumbnailUrl !== 'string'
) return [];
try {
const source = new URL(value.sourceUrl);
const image = new URL(value.imageUrl);
const thumbnail = new URL(value.thumbnailUrl);
if (
source.protocol !== 'https:' ||
source.hostname !== 'www.pinterest.com' ||
image.protocol !== 'https:' ||
image.hostname !== 'i.pinimg.com' ||
thumbnail.protocol !== 'https:' ||
thumbnail.hostname !== 'i.pinimg.com'
) return [];
return [{
title: value.title.trim().slice(0, 240) || 'Pin Pinterest',
sourceUrl: source.toString(),
imageUrl: image.toString(),
thumbnailUrl: thumbnail.toString(),
}];
} catch {
return [];
}
});
};
export const createMoodboardController = (options: MoodboardControllerOptions): MoodboardController => {
const fetchImpl = options.fetchImpl ?? fetch;
const request = async (path: string, init: RequestInit = {}): Promise<unknown> => {
const token = await options.getOpenIdToken();
const response = await fetchImpl(`${BASE}${path}`, {
...init,
headers: {
authorization: `Bearer ${token.access_token}`,
...init.headers,
},
});
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as { error?: string };
throw new Error(payload.error ?? `Moodboard API ${response.status}`);
}
return response.json() as Promise<unknown>;
};
return {
async list(listOptions = {}) {
const params = new URLSearchParams({ limit: '24' });
if (listOptions.cursor) params.set('cursor', listOptions.cursor);
if (listOptions.query?.trim()) params.set('q', listOptions.query.trim());
return parseMoodboardPage(await request(`/cards?${params}`));
},
async getPinterest(sourceUrl) {
const params = new URLSearchParams({ url: sourceUrl.trim() });
return parsePinterestPins(await request(`/pinterest?${params}`));
},
async search(query) {
const params = new URLSearchParams({ q: query.trim() });
return parseSearxngImageResults(await request(`/search?${params}`));
},
async create(draft) {
const normalized = buildMoodboardCardDraft(draft as unknown as Record<string, unknown>);
const payload = await request('/cards', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(normalized),
});
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
throw new TypeError('Réponse Moodboard invalide');
}
return parseMoodboardCard((payload as { card?: unknown }).card);
},
};
};
+1
View File
@@ -0,0 +1 @@
export { BotsuMoodboard } from './BotsuMoodboard';
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildMoodboardCardDraft,
parseMoodboardPage,
parseSearxngImageResults,
} from './model.ts';
test('normalizes relevant SearXNG image results and rejects unsafe URLs', () => {
const results = parseSearxngImageResults({
results: [
{
title: 'Architecture brutaliste',
url: 'https://example.org/article',
img_src: 'https://images.example.org/brutalisme.jpg',
thumbnail_src: 'https://images.example.org/thumb.jpg',
content: 'Béton, lignes et lumière.',
},
{ title: 'Javascript', url: 'javascript:alert(1)', img_src: 'https://example.org/x.jpg' },
{ title: 'Sans image', url: 'https://example.org/no-image' },
],
});
assert.deepEqual(results, [
{
title: 'Architecture brutaliste',
sourceUrl: 'https://example.org/article',
imageUrl: 'https://images.example.org/brutalisme.jpg',
thumbnailUrl: 'https://images.example.org/thumb.jpg',
description: 'Béton, lignes et lumière.',
},
]);
});
test('builds bounded drafts for every Moodboard content type', () => {
assert.deepEqual(buildMoodboardCardDraft({ kind: 'color', title: 'Rouge', color: '#ff3300' }), {
kind: 'color',
title: 'Rouge',
color: '#FF3300',
});
assert.deepEqual(
buildMoodboardCardDraft({ kind: 'image', title: 'Mer', url: 'https://example.org/mer.jpg' }),
{ kind: 'image', title: 'Mer', url: 'https://example.org/mer.jpg' }
);
assert.throws(() => buildMoodboardCardDraft({ kind: 'color', title: 'Non', color: 'red' }), /couleur/i);
assert.throws(
() => buildMoodboardCardDraft({ kind: 'link', title: 'Non', url: 'javascript:alert(1)' }),
/lien/i
);
});
test('parses a paginated shared feed without trusting unknown fields', () => {
const page = parseMoodboardPage({
cards: [
{
version: 1,
id: 'mood_12345678',
kind: 'text',
title: 'Ambiance',
text: 'Calme et minéral',
creatorId: '@alice:botsu.net',
creatorName: 'Alice',
createdAt: 123,
},
],
nextCursor: '1',
});
assert.equal(page.cards[0]?.title, 'Ambiance');
assert.equal(page.nextCursor, '1');
});
+134
View File
@@ -0,0 +1,134 @@
export type MoodboardCardKind = 'image' | 'color' | 'text' | 'file' | 'link';
export type MoodboardCardDraft =
| { kind: 'image'; title: string; url: string; sourceUrl?: string }
| { kind: 'color'; title: string; color: string }
| { kind: 'text'; title: string; text: string }
| { kind: 'file'; title: string; fileName: string; mimeType: string; dataUrl: string }
| { kind: 'link'; title: string; url: string };
export type MoodboardCard = MoodboardCardDraft & {
version: 1;
id: string;
creatorId: string;
creatorName: string;
createdAt: number;
};
export type MoodboardPage = { cards: MoodboardCard[]; nextCursor?: string };
export type MoodboardSearchResult = {
title: string;
sourceUrl: string;
imageUrl: string;
thumbnailUrl?: string;
description?: string;
};
const normalizeText = (value: unknown, label: string, maxLength: number): string => {
if (typeof value !== 'string') throw new TypeError(`${label} invalide`);
const normalized = value.trim().replace(/\s+/g, ' ');
if (!normalized || normalized.length > maxLength) throw new TypeError(`${label} invalide`);
return normalized;
};
const normalizeHttpUrl = (value: unknown, label: string): string => {
const input = normalizeText(value, label, 2048);
const url = new URL(input);
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new TypeError(`${label} invalide`);
return url.toString();
};
const optionalHttpUrl = (value: unknown, label: string): string | undefined => {
if (value === undefined || value === null || value === '') return undefined;
return normalizeHttpUrl(value, label);
};
const parseDataUrl = (value: unknown): string => {
if (typeof value !== 'string' || value.length > 2_800_000) throw new TypeError('Fichier invalide');
if (!/^data:[a-z0-9.+-]+\/[a-z0-9.+-]+;base64,[a-z0-9+/]+={0,2}$/i.test(value)) {
throw new TypeError('Fichier invalide');
}
return value;
};
export const buildMoodboardCardDraft = (input: Record<string, unknown>): MoodboardCardDraft => {
const title = normalizeText(input.title, 'Titre', 160);
if (input.kind === 'image') {
const sourceUrl = optionalHttpUrl(input.sourceUrl, 'Lien source');
const draft: MoodboardCardDraft = { kind: 'image', title, url: normalizeHttpUrl(input.url, 'Lien image') };
return sourceUrl ? { ...draft, sourceUrl } : draft;
}
if (input.kind === 'color') {
if (typeof input.color !== 'string' || !/^#[0-9a-f]{6}$/i.test(input.color)) {
throw new TypeError('Couleur invalide');
}
return { kind: 'color', title, color: input.color.toUpperCase() };
}
if (input.kind === 'text') return { kind: 'text', title, text: normalizeText(input.text, 'Texte', 5000) };
if (input.kind === 'file') {
return {
kind: 'file',
title,
fileName: normalizeText(input.fileName, 'Nom du fichier', 180),
mimeType: normalizeText(input.mimeType, 'Type du fichier', 120),
dataUrl: parseDataUrl(input.dataUrl),
};
}
if (input.kind === 'link') return { kind: 'link', title, url: normalizeHttpUrl(input.url, 'Lien') };
throw new TypeError('Type de carte invalide');
};
export const parseMoodboardCard = (input: unknown): MoodboardCard => {
if (typeof input !== 'object' || input === null || Array.isArray(input)) throw new TypeError('Carte invalide');
const record = input as Record<string, unknown>;
if (record.version !== 1) throw new TypeError('Version de carte invalide');
const draft = buildMoodboardCardDraft(record);
const id = normalizeText(record.id, 'Identifiant', 80);
if (!/^mood_[a-z0-9_-]{8,64}$/i.test(id)) throw new TypeError('Identifiant invalide');
const creatorId = normalizeText(record.creatorId, 'Auteur', 255);
const creatorName = normalizeText(record.creatorName, 'Nom auteur', 160);
if (typeof record.createdAt !== 'number' || !Number.isSafeInteger(record.createdAt) || record.createdAt < 0) {
throw new TypeError('Date invalide');
}
return { version: 1, id, creatorId, creatorName, createdAt: record.createdAt, ...draft };
};
export const parseMoodboardPage = (input: unknown): MoodboardPage => {
if (typeof input !== 'object' || input === null || Array.isArray(input)) throw new TypeError('Page invalide');
const record = input as Record<string, unknown>;
if (!Array.isArray(record.cards)) throw new TypeError('Cartes invalides');
const cards = record.cards.map(parseMoodboardCard);
const nextCursor = record.nextCursor === undefined ? undefined : normalizeText(record.nextCursor, 'Curseur', 32);
return nextCursor ? { cards, nextCursor } : { cards };
};
export const parseSearxngImageResults = (input: unknown): MoodboardSearchResult[] => {
if (typeof input !== 'object' || input === null || Array.isArray(input)) return [];
const rawResults = (input as { results?: unknown }).results;
if (!Array.isArray(rawResults)) return [];
const results: MoodboardSearchResult[] = [];
rawResults.slice(0, 60).forEach((item) => {
if (typeof item !== 'object' || item === null || Array.isArray(item)) return;
const record = item as Record<string, unknown>;
try {
const title = normalizeText(record.title, 'Titre', 240);
const sourceUrl = normalizeHttpUrl(record.url, 'Lien source');
const imageUrl = normalizeHttpUrl(record.img_src ?? record.thumbnail_src, 'Lien image');
const thumbnailUrl = optionalHttpUrl(record.thumbnail_src, 'Miniature');
const description = typeof record.content === 'string' && record.content.trim()
? normalizeText(record.content, 'Description', 600)
: undefined;
results.push({
title,
sourceUrl,
imageUrl,
...(thumbnailUrl ? { thumbnailUrl } : {}),
...(description ? { description } : {}),
});
} catch {
// A malformed third-party result must not break the whole feed.
}
});
return results;
};
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const pageUrl = new URL('./BotsuMoodboard.tsx', import.meta.url);
const cssUrl = new URL('./moodboard.css', import.meta.url);
const routerUrl = new URL('../../app/pages/Router.tsx', import.meta.url);
const pathsUrl = new URL('../../app/pages/paths.ts', import.meta.url);
const navUrl = new URL('../shell/BotsuNav.tsx', import.meta.url);
test('Moodboard is a native BOTSU application with search and shared creation controls', async () => {
const [page, css, router, paths, nav] = await Promise.all([
readFile(pageUrl, 'utf8'),
readFile(cssUrl, 'utf8'),
readFile(routerUrl, 'utf8'),
readFile(pathsUrl, 'utf8'),
readFile(navUrl, 'utf8'),
]);
assert.match(paths, /_BOTSU_MOODBOARD_PATH = 'moodboard\/'/);
assert.match(router, /<BotsuMoodboard \/>/);
assert.match(nav, /'moodboard'/);
assert.match(page, /aria-label="Rechercher dans le Moodboard et sur BOTSU Search"/);
assert.match(page, /aria-label="Profil ou tableau Pinterest"/);
assert.match(page, /Charger Pinterest/);
assert.match(page, /controller\.getPinterest/);
assert.match(page, /Ajouter/);
assert.match(page, /Image|Couleur|Texte|Fichier|Lien/);
assert.match(page, /IntersectionObserver/);
assert.match(css, /column-count:/);
assert.match(css, /break-inside:\s*avoid/);
assert.match(css, /var\(--botsu-color-/);
});
@@ -0,0 +1,289 @@
.botsu-moodboard {
min-height: 100%;
padding: clamp(16px, 3vw, 36px);
color: var(--botsu-color-text);
background: var(--botsu-color-canvas);
}
.botsu-moodboard-toolbar {
position: sticky;
z-index: 5;
top: 0;
display: flex;
gap: 10px;
align-items: center;
max-width: 1180px;
padding: 10px 0 18px;
margin: 0 auto;
background: color-mix(in srgb, var(--botsu-color-canvas) 92%, transparent);
backdrop-filter: blur(14px);
}
.botsu-moodboard-search {
display: flex;
flex: 1;
gap: 9px;
align-items: center;
min-width: 0;
padding: 0 13px;
border: var(--botsu-border-width) solid var(--botsu-color-border);
border-radius: var(--botsu-radius);
background: var(--botsu-color-surface);
}
.botsu-moodboard-search:focus-within {
border-color: var(--botsu-color-accent);
box-shadow: 0 0 0 2px var(--botsu-color-focus);
}
.botsu-moodboard-search input {
width: 100%;
min-width: 0;
padding: 12px 0;
border: 0;
outline: 0;
color: inherit;
background: transparent;
font: inherit;
}
.botsu-moodboard button,
.botsu-moodboard input,
.botsu-moodboard textarea {
font: inherit;
}
.botsu-moodboard button {
color: inherit;
}
.botsu-moodboard-search button,
.botsu-moodboard-add,
.botsu-moodboard-pinterest-source button,
.botsu-moodboard-composer button,
.botsu-moodboard-card-meta button {
display: inline-flex;
gap: 6px;
align-items: center;
justify-content: center;
border: var(--botsu-border-width) solid var(--botsu-color-border);
border-radius: var(--botsu-radius);
background: var(--botsu-color-surface);
cursor: pointer;
}
.botsu-moodboard-search button {
padding: 4px;
border: 0;
background: transparent;
}
.botsu-moodboard-add {
flex: 0 0 auto;
padding: 11px 14px;
}
.botsu-moodboard-add:hover,
.botsu-moodboard-composer button:hover,
.botsu-moodboard-card-meta button:hover {
border-color: var(--botsu-color-accent);
}
.botsu-moodboard-pinterest-source {
display: flex;
gap: 8px;
max-width: 1180px;
margin: -8px auto 18px;
}
.botsu-moodboard-pinterest-source input {
flex: 1;
min-width: 0;
padding: 9px 11px;
border: var(--botsu-border-width) solid var(--botsu-color-border);
border-radius: var(--botsu-radius);
outline: 0;
color: inherit;
background: var(--botsu-color-surface);
}
.botsu-moodboard-pinterest-source input:focus {
border-color: var(--botsu-color-accent);
box-shadow: 0 0 0 2px var(--botsu-color-focus);
}
.botsu-moodboard-pinterest-source button {
padding: 9px 12px;
}
.botsu-moodboard-composer {
display: grid;
grid-template-columns: minmax(150px, 0.7fr) minmax(220px, 1fr) auto;
gap: 10px;
max-width: 1180px;
padding: 14px;
margin: 0 auto 20px;
border: var(--botsu-border-width) solid var(--botsu-color-border);
border-radius: var(--botsu-radius);
background: var(--botsu-color-surface-raised);
}
.botsu-moodboard-kinds {
display: flex;
grid-column: 1 / -1;
gap: 6px;
overflow-x: auto;
}
.botsu-moodboard-kinds button,
.botsu-moodboard-composer > button {
padding: 8px 11px;
}
.botsu-moodboard-kinds button[aria-pressed='true'],
.botsu-moodboard-composer > button {
border-color: var(--botsu-color-accent);
color: var(--botsu-color-on-accent);
background: var(--botsu-color-accent);
}
.botsu-moodboard-composer > input,
.botsu-moodboard-composer > textarea {
min-width: 0;
padding: 10px;
border: var(--botsu-border-width) solid var(--botsu-color-border);
border-radius: var(--botsu-radius);
outline: 0;
color: inherit;
background: var(--botsu-color-surface);
}
.botsu-moodboard-composer textarea {
min-height: 90px;
resize: vertical;
}
.botsu-moodboard-context,
.botsu-moodboard-error,
.botsu-moodboard-empty,
.botsu-moodboard-loading {
max-width: 1180px;
margin: 8px auto 16px;
color: var(--botsu-color-text-muted);
}
.botsu-moodboard-error {
color: var(--botsu-color-text);
}
.botsu-moodboard-feed {
max-width: 1180px;
margin: 0 auto;
column-count: 4;
column-gap: 14px;
}
.botsu-moodboard-card {
display: inline-block;
width: 100%;
margin: 0 0 14px;
overflow: hidden;
break-inside: avoid;
border: var(--botsu-border-width) solid var(--botsu-color-border);
border-radius: var(--botsu-radius);
background: var(--botsu-color-surface);
vertical-align: top;
}
.botsu-moodboard-card img {
display: block;
width: 100%;
height: auto;
min-height: 120px;
max-height: 520px;
object-fit: cover;
background: var(--botsu-color-surface-raised);
}
.botsu-moodboard-card a {
color: inherit;
text-decoration: none;
}
.botsu-moodboard-color {
display: grid;
min-height: 190px;
padding: 14px;
place-items: end start;
}
.botsu-moodboard-color span {
padding: 5px 8px;
border-radius: var(--botsu-radius);
color: #fff;
background: rgb(0 0 0 / 58%);
font: 600 0.78rem/1 var(--botsu-font-family);
}
.botsu-moodboard-note {
padding: 24px 18px;
margin: 0;
white-space: pre-wrap;
font-size: clamp(1rem, 1.7vw, 1.3rem);
line-height: 1.45;
}
.botsu-moodboard-link,
.botsu-moodboard-file {
display: flex;
gap: 10px;
align-items: center;
min-height: 100px;
padding: 18px;
overflow-wrap: anywhere;
background: var(--botsu-color-surface-raised);
}
.botsu-moodboard-card-meta {
display: grid;
gap: 5px;
padding: 11px 12px 12px;
}
.botsu-moodboard-card-meta strong {
overflow-wrap: anywhere;
font-size: 0.9rem;
}
.botsu-moodboard-card-meta small {
overflow: hidden;
color: var(--botsu-color-text-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.botsu-moodboard-card-meta button {
justify-self: start;
padding: 6px 9px;
margin-top: 3px;
}
.botsu-moodboard-sentinel {
height: 1px;
}
@media (max-width: 1050px) {
.botsu-moodboard-feed { column-count: 3; }
}
@media (max-width: 760px) {
.botsu-moodboard { padding: 10px; }
.botsu-moodboard-feed { column-count: 2; column-gap: 10px; }
.botsu-moodboard-composer { grid-template-columns: 1fr; }
.botsu-moodboard-composer > * { grid-column: 1; }
.botsu-moodboard-add { padding-inline: 10px; }
}
@media (max-width: 430px) {
.botsu-moodboard-feed { column-count: 1; }
}
@@ -88,3 +88,28 @@ test('accepts document Yjs updates on the presence websocket', async () => {
assert.equal(message.type, 'document.yjs.update');
});
test('accepts semantic drawing snapshots on the presence websocket', async () => {
const { parsePresenceSocketMessage } = await import('./socket-message.ts');
const message = parsePresenceSocketMessage(
JSON.stringify({
type: 'drawing.snapshot',
protocolVersion: 1,
width: 1024,
height: 1024,
revision: 1,
elements: [
{
id: 'draw_123e4567-e89b-42d3-a456-426614174000',
kind: 'stroke',
mode: 'paint',
color: '#123456',
size: 5,
points: [{ x: 10, y: 20 }],
},
],
}),
() => undefined
);
assert.equal(message.type, 'drawing.snapshot');
});
@@ -1,7 +1,9 @@
import {
MAXIMUM_PRESENCE_SERVER_MESSAGE_BYTES,
parseDrawingServerMessage,
parseDocumentServerMessage,
parsePresenceServerMessage,
type DrawingServerMessage,
type DocumentServerMessage,
type PresenceServerMessage,
} from '@botsu/protocol';
@@ -25,7 +27,7 @@ export const createPresenceWebSocketUrl = (origin: string): string => {
export const parsePresenceSocketMessage = (
data: unknown,
closeSocket: CloseSocket
): PresenceServerMessage | DocumentServerMessage => {
): PresenceServerMessage | DocumentServerMessage | DrawingServerMessage => {
const text = String(data);
if (
text.length > MAXIMUM_PRESENCE_SERVER_MESSAGE_BYTES ||
@@ -42,7 +44,11 @@ export const parsePresenceSocketMessage = (
try {
return parseDocumentServerMessage(parsed);
} catch {
throw presenceError;
try {
return parseDrawingServerMessage(parsed);
} catch {
throw presenceError;
}
}
}
} catch (error) {
@@ -13,6 +13,7 @@ const BOTSU_ROUTE_LABELS: Record<string, string> = {
botsu: 'botsu',
apps: 'apps',
documents: 'documents',
moodboard: 'pinterest',
services: 'services',
home: 'home',
};
@@ -1,6 +1,6 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { botsuApps, getVisibleApps, type BotsuApp, type BotsuRole } from '../apps/catalog';
import { botsuApps, getBotsuApplicationApps, type BotsuApp, type BotsuRole } from '../apps/catalog';
import { buildLaunchPlan } from '../apps/launcher';
import { getBotsuEmbedPath } from '../../app/pages/pathUtils';
@@ -32,7 +32,7 @@ function AppAction({ app }: { app: BotsuApp }) {
}
export function BotsuLauncher() {
const apps = getVisibleApps(new Set<BotsuRole>(['member']), botsuApps);
const apps = getBotsuApplicationApps(new Set<BotsuRole>(['member']), botsuApps);
return (
<section aria-labelledby="botsu-apps-title">
+2 -4
View File
@@ -19,12 +19,9 @@ import { Line } from 'folds';
const APP_NAV_IDS: readonly BotsuAppId[] = [
'godot',
'moodboard',
'wikipedia',
'openstreetmap',
'dessin',
'voxel',
'cinema',
'radio',
];
const GENERATIONS_NAV_IDS = new Set(['generations-audiovisuel', 'generations-vision', 'generations-textuel']);
const COOKIES_NAV_IDS: readonly BotsuAppId[] = ['shop', 'emoji-tcg', 'casino', 'bourse', 'pets'];
@@ -45,6 +42,7 @@ function getAppNavTarget(app: BotsuApp): { to: string } | { href: string } | und
const getAppNavIcon = (appId: BotsuAppId | 'test') => {
if (appId === 'godot') return Icons.Category;
if (appId === 'moodboard') return Icons.Pin;
if (appId === 'discussions') return Icons.Pencil;
if (appId === 'documents') return Icons.File;
if (appId === 'tables') return Icons.Category;
+9 -5
View File
@@ -293,7 +293,10 @@ test('BOTSU route restores the default left page panel', async () => {
assert.match(nav, /<BotsuNavLabel icon=\{getServiceNavIcon\(service\.label\)\}>/);
assert.match(nav, /appId === 'godot'\) return Icons\.Category/);
assert.match(nav, /appId === 'discussions'\) return Icons\.Pencil/);
assert.match(nav, /APP_NAV_IDS/);
assert.match(
nav,
/const APP_NAV_IDS: readonly BotsuAppId\[\] = \[\s*'godot',\s*'moodboard',\s*'wikipedia',\s*'openstreetmap',\s*\];/s
);
assert.match(nav, /COOKIES_NAV_IDS/);
assert.match(nav, /GENERATIONS_NAV_IDS = new Set\(\['generations-audiovisuel', 'generations-vision', 'generations-textuel'\]\)/);
assert.match(nav, /appId === 'documents'\) return Icons\.File/);
@@ -367,13 +370,14 @@ test('BOTSU exposes a Matrix Drawing Room type with a Paint canvas surface', asy
assert.match(roomUtils, /type !== RoomType\.Drawing/);
assert.match(room, /BotsuDrawingRoomView/);
assert.match(room, /BotsuDrawingTransport/);
assert.match(room, /PixelCanvasProvider/);
assert.match(room, /DrawingProvider/);
assert.match(room, /drawingChatAtom/);
assert.match(room, /CallChatView onClose=/);
assert.match(header, /isDrawingRoom/);
assert.match(drawingView, /BotsuPixelCanvas interactive tool=\{tool\} variant="inline"/);
assert.match(drawingView, /Canevas 1024 × 1024/);
assert.match(drawingView, /BotsuDrawingCanvas/);
assert.match(drawingView, /Salle de dessin/);
assert.match(header, /DrawingRoomToolbar/);
assert.match(header, /isDrawingRoom\(room\) \? \(\s*<DrawingRoomToolbar \/>/s);
assert.match(drawingView, /useDrawingTool\(\)/);
});
test('BOTSU OpenStreetMap requests CORS tile images under COEP', async () => {
@@ -1,6 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { BOTSU_PIXEL_CANVAS_SIZE, type PixelCanvasPixel } from '@botsu/protocol';
import type { DrawingTool } from '../drawing/DrawingToolContext';
import { interpolatePixelLine } from './model';
import { usePixelCanvas } from './PixelCanvasContext';
import { createPixelCanvasPublisher } from './pixel-canvas-publisher';
@@ -23,7 +22,7 @@ const getPointerPixel = (
type BotsuPixelCanvasProps = {
interactive?: boolean;
tool?: DrawingTool;
tool?: 'draw' | 'erase';
variant?: 'inline' | 'background';
};
@@ -4,7 +4,7 @@ import { Link } from 'react-router-dom';
import { useMatrixClient } from '../../app/hooks/useMatrixClient';
import { useSetting } from '../../app/state/hooks/settings';
import { settingsAtom } from '../../app/state/settings';
import { botsuApps, getVisibleApps, type BotsuRole } from '../apps/catalog';
import { botsuApps, getBotsuApplicationApps, type BotsuRole } from '../apps/catalog';
import { buildLaunchPlan } from '../apps/launcher';
import { BotsuPresence } from '../presence/BotsuPresence';
import { PixelCanvasProvider } from './PixelCanvasContext';
@@ -155,7 +155,7 @@ export function BotsuStartPage() {
const controllerRef = useRef<ReturnType<typeof createCookieController> | null>(null);
const startPageRef = useRef<HTMLDivElement>(null);
const reducedMotionRef = useRef(false);
const apps = useMemo(() => getVisibleApps(new Set<BotsuRole>(['member']), botsuApps), []);
const apps = useMemo(() => getBotsuApplicationApps(new Set<BotsuRole>(['member']), botsuApps), []);
const results = useMemo(() => searchBotsuStartItems(query, apps, []), [apps, query]);
const cookieVelocity = averageClicksPerSecond * cookies.cookiesPerClick + cookies.cookiesPerSecond;
const backgroundStyle = {
@@ -0,0 +1,310 @@
import React, { CSSProperties, useMemo, useState } from 'react';
import { color, config, vars } from 'folds';
import type { Room } from 'matrix-js-sdk/lib/models/room';
import {
ArrowLeftIcon,
ArrowRightIcon,
ChatCircleTextIcon,
ImageIcon,
PlusIcon,
SpeakerHighIcon,
TrashIcon,
VideoCameraIcon,
} from '@phosphor-icons/react';
import { useMatrixClient } from '../../app/hooks/useMatrixClient';
import { useMediaAuthentication } from '../../app/hooks/useMediaAuthentication';
import { useRoomState } from '../../app/hooks/useRoomState';
import { mxcUrlToHttp } from '../../app/utils/matrix';
import { StateEvent } from '../../types/matrix/room';
import {
createStoryboardCard,
moveStoryboardCard,
parseStoryboardCard,
sortStoryboardCards,
type StoryboardCard,
type StoryboardCardKind,
} from './model';
import './storyboard.css';
type StoryThemeStyle = CSSProperties & Record<`--story-${string}`, string>;
const storyThemeStyle = {
'--story-canvas': color.Background.Container,
'--story-surface': color.Surface.Container,
'--story-surface-raised': color.SurfaceVariant.Container,
'--story-text': color.Background.OnContainer,
'--story-muted': color.SurfaceVariant.OnContainer,
'--story-border': color.Surface.ContainerLine,
'--story-accent': color.Primary.Main,
'--story-on-accent': color.Primary.OnMain,
'--story-critical': color.Critical.Main,
'--story-focus': vars.outline.FocusRing,
'--story-radius': config.radii.R300,
} satisfies StoryThemeStyle;
const KIND_LABELS: Record<StoryboardCardKind, string> = {
scene: 'Scène',
dialogue: 'Dialogue',
image: 'Image',
sound: 'Son',
};
const KIND_ICONS = {
scene: VideoCameraIcon,
dialogue: ChatCircleTextIcon,
image: ImageIcon,
sound: SpeakerHighIcon,
};
function StoryboardCardView({
card,
index,
total,
mediaUrl,
onMove,
onRemove,
}: {
card: StoryboardCard;
index: number;
total: number;
mediaUrl?: string;
onMove: (destination: number) => void;
onRemove: () => void;
}) {
const KindIcon = KIND_ICONS[card.kind];
return (
<article className={`botsu-storyboard-card is-${card.kind}`}>
<header>
<span className="botsu-storyboard-card__number">{String(index + 1).padStart(2, '0')}</span>
<KindIcon aria-hidden="true" size={18} />
<span>{KIND_LABELS[card.kind]}</span>
</header>
{card.kind === 'image' && mediaUrl && <img alt={card.title} src={mediaUrl} />}
{card.kind === 'sound' && mediaUrl && <audio controls preload="metadata" src={mediaUrl} />}
<div className="botsu-storyboard-card__body">
<h2>{card.title}</h2>
{card.body && <p>{card.body}</p>}
</div>
<footer>
<button
aria-label="Déplacer vers la gauche"
disabled={index === 0}
type="button"
onClick={() => onMove(index - 1)}
>
<ArrowLeftIcon aria-hidden="true" size={17} />
</button>
<button
aria-label="Déplacer vers la droite"
disabled={index === total - 1}
type="button"
onClick={() => onMove(index + 1)}
>
<ArrowRightIcon aria-hidden="true" size={17} />
</button>
<button aria-label="Supprimer la carte" type="button" onClick={onRemove}>
<TrashIcon aria-hidden="true" size={17} />
</button>
</footer>
</article>
);
}
export function BotsuStoryboardRoomView({ room }: { room: Room }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const roomState = useRoomState(room);
const [composerOpen, setComposerOpen] = useState(false);
const [kind, setKind] = useState<StoryboardCardKind>('scene');
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [file, setFile] = useState<File>();
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const cards = useMemo(() => {
const events = roomState.get(StateEvent.BotsuStoryboardCard);
if (!events) return [];
const parsed: StoryboardCard[] = [];
events.forEach((event, stateKey) => {
try {
const card = parseStoryboardCard(event.getContent());
if (card.id === stateKey) parsed.push(card);
} catch {
// Ignore malformed or obsolete third-party room state.
}
});
return sortStoryboardCards(parsed);
}, [roomState]);
const sendCard = (card: StoryboardCard) =>
mx.sendStateEvent(room.roomId, StateEvent.BotsuStoryboardCard as any, card, card.id);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (!title.trim() || ((kind === 'image' || kind === 'sound') && !file)) return;
setSaving(true);
setError('');
try {
let media: StoryboardCard['media'];
if (file) {
const uploaded = await mx.uploadContent(file, {
name: file.name,
type: file.type || 'application/octet-stream',
includeFilename: true,
});
media = {
url: uploaded.content_uri,
name: file.name,
mimeType: file.type || 'application/octet-stream',
size: file.size,
};
}
const card = createStoryboardCard({
id: `card_${crypto.randomUUID()}`,
kind,
title: title.trim(),
body: body.trim(),
order: cards.length,
createdBy: mx.getSafeUserId(),
updatedAt: Date.now(),
...(media ? { media } : {}),
});
await sendCard(card);
setTitle('');
setBody('');
setFile(undefined);
setComposerOpen(false);
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Carte impossible à ajouter.');
} finally {
setSaving(false);
}
};
const move = async (cardId: string, destination: number) => {
setError('');
const next = moveStoryboardCard(cards, cardId, destination);
try {
await Promise.all(
next.map((card) =>
card.order === cards.find(({ id }) => id === card.id)?.order
? Promise.resolve()
: sendCard({ ...card, updatedAt: Date.now() })
)
);
} catch {
setError('Le déplacement na pas pu être synchronisé.');
}
};
const remove = async (card: StoryboardCard) => {
setError('');
try {
await sendCard({ ...card, deleted: true, updatedAt: Date.now() });
} catch {
setError('La carte na pas pu être supprimée.');
}
};
return (
<section
className="botsu-storyboard"
aria-label="Story Room"
style={storyThemeStyle as CSSProperties}
>
<div className="botsu-storyboard-toolbar">
<div>
<strong>Story Room</strong>
<span>
{cards.length} carte{cards.length === 1 ? '' : 's'}
</span>
</div>
<button type="button" onClick={() => setComposerOpen((open) => !open)}>
<PlusIcon aria-hidden="true" size={18} />
Carte
</button>
</div>
{composerOpen && (
<form className="botsu-storyboard-composer" onSubmit={submit}>
<div className="botsu-storyboard-kinds" role="group" aria-label="Type de carte">
{(Object.keys(KIND_LABELS) as StoryboardCardKind[]).map((value) => (
<button
aria-pressed={kind === value}
key={value}
type="button"
onClick={() => {
setKind(value);
setFile(undefined);
}}
>
{KIND_LABELS[value]}
</button>
))}
</div>
<input
aria-label="Titre de la carte"
maxLength={160}
placeholder={kind === 'dialogue' ? 'Personnage' : 'Titre'}
required
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<textarea
aria-label="Contenu de la carte"
maxLength={10_000}
placeholder={kind === 'dialogue' ? 'Réplique…' : 'Description, intention, notes…'}
rows={3}
value={body}
onChange={(event) => setBody(event.target.value)}
/>
{(kind === 'image' || kind === 'sound') && (
<input
aria-label={kind === 'image' ? 'Fichier image' : 'Fichier son'}
accept={kind === 'image' ? 'image/*' : 'audio/*'}
required
type="file"
onChange={(event) => setFile(event.target.files?.[0])}
/>
)}
<button disabled={saving} type="submit">
{saving ? 'Ajout…' : 'Ajouter à la séquence'}
</button>
</form>
)}
{error && (
<p className="botsu-storyboard-error" role="alert">
{error}
</p>
)}
{cards.length === 0 ? (
<div className="botsu-storyboard-empty">
<VideoCameraIcon aria-hidden="true" size={42} />
<strong>La séquence est vide.</strong>
<span>Ajoute une scène, un dialogue, une image ou un son.</span>
</div>
) : (
<div className="botsu-storyboard-track">
{cards.map((card, index) => {
const mediaUrl = card.media
? mxcUrlToHttp(mx, card.media.url, useAuthentication) ?? undefined
: undefined;
return (
<StoryboardCardView
card={card}
index={index}
key={card.id}
mediaUrl={mediaUrl}
total={cards.length}
onMove={(destination) => void move(card.id, destination)}
onRemove={() => void remove(card)}
/>
);
})}
</div>
)}
</section>
);
}
@@ -0,0 +1,2 @@
export { BotsuStoryboardRoomView } from './BotsuStoryboardRoomView';
export * from './model';
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createStoryboardCard,
moveStoryboardCard,
parseStoryboardCard,
sortStoryboardCards,
} from './model.ts';
const scene = createStoryboardCard({
id: 'card_scene',
kind: 'scene',
title: 'Ouverture',
body: 'EXT. NUIT — Le portail souvre.',
order: 0,
createdBy: '@alice:botsu.net',
updatedAt: 10,
});
const dialogue = createStoryboardCard({
id: 'card_dialogue',
kind: 'dialogue',
title: 'Nora',
body: 'On ne reviendra pas en arrière.',
order: 1,
createdBy: '@bob:botsu.net',
updatedAt: 20,
});
test('parses bounded storyboard cards with optional Matrix media', () => {
assert.deepEqual(
parseStoryboardCard({
...scene,
kind: 'image',
media: {
url: 'mxc://botsu.net/media123',
name: 'plan.png',
mimeType: 'image/png',
size: 42,
},
}),
{
...scene,
kind: 'image',
media: {
url: 'mxc://botsu.net/media123',
name: 'plan.png',
mimeType: 'image/png',
size: 42,
},
}
);
});
test('rejects unsafe media and oversized card text', () => {
assert.throws(
() =>
parseStoryboardCard({
...scene,
kind: 'sound',
media: {
url: 'https://evil.example/audio.mp3',
name: 'x',
mimeType: 'audio/mpeg',
size: 1,
},
}),
/media/i
);
assert.throws(() => parseStoryboardCard({ ...scene, body: 'x'.repeat(10_001) }), /body/i);
});
test('sorts cards deterministically and moves one card while normalizing order', () => {
const image = createStoryboardCard({
id: 'card_image',
kind: 'image',
title: 'Plan large',
body: '',
media: {
url: 'mxc://botsu.net/planlarge',
name: 'plan.png',
mimeType: 'image/png',
size: 42,
},
order: 2,
createdBy: '@alice:botsu.net',
updatedAt: 30,
});
const moved = moveStoryboardCard([scene, dialogue, image], 'card_image', 0);
assert.deepEqual(
moved.map(({ id, order }) => [id, order]),
[
['card_image', 0],
['card_scene', 1],
['card_dialogue', 2],
]
);
assert.deepEqual(
sortStoryboardCards([dialogue, scene]).map(({ id }) => id),
['card_scene', 'card_dialogue']
);
});
test('tombstoned cards remain valid state but are excluded from sorting', () => {
const deleted = parseStoryboardCard({ ...scene, deleted: true });
assert.equal(deleted.deleted, true);
assert.deepEqual(
sortStoryboardCards([deleted, dialogue]).map(({ id }) => id),
['card_dialogue']
);
});
+141
View File
@@ -0,0 +1,141 @@
export type StoryboardCardKind = 'image' | 'sound' | 'dialogue' | 'scene';
export type StoryboardMedia = {
url: string;
name: string;
mimeType: string;
size: number;
};
export type StoryboardCard = {
version: 1;
id: string;
kind: StoryboardCardKind;
title: string;
body: string;
order: number;
createdBy: string;
updatedAt: number;
media?: StoryboardMedia;
deleted?: boolean;
};
export type StoryboardCardInput = Omit<StoryboardCard, 'version'>;
const ID_PATTERN = /^card_[A-Za-z0-9_-]{1,120}$/;
const MXC_PATTERN = /^mxc:\/\/[A-Za-z0-9.-]+\/[A-Za-z0-9_-]+$/;
const USER_ID_PATTERN = /^@[^:\s]{1,255}:[^\s]{1,255}$/;
const KINDS = new Set<StoryboardCardKind>(['image', 'sound', 'dialogue', 'scene']);
const own = (record: object, key: string): unknown => {
const descriptor = Object.getOwnPropertyDescriptor(record, key);
return descriptor?.enumerable && 'value' in descriptor ? descriptor.value : undefined;
};
const boundedString = (value: unknown, name: string, maximum: number): string => {
if (typeof value !== 'string' || value.length > maximum) {
throw new TypeError(`Invalid storyboard ${name}`);
}
return value;
};
const parseMedia = (value: unknown): StoryboardMedia | undefined => {
if (value === undefined) return undefined;
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TypeError('Invalid storyboard media');
}
const url = boundedString(own(value, 'url'), 'media URL', 500);
const name = boundedString(own(value, 'name'), 'media name', 255);
const mimeType = boundedString(own(value, 'mimeType'), 'media MIME type', 120);
const size = own(value, 'size');
if (
!MXC_PATTERN.test(url) ||
!name ||
!mimeType ||
!Number.isSafeInteger(size) ||
(size as number) < 0 ||
(size as number) > 100 * 1024 * 1024
) {
throw new TypeError('Invalid storyboard media');
}
return { url, name, mimeType, size: size as number };
};
export const parseStoryboardCard = (value: unknown): StoryboardCard => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TypeError('Invalid storyboard card');
}
const version = own(value, 'version');
const id = own(value, 'id');
const kind = own(value, 'kind');
const title = boundedString(own(value, 'title'), 'title', 160).trim();
const body = boundedString(own(value, 'body'), 'body', 10_000);
const order = own(value, 'order');
const createdBy = own(value, 'createdBy');
const updatedAt = own(value, 'updatedAt');
const deleted = own(value, 'deleted');
if (version !== 1) throw new TypeError('Invalid storyboard version');
if (typeof id !== 'string' || !ID_PATTERN.test(id)) throw new TypeError('Invalid storyboard id');
if (typeof kind !== 'string' || !KINDS.has(kind as StoryboardCardKind))
throw new TypeError('Invalid storyboard kind');
if (!title) throw new TypeError('Invalid storyboard title');
if (!Number.isSafeInteger(order) || (order as number) < 0 || (order as number) > 100_000)
throw new TypeError('Invalid storyboard order');
if (typeof createdBy !== 'string' || !USER_ID_PATTERN.test(createdBy))
throw new TypeError('Invalid storyboard creator');
if (!Number.isSafeInteger(updatedAt) || (updatedAt as number) < 0)
throw new TypeError('Invalid storyboard timestamp');
if (deleted !== undefined && typeof deleted !== 'boolean')
throw new TypeError('Invalid storyboard deletion marker');
const media = parseMedia(own(value, 'media'));
if ((kind === 'image' || kind === 'sound') && !media && !deleted) {
// Draft media cards can exist briefly while composing, but published cards cannot.
throw new TypeError('Invalid storyboard media');
}
if (media && kind !== 'image' && kind !== 'sound')
throw new TypeError('Invalid storyboard media kind');
return {
version: 1,
id,
kind: kind as StoryboardCardKind,
title,
body,
order: order as number,
createdBy,
updatedAt: updatedAt as number,
...(media ? { media } : {}),
...(deleted === true ? { deleted: true } : {}),
};
};
export const createStoryboardCard = (input: StoryboardCardInput): StoryboardCard =>
parseStoryboardCard({ version: 1, ...input });
export const sortStoryboardCards = (cards: readonly StoryboardCard[]): StoryboardCard[] =>
cards
.filter(({ deleted }) => !deleted)
.slice()
.sort(
(left, right) =>
left.order - right.order ||
left.updatedAt - right.updatedAt ||
left.id.localeCompare(right.id)
);
export const moveStoryboardCard = (
cards: readonly StoryboardCard[],
cardId: string,
destination: number
): StoryboardCard[] => {
const sorted = sortStoryboardCards(cards);
const source = sorted.findIndex(({ id }) => id === cardId);
if (source < 0) return sorted;
const target = Math.max(0, Math.min(Math.trunc(destination), sorted.length - 1));
const [card] = sorted.splice(source, 1);
if (!card) return sorted;
sorted.splice(target, 0, card);
return sorted.map((item, order) => ({ ...item, order }));
};
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const read = (path: string) => readFile(new URL(path, import.meta.url), 'utf8');
test('storyboard is exposed as a dedicated room type and creation choice', async () => {
const [types, selector, form, roomTypes, roomUtils] = await Promise.all([
read('../../app/components/create-room/types.ts'),
read('../../app/components/create-room/CreateRoomTypeSelector.tsx'),
read('../../app/features/create-room/CreateRoom.tsx'),
read('../../types/matrix/room.ts'),
read('../../app/utils/room.ts'),
]);
assert.match(types, /StoryboardRoom\s*=\s*'storyboard'/);
assert.match(selector, />\s*Story Room\s*</);
assert.match(form, /RoomType\.Storyboard/);
assert.match(roomTypes, /Storyboard\s*=\s*'net\.botsu\.storyboard'/);
assert.match(roomUtils, /isStoryboardRoom/);
});
test('room view renders the collaborative storyboard and Matrix-backed card events', async () => {
const [room, view, model, createUtils, css] = await Promise.all([
read('../../app/features/room/Room.tsx'),
read('./BotsuStoryboardRoomView.tsx'),
read('./model.ts'),
read('../../app/components/create-room/utils.ts'),
read('./storyboard.css'),
]);
assert.match(room, /<BotsuStoryboardRoomView room=\{room\}/);
assert.match(view, /StateEvent\.BotsuStoryboardCard/);
assert.match(view, /uploadContent\(/);
assert.match(view, /sendStateEvent\(/);
assert.match(view, /Déplacer vers la gauche/);
assert.match(view, /Déplacer vers la droite/);
assert.match(
model,
/StoryboardCardKind\s*=\s*'image'\s*\|\s*'sound'\s*\|\s*'dialogue'\s*\|\s*'scene'/
);
assert.match(view, /color\.Background\.Container/);
assert.match(view, /color\.Surface\.Container/);
assert.match(view, /color\.Primary\.Main/);
assert.match(view, /storyThemeStyle/);
assert.match(css, /width:\s*100%/);
assert.match(css, /grid-template-columns:\s*repeat\(auto-fit,/);
assert.match(css, /var\(--story-canvas\)/);
assert.match(css, /var\(--story-accent\)/);
assert.doesNotMatch(css, /min-width:\s*max-content/);
assert.match(createUtils, /StateEvent\.BotsuStoryboardCard/);
});
@@ -0,0 +1,283 @@
.botsu-storyboard {
width: 100%;
min-width: 0;
min-height: 0;
height: 100%;
box-sizing: border-box;
overflow: auto;
padding: clamp(12px, 2.2vw, 28px);
color: var(--story-text);
background: var(--story-canvas);
font-family: var(--font-secondary, Inter, sans-serif);
}
.botsu-storyboard-toolbar,
.botsu-storyboard-composer,
.botsu-storyboard-card {
color: var(--story-text);
border: 1px solid var(--story-border);
border-radius: var(--story-radius);
background: var(--story-surface);
box-shadow: 0 10px 30px color-mix(in srgb, var(--story-text) 8%, transparent);
}
.botsu-storyboard-toolbar {
position: sticky;
z-index: 3;
top: 0;
display: flex;
width: 100%;
min-height: 44px;
box-sizing: border-box;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 8px 10px 8px 14px;
}
.botsu-storyboard-toolbar > div {
display: flex;
min-width: 0;
align-items: baseline;
gap: 10px;
}
.botsu-storyboard-toolbar span {
overflow: hidden;
color: var(--story-muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.botsu-storyboard button {
min-height: 34px;
color: var(--story-text);
border: 1px solid var(--story-border);
border-radius: var(--story-radius);
background: transparent;
font: inherit;
cursor: pointer;
}
.botsu-storyboard button:hover:not(:disabled) {
background: var(--story-surface-raised);
}
.botsu-storyboard button:focus-visible,
.botsu-storyboard input:focus-visible,
.botsu-storyboard textarea:focus-visible,
.botsu-storyboard audio:focus-visible {
outline: 2px solid var(--story-focus);
outline-offset: 2px;
}
.botsu-storyboard button:disabled {
cursor: not-allowed;
opacity: 0.38;
}
.botsu-storyboard-toolbar > button {
display: flex;
min-height: 36px;
padding: 7px 10px;
align-items: center;
gap: 6px;
color: var(--story-on-accent);
border-color: var(--story-accent);
background: var(--story-accent);
}
.botsu-storyboard-toolbar > button:hover:not(:disabled) {
background: color-mix(in srgb, var(--story-accent) 86%, var(--story-text));
}
.botsu-storyboard-composer {
display: grid;
width: min(100%, 52rem);
box-sizing: border-box;
gap: 10px;
margin: 18px 0 24px;
padding: 14px;
}
.botsu-storyboard-kinds {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.botsu-storyboard-kinds button {
padding: 6px 10px;
}
.botsu-storyboard-kinds button[aria-pressed='true'] {
color: var(--story-on-accent);
border-color: var(--story-accent);
background: var(--story-accent);
}
.botsu-storyboard-composer input,
.botsu-storyboard-composer textarea {
box-sizing: border-box;
width: 100%;
padding: 9px 10px;
color: var(--story-text);
border: 1px solid var(--story-border);
border-radius: var(--story-radius);
background: var(--story-surface-raised);
font: inherit;
}
.botsu-storyboard-composer input::placeholder,
.botsu-storyboard-composer textarea::placeholder {
color: var(--story-muted);
}
.botsu-storyboard-composer > button {
justify-self: start;
padding: 8px 11px;
font-weight: 700;
}
.botsu-storyboard-track {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
width: 100%;
box-sizing: border-box;
align-items: stretch;
gap: clamp(12px, 1.8vw, 20px);
padding: 24px 0 32px;
}
.botsu-storyboard-card {
display: flex;
width: 100%;
min-width: 0;
max-width: 32rem;
min-height: 300px;
box-sizing: border-box;
flex-direction: column;
justify-self: stretch;
overflow: hidden;
}
.botsu-storyboard-card > header {
display: flex;
min-height: 38px;
padding: 7px 10px;
box-sizing: border-box;
align-items: center;
gap: 7px;
color: var(--story-muted);
border-bottom: 1px solid var(--story-border);
background: var(--story-surface-raised);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.botsu-storyboard-card__number {
margin-right: auto;
color: var(--story-text);
font-variant-numeric: tabular-nums;
}
.botsu-storyboard-card img {
width: 100%;
height: clamp(150px, 24vw, 220px);
border-bottom: 1px solid var(--story-border);
background: var(--story-surface-raised);
object-fit: cover;
}
.botsu-storyboard-card audio {
box-sizing: border-box;
width: calc(100% - 20px);
margin: 18px 10px 0;
accent-color: var(--story-accent);
}
.botsu-storyboard-card__body {
min-width: 0;
flex: 1;
padding: 14px;
}
.botsu-storyboard-card__body h2 {
margin: 0 0 10px;
color: var(--story-text);
font-size: clamp(18px, 2vw, 22px);
overflow-wrap: anywhere;
}
.botsu-storyboard-card__body p {
margin: 0;
color: var(--story-text);
white-space: pre-wrap;
line-height: 1.5;
overflow-wrap: anywhere;
}
.botsu-storyboard-card.is-dialogue .botsu-storyboard-card__body p {
color: var(--story-muted);
font-size: 18px;
font-style: italic;
}
.botsu-storyboard-card > footer {
display: flex;
padding: 8px;
justify-content: flex-end;
gap: 5px;
border-top: 1px solid var(--story-border);
background: var(--story-surface-raised);
}
.botsu-storyboard-card > footer button {
display: grid;
width: 34px;
height: 34px;
padding: 0;
place-items: center;
}
.botsu-storyboard-empty {
display: grid;
min-height: 55%;
color: var(--story-muted);
place-content: center;
justify-items: center;
gap: 8px;
text-align: center;
}
.botsu-storyboard-empty strong {
color: var(--story-text);
}
.botsu-storyboard-error {
padding: 9px 12px;
color: var(--story-critical);
border: 1px solid var(--story-critical);
border-radius: var(--story-radius);
background: color-mix(in srgb, var(--story-critical) 10%, var(--story-surface));
}
@media (max-width: 640px) {
.botsu-storyboard {
padding: 10px;
}
.botsu-storyboard-toolbar {
gap: 8px;
}
.botsu-storyboard-track {
grid-template-columns: minmax(0, 1fr);
}
.botsu-storyboard-card {
max-width: none;
}
}
@@ -21,7 +21,7 @@
min-width: 0;
min-height: 0;
overflow: hidden;
background: #050505;
background: var(--bg-surface-low, #050505);
}
.botsu-voxel-frame {
@@ -29,7 +29,7 @@
width: 100%;
height: 100%;
border: 0;
background: #050505;
background: var(--bg-surface-low, #050505);
}
.botsu-voxel-loading {
@@ -39,7 +39,7 @@
place-items: center;
color: #a3a3a3;
font: 500 13px/1.4 Inter, ui-sans-serif, system-ui, sans-serif;
background: #050505;
background: var(--bg-surface-low, #050505);
}
.botsu-voxel-statusbar {
@@ -128,6 +128,195 @@
background: var(--bg-primary, #f0f0f0);
}
.botsu-voxel-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
max-width: min(58vw, 760px);
overflow-x: auto;
scrollbar-width: none;
}
.botsu-voxel-toolbar::-webkit-scrollbar {
display: none;
}
.botsu-voxel-toolbar__button {
display: grid;
place-items: center;
width: 32px;
height: 32px;
flex: 0 0 auto;
padding: 0;
color: var(--tc-surface-normal);
background: transparent;
border: 0;
border-radius: var(--botsu-radius, 4px);
cursor: pointer;
}
.botsu-voxel-toolbar__button:hover:not(:disabled) {
background: var(--bg-surface-hover);
}
.botsu-voxel-toolbar__button[aria-pressed='true'] {
color: var(--tc-primary-normal);
background: var(--bg-primary);
}
.botsu-voxel-toolbar__button:disabled {
opacity: 0.35;
cursor: default;
}
.botsu-voxel-toolbar__separator {
width: 1px;
height: 20px;
flex: 0 0 auto;
margin-inline: 3px;
background: var(--bg-surface-border);
}
.botsu-voxel-toolbar__group {
display: flex;
align-items: center;
flex: 0 0 auto;
}
.botsu-voxel-materials {
min-width: 0;
color: var(--tc-surface-normal);
background: var(--bg-surface);
}
.botsu-voxel-materials__tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px;
padding: 8px;
border-bottom: 1px solid var(--bg-surface-border);
}
.botsu-voxel-materials__tabs button,
.botsu-voxel-materials__search {
min-height: 32px;
color: var(--tc-surface-normal);
background: var(--bg-surface-low);
border: 1px solid var(--bg-surface-border);
border-radius: var(--botsu-radius, 4px);
font: inherit;
}
.botsu-voxel-materials__tabs button {
cursor: pointer;
}
.botsu-voxel-materials__tabs button:hover,
.botsu-voxel-materials__tabs button.is-active {
background: var(--bg-surface-hover);
}
.botsu-voxel-materials__tabs button.is-active {
color: var(--tc-primary-normal);
background: var(--bg-primary);
}
.botsu-voxel-materials__content {
height: 100%;
min-height: 0;
flex: 1;
overflow-y: auto;
padding: 12px;
}
.botsu-voxel-hsl,
.botsu-voxel-minecraft {
display: grid;
gap: 14px;
}
.botsu-voxel-hsl__preview {
display: flex;
align-items: flex-end;
min-height: 112px;
padding: 10px;
border: 1px solid var(--bg-surface-border);
border-radius: var(--botsu-radius, 4px);
}
.botsu-voxel-hsl__preview span {
padding: 4px 6px;
color: #fff;
background: rgb(0 0 0 / 65%);
border-radius: max(2px, calc(var(--botsu-radius, 4px) / 2));
font-size: 11px;
}
.botsu-voxel-hsl__hues,
.botsu-voxel-materials__grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
}
.botsu-voxel-hsl__hues button,
.botsu-voxel-materials__grid button {
aspect-ratio: 1;
min-width: 0;
padding: 3px;
overflow: hidden;
background: var(--bg-surface-low);
border: 1px solid var(--bg-surface-border);
border-radius: var(--botsu-radius, 4px);
cursor: pointer;
}
.botsu-voxel-hsl__hues button[aria-pressed='true'],
.botsu-voxel-materials__grid button[aria-pressed='true'] {
outline: 2px solid var(--bg-primary);
outline-offset: 1px;
}
.botsu-voxel-materials__grid button span,
.botsu-voxel-materials__grid button img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: max(1px, calc(var(--botsu-radius, 4px) / 2));
}
.botsu-voxel-hsl__slider {
display: grid;
grid-template-columns: 1fr auto;
gap: 6px 10px;
color: var(--tc-surface-low);
font-size: 12px;
}
.botsu-voxel-hsl__slider input {
grid-column: 1 / -1;
width: 100%;
accent-color: var(--bg-primary);
}
.botsu-voxel-materials__search {
width: 100%;
padding-inline: 10px;
outline: none;
}
.botsu-voxel-materials__search:focus {
border-color: var(--bg-primary);
}
@media (max-width: 1100px) {
.botsu-voxel-toolbar {
max-width: 48vw;
}
}
@media (max-width: 680px) {
.botsu-voxel-statusbar {
padding-left: 10px;
@@ -7,6 +7,7 @@ import { useMatrixClient } from '../../app/hooks/useMatrixClient';
import { StateEvent } from '../../types/matrix/room';
import { useDocumentSyncBridge } from '../documents/DocumentSyncContext';
import type { BotsuSharedDocumentState } from '../documents/document-sync-bridge';
import { useVoxelEditor } from './VoxelEditorContext';
import {
applyVoxelierProject,
getVoxelierRoomCode,
@@ -166,7 +167,9 @@ const forwardAwarenessState = (
export function BotsuVoxelRoomView({ room }: { room: Room }) {
const mx = useMatrixClient();
const documentSync = useDocumentSyncBridge();
const { attachFrame, mode, setMode } = useVoxelEditor();
const iframeRef = useRef<HTMLIFrameElement>(null);
const frameObserverRef = useRef<MutationObserver>();
const channelRef = useRef<BroadcastChannel>();
const lastIframeProjectRef = useRef<VoxelierProject>();
const [objectId, setObjectId] = useState<BotsuDocumentObjectId | undefined>(() =>
@@ -177,7 +180,6 @@ export function BotsuVoxelRoomView({ room }: { room: Room }) {
const [frameReady, setFrameReady] = useState(false);
const [project, setProject] = useState<VoxelierProject>();
const [onlineCount, setOnlineCount] = useState(1);
const [mode, setMode] = useState<'voxel' | 'minecraft'>('voxel');
useEffect(() => {
const updateObjectId = () => setObjectId(readRoomVoxelObjectId(room));
@@ -353,32 +355,44 @@ export function BotsuVoxelRoomView({ room }: { room: Room }) {
};
}, [frameReady, project?.id, ydoc]);
useEffect(
() => () => {
frameObserverRef.current?.disconnect();
},
[]
);
const handleFrameLoad = useCallback(() => {
const frameDocument = iframeRef.current?.contentDocument;
if (!frameDocument) return;
setFrameReady(true);
let attempts = 0;
const enterStudio = window.setInterval(() => {
attempts += 1;
const hostButton = Array.from(frameDocument.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Host'
);
if (hostButton instanceof HTMLButtonElement) hostButton.click();
if (hostButton || attempts >= 25) window.clearInterval(enterStudio);
}, 200);
}, []);
setFrameReady(false);
frameObserverRef.current?.disconnect();
const setEditorMode = useCallback((nextMode: 'voxel' | 'minecraft') => {
const frameDocument = iframeRef.current?.contentDocument;
if (!frameDocument) return;
const panel = frameDocument.querySelector('.vox-bs-panel');
const open = panel?.classList.contains('is-open') ?? false;
if ((nextMode === 'minecraft') !== open) {
const toggle = frameDocument.querySelector<HTMLElement>('.vox-bs-toggle');
toggle?.click();
}
setMode(nextMode);
}, []);
const enterEditor = () => {
if (!frameDocument.querySelector('.app-shell')) return false;
frameObserverRef.current?.disconnect();
frameObserverRef.current = undefined;
if (!ydoc || !readVoxelierProject(ydoc)) {
frameDocument.querySelector<HTMLButtonElement>('button[title="Nouveau projet"]')?.click();
}
attachFrame(frameDocument);
setFrameReady(true);
return true;
};
if (enterEditor()) return;
const observer = new MutationObserver(enterEditor);
observer.observe(frameDocument.getElementById('root') ?? frameDocument.body, {
childList: true,
subtree: true,
});
frameObserverRef.current = observer;
}, [attachFrame, ydoc]);
const setEditorMode = useCallback(
(nextMode: 'voxel' | 'minecraft') => setMode(nextMode),
[setMode]
);
let syncLabel = 'Synchronisation…';
if (syncState.status === 'online') syncLabel = 'Synchronisé';
@@ -0,0 +1,256 @@
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
export type VoxelEditorAction =
| 'undo'
| 'redo'
| 'reset-view'
| 'paint'
| 'build'
| 'eraser'
| 'pencil'
| 'fill'
| 'line'
| 'box'
| 'box-fill'
| 'round'
| 'round-fill';
export type VoxelEditorMode = 'voxel' | 'minecraft';
export type VoxelMaterial = {
id: string;
label: string;
color?: string;
image?: string;
};
type VoxelEditorContextValue = {
ready: boolean;
mode: VoxelEditorMode;
materialsOpen: boolean;
materials: readonly VoxelMaterial[];
selectedMaterialId: string | undefined;
attachFrame: (document: Document) => void;
runAction: (action: VoxelEditorAction) => void;
isActionActive: (action: VoxelEditorAction) => boolean;
isActionDisabled: (action: VoxelEditorAction) => boolean;
selectMaterial: (id: string) => void;
setMode: (mode: VoxelEditorMode) => void;
closeMaterials: () => void;
toggleMaterials: () => void;
};
const actionLabels: Partial<Record<VoxelEditorAction, string>> = {
paint: 'Paint',
build: 'Build',
eraser: 'Eraser',
pencil: 'Pencil',
fill: 'Fill',
line: 'Line',
box: 'Box',
'box-fill': 'Box Fill',
round: 'Round',
'round-fill': 'Round Fill',
};
const findActionButton = (
frameDocument: Document,
action: VoxelEditorAction
): HTMLButtonElement | undefined => {
const buttons = Array.from(
frameDocument.querySelectorAll<HTMLButtonElement>('.compact-toolbar button')
);
if (action === 'undo') return buttons.find((button) => button.title.startsWith('Annuler'));
if (action === 'redo') return buttons.find((button) => button.title.startsWith('Rétablir'));
if (action === 'reset-view')
return buttons.find((button) => button.classList.contains('compact-view'));
const label = actionLabels[action];
return buttons.find((button) => button.getAttribute('aria-label')?.startsWith(`${label}`));
};
const readMaterials = (frameDocument: Document): VoxelMaterial[] =>
Array.from(frameDocument.querySelectorAll<HTMLButtonElement>('.palette-panel .block-swatch'))
.map((button): VoxelMaterial | undefined => {
const separator = button.title.lastIndexOf(' · ');
if (separator < 1) return undefined;
const label = button.title.slice(0, separator).trim();
const id = button.title.slice(separator + 3).trim();
if (!id) return undefined;
const color = button.querySelector<HTMLElement>('span')?.style.background;
const image = button.querySelector<HTMLImageElement>('img')?.src;
return { id, label, ...(color ? { color } : {}), ...(image ? { image } : {}) };
})
.filter((material): material is VoxelMaterial => material !== undefined);
const parentToken = (name: string, fallback: string): string => {
const bodyValue = window.getComputedStyle(document.body).getPropertyValue(name).trim();
if (bodyValue) return bodyValue;
const rootValue = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return rootValue || fallback;
};
const syncCinnyTheme = (frameDocument: Document): void => {
const variables: Record<string, string> = {
'--botsu-voxel-bg': parentToken('--bg-surface-low', '#050505'),
'--botsu-voxel-panel': parentToken('--bg-surface', '#111111'),
'--botsu-voxel-panel-strong': parentToken('--bg-surface-high', '#181818'),
'--botsu-voxel-hover': parentToken('--bg-surface-hover', '#202020'),
'--botsu-voxel-line': parentToken('--bg-surface-border', '#303030'),
'--botsu-voxel-text': parentToken('--tc-surface-normal', '#e8e8e8'),
'--botsu-voxel-muted': parentToken('--tc-surface-low', '#a3a3a3'),
'--botsu-voxel-accent': parentToken('--bg-primary', '#f0f0f0'),
'--botsu-voxel-on-accent': parentToken('--tc-primary-normal', '#050505'),
'--botsu-voxel-radius': parentToken('--botsu-radius', '4px'),
};
Object.entries(variables).forEach(([name, value]) => {
frameDocument.documentElement.style.setProperty(name, value);
frameDocument.body.style.setProperty(name, value);
});
};
const VoxelEditorContext = createContext<VoxelEditorContextValue | undefined>(undefined);
export function VoxelEditorProvider({ children }: React.PropsWithChildren) {
const frameDocumentRef = useRef<Document>();
const frameObserverRef = useRef<MutationObserver>();
const themeObserverRef = useRef<MutationObserver>();
const [ready, setReady] = useState(false);
const [mode, setMode] = useState<VoxelEditorMode>('voxel');
const [materialsOpen, setMaterialsOpen] = useState(false);
const [materials, setMaterials] = useState<VoxelMaterial[]>([]);
const [selectedMaterialId, setSelectedMaterialId] = useState<string>();
const [, setRevision] = useState(0);
const refresh = useCallback(() => {
const frameDocument = frameDocumentRef.current;
if (frameDocument) {
setMaterials(readMaterials(frameDocument));
setSelectedMaterialId(
frameDocument
.querySelector<HTMLButtonElement>('.palette-panel .block-swatch.is-active')
?.title.split(' · ')
.at(-1)
?.trim()
);
}
setRevision((value) => value + 1);
}, []);
const attachFrame = useCallback(
(frameDocument: Document) => {
frameObserverRef.current?.disconnect();
themeObserverRef.current?.disconnect();
frameDocumentRef.current = frameDocument;
syncCinnyTheme(frameDocument);
const frameObserver = new MutationObserver(refresh);
const toolbar = frameDocument.querySelector('.compact-toolbar');
const palette = frameDocument.querySelector('.palette-panel');
if (toolbar) {
frameObserver.observe(toolbar, {
attributes: true,
subtree: true,
attributeFilter: ['class', 'disabled', 'aria-pressed'],
});
}
if (palette) {
frameObserver.observe(palette, {
attributes: true,
childList: true,
subtree: true,
attributeFilter: ['class', 'style'],
});
}
frameObserverRef.current = frameObserver;
const themeObserver = new MutationObserver(() => syncCinnyTheme(frameDocument));
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'style'],
});
themeObserver.observe(document.body, {
attributes: true,
attributeFilter: ['class', 'style'],
});
themeObserverRef.current = themeObserver;
setReady(true);
refresh();
},
[refresh]
);
useEffect(
() => () => {
frameObserverRef.current?.disconnect();
themeObserverRef.current?.disconnect();
frameDocumentRef.current = undefined;
},
[]
);
const runAction = useCallback(
(action: VoxelEditorAction) => {
const button = frameDocumentRef.current
? findActionButton(frameDocumentRef.current, action)
: undefined;
if (!button || button.disabled) return;
button.click();
refresh();
},
[refresh]
);
const isActionActive = useCallback((action: VoxelEditorAction) => {
const frameDocument = frameDocumentRef.current;
return frameDocument
? findActionButton(frameDocument, action)?.getAttribute('aria-pressed') === 'true'
: false;
}, []);
const isActionDisabled = useCallback((action: VoxelEditorAction) => {
const frameDocument = frameDocumentRef.current;
return !frameDocument || (findActionButton(frameDocument, action)?.disabled ?? true);
}, []);
const selectMaterial = useCallback(
(id: string) => {
const button = frameDocumentRef.current
? Array.from(
frameDocumentRef.current.querySelectorAll<HTMLButtonElement>(
'.palette-panel .block-swatch'
)
).find((candidate) => candidate.title.endsWith(` · ${id}`))
: undefined;
button?.click();
if (button) {
setSelectedMaterialId(id);
refresh();
}
},
[refresh]
);
const value: VoxelEditorContextValue = {
ready,
mode,
materialsOpen,
materials,
selectedMaterialId,
attachFrame,
runAction,
isActionActive,
isActionDisabled,
selectMaterial,
setMode,
closeMaterials: () => setMaterialsOpen(false),
toggleMaterials: () => setMaterialsOpen((open) => !open),
};
return <VoxelEditorContext.Provider value={value}>{children}</VoxelEditorContext.Provider>;
}
export const useVoxelEditor = (): VoxelEditorContextValue => {
const value = useContext(VoxelEditorContext);
if (!value) throw new Error('Voxel editor provider is missing');
return value;
};
@@ -0,0 +1,187 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box, Icon, IconButton, Icons, Text, Tooltip, TooltipProvider, toRem } from 'folds';
import { Page, PageHeader } from '../../app/components/page';
import { ScreenSize, useScreenSizeContext } from '../../app/hooks/useScreenSize';
import {
createVoxelHslId,
parseVoxelHslId,
snapVoxelHsl,
VOXEL_HUES,
type VoxelHsl,
} from './voxel-color';
import { useVoxelEditor } from './VoxelEditorContext';
const DEFAULT_HSL: VoxelHsl = { hue: 0, saturation: 75, lightness: 50 };
export function VoxelMaterialsDrawer() {
const screenSize = useScreenSizeContext();
const { mode, materials, selectedMaterialId, closeMaterials, selectMaterial, setMode } =
useVoxelEditor();
const [hsl, setHsl] = useState<VoxelHsl>(
() => (selectedMaterialId && parseVoxelHslId(selectedMaterialId)) || DEFAULT_HSL
);
const [query, setQuery] = useState('');
const minecraftMaterials = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase('fr');
return materials.filter(
(material) =>
!material.id.startsWith('botsu:hsl_') &&
(!normalized ||
material.label.toLocaleLowerCase('fr').includes(normalized) ||
material.id.toLocaleLowerCase('fr').includes(normalized))
);
}, [materials, query]);
const applyHsl = (nextValue: VoxelHsl) => {
const next = snapVoxelHsl(nextValue);
setHsl(next);
selectMaterial(createVoxelHslId(next));
};
useEffect(() => {
const selectedHsl = selectedMaterialId ? parseVoxelHslId(selectedMaterialId) : undefined;
if (selectedHsl) setHsl(selectedHsl);
}, [selectedMaterialId]);
useEffect(() => {
if (mode === 'voxel') selectMaterial(createVoxelHslId(hsl));
}, [mode]);
return (
<Page
className="botsu-voxel-materials"
style={{
width: screenSize === ScreenSize.Desktop ? toRem(320) : '100%',
flexShrink: 0,
}}
>
<PageHeader>
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes">
<Text size="H5" truncate>
Matériaux
</Text>
</Box>
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Fermer</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton ref={triggerRef} variant="Surface" onClick={closeMaterials}>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
</PageHeader>
<div className="botsu-voxel-materials__tabs" role="tablist" aria-label="Mode de matériau">
<button
aria-selected={mode === 'voxel'}
className={mode === 'voxel' ? 'is-active' : undefined}
onClick={() => setMode('voxel')}
role="tab"
type="button"
>
Voxel
</button>
<button
aria-selected={mode === 'minecraft'}
className={mode === 'minecraft' ? 'is-active' : undefined}
onClick={() => setMode('minecraft')}
role="tab"
type="button"
>
Minecraft
</button>
</div>
<div className="botsu-voxel-materials__content">
{mode === 'voxel' ? (
<section className="botsu-voxel-hsl" aria-label="Palette HSL">
<div
className="botsu-voxel-hsl__preview"
style={{ background: `hsl(${hsl.hue} ${hsl.saturation}% ${hsl.lightness}%)` }}
>
<span>{`H ${hsl.hue} · S ${hsl.saturation}% · L ${hsl.lightness}%`}</span>
</div>
<div className="botsu-voxel-hsl__hues" aria-label="Teinte">
{VOXEL_HUES.map((hue) => (
<button
aria-label={`Teinte ${hue}`}
aria-pressed={hsl.hue === hue}
key={hue}
onClick={() => applyHsl({ ...hsl, hue })}
style={{ background: `hsl(${hue} ${hsl.saturation}% ${hsl.lightness}%)` }}
type="button"
/>
))}
</div>
<label className="botsu-voxel-hsl__slider">
<span>Saturation</span>
<output>{`${hsl.saturation}%`}</output>
<input
aria-label="Saturation HSL"
max="100"
min="25"
onChange={(event) => applyHsl({ ...hsl, saturation: Number(event.target.value) })}
step="25"
type="range"
value={hsl.saturation}
/>
</label>
<label className="botsu-voxel-hsl__slider">
<span>Luminosité</span>
<output>{`${hsl.lightness}%`}</output>
<input
aria-label="Luminosité HSL"
max="65"
min="35"
onChange={(event) => applyHsl({ ...hsl, lightness: Number(event.target.value) })}
step="15"
type="range"
value={hsl.lightness}
/>
</label>
</section>
) : (
<section className="botsu-voxel-minecraft" aria-label="Palette Minecraft">
<input
aria-label="Rechercher un bloc Minecraft"
className="botsu-voxel-materials__search"
onChange={(event) => setQuery(event.target.value)}
placeholder="Rechercher"
type="search"
value={query}
/>
<div className="botsu-voxel-materials__grid">
{minecraftMaterials.map((material) => (
<button
aria-label={material.label}
aria-pressed={selectedMaterialId === material.id}
key={material.id}
onClick={() => selectMaterial(material.id)}
title={`${material.label} · ${material.id}`}
type="button"
>
{material.image ? (
<img alt="" src={material.image} />
) : (
<span style={{ background: material.color ?? '#7f7f7f' }} />
)}
</button>
))}
</div>
</section>
)}
</div>
</Page>
);
}
@@ -0,0 +1,159 @@
import React from 'react';
import {
ArrowClockwiseIcon,
ArrowCounterClockwiseIcon,
CircleIcon,
CubeFocusIcon,
CubeIcon,
EraserIcon,
LineSegmentIcon,
PaintBrushIcon,
PaintBucketIcon,
PaletteIcon,
PencilSimpleIcon,
RectangleIcon,
} from '@phosphor-icons/react';
import { useVoxelEditor, type VoxelEditorAction } from './VoxelEditorContext';
type Tool = {
action: VoxelEditorAction;
label: string;
icon: React.ReactNode;
};
const historyTools: Tool[] = [
{
action: 'undo',
label: 'Annuler',
icon: <ArrowCounterClockwiseIcon aria-hidden="true" size={18} />,
},
{
action: 'redo',
label: 'Rétablir',
icon: <ArrowClockwiseIcon aria-hidden="true" size={18} />,
},
];
const actionTools: Tool[] = [
{
action: 'paint',
label: 'Peindre',
icon: <PaintBrushIcon aria-hidden="true" size={18} />,
},
{
action: 'build',
label: 'Construire',
icon: <CubeIcon aria-hidden="true" size={18} />,
},
{
action: 'eraser',
label: 'Effacer',
icon: <EraserIcon aria-hidden="true" size={18} />,
},
];
const shapeTools: Tool[] = [
{
action: 'pencil',
label: 'Crayon',
icon: <PencilSimpleIcon aria-hidden="true" size={18} />,
},
{
action: 'fill',
label: 'Remplir',
icon: <PaintBucketIcon aria-hidden="true" size={18} />,
},
{
action: 'line',
label: 'Ligne',
icon: <LineSegmentIcon aria-hidden="true" size={18} />,
},
{
action: 'box',
label: 'Boîte',
icon: <RectangleIcon aria-hidden="true" size={18} />,
},
{
action: 'box-fill',
label: 'Boîte pleine',
icon: <RectangleIcon aria-hidden="true" size={18} weight="fill" />,
},
{
action: 'round',
label: 'Rond',
icon: <CircleIcon aria-hidden="true" size={18} />,
},
{
action: 'round-fill',
label: 'Rond plein',
icon: <CircleIcon aria-hidden="true" size={18} weight="fill" />,
},
];
function ToolButton({ tool }: { tool: Tool }) {
const { ready, runAction, isActionActive, isActionDisabled } = useVoxelEditor();
return (
<button
aria-label={tool.label}
aria-pressed={isActionActive(tool.action)}
className="botsu-voxel-toolbar__button"
disabled={!ready || isActionDisabled(tool.action)}
onClick={() => runAction(tool.action)}
title={tool.label}
type="button"
>
{tool.icon}
</button>
);
}
export function VoxelRoomToolbar() {
const { ready, materialsOpen, runAction, toggleMaterials } = useVoxelEditor();
return (
<div className="botsu-voxel-toolbar" aria-label="Outils Voxelier">
<div className="botsu-voxel-toolbar__group" aria-label="Historique">
{historyTools.map((tool) => (
<ToolButton key={tool.action} tool={tool} />
))}
</div>
<span className="botsu-voxel-toolbar__separator" aria-hidden="true" />
<div className="botsu-voxel-toolbar__group" aria-label="Vue">
<button
aria-label="Réinitialiser la vue"
className="botsu-voxel-toolbar__button"
disabled={!ready}
onClick={() => runAction('reset-view')}
title="Réinitialiser la vue"
type="button"
>
<CubeFocusIcon aria-hidden="true" size={18} />
</button>
</div>
<span className="botsu-voxel-toolbar__separator" aria-hidden="true" />
<div className="botsu-voxel-toolbar__group" aria-label="Outils A">
{actionTools.map((tool) => (
<ToolButton key={tool.action} tool={tool} />
))}
</div>
<span className="botsu-voxel-toolbar__separator" aria-hidden="true" />
<div className="botsu-voxel-toolbar__group" aria-label="Outils B">
{shapeTools.map((tool) => (
<ToolButton key={tool.action} tool={tool} />
))}
</div>
<span className="botsu-voxel-toolbar__separator" aria-hidden="true" />
<button
aria-label="Matériaux"
aria-pressed={materialsOpen}
className="botsu-voxel-toolbar__button"
disabled={!ready}
onClick={toggleMaterials}
title={materialsOpen ? 'Masquer les matériaux' : 'Afficher les matériaux'}
type="button"
>
<PaletteIcon aria-hidden="true" size={18} weight={materialsOpen ? 'fill' : 'regular'} />
</button>
</div>
);
}
+3
View File
@@ -1 +1,4 @@
export { BotsuVoxelRoomView } from './BotsuVoxelRoomView';
export { VoxelEditorProvider, useVoxelEditor } from './VoxelEditorContext';
export { VoxelMaterialsDrawer } from './VoxelMaterialsDrawer';
export { VoxelRoomToolbar } from './VoxelRoomToolbar';
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createVoxelHslId, parseVoxelHslId, snapVoxelHsl } from './voxel-color.ts';
test('snaps HSL controls to the bounded Voxelier colour palette', () => {
assert.deepEqual(snapVoxelHsl({ hue: 359, saturation: 63, lightness: 58 }), {
hue: 330,
saturation: 75,
lightness: 65,
});
assert.deepEqual(snapVoxelHsl({ hue: -10, saturation: 500, lightness: -4 }), {
hue: 0,
saturation: 100,
lightness: 35,
});
});
test('round-trips strict BOTSU HSL material identifiers', () => {
const id = createVoxelHslId({ hue: 210, saturation: 75, lightness: 50 });
assert.equal(id, 'botsu:hsl_210_75_50');
assert.deepEqual(parseVoxelHslId(id), { hue: 210, saturation: 75, lightness: 50 });
assert.equal(parseVoxelHslId('minecraft:red_wool'), undefined);
assert.equal(parseVoxelHslId('botsu:hsl_211_75_50'), undefined);
});
@@ -0,0 +1,40 @@
export type VoxelHsl = {
hue: number;
saturation: number;
lightness: number;
};
export const VOXEL_HUES = Array.from({ length: 12 }, (_, index) => index * 30);
export const VOXEL_SATURATIONS = [25, 50, 75, 100] as const;
export const VOXEL_LIGHTNESSES = [35, 50, 65] as const;
const nearest = (value: number, values: readonly number[]): number => {
const finite = Number.isFinite(value) ? value : values[0] ?? 0;
return values.reduce((best, candidate) =>
Math.abs(candidate - finite) < Math.abs(best - finite) ? candidate : best
);
};
export const snapVoxelHsl = (value: VoxelHsl): VoxelHsl => ({
hue: nearest(Math.max(0, Math.min(330, value.hue)), VOXEL_HUES),
saturation: nearest(Math.max(25, Math.min(100, value.saturation)), VOXEL_SATURATIONS),
lightness: nearest(Math.max(35, Math.min(65, value.lightness)), VOXEL_LIGHTNESSES),
});
export const createVoxelHslId = (value: VoxelHsl): string => {
const snapped = snapVoxelHsl(value);
return `botsu:hsl_${snapped.hue}_${snapped.saturation}_${snapped.lightness}`;
};
const HSL_ID_PATTERN =
/^botsu:hsl_(0|30|60|90|120|150|180|210|240|270|300|330)_(25|50|75|100)_(35|50|65)$/;
export const parseVoxelHslId = (value: string): VoxelHsl | undefined => {
const match = HSL_ID_PATTERN.exec(value);
if (!match) return undefined;
return {
hue: Number(match[1]),
saturation: Number(match[2]),
lightness: Number(match[3]),
};
};
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import test from 'node:test';
const read = (path: string) => {
const url = new URL(path, import.meta.url);
return existsSync(url) ? readFileSync(url, 'utf8') : '';
};
const room = read('../../app/features/room/Room.tsx');
const header = read('../../app/features/room/RoomViewHeader.tsx');
const view = read('./BotsuVoxelRoomView.tsx');
const toolbar = read('./VoxelRoomToolbar.tsx');
const context = read('./VoxelEditorContext.tsx');
const drawer = read('./VoxelMaterialsDrawer.tsx');
const colorModel = read('./voxel-color.ts');
const embeddedBundle = read('../../../vendor/voxelier/assets/index-B558jG0T.js');
const embeddedCss = read('../../../vendor/voxelier/botsu-embedded.css');
const embeddedHtml = read('../../../vendor/voxelier/index.html');
test('Voxel Rooms provide their editor controls to the Matrix room header', () => {
assert.match(room, /<VoxelEditorProvider key=\{room\.roomId\}>/);
assert.match(header, /isVoxelRoom\(room\) \? \(\s*<VoxelRoomToolbar \/>/);
assert.match(toolbar, /@phosphor-icons\/react/);
assert.match(toolbar, /label: 'Annuler'/);
assert.match(toolbar, /label: 'Construire'/);
assert.match(toolbar, /label: 'Crayon'/);
assert.match(toolbar, /aria-label=\{tool\.label\}/);
assert.match(toolbar, /aria-label="Matériaux"/);
assert.doesNotMatch(toolbar, />\s*(Paint|Build|Eraser|Pencil|Fill|Line|Box|Round)\s*</);
});
test('the header toolbar separates history, action tools A, and commuting shape tools B', () => {
assert.match(toolbar, /aria-label="Historique"/);
assert.match(toolbar, /aria-label="Outils A"/);
assert.match(toolbar, /aria-label="Outils B"/);
assert.match(toolbar, /historyTools\.map/);
assert.match(toolbar, /actionTools\.map/);
assert.match(toolbar, /shapeTools\.map/);
assert.match(toolbar, /botsu-voxel-toolbar__separator/);
});
test('materials use a native Cinny drawer with Voxel HSL and Minecraft modes', () => {
assert.match(room, /<VoxelMaterialsDrawer \/>/);
assert.match(room, /if \(!materialsOpen\) return null/);
assert.match(room, /<Line variant="Background" direction="Vertical"/);
assert.match(drawer, /PageHeader/);
assert.match(drawer, /Icons\.Cross/);
assert.match(drawer, /mode === 'voxel'/);
assert.match(drawer, /createVoxelHslId/);
assert.match(drawer, /type="range"/);
assert.match(drawer, /mode === 'minecraft'/);
assert.match(context, /selectMaterial/);
assert.doesNotMatch(context, /data-botsu-materials-open/);
assert.match(embeddedCss, /\.palette-panel[\s\S]*display: none !important/);
});
test('embedded Voxelier follows Cinny theme tokens and starts new rooms blank', () => {
assert.match(context, /--botsu-voxel-bg/);
assert.match(context, /MutationObserver/);
assert.match(embeddedCss, /var\(--botsu-voxel-bg/);
assert.match(view, /title="Nouveau projet"/);
assert.match(view, /readVoxelierProject\(ydoc\)/);
});
test('the embedded material registry exposes the bounded BOTSU HSL palette', () => {
assert.match(colorModel, /VOXEL_HUES/);
assert.match(embeddedBundle, /botsu:hsl_/);
assert.match(embeddedBundle, /source:"botsu-hsl"/);
});
test('BOTSU embedded Voxelier skips the standalone host and join screen', () => {
assert.match(embeddedHtml, /data-botsu-editor-ready/);
assert.match(embeddedHtml, /home-primary-action/);
assert.match(embeddedHtml, /MutationObserver/);
assert.doesNotMatch(view, /textContent\?\.trim\(\) === 'Host'/);
});
+502 -203
View File
@@ -9,7 +9,7 @@ import React, {
} from 'react';
import Hls from 'hls.js';
import { MatrixEvent, Room, RoomStateEvent } from 'matrix-js-sdk';
import { color } from 'folds';
import { color, config } from 'folds';
import {
ArrowsClockwiseIcon,
CheckCircleIcon,
@@ -29,7 +29,6 @@ import {
parseBotsuWatchSession,
type BotsuWatchSession,
} from '@botsu/protocol';
import { RoomView } from '../../app/features/room/RoomView';
import { useMatrixClient } from '../../app/hooks/useMatrixClient';
import { StateEvent } from '../../types/matrix/room';
import {
@@ -40,6 +39,9 @@ import {
shouldCorrectBotsuWatchDrift,
type BotsuWatchSnapshot,
} from './model';
import { createBrowserJellyfinClient, type JellyfinItem, type JellyfinPlayback } from './jellyfin';
import { createJellyfinWatchUrl, normalizeWatchSourceUrl, parseWatchSource } from './providers';
import { WatchEmbedPlayer, type WatchPlaybackController } from './WatchEmbedPlayer';
import './watch-room.css';
type WatchRoomState = {
@@ -72,6 +74,7 @@ const watchThemeStyle = {
'--botsu-watch-border': color.Surface.ContainerLine,
'--botsu-watch-accent': color.Primary.Main,
'--botsu-watch-on-accent': color.Primary.OnMain,
'--botsu-watch-radius': config.radii.R400,
} satisfies WatchThemeStyle;
const loadWatchPreferences = (): WatchPreferences => {
@@ -110,21 +113,26 @@ const readWatchRoomState = (room: Room, now = Date.now()): WatchRoomState => {
};
};
const memberInitials = (name: string): string =>
name
.replace(/^@/, '')
.split(/[\s._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('') || '?';
const qualityLabel = (quality: WatchQuality): string => {
if (quality.height > 0) return `${quality.height}p`;
if (quality.bitrate > 0) return `${Math.round(quality.bitrate / 1_000)} kb/s`;
return `Piste ${quality.index + 1}`;
};
const isPlayableJellyfinItem = (item: JellyfinItem): boolean =>
item.type === 'Movie' || item.type === 'Episode' || item.type === 'Video';
const jellyfinTypeLabel = (type: JellyfinItem['type']): string =>
({
CollectionFolder: 'Bibliothèque',
Folder: 'Dossier',
Movie: 'Film',
Series: 'Série',
Season: 'Saison',
Episode: 'Épisode',
Video: 'Vidéo',
}[type]);
function SourceDialog({
session,
busy,
@@ -140,6 +148,69 @@ function SourceDialog({
onSubmit: (url: string, title: string) => Promise<void>;
onRemove: () => Promise<void>;
}) {
const jellyfinClient = useMemo(createBrowserJellyfinClient, []);
const [jellyfinQuery, setJellyfinQuery] = useState('');
const [jellyfinItems, setJellyfinItems] = useState<JellyfinItem[]>([]);
const [jellyfinPath, setJellyfinPath] = useState<JellyfinItem[]>([]);
const [jellyfinBusy, setJellyfinBusy] = useState(false);
const [jellyfinError, setJellyfinError] = useState<string>();
const loadJellyfin = async () => {
setJellyfinBusy(true);
setJellyfinError(undefined);
try {
const normalized = jellyfinQuery.trim();
if (normalized) {
setJellyfinItems(await jellyfinClient.search(normalized));
} else {
const parent = jellyfinPath.at(-1);
setJellyfinItems(
parent ? await jellyfinClient.browse(parent.id) : await jellyfinClient.getViews()
);
}
} catch (nextError) {
setJellyfinError((nextError as Error).message);
} finally {
setJellyfinBusy(false);
}
};
const openJellyfinItem = async (item: JellyfinItem) => {
if (isPlayableJellyfinItem(item)) {
await onSubmit(createJellyfinWatchUrl(item.id), item.title);
return;
}
setJellyfinBusy(true);
setJellyfinError(undefined);
try {
setJellyfinQuery('');
setJellyfinItems(await jellyfinClient.browse(item.id));
setJellyfinPath((current) => [...current, item]);
} catch (nextError) {
setJellyfinError((nextError as Error).message);
} finally {
setJellyfinBusy(false);
}
};
const goBackInJellyfin = async () => {
const nextPath = jellyfinPath.slice(0, -1);
setJellyfinBusy(true);
setJellyfinError(undefined);
try {
const parent = nextPath.at(-1);
setJellyfinItems(
parent ? await jellyfinClient.browse(parent.id) : await jellyfinClient.getViews()
);
setJellyfinPath(nextPath);
setJellyfinQuery('');
} catch (nextError) {
setJellyfinError((nextError as Error).message);
} finally {
setJellyfinBusy(false);
}
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
@@ -191,7 +262,7 @@ function SourceDialog({
/>
</label>
<label>
<span>URL HTTPS du média</span>
<span>Lien vidéo, YouTube ou Twitch</span>
<div className="botsu-watch-input-with-icon">
<LinkIcon aria-hidden size={18} />
<input
@@ -199,15 +270,74 @@ function SourceDialog({
inputMode="url"
maxLength={2048}
name="url"
placeholder="https://media.example/film/master.m3u8"
placeholder="https://youtube.com/watch?v=…"
required
type="url"
/>
</div>
</label>
<section className="botsu-watch-jellyfin" aria-labelledby="botsu-watch-jellyfin-title">
<div className="botsu-watch-jellyfin-heading">
<div>
<span className="botsu-watch-eyebrow">JELLYFIN</span>
<strong id="botsu-watch-jellyfin-title">Bibliothèque Pipou</strong>
</div>
<button
className="botsu-watch-button"
disabled={busy || jellyfinBusy}
onClick={() => void loadJellyfin()}
type="button"
>
{jellyfinBusy ? 'Chargement…' : jellyfinItems.length ? 'Actualiser' : 'Ouvrir'}
</button>
</div>
{jellyfinPath.length > 0 && (
<div className="botsu-watch-jellyfin-path">
<button disabled={jellyfinBusy} onClick={() => void goBackInJellyfin()} type="button">
Retour
</button>
<span>Bibliothèque / {jellyfinPath.map((item) => item.title).join(' / ')}</span>
</div>
)}
<input
aria-label="Rechercher dans Jellyfin"
maxLength={100}
onChange={(event) => setJellyfinQuery(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
void loadJellyfin();
}}
placeholder="Rechercher un film, une série ou un épisode"
value={jellyfinQuery}
/>
{jellyfinError && (
<p className="botsu-watch-error" role="alert">
{jellyfinError}
</p>
)}
{jellyfinItems.length > 0 && (
<div className="botsu-watch-jellyfin-results">
{jellyfinItems.map((item) => (
<button
className="botsu-watch-jellyfin-item"
disabled={busy}
key={item.id}
onClick={() => void openJellyfinItem(item)}
type="button"
>
<span>{item.title}</span>
<small>
{jellyfinTypeLabel(item.type)} {isPlayableJellyfinItem(item) ? '' : ''}
</small>
</button>
))}
</div>
)}
</section>
<p className="botsu-watch-source-note">
MP4, WebM et HLS (.m3u8). Ladresse est enregistrée dans létat Matrix du salon :
nutilisez pas dURL contenant un secret.
MP4, WebM, HLS, YouTube et Twitch. Pour Jellyfin, seul lidentifiant du média est partagé
dans Matrix ; la session Pipou reste locale à votre navigateur.
</p>
{error && (
<p className="botsu-watch-error" role="alert">
@@ -245,8 +375,10 @@ function SourceDialog({
export function BotsuWatchRoomView({ room }: { room: Room }) {
const mx = useMatrixClient();
const videoRef = useRef<HTMLVideoElement>(null);
const playbackRef = useRef<WatchPlaybackController>();
const stageRef = useRef<HTMLDivElement>(null);
const hlsRef = useRef<Hls>();
const jellyfinClient = useMemo(createBrowserJellyfinClient, []);
const [watchState, setWatchState] = useState<WatchRoomState>(() => {
try {
return readWatchRoomState(room);
@@ -267,33 +399,54 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
const [mediaError, setMediaError] = useState<string>();
const [sending, setSending] = useState(false);
const [autoplayBlocked, setAutoplayBlocked] = useState(false);
const [jellyfinPlayback, setJellyfinPlayback] = useState<JellyfinPlayback>();
const [jellyfinSubtitleIndex, setJellyfinSubtitleIndex] = useState(-1);
const [jellyfinSubtitleUrl, setJellyfinSubtitleUrl] = useState<string>();
const [jellyfinTrackBusy, setJellyfinTrackBusy] = useState(false);
const session = watchState.snapshot.session;
const source = session.source;
const sourceDescriptor = useMemo(() => {
if (!source) return undefined;
try {
return parseWatchSource(source.url);
} catch {
return undefined;
}
}, [source?.url]);
const embeddedSource =
sourceDescriptor?.kind === 'youtube' ||
sourceDescriptor?.kind === 'twitch-live' ||
sourceDescriptor?.kind === 'twitch-video'
? sourceDescriptor
: undefined;
const playbackUrl =
sourceDescriptor?.kind === 'direct'
? sourceDescriptor.url
: sourceDescriptor?.kind === 'jellyfin'
? jellyfinPlayback?.manifestUrl
: undefined;
const joinedMembers = useMemo(() => room.getJoinedMembers(), [room, watchState]);
const visibleMembers = joinedMembers.slice(0, 4);
const canSeek = Number.isFinite(duration) && duration > 0;
const applyPlaybackState = useCallback(async (snapshot: BotsuWatchSnapshot) => {
const video = videoRef.current;
if (!video || !snapshot.session.source) return;
const knownDuration = Number.isFinite(video.duration) ? video.duration * 1_000 : undefined;
const player = playbackRef.current;
if (!player?.ready() || !snapshot.session.source) return;
const playerDuration = player.duration();
const knownDuration = playerDuration > 0 ? playerDuration * 1_000 : undefined;
const expectedMs = projectBotsuWatchPositionMs(snapshot, Date.now(), knownDuration);
if (
video.readyState >= HTMLMediaElement.HAVE_METADATA &&
Number.isFinite(video.duration) &&
shouldCorrectBotsuWatchDrift(video.currentTime * 1_000, expectedMs)
) {
video.currentTime = expectedMs / 1_000;
if (shouldCorrectBotsuWatchDrift(player.currentTime() * 1_000, expectedMs)) {
player.seek(expectedMs / 1_000);
}
setPosition(expectedMs / 1_000);
setDuration(playerDuration);
if (snapshot.session.paused) {
video.pause();
player.pause();
setAutoplayBlocked(false);
return;
}
try {
await video.play();
await player.play();
setAutoplayBlocked(false);
} catch {
setAutoplayBlocked(true);
@@ -323,26 +476,93 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
};
}, [room]);
useEffect(() => {
setJellyfinPlayback(undefined);
setJellyfinSubtitleIndex(-1);
if (sourceDescriptor?.kind !== 'jellyfin') return undefined;
let active = true;
setMediaError(undefined);
void jellyfinClient
.resolvePlayback(sourceDescriptor.itemId)
.then((nextPlayback) => {
if (active) setJellyfinPlayback(nextPlayback);
})
.catch((nextError) => {
if (active) setMediaError((nextError as Error).message);
});
return () => {
active = false;
};
}, [jellyfinClient, sourceDescriptor]);
useEffect(() => {
setJellyfinSubtitleUrl(undefined);
if (sourceDescriptor?.kind !== 'jellyfin' || !jellyfinPlayback || jellyfinSubtitleIndex < 0) {
return undefined;
}
let active = true;
let objectUrl: string | undefined;
setJellyfinTrackBusy(true);
void jellyfinClient
.loadSubtitle(sourceDescriptor.itemId, jellyfinPlayback.mediaSourceId, jellyfinSubtitleIndex)
.then((subtitle) => {
if (!active) return;
objectUrl = URL.createObjectURL(new Blob([subtitle], { type: 'text/vtt' }));
setJellyfinSubtitleUrl(objectUrl);
})
.catch((nextError) => {
if (active) setMediaError((nextError as Error).message);
})
.finally(() => {
if (active) setJellyfinTrackBusy(false);
});
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [jellyfinClient, jellyfinPlayback, jellyfinSubtitleIndex, sourceDescriptor]);
useEffect(() => {
const video = videoRef.current;
hlsRef.current?.destroy();
hlsRef.current = undefined;
playbackRef.current = undefined;
setQualities([]);
setQualityIndex(-1);
setMediaError(undefined);
setDuration(0);
setPosition(0);
setAutoplayBlocked(false);
if (!video) return undefined;
video.pause();
if (!video || !playbackUrl) return undefined;
const controller: WatchPlaybackController = {
ready: () => video.readyState >= HTMLMediaElement.HAVE_METADATA,
currentTime: () => (Number.isFinite(video.currentTime) ? video.currentTime : 0),
duration: () => (Number.isFinite(video.duration) ? video.duration : 0),
play: () => video.play(),
pause: () => video.pause(),
seek: (seconds) => {
video.currentTime = Math.max(seconds, 0);
},
setVolume: (volume, muted) => {
video.volume = volume;
video.muted = muted;
},
};
playbackRef.current = controller;
controller.pause();
video.removeAttribute('src');
video.load();
if (!source) return undefined;
if (isHlsWatchSource(source.url) && Hls.isSupported()) {
const hls = new Hls({ enableWorker: true });
if (isHlsWatchSource(playbackUrl) && Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
xhrSetup: jellyfinPlayback?.authorization
? (request) => request.setRequestHeader('Authorization', jellyfinPlayback.authorization)
: undefined,
});
hlsRef.current = hls;
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(source.url));
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(playbackUrl));
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setQualities(
hls.levels.map((level, index) => ({
@@ -362,21 +582,29 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
return () => {
hls.destroy();
if (hlsRef.current === hls) hlsRef.current = undefined;
if (playbackRef.current === controller) playbackRef.current = undefined;
};
}
if (isHlsWatchSource(source.url) && !video.canPlayType('application/vnd.apple.mpegurl')) {
if (isHlsWatchSource(playbackUrl) && !video.canPlayType('application/vnd.apple.mpegurl')) {
setMediaError('Ce navigateur ne prend pas en charge ce flux HLS.');
return undefined;
}
video.src = source.url;
video.src = playbackUrl;
video.load();
return () => {
video.pause();
video.removeAttribute('src');
video.load();
if (playbackRef.current === controller) playbackRef.current = undefined;
};
}, [applyPlaybackState, source?.url]);
}, [applyPlaybackState, jellyfinPlayback, playbackUrl]);
useEffect(() => {
if (source && !sourceDescriptor) {
setMediaError('La source partagée est invalide ou contient un secret.');
}
}, [source, sourceDescriptor]);
useEffect(() => {
void applyPlaybackState(watchState.snapshot);
@@ -384,32 +612,29 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
useEffect(() => {
const timer = window.setInterval(() => {
const video = videoRef.current;
if (!video || !session.source) return;
const knownDuration = Number.isFinite(video.duration) ? video.duration * 1_000 : undefined;
const player = playbackRef.current;
if (!player?.ready() || !session.source) return;
const playerDuration = player.duration();
const knownDuration = playerDuration > 0 ? playerDuration * 1_000 : undefined;
const expectedMs = projectBotsuWatchPositionMs(
watchState.snapshot,
Date.now(),
knownDuration
);
setPosition(expectedMs / 1_000);
setDuration(playerDuration);
if (
!session.paused &&
video.readyState >= HTMLMediaElement.HAVE_METADATA &&
Number.isFinite(video.duration) &&
shouldCorrectBotsuWatchDrift(video.currentTime * 1_000, expectedMs)
shouldCorrectBotsuWatchDrift(player.currentTime() * 1_000, expectedMs)
) {
video.currentTime = expectedMs / 1_000;
player.seek(expectedMs / 1_000);
}
}, 1_000);
return () => window.clearInterval(timer);
}, [session.paused, session.source, watchState.snapshot]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.volume = preferences.volume;
video.muted = preferences.muted;
playbackRef.current?.setVolume(preferences.volume, preferences.muted);
try {
window.localStorage.setItem(WATCH_PREFERENCES_KEY, JSON.stringify(preferences));
} catch {
@@ -441,16 +666,17 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
);
const handleTogglePlayback = async () => {
const video = videoRef.current;
if (!video || !source || sending) return;
const player = playbackRef.current;
if (!player?.ready() || !source || sending) return;
const nextPaused = !session.paused;
const positionMs = Number.isFinite(video.duration)
? Math.round(video.currentTime * 1_000)
const currentTime = player.currentTime();
const positionMs = Number.isFinite(currentTime)
? Math.round(currentTime * 1_000)
: session.positionMs;
if (nextPaused) video.pause();
if (nextPaused) player.pause();
else {
try {
await video.play();
await player.play();
setAutoplayBlocked(false);
} catch {
setAutoplayBlocked(true);
@@ -464,9 +690,9 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
};
const commitSeek = async () => {
const video = videoRef.current;
if (!video || seekDraft === undefined || !canSeek || sending) return;
video.currentTime = seekDraft;
const player = playbackRef.current;
if (!player?.ready() || seekDraft === undefined || !canSeek || sending) return;
player.seek(seekDraft);
setPosition(seekDraft);
setSeekDraft(undefined);
try {
@@ -478,9 +704,10 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
const handleSourceSubmit = async (url: string, title: string) => {
try {
const normalizedUrl = normalizeWatchSourceUrl(url.trim());
await sendSession({
version: 1,
source: { url: url.trim(), title: title.trim() },
source: { url: normalizedUrl, title: title.trim() },
paused: true,
positionMs: 0,
});
@@ -516,6 +743,23 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
if (hlsRef.current) hlsRef.current.currentLevel = value;
};
const handleJellyfinAudioChange = async (streamIndex: number) => {
if (sourceDescriptor?.kind !== 'jellyfin') return;
setJellyfinTrackBusy(true);
setMediaError(undefined);
try {
setJellyfinPlayback(
await jellyfinClient.resolvePlayback(sourceDescriptor.itemId, {
audioStreamIndex: streamIndex,
})
);
} catch (nextError) {
setMediaError((nextError as Error).message);
} finally {
setJellyfinTrackBusy(false);
}
};
const handleCaptionsToggle = () => {
const video = videoRef.current;
if (!video) return;
@@ -568,18 +812,48 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
<section aria-label="Écran partagé" className="botsu-watch-screen">
{source ? (
<>
<video
aria-label={source.title}
onDurationChange={handleLoadedMetadata}
onEnded={handleEnded}
onError={() => setMediaError('Le média ne peut pas être lu par ce navigateur.')}
onLoadedMetadata={handleLoadedMetadata}
onTimeUpdate={(event) => {
if (seekDraft === undefined) setPosition(event.currentTarget.currentTime);
}}
playsInline
ref={videoRef}
/>
{embeddedSource ? (
<WatchEmbedPlayer
controllerRef={playbackRef}
muted={preferences.muted}
onEnded={handleEnded}
onError={setMediaError}
onReady={() => void applyPlaybackState(watchState.snapshot)}
source={embeddedSource}
volume={preferences.volume}
/>
) : (
<video
aria-label={source.title}
onDurationChange={handleLoadedMetadata}
onEnded={handleEnded}
onError={() => setMediaError('Le média ne peut pas être lu par ce navigateur.')}
onLoadedMetadata={handleLoadedMetadata}
onTimeUpdate={(event) => {
if (seekDraft === undefined) setPosition(event.currentTarget.currentTime);
}}
playsInline
ref={videoRef}
>
{jellyfinSubtitleUrl && (
<track
default
kind="subtitles"
label={
jellyfinPlayback?.subtitleTracks.find(
(track) => track.index === jellyfinSubtitleIndex
)?.label ?? 'Sous-titres'
}
onLoad={(event) => {
event.currentTarget.track.mode = 'showing';
setCaptions(true);
setCaptionsAvailable(true);
}}
src={jellyfinSubtitleUrl}
/>
)}
</video>
)}
<div className="botsu-watch-screen-title">
<FilmSlateIcon aria-hidden size={18} />
<span>{source.title}</span>
@@ -588,9 +862,9 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
<button
className="botsu-watch-join-playback"
onClick={() => {
const video = videoRef.current;
if (!video) return;
void video
const player = playbackRef.current;
if (!player?.ready()) return;
void player
.play()
.then(() => setAutoplayBlocked(false))
.catch(() => setAutoplayBlocked(true));
@@ -624,151 +898,176 @@ export function BotsuWatchRoomView({ room }: { room: Room }) {
)}
</section>
<section
aria-labelledby="botsu-watch-shared-controls"
className="botsu-watch-controls botsu-watch-controls--shared"
>
<span className="botsu-watch-eyebrow" id="botsu-watch-shared-controls">
POUR TOUT LE MONDE
</span>
<div className="botsu-watch-shared-row">
<button
aria-label={
session.paused ? 'Lire pour tout le monde' : 'Mettre en pause pour tout le monde'
}
className="botsu-watch-play-button"
disabled={!source || sending}
onClick={() => void handleTogglePlayback()}
type="button"
>
{session.paused ? (
<PlayIcon aria-hidden size={24} weight="fill" />
) : (
<PauseIcon aria-hidden size={24} weight="fill" />
)}
</button>
<span className="botsu-watch-time">{formatBotsuWatchTime(displayPosition)}</span>
<input
aria-label="Position commune"
disabled={!source || !canSeek || sending}
max={canSeek ? duration : 1}
min={0}
onBlur={() => void commitSeek()}
onChange={(event) => setSeekDraft(Number(event.currentTarget.value))}
onKeyUp={() => void commitSeek()}
onPointerUp={() => void commitSeek()}
step={0.1}
type="range"
value={canSeek ? Math.min(displayPosition, duration) : 0}
/>
<span className="botsu-watch-time">
{canSeek ? formatBotsuWatchTime(duration) : source ? 'EN DIRECT' : '--:--'}
<div className="botsu-watch-control-dock">
<section
aria-labelledby="botsu-watch-shared-controls"
className="botsu-watch-controls botsu-watch-controls--shared"
>
<span className="botsu-watch-eyebrow" id="botsu-watch-shared-controls">
LECTURE PARTAGÉE
</span>
</div>
</section>
<section
aria-labelledby="botsu-watch-personal-controls"
className="botsu-watch-controls botsu-watch-controls--personal"
>
<span className="botsu-watch-eyebrow" id="botsu-watch-personal-controls">
POUR MOI
</span>
<div className="botsu-watch-personal-row">
<div className="botsu-watch-volume">
<div className="botsu-watch-shared-row">
<button
aria-label={preferences.muted ? 'Réactiver le son' : 'Couper le son'}
className="botsu-watch-icon-button"
onClick={() => setPreferences((current) => ({ ...current, muted: !current.muted }))}
aria-label={
session.paused ? 'Lire pour tout le monde' : 'Mettre en pause pour tout le monde'
}
className="botsu-watch-play-button"
disabled={!source || sending}
onClick={() => void handleTogglePlayback()}
type="button"
>
{preferences.muted || preferences.volume === 0 ? (
<SpeakerSlashIcon aria-hidden size={22} />
{session.paused ? (
<PlayIcon aria-hidden size={24} weight="fill" />
) : (
<SpeakerHighIcon aria-hidden size={22} />
<PauseIcon aria-hidden size={24} weight="fill" />
)}
</button>
<span className="botsu-watch-time">{formatBotsuWatchTime(displayPosition)}</span>
<input
aria-label="Volume personnel"
max={1}
aria-label="Position commune"
disabled={!source || !canSeek || sending}
max={canSeek ? duration : 1}
min={0}
onChange={(event) => {
const volume = Number(event.currentTarget.value);
setPreferences({ volume, muted: volume === 0 ? true : false });
}}
step={0.01}
onBlur={() => void commitSeek()}
onChange={(event) => setSeekDraft(Number(event.currentTarget.value))}
onKeyUp={() => void commitSeek()}
onPointerUp={() => void commitSeek()}
step={0.1}
type="range"
value={preferences.volume}
value={canSeek ? Math.min(displayPosition, duration) : 0}
/>
<output>{Math.round(preferences.volume * 100)}</output>
<span className="botsu-watch-time">
{canSeek ? formatBotsuWatchTime(duration) : source ? 'EN DIRECT' : '--:--'}
</span>
</div>
<label className="botsu-watch-quality">
<span className="botsu-watch-visually-hidden">Qualité personnelle</span>
<select
aria-label="Qualité personnelle"
disabled={qualities.length === 0}
onChange={(event) => handleQualityChange(Number(event.currentTarget.value))}
value={qualityIndex}
>
<option value={-1}>
Auto{qualities[0] ? ` · ${qualityLabel(qualities[qualities.length - 1]!)}` : ''}
</option>
{qualities.map((quality) => (
<option
key={`${quality.index}-${quality.height}-${quality.bitrate}`}
value={quality.index}
>
{qualityLabel(quality)}
</option>
))}
</select>
</label>
<button
aria-label={captions ? 'Masquer les sous-titres' : 'Afficher les sous-titres'}
aria-pressed={captions}
className="botsu-watch-icon-button"
disabled={!captionsAvailable}
onClick={handleCaptionsToggle}
type="button"
>
<ClosedCaptioningIcon aria-hidden size={22} />
</button>
<button
aria-label="Plein écran"
className="botsu-watch-icon-button"
disabled={!source}
onClick={() => void stageRef.current?.requestFullscreen()}
type="button"
>
<CornersOutIcon aria-hidden size={22} />
</button>
</div>
</section>
</main>
</section>
<aside aria-label="Discussion Matrix" className="botsu-watch-chat">
<header className="botsu-watch-chat-header">
<div>
<span className="botsu-watch-eyebrow">MATRIX</span>
<h2>Discussion</h2>
</div>
<div aria-label={`${joinedMembers.length} membres`} className="botsu-watch-avatars">
{visibleMembers.map((member) => (
<span className="botsu-watch-avatar" key={member.userId} title={member.name}>
{memberInitials(member.name)}
</span>
))}
{joinedMembers.length > visibleMembers.length && (
<span className="botsu-watch-avatar">
+{joinedMembers.length - visibleMembers.length}
</span>
)}
</div>
</header>
<div className="botsu-watch-chat-feed">
<RoomView />
<span aria-hidden className="botsu-watch-control-divider" />
<section
aria-labelledby="botsu-watch-personal-controls"
className="botsu-watch-controls botsu-watch-controls--personal"
>
<span className="botsu-watch-eyebrow" id="botsu-watch-personal-controls">
APPAREIL
</span>
<div className="botsu-watch-personal-row">
<div className="botsu-watch-volume">
<button
aria-label={preferences.muted ? 'Réactiver le son' : 'Couper le son'}
className="botsu-watch-icon-button"
onClick={() =>
setPreferences((current) => ({ ...current, muted: !current.muted }))
}
type="button"
>
{preferences.muted || preferences.volume === 0 ? (
<SpeakerSlashIcon aria-hidden size={22} />
) : (
<SpeakerHighIcon aria-hidden size={22} />
)}
</button>
<input
aria-label="Volume personnel"
max={1}
min={0}
onChange={(event) => {
const volume = Number(event.currentTarget.value);
setPreferences({ volume, muted: volume === 0 ? true : false });
}}
step={0.01}
type="range"
value={preferences.volume}
/>
<output>{Math.round(preferences.volume * 100)}</output>
</div>
<label className="botsu-watch-quality">
<span className="botsu-watch-visually-hidden">Qualité personnelle</span>
<select
aria-label="Qualité personnelle"
disabled={qualities.length === 0}
onChange={(event) => handleQualityChange(Number(event.currentTarget.value))}
value={qualityIndex}
>
<option value={-1}>
Auto{qualities[0] ? ` · ${qualityLabel(qualities[qualities.length - 1]!)}` : ''}
</option>
{qualities.map((quality) => (
<option
key={`${quality.index}-${quality.height}-${quality.bitrate}`}
value={quality.index}
>
{qualityLabel(quality)}
</option>
))}
</select>
</label>
{jellyfinPlayback && (
<>
<label className="botsu-watch-quality">
<span className="botsu-watch-visually-hidden">Piste audio personnelle</span>
<select
aria-label="Piste audio personnelle"
disabled={jellyfinTrackBusy || jellyfinPlayback.audioTracks.length < 2}
onChange={(event) =>
void handleJellyfinAudioChange(Number(event.currentTarget.value))
}
value={jellyfinPlayback.selectedAudioStreamIndex ?? -1}
>
{jellyfinPlayback.audioTracks.map((track) => (
<option key={track.index} value={track.index}>
Audio · {track.label}
</option>
))}
</select>
</label>
<label className="botsu-watch-quality">
<span className="botsu-watch-visually-hidden">Sous-titres personnels</span>
<select
aria-label="Sous-titres personnels"
disabled={jellyfinTrackBusy || jellyfinPlayback.subtitleTracks.length === 0}
onChange={(event) => {
const nextIndex = Number(event.currentTarget.value);
setJellyfinSubtitleIndex(nextIndex);
setCaptions(nextIndex >= 0);
}}
value={jellyfinSubtitleIndex}
>
<option value={-1}>Sous-titres · Aucun</option>
{jellyfinPlayback.subtitleTracks.map((track) => (
<option key={track.index} value={track.index}>
Sous-titres · {track.label}
</option>
))}
</select>
</label>
</>
)}
{!jellyfinPlayback && (
<button
aria-label={captions ? 'Masquer les sous-titres' : 'Afficher les sous-titres'}
aria-pressed={captions}
className="botsu-watch-icon-button"
disabled={!captionsAvailable}
onClick={handleCaptionsToggle}
type="button"
>
<ClosedCaptioningIcon aria-hidden size={22} />
</button>
)}
<button
aria-label="Plein écran"
className="botsu-watch-icon-button"
disabled={!source}
onClick={() => void stageRef.current?.requestFullscreen()}
type="button"
>
<CornersOutIcon aria-hidden size={22} />
</button>
</div>
</section>
</div>
</aside>
</main>
{sourceDialog && (
<SourceDialog
@@ -0,0 +1,268 @@
import React, { MutableRefObject, useEffect, useRef } from 'react';
import type { WatchSourceDescriptor } from './providers';
export type WatchPlaybackController = {
ready: () => boolean;
currentTime: () => number;
duration: () => number;
play: () => Promise<void>;
pause: () => void;
seek: (seconds: number) => void;
setVolume: (volume: number, muted: boolean) => void;
};
type EmbedSource = Extract<
WatchSourceDescriptor,
{ kind: 'youtube' | 'twitch-live' | 'twitch-video' }
>;
type EmbedProps = {
source: EmbedSource;
controllerRef: MutableRefObject<WatchPlaybackController | undefined>;
volume: number;
muted: boolean;
onReady: () => void;
onEnded: () => void;
onError: (message: string) => void;
};
type YouTubePlayer = {
destroy: () => void;
getCurrentTime: () => number;
getDuration: () => number;
mute: () => void;
pauseVideo: () => void;
playVideo: () => void;
seekTo: (seconds: number, allowSeekAhead: boolean) => void;
setVolume: (volume: number) => void;
unMute: () => void;
};
type YouTubeApi = {
Player: new (
element: HTMLElement,
options: {
videoId?: string;
playerVars?: Record<string, number | string>;
events: {
onReady: () => void;
onStateChange: (event: { data: number }) => void;
onError: () => void;
};
}
) => YouTubePlayer;
PlayerState: { ENDED: number };
};
type TwitchPlayer = {
addEventListener: (event: string, listener: () => void) => void;
destroy?: () => void;
getCurrentTime: () => number;
getDuration: () => number;
pause: () => void;
play: () => void | Promise<void>;
seek: (seconds: number) => void;
setMuted: (muted: boolean) => void;
setVolume: (volume: number) => void;
};
type TwitchApi = {
Player: (new (elementId: string, options: Record<string, unknown>) => TwitchPlayer) & {
READY: string;
ENDED: string;
};
};
declare global {
interface Window {
YT?: YouTubeApi;
Twitch?: TwitchApi;
onYouTubeIframeAPIReady?: () => void;
}
}
const scripts = new Map<string, Promise<void>>();
const loadScript = (src: string): Promise<void> => {
const existing = scripts.get(src);
if (existing) return existing;
const found = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
found?.remove();
const script = document.createElement('script');
script.src = src;
script.async = true;
script.referrerPolicy = 'strict-origin-when-cross-origin';
const promise = new Promise<void>((resolve, reject) => {
script.addEventListener('load', () => resolve(), { once: true });
script.addEventListener('error', () => reject(new Error('sdk_load_failed')), { once: true });
document.head.append(script);
}).catch((error) => {
scripts.delete(src);
script.remove();
throw error;
});
scripts.set(src, promise);
return promise;
};
const loadYouTube = async (): Promise<YouTubeApi> => {
if (window.YT?.Player) return window.YT;
await new Promise<void>((resolve, reject) => {
const previous = window.onYouTubeIframeAPIReady;
window.onYouTubeIframeAPIReady = () => {
previous?.();
resolve();
};
void loadScript('https://www.youtube.com/iframe_api').catch(reject);
});
if (!window.YT?.Player) throw new Error('youtube_sdk_missing');
return window.YT;
};
const loadTwitch = async (): Promise<TwitchApi> => {
if (window.Twitch?.Player) return window.Twitch;
await loadScript('/vendor/twitch/embed-v1.js');
if (!window.Twitch?.Player) throw new Error('twitch_sdk_missing');
return window.Twitch;
};
const finiteOrZero = (value: number): number => (Number.isFinite(value) && value >= 0 ? value : 0);
export function WatchEmbedPlayer({
source,
controllerRef,
volume,
muted,
onReady,
onEnded,
onError,
}: EmbedProps) {
const hostRef = useRef<HTMLDivElement>(null);
const callbacksRef = useRef({ onReady, onEnded, onError });
callbacksRef.current = { onReady, onEnded, onError };
useEffect(() => {
const controller = controllerRef.current;
controller?.setVolume(volume, muted);
}, [controllerRef, muted, volume]);
useEffect(() => {
const host = hostRef.current;
if (!host) return undefined;
let disposed = false;
let destroy: (() => void) | undefined;
controllerRef.current = undefined;
host.replaceChildren();
const mount = async () => {
if (source.kind === 'youtube') {
const api = await loadYouTube();
if (disposed) return;
let ready = false;
const youtubeParams = new URLSearchParams({
autoplay: '0',
controls: '0',
disablekb: '1',
enablejsapi: '1',
origin: window.location.origin,
playsinline: '1',
rel: '0',
});
const iframe = document.createElement('iframe');
iframe.setAttribute('credentialless', '');
iframe.allow = 'autoplay; encrypted-media; picture-in-picture';
iframe.allowFullscreen = true;
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
iframe.title = 'Lecteur YouTube';
iframe.src = `https://www.youtube.com/embed/${source.videoId}?${youtubeParams.toString()}`;
host.append(iframe);
const player = new api.Player(iframe, {
events: {
onReady: () => {
if (disposed) return;
ready = true;
controllerRef.current = {
ready: () => ready,
currentTime: () => finiteOrZero(player.getCurrentTime()),
duration: () => finiteOrZero(player.getDuration()),
play: async () => player.playVideo(),
pause: () => player.pauseVideo(),
seek: (seconds) => player.seekTo(Math.max(seconds, 0), true),
setVolume: (nextVolume, nextMuted) => {
player.setVolume(Math.round(Math.min(Math.max(nextVolume, 0), 1) * 100));
if (nextMuted) player.mute();
else player.unMute();
},
};
controllerRef.current.setVolume(volume, muted);
callbacksRef.current.onReady();
},
onStateChange: (event) => {
if (event.data === api.PlayerState.ENDED) callbacksRef.current.onEnded();
},
onError: () => callbacksRef.current.onError('La vidéo YouTube ne peut pas être lue.'),
},
});
destroy = () => {
ready = false;
player.destroy();
};
return;
}
const api = await loadTwitch();
if (disposed) return;
let ready = false;
const options: Record<string, unknown> = {
width: '100%',
height: '100%',
parent: [window.location.hostname],
autoplay: false,
muted,
};
if (source.kind === 'twitch-live') options.channel = source.channel;
else options.video = source.videoId;
if (!host.id) host.id = `botsu-twitch-${Math.random().toString(36).slice(2)}`;
const player = new api.Player(host.id, options);
player.addEventListener(api.Player.READY, () => {
if (disposed) return;
ready = true;
controllerRef.current = {
ready: () => ready,
currentTime: () => finiteOrZero(player.getCurrentTime()),
duration: () => finiteOrZero(player.getDuration()),
play: async () => {
await player.play();
},
pause: () => player.pause(),
seek: (seconds) => player.seek(Math.max(seconds, 0)),
setVolume: (nextVolume, nextMuted) => {
player.setVolume(Math.min(Math.max(nextVolume, 0), 1));
player.setMuted(nextMuted);
},
};
controllerRef.current.setVolume(volume, muted);
callbacksRef.current.onReady();
});
player.addEventListener(api.Player.ENDED, () => callbacksRef.current.onEnded());
destroy = () => {
ready = false;
player.destroy?.();
host.replaceChildren();
};
};
void mount().catch(() => {
if (!disposed) callbacksRef.current.onError('Le lecteur externe ne peut pas être chargé.');
});
return () => {
disposed = true;
destroy?.();
if (controllerRef.current) controllerRef.current = undefined;
};
}, [controllerRef, source]);
return <div className="botsu-watch-embed-player" ref={hostRef} />;
}
@@ -0,0 +1,236 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createJellyfinClient, type JellyfinStorage } from './jellyfin.ts';
const createStorage = (): JellyfinStorage => {
const values = new Map<string, string>();
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
removeItem: (key) => values.delete(key),
};
};
test('authenticates passwordless as Pipou and never places the token in shared URLs', async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
const fetchImpl: typeof fetch = async (input, init) => {
const url = String(input);
requests.push({ url, init });
if (url.endsWith('/Users/AuthenticateByName')) {
return new Response(
JSON.stringify({
AccessToken: 'local-browser-token',
User: { Id: 'user-id', Name: 'Pipou' },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
if (url.includes('/Users/user-id/Items?')) {
return new Response(
JSON.stringify({
Items: [
{
Id: 'aea05d9c7069e326d09184d7cb3d2d57',
Name: 'Souvenirs',
SeriesName: 'HPI',
Type: 'Episode',
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
if (url.includes('/PlaybackInfo')) {
return new Response(
JSON.stringify({ MediaSources: [{ Id: 'source-id', SupportsTranscoding: true }] }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
throw new Error(`Unexpected request: ${url}`);
};
const client = createJellyfinClient({
fetchImpl,
storage: createStorage(),
deviceId: 'device-id',
});
const items = await client.search('souvenirs');
assert.deepEqual(items, [
{
id: 'aea05d9c7069e326d09184d7cb3d2d57',
title: 'HPI · Souvenirs',
type: 'Episode',
},
]);
const loginBody = JSON.parse(String(requests[0]?.init?.body));
assert.deepEqual(loginBody, { Username: 'Pipou', Pw: '' });
const playback = await client.resolvePlayback('aea05d9c7069e326d09184d7cb3d2d57');
assert.match(playback.manifestUrl, /\/Videos\/aea05d9c7069e326d09184d7cb3d2d57\/master\.m3u8/);
assert.doesNotMatch(playback.manifestUrl, /token|api_key|local-browser-token/i);
assert.match(playback.authorization, /Token="local-browser-token"/);
});
test('rejects malformed Jellyfin records instead of carrying untrusted fields into the UI', async () => {
const fetchImpl: typeof fetch = async (input) => {
const url = String(input);
if (url.endsWith('/Users/AuthenticateByName')) {
return new Response(
JSON.stringify({ AccessToken: 'token', User: { Id: 'user-id', Name: 'Pipou' } }),
{ status: 200 }
);
}
return new Response(
JSON.stringify({
Items: [
{ Id: '../bad', Name: 'Bad', Type: 'Movie' },
{ Id: 'b3a6bb24a26adc52420a00db501b25e2', Name: 'Valide', Type: 'Movie' },
],
}),
{ status: 200 }
);
};
const client = createJellyfinClient({
fetchImpl,
storage: createStorage(),
deviceId: 'device-id',
});
assert.deepEqual(await client.search(''), [
{ id: 'b3a6bb24a26adc52420a00db501b25e2', title: 'Valide', type: 'Movie' },
]);
});
test('browses every Jellyfin library level instead of exposing one flat recent-item sample', async () => {
const requests: string[] = [];
const fetchImpl: typeof fetch = async (input) => {
const url = String(input);
requests.push(url);
if (url.endsWith('/Users/AuthenticateByName')) {
return new Response(
JSON.stringify({ AccessToken: 'token', User: { Id: 'user-id', Name: 'Pipou' } }),
{ status: 200 }
);
}
if (url.endsWith('/Users/user-id/Views')) {
return new Response(
JSON.stringify({
Items: [
{
Id: 'db4c1708cbb5dd1676284a40f2950aba',
Name: 'Films',
Type: 'CollectionFolder',
},
{
Id: 'd565273fd114d77bdf349a2896867069',
Name: 'Séries',
Type: 'CollectionFolder',
},
],
}),
{ status: 200 }
);
}
if (url.includes('ParentId=d565273fd114d77bdf349a2896867069')) {
return new Response(
JSON.stringify({
Items: [
{
Id: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
Name: 'Breaking Bad',
Type: 'Series',
},
],
}),
{ status: 200 }
);
}
throw new Error(`Unexpected request: ${url}`);
};
const client = createJellyfinClient({
fetchImpl,
storage: createStorage(),
deviceId: 'device-id',
});
assert.deepEqual(await client.getViews(), [
{ id: 'db4c1708cbb5dd1676284a40f2950aba', title: 'Films', type: 'CollectionFolder' },
{ id: 'd565273fd114d77bdf349a2896867069', title: 'Séries', type: 'CollectionFolder' },
]);
assert.deepEqual(await client.browse('d565273fd114d77bdf349a2896867069'), [
{ id: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', title: 'Breaking Bad', type: 'Series' },
]);
assert.match(requests.at(-1) ?? '', /Limit=500/);
});
test('resolves local Jellyfin audio choices and fetches selected subtitles with authorization', async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
const fetchImpl: typeof fetch = async (input, init) => {
const url = String(input);
requests.push({ url, init });
if (url.endsWith('/Users/AuthenticateByName')) {
return new Response(
JSON.stringify({ AccessToken: 'local-token', User: { Id: 'user-id', Name: 'Pipou' } }),
{ status: 200 }
);
}
if (url.includes('/PlaybackInfo')) {
return new Response(
JSON.stringify({
MediaSources: [
{
Id: 'media-source-id',
MediaStreams: [
{
Type: 'Audio',
Index: 4,
Language: 'fra',
DisplayTitle: 'Français',
IsDefault: true,
},
{ Type: 'Audio', Index: 5, Language: 'eng', DisplayTitle: 'English' },
{ Type: 'Subtitle', Index: 2, Language: 'fra', DisplayTitle: 'Français complet' },
],
},
],
}),
{ status: 200 }
);
}
if (url.includes('/Subtitles/2/Stream.vtt')) {
return new Response('WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nBonjour', {
status: 200,
headers: { 'Content-Type': 'text/vtt' },
});
}
throw new Error(`Unexpected request: ${url}`);
};
const client = createJellyfinClient({
fetchImpl,
storage: createStorage(),
deviceId: 'device-id',
});
const itemId = 'aea05d9c7069e326d09184d7cb3d2d57';
const playback = await client.resolvePlayback(itemId, { audioStreamIndex: 5 });
assert.equal(playback.selectedAudioStreamIndex, 5);
assert.deepEqual(
playback.audioTracks.map((track) => track.index),
[4, 5]
);
assert.deepEqual(
playback.subtitleTracks.map((track) => track.index),
[2]
);
assert.match(playback.manifestUrl, /AudioStreamIndex=5/);
assert.equal(
await client.loadSubtitle(itemId, playback.mediaSourceId, 2),
'WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nBonjour'
);
const subtitleRequest = requests.at(-1);
assert.match(subtitleRequest?.url ?? '', /\/Subtitles\/2\/Stream\.vtt$/);
assert.match(
new Headers(subtitleRequest?.init?.headers).get('Authorization') ?? '',
/Token="local-token"/
);
});
+340
View File
@@ -0,0 +1,340 @@
import { isJellyfinItemId, JELLYFIN_WATCH_ORIGIN } from './providers.ts';
const JELLYFIN_SESSION_KEY = 'botsu.watch.jellyfin.session.v1';
const JELLYFIN_DEVICE_KEY = 'botsu.watch.jellyfin.device.v1';
const JELLYFIN_USERNAME = 'Pipou';
const MAXIMUM_QUERY_LENGTH = 100;
export type JellyfinStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
export type JellyfinItem = {
id: string;
title: string;
type: 'CollectionFolder' | 'Folder' | 'Movie' | 'Series' | 'Season' | 'Episode' | 'Video';
};
export type JellyfinMediaTrack = {
index: number;
label: string;
language?: string;
isDefault: boolean;
isForced: boolean;
};
export type JellyfinPlayback = {
manifestUrl: string;
authorization: string;
mediaSourceId: string;
audioTracks: JellyfinMediaTrack[];
subtitleTracks: JellyfinMediaTrack[];
selectedAudioStreamIndex?: number;
};
type JellyfinSession = {
accessToken: string;
userId: string;
userName: string;
};
type JellyfinPlaybackOptions = { audioStreamIndex?: number };
type JellyfinClientOptions = {
fetchImpl?: typeof fetch;
storage: JellyfinStorage;
deviceId?: string;
};
const boundedString = (value: unknown, maximum: number): string | undefined =>
typeof value === 'string' &&
value.length > 0 &&
value.length <= maximum &&
!/[\u0000-\u001f\u007f]/.test(value)
? value
: undefined;
const parseStoredSession = (value: string | null): JellyfinSession | undefined => {
if (!value) return undefined;
try {
const parsed = JSON.parse(value) as unknown;
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined;
const record = parsed as Record<string, unknown>;
const accessToken = boundedString(record.accessToken, 4_096);
const userId = boundedString(record.userId, 128);
const userName = boundedString(record.userName, 128);
if (!accessToken || !userId || userName !== JELLYFIN_USERNAME) return undefined;
return { accessToken, userId, userName };
} catch {
return undefined;
}
};
const createDeviceId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `botsu-watch-${crypto.randomUUID()}`;
}
return `botsu-watch-${Date.now()}-${Math.random().toString(36).slice(2)}`;
};
const ITEM_TYPES = new Set([
'CollectionFolder',
'Folder',
'Movie',
'Series',
'Season',
'Episode',
'Video',
]);
const parseItem = (value: unknown): JellyfinItem | undefined => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
const id = typeof record.Id === 'string' && isJellyfinItemId(record.Id) ? record.Id : undefined;
const name = boundedString(record.Name, 200);
const type = record.Type;
if (!id || !name || typeof type !== 'string' || !ITEM_TYPES.has(type)) return undefined;
const seriesName = boundedString(record.SeriesName, 200);
return {
id,
title: seriesName && seriesName !== name ? `${seriesName} · ${name}` : name,
type: type as JellyfinItem['type'],
};
};
const parseItems = (payload: unknown): JellyfinItem[] => {
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return [];
const items = (payload as Record<string, unknown>).Items;
if (!Array.isArray(items)) return [];
return items.map(parseItem).filter((item): item is JellyfinItem => item !== undefined);
};
const parseMediaTrack = (value: unknown): JellyfinMediaTrack | undefined => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
const index = record.Index;
if (!Number.isInteger(index) || (index as number) < 0 || (index as number) > 10_000) {
return undefined;
}
const language = boundedString(record.Language, 32);
const displayTitle = boundedString(record.DisplayTitle, 240);
return {
index: index as number,
label: displayTitle ?? language ?? `Piste ${(index as number) + 1}`,
...(language ? { language } : {}),
isDefault: record.IsDefault === true,
isForced: record.IsForced === true,
};
};
export const createJellyfinClient = ({
fetchImpl = fetch,
storage,
deviceId: explicitDeviceId,
}: JellyfinClientOptions) => {
let deviceId = explicitDeviceId ?? boundedString(storage.getItem(JELLYFIN_DEVICE_KEY), 200);
if (!deviceId) {
deviceId = createDeviceId();
storage.setItem(JELLYFIN_DEVICE_KEY, deviceId);
}
let session = parseStoredSession(storage.getItem(JELLYFIN_SESSION_KEY));
const authorization = (token?: string): string =>
`MediaBrowser Client="BOTSU Watch", Device="Browser", DeviceId="${deviceId}", Version="1.0"${
token ? `, Token="${token}"` : ''
}`;
const requestJson = async (
path: string,
init: RequestInit = {},
activeSession?: JellyfinSession
): Promise<unknown> => {
const response = await fetchImpl(`${JELLYFIN_WATCH_ORIGIN}${path}`, {
...init,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: authorization(activeSession?.accessToken),
...init.headers,
},
});
if (!response.ok) throw new Error(`Jellyfin a répondu ${response.status}.`);
return response.json();
};
const connect = async (): Promise<JellyfinSession> => {
if (session) return session;
const payload = await requestJson('/Users/AuthenticateByName', {
method: 'POST',
body: JSON.stringify({ Username: JELLYFIN_USERNAME, Pw: '' }),
});
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
throw new Error('Réponse Jellyfin invalide.');
}
const record = payload as Record<string, unknown>;
const user =
typeof record.User === 'object' && record.User !== null && !Array.isArray(record.User)
? (record.User as Record<string, unknown>)
: undefined;
const accessToken = boundedString(record.AccessToken, 4_096);
const userId = boundedString(user?.Id, 128);
const userName = boundedString(user?.Name, 128);
if (!accessToken || !userId || userName !== JELLYFIN_USERNAME) {
throw new Error('Connexion Jellyfin Pipou impossible.');
}
session = { accessToken, userId, userName };
storage.setItem(JELLYFIN_SESSION_KEY, JSON.stringify(session));
return session;
};
const search = async (query: string): Promise<JellyfinItem[]> => {
const current = await connect();
const normalized = query.trim().slice(0, MAXIMUM_QUERY_LENGTH);
const params = new URLSearchParams({
Recursive: 'true',
IncludeItemTypes: 'Movie,Series,Episode,Video',
Fields: 'SeriesName,RunTimeTicks',
SortBy: 'SortName',
SortOrder: 'Ascending',
Limit: '100',
});
if (normalized) params.set('SearchTerm', normalized);
return parseItems(
await requestJson(
`/Users/${encodeURIComponent(current.userId)}/Items?${params.toString()}`,
{},
current
)
);
};
const getViews = async (): Promise<JellyfinItem[]> => {
const current = await connect();
return parseItems(
await requestJson(`/Users/${encodeURIComponent(current.userId)}/Views`, {}, current)
);
};
const browse = async (parentId: string): Promise<JellyfinItem[]> => {
if (!isJellyfinItemId(parentId)) throw new TypeError('Jellyfin parent id is invalid');
const current = await connect();
const params = new URLSearchParams({
ParentId: parentId,
SortBy: 'SortName',
SortOrder: 'Ascending',
Fields: 'SeriesName,IndexNumber,ParentIndexNumber',
Limit: '500',
});
return parseItems(
await requestJson(
`/Users/${encodeURIComponent(current.userId)}/Items?${params.toString()}`,
{},
current
)
);
};
const resolvePlayback = async (
itemId: string,
options: JellyfinPlaybackOptions = {}
): Promise<JellyfinPlayback> => {
if (!isJellyfinItemId(itemId)) throw new TypeError('Jellyfin item id is invalid');
const current = await connect();
const payload = await requestJson(
`/Items/${itemId}/PlaybackInfo?UserId=${encodeURIComponent(current.userId)}`,
{ method: 'POST', body: '{}' },
current
);
const mediaSources =
typeof payload === 'object' && payload !== null && !Array.isArray(payload)
? (payload as Record<string, unknown>).MediaSources
: undefined;
const firstSource = Array.isArray(mediaSources) ? mediaSources[0] : undefined;
const sourceRecord =
typeof firstSource === 'object' && firstSource !== null && !Array.isArray(firstSource)
? (firstSource as Record<string, unknown>)
: undefined;
const mediaSourceId = boundedString(sourceRecord?.Id, 512);
if (!mediaSourceId) throw new Error('Aucune source Jellyfin lisible.');
const mediaStreams = sourceRecord?.MediaStreams;
const streams = Array.isArray(mediaStreams) ? mediaStreams : [];
const parseTracks = (type: 'Audio' | 'Subtitle'): JellyfinMediaTrack[] =>
streams
.filter(
(stream) =>
typeof stream === 'object' &&
stream !== null &&
!Array.isArray(stream) &&
(stream as Record<string, unknown>).Type === type
)
.map(parseMediaTrack)
.filter((track): track is JellyfinMediaTrack => track !== undefined);
const audioTracks = parseTracks('Audio');
const subtitleTracks = parseTracks('Subtitle');
const requestedAudio = options.audioStreamIndex;
if (
requestedAudio !== undefined &&
!audioTracks.some((track) => track.index === requestedAudio)
) {
throw new TypeError('Jellyfin audio stream index is invalid');
}
const selectedAudioStreamIndex =
requestedAudio ??
audioTracks.find((track) => track.isDefault)?.index ??
audioTracks[0]?.index;
const params = new URLSearchParams({
MediaSourceId: mediaSourceId,
VideoCodec: 'h264',
AudioCodec: 'aac',
TranscodingContainer: 'ts',
SegmentContainer: 'ts',
});
if (selectedAudioStreamIndex !== undefined) {
params.set('AudioStreamIndex', String(selectedAudioStreamIndex));
}
return {
manifestUrl: `${JELLYFIN_WATCH_ORIGIN}/Videos/${itemId}/master.m3u8?${params.toString()}`,
authorization: authorization(current.accessToken),
mediaSourceId,
audioTracks,
subtitleTracks,
...(selectedAudioStreamIndex !== undefined ? { selectedAudioStreamIndex } : {}),
};
};
const loadSubtitle = async (
itemId: string,
mediaSourceId: string,
streamIndex: number
): Promise<string> => {
if (!isJellyfinItemId(itemId)) throw new TypeError('Jellyfin item id is invalid');
const safeMediaSourceId = boundedString(mediaSourceId, 512);
if (!safeMediaSourceId || /[/?#\\]/.test(safeMediaSourceId)) {
throw new TypeError('Jellyfin media source id is invalid');
}
if (!Number.isInteger(streamIndex) || streamIndex < 0 || streamIndex > 10_000) {
throw new TypeError('Jellyfin subtitle stream index is invalid');
}
const current = await connect();
const response = await fetchImpl(
`${JELLYFIN_WATCH_ORIGIN}/Videos/${itemId}/${encodeURIComponent(
safeMediaSourceId
)}/Subtitles/${streamIndex}/Stream.vtt`,
{
headers: {
Accept: 'text/vtt',
Authorization: authorization(current.accessToken),
},
}
);
if (!response.ok) throw new Error(`Jellyfin a répondu ${response.status}.`);
const text = await response.text();
if (!text.startsWith('WEBVTT') || text.length > 5_000_000) {
throw new Error('Sous-titres Jellyfin invalides.');
}
return text;
};
return { connect, getViews, browse, search, resolvePlayback, loadSubtitle };
};
export const createBrowserJellyfinClient = () =>
createJellyfinClient({ storage: window.sessionStorage });
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createJellyfinWatchUrl,
normalizeWatchSourceUrl,
parseWatchSource,
type WatchSourceDescriptor,
} from './providers.ts';
const expectSource = (url: string, expected: WatchSourceDescriptor) => {
assert.deepEqual(parseWatchSource(url), expected);
};
test('recognizes bounded YouTube videos across canonical URL forms', () => {
expectSource('https://www.youtube.com/watch?v=dQw4w9WgXcQ', {
kind: 'youtube',
videoId: 'dQw4w9WgXcQ',
});
expectSource('https://youtu.be/dQw4w9WgXcQ?t=10', {
kind: 'youtube',
videoId: 'dQw4w9WgXcQ',
});
expectSource('https://www.youtube.com/live/dQw4w9WgXcQ', {
kind: 'youtube',
videoId: 'dQw4w9WgXcQ',
});
assert.deepEqual(parseWatchSource('https://youtube.example/watch?v=dQw4w9WgXcQ'), {
kind: 'direct',
url: 'https://youtube.example/watch?v=dQw4w9WgXcQ',
});
});
test('recognizes Twitch live channels and VODs without accepting reserved paths', () => {
expectSource('https://www.twitch.tv/chris_live', {
kind: 'twitch-live',
channel: 'chris_live',
});
expectSource('https://twitch.tv/videos/123456789', {
kind: 'twitch-video',
videoId: 'v123456789',
});
assert.deepEqual(parseWatchSource('https://www.twitch.tv/directory'), {
kind: 'direct',
url: 'https://www.twitch.tv/directory',
});
});
test('creates and parses a token-free Jellyfin item reference for the fixed friend server', () => {
const itemId = 'aea05d9c7069e326d09184d7cb3d2d57';
const url = createJellyfinWatchUrl(itemId);
assert.equal(url, `https://jellyfin.chris-pek.fr/Items/${itemId}`);
expectSource(url, { kind: 'jellyfin', itemId });
assert.throws(
() => parseWatchSource(`${url}?api_key=must-never-be-shared`),
/Jellyfin shared URL/
);
assert.throws(() => createJellyfinWatchUrl('../secret'), /item id/);
});
test('keeps ordinary HTTPS media as a direct source', () => {
expectSource('https://media.botsu.net/movie.mp4', {
kind: 'direct',
url: 'https://media.botsu.net/movie.mp4',
});
});
test('canonicalizes provider links before sharing them through Matrix', () => {
assert.equal(
normalizeWatchSourceUrl('https://youtu.be/dQw4w9WgXcQ?t=10&utm_source=secret'),
'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
);
assert.equal(
normalizeWatchSourceUrl('https://www.twitch.tv/CHRIS_LIVE?ref=tracking'),
'https://www.twitch.tv/chris_live'
);
});
+126
View File
@@ -0,0 +1,126 @@
export const JELLYFIN_WATCH_ORIGIN = 'https://jellyfin.chris-pek.fr';
const YOUTUBE_VIDEO_ID = /^[A-Za-z0-9_-]{11}$/;
const TWITCH_CHANNEL = /^[a-z0-9_]{4,25}$/;
const TWITCH_VIDEO_ID = /^\d{1,20}$/;
const JELLYFIN_ITEM_ID = /^[a-f0-9]{32}$/;
const TWITCH_RESERVED_PATHS = new Set([
'directory',
'downloads',
'inventory',
'jobs',
'login',
'p',
'search',
'settings',
'signup',
'subscriptions',
'videos',
'wallet',
]);
export type WatchSourceDescriptor =
| { kind: 'direct'; url: string }
| { kind: 'youtube'; videoId: string }
| { kind: 'twitch-live'; channel: string }
| { kind: 'twitch-video'; videoId: string }
| { kind: 'jellyfin'; itemId: string };
const parseUrl = (value: string): URL => {
let url: URL;
try {
url = new URL(value);
} catch {
throw new TypeError('Watch source URL is invalid');
}
if (url.protocol !== 'https:' || url.username || url.password) {
throw new TypeError('Watch source must be a credential-free HTTPS URL');
}
return url;
};
const parseYouTubeVideoId = (url: URL): string | undefined => {
const host = url.hostname.toLowerCase();
let videoId: string | null | undefined;
if (host === 'youtu.be') {
videoId = url.pathname.split('/').filter(Boolean)[0];
} else if (
host === 'youtube.com' ||
host === 'www.youtube.com' ||
host === 'm.youtube.com' ||
host === 'youtube-nocookie.com' ||
host === 'www.youtube-nocookie.com'
) {
if (url.pathname === '/watch') videoId = url.searchParams.get('v');
else {
const [section, id] = url.pathname.split('/').filter(Boolean);
if (section === 'embed' || section === 'shorts' || section === 'live') videoId = id;
}
if (videoId === undefined || videoId === null || !YOUTUBE_VIDEO_ID.test(videoId)) {
throw new TypeError('Unsupported YouTube video URL');
}
} else {
return undefined;
}
if (!videoId || !YOUTUBE_VIDEO_ID.test(videoId)) {
throw new TypeError('Unsupported YouTube video URL');
}
return videoId;
};
export const isJellyfinItemId = (value: string): boolean => JELLYFIN_ITEM_ID.test(value);
export const createJellyfinWatchUrl = (itemId: string): string => {
if (!isJellyfinItemId(itemId)) throw new TypeError('Jellyfin item id is invalid');
return `${JELLYFIN_WATCH_ORIGIN}/Items/${itemId}`;
};
export const parseWatchSource = (value: string): WatchSourceDescriptor => {
const url = parseUrl(value);
const youtubeVideoId = parseYouTubeVideoId(url);
if (youtubeVideoId) return { kind: 'youtube', videoId: youtubeVideoId };
const host = url.hostname.toLowerCase();
if (host === 'twitch.tv' || host === 'www.twitch.tv' || host === 'm.twitch.tv') {
const segments = url.pathname.split('/').filter(Boolean);
if (segments[0]?.toLowerCase() === 'videos' && segments.length === 2) {
const videoId = segments[1];
if (!videoId || !TWITCH_VIDEO_ID.test(videoId)) {
throw new TypeError('Unsupported Twitch video URL');
}
return { kind: 'twitch-video', videoId: `v${videoId}` };
}
if (segments.length === 1) {
const channel = segments[0]?.toLowerCase();
if (channel && TWITCH_CHANNEL.test(channel) && !TWITCH_RESERVED_PATHS.has(channel)) {
return { kind: 'twitch-live', channel };
}
}
return { kind: 'direct', url: url.href };
}
if (url.origin === JELLYFIN_WATCH_ORIGIN) {
const match = /^\/Items\/([a-f0-9]{32})$/.exec(url.pathname);
if (!match?.[1] || url.search || url.hash) {
throw new TypeError('Jellyfin shared URL must contain only a token-free item id');
}
return { kind: 'jellyfin', itemId: match[1] };
}
return { kind: 'direct', url: url.href };
};
export const normalizeWatchSourceUrl = (value: string): string => {
const source = parseWatchSource(value);
if (source.kind === 'youtube') {
return `https://www.youtube.com/watch?v=${source.videoId}`;
}
if (source.kind === 'twitch-live') {
return `https://www.twitch.tv/${source.channel}`;
}
if (source.kind === 'twitch-video') {
return `https://www.twitch.tv/videos/${source.videoId.slice(1)}`;
}
if (source.kind === 'jellyfin') return createJellyfinWatchUrl(source.itemId);
return source.url;
};
+189 -119
View File
@@ -1,39 +1,42 @@
.botsu-watch-room {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
color: var(--botsu-watch-text);
background: var(--botsu-watch-canvas);
background: var(--botsu-watch-surface);
font-family: var(--font-secondary, Inter, sans-serif);
}
.botsu-watch-stage,
.botsu-watch-chat {
min-width: 0;
min-height: 0;
}
.botsu-watch-stage {
display: flex;
width: 100%;
min-width: 0;
min-height: 0;
padding: clamp(12px, 2.2vw, 28px);
flex-direction: column;
overflow: auto;
background: #050505;
color: #f5f5f5;
scrollbar-width: thin;
gap: clamp(12px, 1.8vw, 20px);
overflow: hidden;
background: var(--botsu-watch-surface);
color: var(--botsu-watch-text);
box-sizing: border-box;
}
.botsu-watch-statusbar {
display: flex;
min-height: 56px;
padding: 8px 16px;
width: min(100%, 1120px);
min-height: 44px;
margin: 0 auto;
padding: 6px 8px 6px 14px;
align-items: center;
gap: 16px;
border-bottom: 1px solid #313131;
gap: 12px;
border: 1px solid var(--botsu-watch-border);
border-radius: var(--botsu-watch-radius);
background: var(--botsu-watch-surface-raised);
box-sizing: border-box;
}
.botsu-watch-sync-status,
@@ -51,34 +54,49 @@
.botsu-watch-sync-status small {
max-width: 11rem;
overflow: hidden;
color: #9d9d9d;
color: var(--botsu-watch-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.botsu-watch-viewers {
color: #bdbdbd;
color: var(--botsu-watch-muted);
}
.botsu-watch-screen {
position: relative;
display: grid;
flex: 1 1 auto;
min-height: 260px;
max-height: calc(100vh - 350px);
width: min(100%, 1120px);
min-height: 0;
margin: 0 auto;
place-items: center;
overflow: hidden;
background: #000;
border-bottom: 1px solid #313131;
border: 1px solid var(--botsu-watch-border);
border-radius: var(--botsu-watch-radius);
box-shadow: 0 16px 48px rgb(0 0 0 / 18%);
}
.botsu-watch-screen video,
.botsu-watch-embed-player,
.botsu-watch-embed-player iframe {
width: 100%;
height: 100%;
border: 0;
}
.botsu-watch-screen video {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.botsu-watch-embed-player {
min-width: 0;
min-height: 0;
background: #000;
}
.botsu-watch-screen-title {
position: absolute;
top: 12px;
@@ -90,6 +108,7 @@
gap: 8px;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 28%);
border-radius: var(--botsu-watch-radius);
background: rgb(0 0 0 / 68%);
backdrop-filter: blur(10px);
color: #f5f5f5;
@@ -106,6 +125,7 @@
align-items: center;
gap: 12px;
border: 1px solid #313131;
border-radius: var(--botsu-watch-radius);
text-align: center;
}
@@ -157,8 +177,28 @@
}
.botsu-watch-controls {
padding: 18px 20px 20px;
border-bottom: 1px solid #313131;
min-width: 0;
padding: 8px 10px;
}
.botsu-watch-control-dock {
display: grid;
grid-template-columns: minmax(0, 1.35fr) 1px minmax(320px, 1fr);
width: min(100%, 980px);
margin: 0 auto;
padding: 8px;
align-items: center;
gap: 8px;
border: 1px solid var(--botsu-watch-border);
border-radius: var(--botsu-watch-radius);
background: var(--botsu-watch-surface-raised);
box-sizing: border-box;
}
.botsu-watch-control-divider {
width: 1px;
height: 32px;
background: var(--botsu-watch-border);
}
.botsu-watch-eyebrow {
@@ -179,23 +219,28 @@
.botsu-watch-shared-row {
gap: 12px;
min-height: 52px;
min-height: 44px;
}
.botsu-watch-personal-row {
flex-wrap: wrap;
gap: 10px;
min-height: 48px;
min-height: 44px;
}
.botsu-watch-personal-row .botsu-watch-quality select {
max-width: 220px;
}
.botsu-watch-volume {
min-width: 190px;
min-width: 130px;
flex: 1 1 260px;
gap: 10px;
}
.botsu-watch-volume output {
width: 2.2rem;
color: #bdbdbd;
color: var(--botsu-watch-muted);
font-variant-numeric: tabular-nums;
text-align: right;
}
@@ -205,7 +250,7 @@
min-width: 0;
height: 3px;
flex: 1 1 auto;
accent-color: #f5f5f5;
accent-color: var(--botsu-watch-accent);
cursor: pointer;
}
@@ -216,7 +261,7 @@
.botsu-watch-time {
min-width: 3.7rem;
color: #d0d0d0;
color: var(--botsu-watch-muted);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
text-align: center;
@@ -227,9 +272,9 @@
.botsu-watch-button,
.botsu-watch-quality select {
min-height: 42px;
border: 1px solid #4a4a4a;
border-radius: 2px;
background: transparent;
border: 1px solid var(--botsu-watch-border);
border-radius: var(--botsu-watch-radius);
background: var(--botsu-watch-surface);
color: inherit;
font: inherit;
}
@@ -245,9 +290,11 @@
}
.botsu-watch-play-button {
width: 54px;
min-height: 48px;
border-color: #f5f5f5;
width: 48px;
min-height: 44px;
border-color: var(--botsu-watch-accent);
background: var(--botsu-watch-accent);
color: var(--botsu-watch-on-accent);
}
.botsu-watch-button {
@@ -262,9 +309,9 @@
}
.botsu-watch-button--primary {
border-color: #f5f5f5;
background: #f5f5f5;
color: #111;
border-color: var(--botsu-watch-accent);
background: var(--botsu-watch-accent);
color: var(--botsu-watch-on-accent);
}
.botsu-watch-button--danger {
@@ -276,12 +323,16 @@
.botsu-watch-icon-button:hover:not(:disabled),
.botsu-watch-button:hover:not(:disabled),
.botsu-watch-quality select:hover:not(:disabled) {
border-color: #f5f5f5;
background: rgb(255 255 255 / 7%);
border-color: var(--botsu-watch-accent);
background: var(--botsu-watch-surface-raised);
}
.botsu-watch-play-button:hover:not(:disabled),
.botsu-watch-button--primary:hover:not(:disabled) {
background: #fff;
border-color: var(--botsu-watch-accent);
background: var(--botsu-watch-accent);
color: var(--botsu-watch-on-accent);
filter: brightness(1.06);
}
.botsu-watch-play-button:disabled,
@@ -298,7 +349,7 @@
.botsu-watch-quality select:focus-visible,
.botsu-watch-source-dialog input:focus-visible,
.botsu-watch-room input[type='range']:focus-visible {
outline: 2px solid #fff;
outline: 2px solid var(--botsu-watch-accent);
outline-offset: 2px;
}
@@ -307,70 +358,12 @@
}
.botsu-watch-quality select {
min-width: 150px;
min-width: 112px;
padding: 0 34px 0 12px;
appearance: auto;
cursor: pointer;
}
.botsu-watch-chat {
display: flex;
flex-direction: column;
border-left: 1px solid var(--botsu-watch-border);
background: var(--botsu-watch-canvas);
}
.botsu-watch-chat-header {
display: flex;
min-height: 100px;
padding: 14px 16px;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--botsu-watch-border);
}
.botsu-watch-chat-header .botsu-watch-eyebrow {
margin-bottom: 4px;
color: var(--botsu-watch-muted);
}
.botsu-watch-chat-header h2 {
font-size: 1.12rem;
line-height: 1.2;
}
.botsu-watch-avatars {
display: flex;
padding-left: 10px;
}
.botsu-watch-avatar {
display: grid;
width: 30px;
height: 30px;
margin-left: -8px;
place-items: center;
border: 1px solid var(--botsu-watch-border);
border-radius: 50%;
background: var(--botsu-watch-surface-raised);
color: var(--botsu-watch-text);
font-size: 0.62rem;
font-weight: 700;
}
.botsu-watch-chat-feed {
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.botsu-watch-chat-feed > * {
width: 100%;
min-width: 0;
}
.botsu-watch-source-overlay {
position: fixed;
z-index: 80;
@@ -384,15 +377,15 @@
.botsu-watch-source-dialog {
display: flex;
width: min(560px, 100%);
width: min(680px, 100%);
max-height: calc(100vh - 48px);
padding: 20px;
flex-direction: column;
gap: 18px;
overflow: auto;
border: 1px solid var(--botsu-watch-border);
border-radius: 2px;
background: var(--botsu-watch-canvas);
border-radius: var(--botsu-watch-radius);
background: var(--botsu-watch-surface-raised);
color: var(--botsu-watch-text);
box-shadow: 0 24px 80px rgb(0 0 0 / 55%);
}
@@ -430,7 +423,7 @@
min-height: 44px;
padding: 0 12px;
border: 1px solid var(--botsu-watch-border);
border-radius: 2px;
border-radius: var(--botsu-watch-radius);
background: var(--botsu-watch-surface);
color: var(--botsu-watch-text);
font: inherit;
@@ -453,6 +446,85 @@
padding-left: 40px;
}
.botsu-watch-jellyfin {
display: flex;
padding: 14px;
flex-direction: column;
gap: 10px;
border: 1px solid var(--botsu-watch-border);
border-radius: var(--botsu-watch-radius);
background: var(--botsu-watch-surface);
}
.botsu-watch-jellyfin-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.botsu-watch-jellyfin-heading .botsu-watch-eyebrow {
margin-bottom: 3px;
color: var(--botsu-watch-muted);
}
.botsu-watch-jellyfin-path {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
color: var(--botsu-watch-muted);
font-size: 0.78rem;
}
.botsu-watch-jellyfin-path button {
min-height: 32px;
padding: 0 10px;
border: 1px solid var(--botsu-watch-border);
border-radius: var(--botsu-watch-radius);
background: transparent;
color: inherit;
cursor: pointer;
}
.botsu-watch-jellyfin-path span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.botsu-watch-jellyfin-results {
display: grid;
max-height: min(360px, 42vh);
gap: 6px;
overflow: auto;
}
.botsu-watch-jellyfin-item {
display: flex;
min-height: 40px;
padding: 8px 10px;
align-items: center;
justify-content: space-between;
gap: 12px;
border: 1px solid var(--botsu-watch-border);
border-radius: var(--botsu-watch-radius);
background: var(--botsu-watch-surface-raised);
color: var(--botsu-watch-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.botsu-watch-jellyfin-item:hover:not(:disabled) {
border-color: var(--botsu-watch-accent);
}
.botsu-watch-jellyfin-item small {
flex: 0 0 auto;
color: var(--botsu-watch-muted);
}
.botsu-watch-source-note,
.botsu-watch-error {
margin: 0;
@@ -497,25 +569,23 @@
}
@media (max-width: 960px) {
.botsu-watch-room {
display: block;
.botsu-watch-stage {
overflow: auto;
}
.botsu-watch-stage {
min-height: auto;
overflow: visible;
}
.botsu-watch-screen {
flex: 0 0 auto;
min-height: 240px;
aspect-ratio: 16 / 9;
}
.botsu-watch-chat {
min-height: 540px;
border-top: 1px solid var(--botsu-watch-border);
border-left: 0;
.botsu-watch-control-dock {
grid-template-columns: minmax(0, 1fr);
}
.botsu-watch-control-divider {
width: 100%;
height: 1px;
}
}
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const watchViewPath = new URL('./BotsuWatchRoomView.tsx', import.meta.url);
const watchCssPath = new URL('./watch-room.css', import.meta.url);
const roomPath = new URL('../../app/features/room/Room.tsx', import.meta.url);
const headerPath = new URL('../../app/features/room/RoomViewHeader.tsx', import.meta.url);
const statePath = new URL('../../app/state/callEmbed.ts', import.meta.url);
const embedPath = new URL('./WatchEmbedPlayer.tsx', import.meta.url);
const jellyfinPath = new URL('./jellyfin.ts', import.meta.url);
const twitchSdkPath = new URL('../../../vendor/twitch/embed-v1.js', import.meta.url);
test('Watch Room follows the Cinny voice-room layout and opens chat in the shared side panel', async () => {
const [watchView, watchCss, room, header, state] = await Promise.all([
readFile(watchViewPath, 'utf8'),
readFile(watchCssPath, 'utf8'),
readFile(roomPath, 'utf8'),
readFile(headerPath, 'utf8'),
readFile(statePath, 'utf8'),
]);
assert.match(watchView, /botsu-watch-control-dock/);
assert.doesNotMatch(watchView, /<RoomView/);
assert.doesNotMatch(watchView, /ChatsCircleIcon/);
assert.match(state, /watchChatAtom = atom<boolean>\(false\)/);
assert.match(room, /watchView && watchChat/);
assert.match(room, /<CallChatView onClose=\{\(\) => setWatchChat\(false\)\}/);
assert.match(header, /function WatchChatButton/);
assert.match(header, /isWatchRoom\(room\) && <WatchChatButton \/>/);
assert.match(header, /Icons\.Message/);
assert.match(watchCss, /background: var\(--botsu-watch-surface\)/);
assert.match(watchCss, /\.botsu-watch-control-dock/);
assert.match(watchCss, /border-radius: var\(--botsu-watch-radius\)/);
assert.doesNotMatch(watchCss, /grid-template-columns: minmax\(0, 1fr\) minmax\(320px, 380px\)/);
});
test('Watch Room wires synchronized YouTube, Twitch, and token-local Jellyfin playback', async () => {
const [watchView, embed, jellyfin, twitchSdk] = await Promise.all([
readFile(watchViewPath, 'utf8'),
readFile(embedPath, 'utf8'),
readFile(jellyfinPath, 'utf8'),
readFile(twitchSdkPath, 'utf8'),
]);
assert.match(watchView, /<WatchEmbedPlayer/);
assert.match(watchView, /Bibliothèque Pipou/);
assert.match(watchView, /createJellyfinWatchUrl/);
assert.match(watchView, /jellyfinClient\.getViews\(\)/);
assert.match(watchView, /jellyfinClient\.browse\(/);
assert.match(watchView, /Piste audio personnelle/);
assert.match(watchView, /Sous-titres personnels/);
assert.match(watchView, /\.loadSubtitle\(/);
assert.match(watchView, /xhrSetup/);
assert.match(embed, /https:\/\/www\.youtube\.com\/iframe_api/);
assert.doesNotMatch(embed, /script\.crossOrigin = 'anonymous'/);
assert.match(embed, /found\?\.remove\(\)/);
assert.match(embed, /scripts\.delete\(src\)/);
assert.match(embed, /iframe\.setAttribute\('credentialless', ''\)/);
assert.match(embed, /\/vendor\/twitch\/embed-v1\.js/);
assert.match(embed, /parent: \[window\.location\.hostname\]/);
assert.match(twitchSdk, /setAttribute\(['"]credentialless['"], ['"]['"]\)/);
assert.match(jellyfin, /Username: JELLYFIN_USERNAME, Pw: ''/);
assert.match(jellyfin, /window\.sessionStorage/);
assert.doesNotMatch(watchView, /api_key=/);
});
+3
View File
@@ -42,8 +42,10 @@ export enum StateEvent {
PowerLevelTags = 'in.cinny.room.power_level_tags',
BotsuWorkspace = 'net.botsu.workspace',
BotsuDocument = 'net.botsu.document',
BotsuDrawing = 'net.botsu.drawing',
BotsuVoxel = 'net.botsu.voxel',
BotsuWatchSession = 'net.botsu.watch.session',
BotsuStoryboardCard = 'net.botsu.storyboard.card',
}
export enum MessageEvent {
@@ -61,6 +63,7 @@ export enum RoomType {
Drawing = 'net.botsu.drawing',
Voxel = 'net.botsu.voxel',
Watch = 'net.botsu.watch',
Storyboard = 'net.botsu.storyboard',
}
export type MSpaceChildContent = {
+3
View File
@@ -9,6 +9,8 @@
"include": [
"src/ext.d.ts",
"src/botsu/documents/*.tsx",
"src/botsu/drawing/*.tsx",
"src/botsu/moodboard/*.tsx",
"src/botsu/profile/*.tsx",
"src/botsu/presence/*.tsx",
"src/botsu/workspace/*.tsx",
@@ -16,6 +18,7 @@
"src/botsu/workspace/*.d.ts",
"src/botsu/shell/BotsuFrame.tsx",
"src/botsu/start/*.tsx",
"src/botsu/storyboard/*.tsx",
"src/botsu/voxel/*.tsx",
"src/botsu/watch/*.tsx"
]
+6
View File
@@ -16,14 +16,20 @@
"src/botsu/documents/model.ts",
"src/botsu/documents/document-share.ts",
"src/botsu/documents/document-sync-bridge.ts",
"src/botsu/drawing/*.ts",
"src/botsu/moodboard/model.ts",
"src/botsu/moodboard/controller.ts",
"src/botsu/profile/*.ts",
"src/botsu/presence/*.ts",
"src/botsu/start/model.ts",
"src/botsu/start/cookie-controller.ts",
"src/botsu/start/pixel-canvas-bridge.ts",
"src/botsu/start/pixel-canvas-publisher.ts",
"src/botsu/storyboard/model.ts",
"src/botsu/voxel/model.ts",
"src/botsu/watch/model.ts",
"src/botsu/watch/providers.ts",
"src/botsu/watch/jellyfin.ts",
"src/botsu/workspace/*.ts"
]
}
+9
View File
@@ -0,0 +1,9 @@
# Twitch embedded player SDK
Source: https://player.twitch.tv/js/embed/v1.js
Vendored for the BOTSU Watch Room because `test.botsu.net` and `client.botsu.net` run with cross-origin isolation. The upstream SDK response does not expose CORS/CORP headers, so loading it directly is blocked by COEP.
Local change: the iframe created by the SDK receives the HTML `credentialless` attribute before its `src` is assigned. This preserves cross-origin isolation while allowing Twitch's official player and command API to run.
When refreshing the upstream file, reapply that single change and rerun `src/botsu/watch/watch-ui.test.ts` plus the live provider probe.
+910
View File
@@ -0,0 +1,910 @@
!(function (e, t) {
'object' == typeof exports && 'object' == typeof module
? (module.exports = t())
: 'function' == typeof define && define.amd
? define([], t)
: 'object' == typeof exports
? (exports.Twitch = t())
: (e.Twitch = t());
})(self, () =>
(() => {
'use strict';
var e = {
454: (e) => {
var t = '%[a-f0-9]{2}',
r = new RegExp(t, 'gi'),
n = new RegExp('(' + t + ')+', 'gi');
function i(e, t) {
try {
return [decodeURIComponent(e.join(''))];
} catch (e) {}
if (1 === e.length) return e;
t = t || 1;
var r = e.slice(0, t),
n = e.slice(t);
return Array.prototype.concat.call([], i(r), i(n));
}
function a(e) {
try {
return decodeURIComponent(e);
} catch (a) {
for (var t = e.match(r) || [], n = 1; n < t.length; n++)
t = (e = i(t, n).join('')).match(r) || [];
return e;
}
}
e.exports = function (e) {
if ('string' != typeof e)
throw new TypeError(
'Expected `encodedURI` to be of type `string`, got `' + typeof e + '`'
);
try {
return (e = e.replace(/\+/g, ' ')), decodeURIComponent(e);
} catch (t) {
return (function (e) {
for (var t = { '%FE%FF': '', '%FF%FE': '' }, r = n.exec(e); r; ) {
try {
t[r[0]] = decodeURIComponent(r[0]);
} catch (e) {
var i = a(r[0]);
i !== r[0] && (t[r[0]] = i);
}
r = n.exec(e);
}
t['%C2'] = '';
for (var s = Object.keys(t), o = 0; o < s.length; o++) {
var l = s[o];
e = e.replace(new RegExp(l, 'g'), t[l]);
}
return e;
})(e);
}
};
},
228: (e) => {
var t = Object.prototype.hasOwnProperty,
r = '~';
function n() {}
function i(e, t, r) {
(this.fn = e), (this.context = t), (this.once = r || !1);
}
function a(e, t, n, a, s) {
if ('function' != typeof n) throw new TypeError('The listener must be a function');
var o = new i(n, a || e, s),
l = r ? r + t : t;
return (
e._events[l]
? e._events[l].fn
? (e._events[l] = [e._events[l], o])
: e._events[l].push(o)
: ((e._events[l] = o), e._eventsCount++),
e
);
}
function s(e, t) {
0 == --e._eventsCount ? (e._events = new n()) : delete e._events[t];
}
function o() {
(this._events = new n()), (this._eventsCount = 0);
}
Object.create && ((n.prototype = Object.create(null)), new n().__proto__ || (r = !1)),
(o.prototype.eventNames = function () {
var e,
n,
i = [];
if (0 === this._eventsCount) return i;
for (n in (e = this._events)) t.call(e, n) && i.push(r ? n.slice(1) : n);
return Object.getOwnPropertySymbols ? i.concat(Object.getOwnPropertySymbols(e)) : i;
}),
(o.prototype.listeners = function (e) {
var t = r ? r + e : e,
n = this._events[t];
if (!n) return [];
if (n.fn) return [n.fn];
for (var i = 0, a = n.length, s = new Array(a); i < a; i++) s[i] = n[i].fn;
return s;
}),
(o.prototype.listenerCount = function (e) {
var t = r ? r + e : e,
n = this._events[t];
return n ? (n.fn ? 1 : n.length) : 0;
}),
(o.prototype.emit = function (e, t, n, i, a, s) {
var o = r ? r + e : e;
if (!this._events[o]) return !1;
var l,
c,
u = this._events[o],
d = arguments.length;
if (u.fn) {
switch ((u.once && this.removeListener(e, u.fn, void 0, !0), d)) {
case 1:
return u.fn.call(u.context), !0;
case 2:
return u.fn.call(u.context, t), !0;
case 3:
return u.fn.call(u.context, t, n), !0;
case 4:
return u.fn.call(u.context, t, n, i), !0;
case 5:
return u.fn.call(u.context, t, n, i, a), !0;
case 6:
return u.fn.call(u.context, t, n, i, a, s), !0;
}
for (c = 1, l = new Array(d - 1); c < d; c++) l[c - 1] = arguments[c];
u.fn.apply(u.context, l);
} else {
var h,
p = u.length;
for (c = 0; c < p; c++)
switch ((u[c].once && this.removeListener(e, u[c].fn, void 0, !0), d)) {
case 1:
u[c].fn.call(u[c].context);
break;
case 2:
u[c].fn.call(u[c].context, t);
break;
case 3:
u[c].fn.call(u[c].context, t, n);
break;
case 4:
u[c].fn.call(u[c].context, t, n, i);
break;
default:
if (!l) for (h = 1, l = new Array(d - 1); h < d; h++) l[h - 1] = arguments[h];
u[c].fn.apply(u[c].context, l);
}
}
return !0;
}),
(o.prototype.on = function (e, t, r) {
return a(this, e, t, r, !1);
}),
(o.prototype.once = function (e, t, r) {
return a(this, e, t, r, !0);
}),
(o.prototype.removeListener = function (e, t, n, i) {
var a = r ? r + e : e;
if (!this._events[a]) return this;
if (!t) return s(this, a), this;
var o = this._events[a];
if (o.fn) o.fn !== t || (i && !o.once) || (n && o.context !== n) || s(this, a);
else {
for (var l = 0, c = [], u = o.length; l < u; l++)
(o[l].fn !== t || (i && !o[l].once) || (n && o[l].context !== n)) && c.push(o[l]);
c.length ? (this._events[a] = 1 === c.length ? c[0] : c) : s(this, a);
}
return this;
}),
(o.prototype.removeAllListeners = function (e) {
var t;
return (
e
? ((t = r ? r + e : e), this._events[t] && s(this, t))
: ((this._events = new n()), (this._eventsCount = 0)),
this
);
}),
(o.prototype.off = o.prototype.removeListener),
(o.prototype.addListener = o.prototype.on),
(o.prefixed = r),
(o.EventEmitter = o),
(e.exports = o);
},
55: (e) => {
e.exports = function (e, t) {
for (var r = {}, n = Object.keys(e), i = Array.isArray(t), a = 0; a < n.length; a++) {
var s = n[a],
o = e[s];
(i ? -1 !== t.indexOf(s) : t(s, o, e)) && (r[s] = o);
}
return r;
};
},
663: (e, t, r) => {
const n = r(280),
i = r(454),
a = r(528),
s = r(55),
o = Symbol('encodeFragmentIdentifier');
function l(e) {
if ('string' != typeof e || 1 !== e.length)
throw new TypeError('arrayFormatSeparator must be single character string');
}
function c(e, t) {
return t.encode ? (t.strict ? n(e) : encodeURIComponent(e)) : e;
}
function u(e, t) {
return t.decode ? i(e) : e;
}
function d(e) {
return Array.isArray(e)
? e.sort()
: 'object' == typeof e
? d(Object.keys(e))
.sort((e, t) => Number(e) - Number(t))
.map((t) => e[t])
: e;
}
function h(e) {
const t = e.indexOf('#');
return -1 !== t && (e = e.slice(0, t)), e;
}
function p(e) {
const t = (e = h(e)).indexOf('?');
return -1 === t ? '' : e.slice(t + 1);
}
function y(e, t) {
return (
t.parseNumbers && !Number.isNaN(Number(e)) && 'string' == typeof e && '' !== e.trim()
? (e = Number(e))
: !t.parseBooleans ||
null === e ||
('true' !== e.toLowerCase() && 'false' !== e.toLowerCase()) ||
(e = 'true' === e.toLowerCase()),
e
);
}
function f(e, t) {
l(
(t = Object.assign(
{
decode: !0,
sort: !0,
arrayFormat: 'none',
arrayFormatSeparator: ',',
parseNumbers: !1,
parseBooleans: !1,
},
t
)).arrayFormatSeparator
);
const r = (function (e) {
let t;
switch (e.arrayFormat) {
case 'index':
return (e, r, n) => {
(t = /\[(\d*)\]$/.exec(e)),
(e = e.replace(/\[\d*\]$/, '')),
t ? (void 0 === n[e] && (n[e] = {}), (n[e][t[1]] = r)) : (n[e] = r);
};
case 'bracket':
return (e, r, n) => {
(t = /(\[\])$/.exec(e)),
(e = e.replace(/\[\]$/, '')),
t
? void 0 !== n[e]
? (n[e] = [].concat(n[e], r))
: (n[e] = [r])
: (n[e] = r);
};
case 'colon-list-separator':
return (e, r, n) => {
(t = /(:list)$/.exec(e)),
(e = e.replace(/:list$/, '')),
t
? void 0 !== n[e]
? (n[e] = [].concat(n[e], r))
: (n[e] = [r])
: (n[e] = r);
};
case 'comma':
case 'separator':
return (t, r, n) => {
const i = 'string' == typeof r && r.includes(e.arrayFormatSeparator),
a = 'string' == typeof r && !i && u(r, e).includes(e.arrayFormatSeparator);
r = a ? u(r, e) : r;
const s =
i || a
? r.split(e.arrayFormatSeparator).map((t) => u(t, e))
: null === r
? r
: u(r, e);
n[t] = s;
};
case 'bracket-separator':
return (t, r, n) => {
const i = /(\[\])$/.test(t);
if (((t = t.replace(/\[\]$/, '')), !i)) return void (n[t] = r ? u(r, e) : r);
const a =
null === r ? [] : r.split(e.arrayFormatSeparator).map((t) => u(t, e));
void 0 !== n[t] ? (n[t] = [].concat(n[t], a)) : (n[t] = a);
};
default:
return (e, t, r) => {
void 0 !== r[e] ? (r[e] = [].concat(r[e], t)) : (r[e] = t);
};
}
})(t),
n = Object.create(null);
if ('string' != typeof e) return n;
if (!(e = e.trim().replace(/^[?#&]/, ''))) return n;
for (const i of e.split('&')) {
if ('' === i) continue;
let [e, s] = a(t.decode ? i.replace(/\+/g, ' ') : i, '=');
(s =
void 0 === s
? null
: ['comma', 'separator', 'bracket-separator'].includes(t.arrayFormat)
? s
: u(s, t)),
r(u(e, t), s, n);
}
for (const e of Object.keys(n)) {
const r = n[e];
if ('object' == typeof r && null !== r)
for (const e of Object.keys(r)) r[e] = y(r[e], t);
else n[e] = y(r, t);
}
return !1 === t.sort
? n
: (!0 === t.sort ? Object.keys(n).sort() : Object.keys(n).sort(t.sort)).reduce(
(e, t) => {
const r = n[t];
return (
Boolean(r) && 'object' == typeof r && !Array.isArray(r)
? (e[t] = d(r))
: (e[t] = r),
e
);
},
Object.create(null)
);
}
(t.extract = p),
(t.parse = f),
(t.stringify = (e, t) => {
if (!e) return '';
l(
(t = Object.assign(
{
encode: !0,
strict: !0,
arrayFormat: 'none',
arrayFormatSeparator: ',',
},
t
)).arrayFormatSeparator
);
const r = (r) => (t.skipNull && null == e[r]) || (t.skipEmptyString && '' === e[r]),
n = (function (e) {
switch (e.arrayFormat) {
case 'index':
return (t) => (r, n) => {
const i = r.length;
return void 0 === n ||
(e.skipNull && null === n) ||
(e.skipEmptyString && '' === n)
? r
: null === n
? [...r, [c(t, e), '[', i, ']'].join('')]
: [...r, [c(t, e), '[', c(i, e), ']=', c(n, e)].join('')];
};
case 'bracket':
return (t) => (r, n) =>
void 0 === n ||
(e.skipNull && null === n) ||
(e.skipEmptyString && '' === n)
? r
: null === n
? [...r, [c(t, e), '[]'].join('')]
: [...r, [c(t, e), '[]=', c(n, e)].join('')];
case 'colon-list-separator':
return (t) => (r, n) =>
void 0 === n ||
(e.skipNull && null === n) ||
(e.skipEmptyString && '' === n)
? r
: null === n
? [...r, [c(t, e), ':list='].join('')]
: [...r, [c(t, e), ':list=', c(n, e)].join('')];
case 'comma':
case 'separator':
case 'bracket-separator': {
const t = 'bracket-separator' === e.arrayFormat ? '[]=' : '=';
return (r) => (n, i) =>
void 0 === i ||
(e.skipNull && null === i) ||
(e.skipEmptyString && '' === i)
? n
: ((i = null === i ? '' : i),
0 === n.length
? [[c(r, e), t, c(i, e)].join('')]
: [[n, c(i, e)].join(e.arrayFormatSeparator)]);
}
default:
return (t) => (r, n) =>
void 0 === n ||
(e.skipNull && null === n) ||
(e.skipEmptyString && '' === n)
? r
: null === n
? [...r, c(t, e)]
: [...r, [c(t, e), '=', c(n, e)].join('')];
}
})(t),
i = {};
for (const t of Object.keys(e)) r(t) || (i[t] = e[t]);
const a = Object.keys(i);
return (
!1 !== t.sort && a.sort(t.sort),
a
.map((r) => {
const i = e[r];
return void 0 === i
? ''
: null === i
? c(r, t)
: Array.isArray(i)
? 0 === i.length && 'bracket-separator' === t.arrayFormat
? c(r, t) + '[]'
: i.reduce(n(r), []).join('&')
: c(r, t) + '=' + c(i, t);
})
.filter((e) => e.length > 0)
.join('&')
);
}),
(t.parseUrl = (e, t) => {
t = Object.assign({ decode: !0 }, t);
const [r, n] = a(e, '#');
return Object.assign(
{ url: r.split('?')[0] || '', query: f(p(e), t) },
t && t.parseFragmentIdentifier && n ? { fragmentIdentifier: u(n, t) } : {}
);
}),
(t.stringifyUrl = (e, r) => {
r = Object.assign({ encode: !0, strict: !0, [o]: !0 }, r);
const n = h(e.url).split('?')[0] || '',
i = t.extract(e.url),
a = t.parse(i, { sort: !1 }),
s = Object.assign(a, e.query);
let l = t.stringify(s, r);
l && (l = `?${l}`);
let u = (function (e) {
let t = '';
const r = e.indexOf('#');
return -1 !== r && (t = e.slice(r)), t;
})(e.url);
return (
e.fragmentIdentifier &&
(u = `#${r[o] ? c(e.fragmentIdentifier, r) : e.fragmentIdentifier}`),
`${n}${l}${u}`
);
}),
(t.pick = (e, r, n) => {
n = Object.assign({ parseFragmentIdentifier: !0, [o]: !1 }, n);
const { url: i, query: a, fragmentIdentifier: l } = t.parseUrl(e, n);
return t.stringifyUrl({ url: i, query: s(a, r), fragmentIdentifier: l }, n);
}),
(t.exclude = (e, r, n) => {
const i = Array.isArray(r) ? (e) => !r.includes(e) : (e, t) => !r(e, t);
return t.pick(e, i, n);
});
},
528: (e) => {
e.exports = (e, t) => {
if ('string' != typeof e || 'string' != typeof t)
throw new TypeError('Expected the arguments to be of type `string`');
if ('' === t) return [e];
const r = e.indexOf(t);
return -1 === r ? [e] : [e.slice(0, r), e.slice(r + t.length)];
};
},
280: (e) => {
e.exports = (e) =>
encodeURIComponent(e).replace(
/[!'()*]/g,
(e) => `%${e.charCodeAt(0).toString(16).toUpperCase()}`
);
},
},
t = {};
function r(n) {
var i = t[n];
if (void 0 !== i) return i.exports;
var a = (t[n] = { exports: {} });
return e[n](a, a.exports, r), a.exports;
}
(r.n = (e) => {
var t = e && e.__esModule ? () => e.default : () => e;
return r.d(t, { a: t }), t;
}),
(r.d = (e, t) => {
for (var n in t)
r.o(t, n) && !r.o(e, n) && Object.defineProperty(e, n, { enumerable: !0, get: t[n] });
}),
(r.o = (e, t) => Object.prototype.hasOwnProperty.call(e, t));
var n = {};
r.d(n, { default: () => P });
var i = r(228),
a = r.n(i);
const s = 'twitch-embed-player-proxy';
var o, l, c, u, d, h;
!(function (e) {
e.UpdateState = 'UPDATE_STATE';
})(o || (o = {})),
(function (e) {
(e.VideoWithChat = 'video-with-chat'), (e.Video = 'video');
})(l || (l = {})),
(function (e) {
(e.AUTHENTICATE = 'authenticate'),
(e.VIDEO_READY = 'video.ready'),
(e.VIDEO_PLAY = 'video.play'),
(e.VIDEO_PAUSE = 'video.pause'),
(e.CAPTIONS = 'captions'),
(e.ENDED = 'ended'),
(e.ERROR = 'error'),
(e.ONLINE = 'online'),
(e.OFFLINE = 'offline'),
(e.PAUSE = 'pause'),
(e.PLAY = 'play'),
(e.PLAYBACK_BLOCKED = 'playbackBlocked'),
(e.PLAYING = 'playing'),
(e.READY = 'ready'),
(e.SEEK = 'seek');
})(c || (c = {})),
(function (e) {
(e[(e.DisableCaptions = 0)] = 'DisableCaptions'),
(e[(e.EnableCaptions = 1)] = 'EnableCaptions'),
(e[(e.Pause = 2)] = 'Pause'),
(e[(e.Play = 3)] = 'Play'),
(e[(e.Seek = 4)] = 'Seek'),
(e[(e.SetChannel = 5)] = 'SetChannel'),
(e[(e.SetChannelID = 6)] = 'SetChannelID'),
(e[(e.SetCollection = 7)] = 'SetCollection'),
(e[(e.SetQuality = 8)] = 'SetQuality'),
(e[(e.SetVideo = 9)] = 'SetVideo'),
(e[(e.SetMuted = 10)] = 'SetMuted'),
(e[(e.SetVolume = 11)] = 'SetVolume');
})(u || (u = {}));
class p {}
!(function (e) {
(e[(e.GeoBlocked = 1)] = 'GeoBlocked'),
(e[(e.UnsupportedDevice = 2)] = 'UnsupportedDevice'),
(e[(e.AnonymizerBlocked = 3)] = 'AnonymizerBlocked'),
(e[(e.CellularNetworkProhibited = 4)] = 'CellularNetworkProhibited'),
(e[(e.UnauthorizationEntitlements = 5)] = 'UnauthorizationEntitlements'),
(e[(e.VodRestricted = 6)] = 'VodRestricted'),
(e[(e.LVSCCUCap = 509)] = 'LVSCCUCap'),
(e[(e.Aborted = 1e3)] = 'Aborted'),
(e[(e.DRMLicenseServerError = 1001)] = 'DRMLicenseServerError'),
(e[(e.Network = 2e3)] = 'Network'),
(e[(e.CCUCapReached = 2001)] = 'CCUCapReached'),
(e[(e.Decode = 3e3)] = 'Decode'),
(e[(e.FormatNotSupported = 4e3)] = 'FormatNotSupported'),
(e[(e.ContentNotAvailable = 5e3)] = 'ContentNotAvailable'),
(e[(e.DRMLicenseNotAvailable = 5001)] = 'DRMLicenseNotAvailable'),
(e[(e.RendererNotAvailable = 6e3)] = 'RendererNotAvailable'),
(e[(e.SafariUnsupportedDevice = 7004)] = 'SafariUnsupportedDevice'),
(e[(e.CDMNotAuthorized = 7005)] = 'CDMNotAuthorized'),
(e[(e.Fatal = 8001)] = 'Fatal'),
(e[(e.FatalAuth = 8003)] = 'FatalAuth'),
(e[(e.Offline = 8002)] = 'Offline'),
(e[(e.WarnAuth = 8004)] = 'WarnAuth');
})(d || (d = {})),
(function (e) {
(e.PREMIUM_CONTENT_RESTRICTED = 'PREMIUM_CONTENT'),
(e.VOD_RESTRICTED = 'vod_manifest_restricted');
})(h || (h = {}));
class y extends Error {
constructor(e) {
super(e),
Object.setPrototypeOf(this, new.target.prototype),
(this.name = 'MissingParameterError');
}
}
class f extends Error {
constructor(e) {
super(`Could not find the provided element: ${e}`),
Object.setPrototypeOf(this, new.target.prototype),
(this.name = 'MissingElementError');
}
}
var m = r(663);
function g(e, t) {
const r = `${`https://${t}.twitch.tv`}?${m.stringify(
Object.assign(Object.assign({}, e), {
parent: b(e.parent),
referrer: document.location.href,
})
)}`,
n = document.createElement('iframe');
n.setAttribute('credentialless', ''),
n.setAttribute('src', r),
n.setAttribute('allowfullscreen', ''),
n.setAttribute('scrolling', 'no'),
n.setAttribute('frameborder', '0'),
n.setAttribute('allow', 'autoplay; fullscreen'),
n.setAttribute('title', 'Twitch');
let i =
'allow-modals allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox';
return (
'function' == typeof document.hasStorageAccess &&
'function' == typeof document.requestStorageAccess &&
(i += ' allow-storage-access-by-user-activation'),
n.setAttribute('sandbox', i),
e.width && n.setAttribute('width', String(e.width)),
e.height && n.setAttribute('height', String(e.height)),
n
);
}
function b(e) {
const t = document.domain;
if (!e) return [t];
const r = Array.isArray(e) ? e : [e];
return t && -1 === r.indexOf(t) ? r.concat(t) : r;
}
var v;
!(function (e) {
(e.IDLE = 'Idle'),
(e.READY = 'Ready'),
(e.BUFFERING = 'Buffering'),
(e.PLAYING = 'Playing'),
(e.ENDED = 'Ended');
})(v || (v = {}));
const E = {
channelName: '',
channelID: '',
collectionID: '',
currentTime: 0,
duration: 0,
muted: !1,
playback: v.IDLE,
quality: '',
qualitiesAvailable: [],
stats: {
videoStats: {
backendVersion: '',
bufferSize: 0,
codecs: '',
displayResolution: '',
fps: 0,
hlsLatencyBroadcaster: 0,
latencyMode: '',
playbackRate: 0,
skippedFrames: 0,
videoResolution: '',
},
},
videoID: '',
volume: 0,
ended: !1,
};
class _ extends p {
constructor() {
super(),
(this._embedWindow = null),
(this._playerState = E),
window.addEventListener('message', this._handleResponses.bind(this));
}
_setWindowRef(e) {
this._embedWindow = e;
}
disableCaptions() {
this._sendCommand(u.DisableCaptions, null);
}
enableCaptions() {
this._sendCommand(u.EnableCaptions, null);
}
pause() {
this._sendCommand(u.Pause, null);
}
play() {
this._sendCommand(u.Play, null);
}
seek(e) {
this._sendCommand(u.Seek, e);
}
setChannel(e) {
this._sendCommand(u.SetChannel, e);
}
setChannelId(e) {
this._sendCommand(u.SetChannelID, e);
}
setCollection(e, t) {
this._sendCommand(u.SetCollection, [e, t]);
}
setQuality(e) {
this._sendCommand(u.SetQuality, e);
}
setVideo(e) {
this._sendCommand(u.SetVideo, e);
}
setMuted(e) {
const t = 'boolean' != typeof e || e;
this._sendCommand(u.SetMuted, t);
}
setVolume(e) {
this._sendCommand(u.SetVolume, e);
}
getMuted() {
return this._playerState.muted;
}
getVolume() {
return this._playerState.volume;
}
getChannel() {
return this._playerState.channelName;
}
getChannelId() {
return this._playerState.channelID;
}
getCollection() {
return this._playerState.collectionID;
}
getCurrentTime() {
return this._playerState.currentTime;
}
getDuration() {
return this._playerState.duration;
}
getEnded() {
return this._playerState.ended;
}
getPlaybackStats() {
return this._playerState.stats.videoStats;
}
getQualities() {
return this._playerState.qualitiesAvailable;
}
getQuality() {
return this._playerState.quality;
}
getVideo() {
return this._playerState.videoID;
}
isPaused() {
return this._playerState.playback === v.IDLE;
}
getPlayerState() {
return this._playerState;
}
_sendCommand(e, t) {
if (!this._embedWindow)
return void console.warn(
'Cannot send player commands before the video player is initialized. Please wait for the VIDEO_READY event before using the player API.'
);
const r = { eventName: e, params: t, namespace: s };
this._embedWindow.postMessage(r, '*');
}
_handleResponses(e) {
if (!this._embedWindow) return;
const { data: t, source: r } = e,
n = r === this._embedWindow,
i = t.namespace === s,
a = t.eventName === o.UpdateState;
n && i && a && (this._playerState = Object.assign({}, this._playerState, t.params));
}
}
class C extends p {
constructor(e, t) {
super(),
(this._options = {}),
(this._target = null),
(this._player = new _()),
(this._eventEmitter = null),
(this._iframe = null),
(this._forwardEmbedEvents = (e) => {
if (!this._iframe) return;
const { data: t, source: r } = e,
n = r === this._iframe.contentWindow,
i = 'twitch-embed' === t.namespace;
n && i && this._eventEmitter.emit(t.eventName, t.params);
}),
(this.disableCaptions = this.getPlayer().disableCaptions.bind(this.getPlayer())),
(this.enableCaptions = this.getPlayer().enableCaptions.bind(this.getPlayer())),
(this.pause = this.getPlayer().pause.bind(this.getPlayer())),
(this.play = this.getPlayer().play.bind(this.getPlayer())),
(this.seek = this.getPlayer().seek.bind(this.getPlayer())),
(this.setChannel = this.getPlayer().setChannel.bind(this.getPlayer())),
(this.setChannelId = this.getPlayer().setChannelId.bind(this.getPlayer())),
(this.setCollection = this.getPlayer().setCollection.bind(this.getPlayer())),
(this.setQuality = this.getPlayer().setQuality.bind(this.getPlayer())),
(this.setVideo = this.getPlayer().setVideo.bind(this.getPlayer())),
(this.setMuted = this.getPlayer().setMuted.bind(this.getPlayer())),
(this.setVolume = this.getPlayer().setVolume.bind(this.getPlayer())),
(this.getMuted = this.getPlayer().getMuted.bind(this.getPlayer())),
(this.getVolume = this.getPlayer().getVolume.bind(this.getPlayer())),
(this.getChannel = this.getPlayer().getChannel.bind(this.getPlayer())),
(this.getChannelId = this.getPlayer().getChannelId.bind(this.getPlayer())),
(this.getCollection = this.getPlayer().getCollection.bind(this.getPlayer())),
(this.getCurrentTime = this.getPlayer().getCurrentTime.bind(this.getPlayer())),
(this.getDuration = this.getPlayer().getDuration.bind(this.getPlayer())),
(this.getEnded = this.getPlayer().getEnded.bind(this.getPlayer())),
(this.getPlaybackStats = this.getPlayer().getPlaybackStats.bind(this.getPlayer())),
(this.getPlayerState = this.getPlayer().getPlayerState.bind(this.getPlayer())),
(this.getQualities = this.getPlayer().getQualities.bind(this.getPlayer())),
(this.getQuality = this.getPlayer().getQuality.bind(this.getPlayer())),
(this.getVideo = this.getPlayer().getVideo.bind(this.getPlayer())),
(this.isPaused = this.getPlayer().isPaused.bind(this.getPlayer())),
(function (e) {
const t = (null == e ? void 0 : e.channelId) && (null == e ? void 0 : e.stream);
if (!e || (!e.channel && !e.video && !e.collection && !t))
throw new y('A channel, video, or collection id must be provided in options');
})(t),
(this._options = t),
(this._target = (function (e) {
if (!e) throw new y('An element of type String or Element is required');
const t = 'string' == typeof e ? document.getElementById(e) : e;
if (!t) throw new f(e);
if (1 !== t.nodeType) throw new y('An element of type String or Element is required');
return t;
})(e)),
(this._eventEmitter = new (a())()),
this.render();
}
addEventListener(e, t) {
this._eventEmitter && this._eventEmitter.on(e, t);
}
removeEventListener(e, t) {
this._eventEmitter && this._eventEmitter.removeListener(e, t);
}
getPlayer() {
return this._player;
}
destroy() {
var e, t;
this._eventEmitter && this._eventEmitter.removeAllListeners(),
window.removeEventListener('message', this._forwardEmbedEvents),
null === (t = null === (e = this._iframe) || void 0 === e ? void 0 : e.parentNode) ||
void 0 === t ||
t.removeChild(this._iframe),
(this._eventEmitter = null),
this._player._setWindowRef(null),
(this._target = null),
(this._iframe = null);
}
buildIframe() {
return g(this._options, 'embed');
}
render() {
if (this._target) {
const e = this.buildIframe();
this._target.appendChild(e),
(this._iframe = e),
window.addEventListener('message', this._forwardEmbedEvents),
this._player._setWindowRef(this._iframe.contentWindow);
}
}
}
(C.AUTHENTICATE = c.AUTHENTICATE),
(C.CAPTIONS = c.CAPTIONS),
(C.ENDED = c.ENDED),
(C.ERROR = c.ERROR),
(C.OFFLINE = c.OFFLINE),
(C.ONLINE = c.ONLINE),
(C.PAUSE = c.PAUSE),
(C.PLAY = c.PLAY),
(C.PLAYBACK_BLOCKED = c.PLAYBACK_BLOCKED),
(C.PLAYING = c.PLAYING),
(C.VIDEO_PAUSE = c.VIDEO_PAUSE),
(C.VIDEO_PLAY = c.VIDEO_PLAY),
(C.VIDEO_READY = c.VIDEO_READY),
(C.READY = c.READY),
(C.SEEK = c.SEEK),
(C.Errors = Object.assign(
{
ABORTED: d.Aborted,
NETWORK: d.Network,
DECODE: d.Decode,
FORMAT_NOT_SUPPORTED: d.FormatNotSupported,
CONTENT_NOT_AVAILABLE: d.ContentNotAvailable,
RENDERER_NOT_AVAILABLE: d.RendererNotAvailable,
},
d
));
const P = {
Embed: C,
Player: class extends C {
constructor(e, t) {
super(e, t);
}
buildIframe() {
return g(this._options, 'player');
}
},
};
return (n = n.default);
})()
);
File diff suppressed because one or more lines are too long
+23 -151
View File
@@ -1,8 +1,7 @@
:root {
--botsu-voxel-sidebar-width: clamp(17rem, 23vw, 22.5rem);
--radius-lg: 4px;
--radius: 3px;
--radius-sm: 2px;
--radius-lg: var(--botsu-voxel-radius, 4px);
--radius: var(--botsu-voxel-radius, 4px);
--radius-sm: max(2px, calc(var(--botsu-voxel-radius, 4px) / 2));
}
html,
@@ -10,11 +9,12 @@ body,
#root {
min-width: 0;
overflow: hidden;
background: #050505;
color: var(--botsu-voxel-text, #e8e8e8);
background: var(--botsu-voxel-bg, #050505);
}
body {
color-scheme: dark;
html[data-botsu-embedded='true']:not([data-botsu-editor-ready='true']) #root {
visibility: hidden;
}
.app-shell,
@@ -26,11 +26,12 @@ body {
--top-panel: 0px;
--bottom-panel: 0px;
--left-panel: 0px;
--right-panel: var(--botsu-voxel-sidebar-width);
--right-panel: 0px;
display: block;
height: 100%;
padding: 3.25rem 0 0;
background: #050505;
padding: 0;
color: var(--botsu-voxel-text, #e8e8e8);
background: var(--botsu-voxel-bg, #050505);
}
.topbar,
@@ -42,20 +43,27 @@ body {
.studio-header,
.viewport-actions,
.viewport-hud,
.fps-prompt {
.fps-prompt,
.compact-toolbar,
.palette-panel,
.vox-bs-toggle,
.vox-bs-panel,
.vox-bs-panel.is-open {
display: none !important;
}
.workspace,
.focus-mode .workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--botsu-voxel-sidebar-width);
grid-template-columns: minmax(0, 1fr);
gap: 0;
height: 100%;
min-height: 0;
background: var(--botsu-voxel-bg, #050505);
}
.studio {
.studio,
.focus-mode .studio {
position: relative;
z-index: auto;
inset: auto;
@@ -67,154 +75,18 @@ body {
min-height: 0;
}
.focus-mode .studio {
position: relative;
z-index: auto;
inset: auto;
grid-template-rows: minmax(0, 1fr);
}
.viewport-shell,
.focus-mode .viewport-shell {
width: 100%;
height: 100%;
border: 0;
border-right: 1px solid #3a3a3a;
border-radius: 0;
background: #050505;
background: var(--botsu-voxel-bg, #050505);
box-shadow: none;
}
.palette-panel,
.right-collapsed .palette-panel {
grid-column: 2;
display: flex !important;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: 0.65rem;
visibility: visible !important;
pointer-events: auto !important;
opacity: 1 !important;
color: #f5f5f5;
background: #111111;
border: 0;
border-radius: 0;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.compact-toolbar,
.app-shell:not(.top-collapsed) .compact-toolbar,
.focus-mode .compact-toolbar,
.focus-mode.preview-top .compact-toolbar {
position: fixed;
z-index: 60;
inset: 0 0 auto;
left: 0;
display: flex;
width: 100%;
max-width: none;
height: 3.25rem;
overflow-x: auto;
padding: 0.5rem 0.75rem;
transform: none;
color: #f5f5f5;
background: #111111;
border: 0;
border-bottom: 1px solid #3a3a3a;
border-radius: 0;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.compact-toolbar button,
.palette-panel button,
.palette-panel input,
.palette-panel select {
border-radius: 2px;
}
.compact-toolbar .tool-button .tool-label {
display: inline;
}
.remote-ghost-name,
.remote-ghost-core,
.remote-ghost-aura {
border-radius: 3px;
}
.vox-bs-toggle {
display: none !important;
}
.vox-bs-panel,
.vox-bs-panel.is-open {
position: fixed;
z-index: 80;
inset: 3.25rem 0 0 auto;
width: var(--botsu-voxel-sidebar-width);
color: #f5f5f5;
background: #111111;
border: 0;
border-left: 1px solid #3a3a3a;
border-radius: 0;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.vox-bs-panel button,
.vox-bs-panel input,
.vox-bs-panel select,
.vox-bs-panel textarea,
.vox-bs-section,
.vox-bs-row {
border-radius: 2px;
}
.vox-bs-panel .vox-bs-head,
.vox-bs-panel .vox-bs-tabs,
.vox-bs-panel .vox-bs-footer {
background: #111111;
}
@media (max-width: 900px) {
:root {
--botsu-voxel-sidebar-width: 17rem;
}
.compact-toolbar .tool-label {
display: none !important;
}
.compact-toolbar .tool-button.is-active .tool-label,
.compact-toolbar .tool-button.is-armed .tool-label {
display: inline !important;
}
}
@media (max-width: 680px) {
.workspace,
.focus-mode .workspace {
grid-template-columns: minmax(0, 1fr);
}
.viewport-shell,
.focus-mode .viewport-shell {
border-right: 0;
}
.palette-panel,
.right-collapsed .palette-panel {
display: none !important;
}
.vox-bs-panel,
.vox-bs-panel.is-open {
width: 100%;
}
border-radius: var(--botsu-voxel-radius, 4px);
}
+22
View File
@@ -10,6 +10,7 @@
// BroadcastChannel bridge, while its standalone websocket transport is
// replaced by an inert API-compatible transport inside the Matrix room.
try {
document.documentElement.setAttribute('data-botsu-embedded', 'true');
window.WebSocket = class BotsuEmbeddedWebSocket extends EventTarget {
static CONNECTING = 0;
static OPEN = 1;
@@ -36,6 +37,27 @@
// Storage can be unavailable in a hardened browser; the editor still
// starts with Voxelier's own safe defaults.
}
window.addEventListener('DOMContentLoaded', () => {
const root = document.getElementById('root');
if (!root) return;
let hostClicked = false;
const enterVoxelierEditor = () => {
if (root.querySelector('.app-shell')) {
document.documentElement.setAttribute('data-botsu-editor-ready', 'true');
observer.disconnect();
return;
}
const hostButton = root.querySelector('.home-primary-action');
if (!hostClicked && hostButton instanceof HTMLButtonElement) {
hostClicked = true;
hostButton.click();
}
};
const observer = new MutationObserver(enterVoxelierEditor);
observer.observe(root, { childList: true, subtree: true });
enterVoxelierEditor();
});
</script>
<script type="module" crossorigin src="/voxelier/assets/index-B558jG0T.js"></script>
<link rel="stylesheet" crossorigin href="/voxelier/assets/index-B72YrQJ1.css" />
+4
View File
@@ -70,6 +70,10 @@ const copyFiles = {
src: 'vendor/voxelier/sanctuary-assets',
dest: '',
},
{
src: 'vendor/twitch/embed-v1.js',
dest: 'vendor/twitch',
},
],
};
+160
View File
@@ -9,11 +9,13 @@
"version": "0.0.0",
"dependencies": {
"@botsu/protocol": "file:../../packages/protocol",
"pg": "^8.22.0",
"ws": "8.21.1",
"yjs": "^13.6.27"
},
"devDependencies": {
"@types/node": "22.19.11",
"@types/pg": "^8.21.0",
"@types/ws": "8.18.1",
"typescript": "5.9.3"
},
@@ -45,6 +47,18 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pg": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -86,6 +100,143 @@
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -128,6 +279,15 @@
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yjs": {
"version": "13.6.27",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",
+2
View File
@@ -10,11 +10,13 @@
},
"dependencies": {
"@botsu/protocol": "file:../../packages/protocol",
"pg": "^8.22.0",
"ws": "8.21.1",
"yjs": "^13.6.27"
},
"devDependencies": {
"@types/node": "22.19.11",
"@types/pg": "^8.21.0",
"@types/ws": "8.18.1",
"typescript": "5.9.3"
},
@@ -0,0 +1,48 @@
-- Run as the Synapse database owner after creating the LOGIN role
-- botsu_presence_membership. Presence receives EXECUTE only; it must not
-- receive SELECT on Synapse event tables.
CREATE OR REPLACE FUNCTION public.botsu_can_access_drawing(
requested_room_id text,
requested_user_id text,
requested_resource_id text
) RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $function$
SELECT EXISTS (
SELECT 1
FROM current_state_events AS membership_state
JOIN current_state_events AS drawing_state
ON drawing_state.room_id = membership_state.room_id
AND drawing_state.type = 'net.botsu.drawing'
AND drawing_state.state_key = ''
JOIN event_json AS drawing_event
ON drawing_event.event_id = drawing_state.event_id
AND drawing_event.room_id = drawing_state.room_id
WHERE membership_state.room_id = requested_room_id
AND membership_state.type = 'm.room.member'
AND membership_state.state_key = requested_user_id
AND membership_state.membership = 'join'
AND drawing_event.json::jsonb #>> '{content,version}' = '1'
AND drawing_event.json::jsonb #>> '{content,resourceId}' = requested_resource_id
AND 1 = (
SELECT count(*)
FROM current_state_events AS unique_drawing_state
JOIN event_json AS unique_drawing_event
ON unique_drawing_event.event_id = unique_drawing_state.event_id
AND unique_drawing_event.room_id = unique_drawing_state.room_id
WHERE unique_drawing_state.type = 'net.botsu.drawing'
AND unique_drawing_state.state_key = ''
AND unique_drawing_event.json::jsonb #>> '{content,version}' = '1'
AND unique_drawing_event.json::jsonb #>> '{content,resourceId}' = requested_resource_id
)
)
$function$;
REVOKE ALL ON FUNCTION public.botsu_can_access_drawing(text, text, text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.botsu_can_access_drawing(text, text, text)
TO botsu_presence_membership;
REVOKE SELECT ON TABLE current_state_events, event_json FROM botsu_presence_membership;
+214
View File
@@ -0,0 +1,214 @@
import assert from "node:assert/strict";
import { copyFile, mkdtemp, readFile, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { promisify } from "node:util";
import { gzip } from "node:zlib";
import type { DrawingElement } from "@botsu/protocol";
import { BotsuDrawingStore } from "./drawing-store.ts";
const gzipAsync = promisify(gzip);
const scopeA = "drawing_123e4567-e89b-42d3-a456-426614174010";
const scopeB = "drawing_123e4567-e89b-42d3-a456-426614174011";
const roomA = "!drawing-a:botsu.net";
const roomB = "!drawing-b:botsu.net";
const mutation = (suffix: string): string => `mut_123e4567-e89b-42d3-a456-${suffix}`;
const stroke: DrawingElement = {
id: "draw_123e4567-e89b-42d3-a456-426614174000",
kind: "stroke",
mode: "paint",
color: "#123456",
size: 6,
points: Array.from({ length: 64 }, (_, index) => ({ x: index * 2, y: index * 2 })),
};
test("persists compact gzip drawing snapshots per room", async () => {
const root = await mkdtemp(join(tmpdir(), "botsu-drawing-"));
const store = new BotsuDrawingStore({ root });
assert.equal((await store.snapshot(scopeA)).revision, 0);
const update = await store.upsert(scopeA, stroke, mutation("426614174020"));
assert.equal(update.revision, 1);
await store.flush(scopeA);
const persistedPath = join(root, `${scopeA}.json.gz`);
const persisted = await readFile(persistedPath);
const plainBytes = Buffer.byteLength(JSON.stringify({ revision: 1, elements: [stroke] }));
assert.ok((await stat(persistedPath)).size < plainBytes);
assert.equal(persisted[0], 0x1f);
assert.equal(persisted[1], 0x8b);
const reloaded = new BotsuDrawingStore({ root });
assert.deepEqual(await reloaded.snapshot(scopeA), {
type: "drawing.snapshot",
protocolVersion: 1,
width: 1024,
height: 1024,
revision: 1,
elements: [stroke],
});
});
test("updates, removes, and isolates semantic elements by room", async () => {
const root = await mkdtemp(join(tmpdir(), "botsu-drawing-"));
const store = new BotsuDrawingStore({ root });
await store.upsert(scopeA, stroke, mutation("426614174021"));
await store.upsert(scopeA, { ...stroke, color: "#abcdef" }, mutation("426614174022"));
await store.upsert(scopeB, { ...stroke, color: "#fedcba" }, mutation("426614174023"));
assert.equal((await store.snapshot(scopeA)).elements[0]?.color, "#abcdef");
assert.equal((await store.snapshot(scopeB)).elements[0]?.color, "#fedcba");
const remove = await store.remove(scopeA, stroke.id, mutation("426614174024"));
assert.equal(remove.revision, 3);
assert.deepEqual((await store.snapshot(scopeA)).elements, []);
});
test("acknowledges a replayed remove without incrementing the revision", async () => {
const root = await mkdtemp(join(tmpdir(), "botsu-drawing-"));
const store = new BotsuDrawingStore({ root });
await store.upsert(scopeA, stroke, mutation("426614174027"));
const first = await store.remove(scopeA, stroke.id, mutation("426614174028"));
const replay = await store.remove(scopeA, stroke.id, mutation("426614174028"));
assert.equal(first.revision, 2);
assert.equal(replay.revision, 2);
assert.equal(replay.mutationId, mutation("426614174028"));
});
test("deduplicates a replayed upsert before and after reload without overwriting newer work", async () => {
const root = await mkdtemp(join(tmpdir(), "botsu-drawing-"));
const store = new BotsuDrawingStore({ root });
const oldMutation = mutation("426614174029");
await store.upsert(scopeA, stroke, oldMutation);
await store.upsert(scopeA, { ...stroke, color: "#abcdef" }, mutation("426614174030"));
const replay = await store.upsert(scopeA, stroke, oldMutation);
assert.equal(replay.revision, 1);
assert.equal((await store.snapshot(scopeA)).elements[0]?.color, "#abcdef");
await store.flush(scopeA);
const reloaded = new BotsuDrawingStore({ root });
const replayAfterReload = await reloaded.upsert(scopeA, stroke, oldMutation);
assert.equal(replayAfterReload.revision, 1);
const snapshot = await reloaded.snapshot(scopeA);
assert.equal(snapshot.revision, 2);
assert.equal(snapshot.elements[0]?.color, "#abcdef");
});
test("binds a persisted capability to one canonical Matrix room", async () => {
const root = await mkdtemp(join(tmpdir(), "botsu-drawing-"));
const store = new BotsuDrawingStore({ root });
await store.upsert(roomA, scopeA, stroke, mutation("426614174032"));
await assert.rejects(() => store.snapshot(roomB, scopeA), /another Matrix room/);
await store.flush(scopeA);
await assert.rejects(
() => new BotsuDrawingStore({ root }).snapshot(roomB, scopeA),
/another Matrix room/
);
});
test("rejects a reused mutation id carrying a different operation", async () => {
const root = await mkdtemp(join(tmpdir(), "botsu-drawing-"));
const store = new BotsuDrawingStore({ root });
const mutationId = mutation("426614174033");
await store.upsert(roomA, scopeA, stroke, mutationId);
await assert.rejects(
() => store.upsert(roomA, scopeA, { ...stroke, color: "#ffffff" }, mutationId),
/does not match/
);
assert.equal((await store.snapshot(roomA, scopeA)).elements[0]?.color, stroke.color);
});
test("rejects a persisted drawing file copied under another capability", async () => {
const root = await mkdtemp(join(tmpdir(), "botsu-drawing-"));
const store = new BotsuDrawingStore({ root });
await store.upsert(roomA, scopeA, stroke, mutation("426614174034"));
await store.flush(scopeA);
await copyFile(join(root, `${scopeA}.json.gz`), join(root, `${scopeB}.json.gz`));
await assert.rejects(
() => new BotsuDrawingStore({ root }).snapshot(roomA, scopeB),
/does not match its capability/
);
});
test("rejects an old replay after its bounded journal entry is evicted", async () => {
const root = await mkdtemp(join(tmpdir(), "botsu-drawing-"));
const store = new BotsuDrawingStore({ root });
const oldMutation = mutation("426614174035");
await store.upsert(roomA, scopeA, stroke, oldMutation, 0);
for (let index = 1; index <= 1_024; index += 1) {
const suffix = String(426614174035 + index).padStart(12, "0");
await store.upsert(
roomA,
scopeA,
{ ...stroke, color: `#${(index % 0xffffff).toString(16).padStart(6, "0")}` },
mutation(suffix),
index
);
}
await assert.rejects(
() => store.upsert(roomA, scopeA, stroke, oldMutation, 0),
/revision conflict/
);
});
test("retries bounded shutdown flush failures until dirty drawings persist", async () => {
const root = await mkdtemp(join(tmpdir(), "botsu-drawing-"));
let attempts = 0;
const store = new BotsuDrawingStore({
root,
gzip: async (input) => {
attempts += 1;
if (attempts < 3) throw new Error("transient gzip failure");
return gzipAsync(input, { level: 9 });
},
});
await store.upsert(scopeA, stroke, mutation("426614174031"));
await store.flushAll();
assert.equal(attempts, 3);
assert.equal((await new BotsuDrawingStore({ root }).snapshot(scopeA)).revision, 1);
});
test("serializes gzip flushes so an older revision cannot overwrite a newer one", async () => {
const root = await mkdtemp(join(tmpdir(), "botsu-drawing-"));
let releaseFirst!: () => void;
const firstRelease = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
let markFirstStarted!: () => void;
const firstStarted = new Promise<void>((resolve) => {
markFirstStarted = resolve;
});
let compressionCount = 0;
const store = new BotsuDrawingStore({
root,
gzip: async (input) => {
compressionCount += 1;
if (compressionCount === 1) {
markFirstStarted();
await firstRelease;
}
return gzipAsync(input, { level: 9 });
},
});
await store.upsert(scopeA, stroke, mutation("426614174025"));
const firstFlush = store.flush(scopeA);
await Promise.race([
firstStarted,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Injected gzip was not called")), 100)
),
]);
await store.upsert(scopeA, { ...stroke, color: "#abcdef" }, mutation("426614174026"));
const secondFlush = store.flush(scopeA);
releaseFirst();
await Promise.all([firstFlush, secondFlush]);
const reloaded = new BotsuDrawingStore({ root });
const snapshot = await reloaded.snapshot(scopeA);
assert.equal(snapshot.revision, 2);
assert.equal(snapshot.elements[0]?.color, "#abcdef");
});
+478
View File
@@ -0,0 +1,478 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { isDeepStrictEqual, promisify } from "node:util";
import { gzip, gunzip } from "node:zlib";
import {
BOTSU_DRAWING_SIZE,
MAXIMUM_DRAWING_ELEMENTS,
MAXIMUM_DRAWING_FILL_ELEMENTS,
parseBotsuDrawingResourceId,
parseMatrixRoomId,
parseDrawingClientMessage,
parseDrawingMutationId,
parseDrawingServerMessage,
type DrawingElement,
type DrawingRemoteRemove,
type DrawingRemoteUpsert,
type DrawingSnapshot,
} from "@botsu/protocol";
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
const MAXIMUM_RECENT_MUTATIONS = 1_024;
type AppliedMutation =
| { mutationId: string; revision: number; type: "upsert"; element: DrawingElement }
| { mutationId: string; revision: number; type: "remove"; id: string }
| { mutationId: string; revision: number; type: "legacy" };
export type DrawingMutationResult<T extends DrawingRemoteUpsert | DrawingRemoteRemove> = {
update: T;
replay: boolean;
} & T;
type Board = {
roomId: string | undefined;
resourceId: string | undefined;
revision: number;
elements: DrawingElement[];
recentMutations: AppliedMutation[];
generation: number;
dirty: boolean;
};
type BotsuDrawingStoreOptions = {
root: string;
saveDelayMs?: number;
gzip?: (input: Buffer) => Promise<Buffer>;
};
const parseScope = (scope: string): string => {
return parseBotsuDrawingResourceId(scope);
};
const mutationResult = <T extends DrawingRemoteUpsert | DrawingRemoteRemove>(
update: T,
replay: boolean
): DrawingMutationResult<T> => ({ ...update, update, replay });
const parseRecentMutations = (value: unknown, maximumRevision: number): AppliedMutation[] => {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > MAXIMUM_RECENT_MUTATIONS) {
throw new TypeError("Invalid drawing mutation history");
}
const ids = new Set<string>();
return value.map((entry) => {
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
throw new TypeError("Invalid drawing mutation history entry");
}
const record = entry as Record<string, unknown>;
const mutationId = parseDrawingMutationId(record.mutationId);
const revision = record.revision;
if (
!Number.isSafeInteger(revision) ||
(revision as number) < 0 ||
(revision as number) > maximumRevision
) {
throw new TypeError("Invalid drawing mutation history revision");
}
if (ids.has(mutationId)) throw new TypeError("Duplicate drawing mutation history entry");
ids.add(mutationId);
if (record.type === undefined) {
return { mutationId, revision: revision as number, type: "legacy" };
}
if (record.type === "upsert") {
const parsed = parseDrawingClientMessage({
type: "drawing.upsert",
protocolVersion: 1,
mutationId,
element: record.element,
});
if (parsed.type !== "drawing.upsert") throw new TypeError("Invalid stored upsert");
return {
mutationId,
revision: revision as number,
type: "upsert",
element: parsed.element,
};
}
if (record.type === "remove") {
const parsed = parseDrawingClientMessage({
type: "drawing.remove",
protocolVersion: 1,
mutationId,
id: record.id,
});
if (parsed.type !== "drawing.remove") throw new TypeError("Invalid stored remove");
return { mutationId, revision: revision as number, type: "remove", id: parsed.id };
}
throw new TypeError("Invalid drawing mutation history operation");
});
};
export class BotsuDrawingStore {
private readonly root: string;
private readonly saveDelayMs: number;
private readonly compress: (input: Buffer) => Promise<Buffer>;
private readonly boards = new Map<string, Board>();
private readonly loading = new Map<string, Promise<Board>>();
private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly flushes = new Map<string, Promise<void>>();
constructor(options: BotsuDrawingStoreOptions) {
this.root = options.root;
this.saveDelayMs = options.saveDelayMs ?? 500;
this.compress = options.gzip ?? ((input) => gzipAsync(input, { level: 9 }));
}
private path(scope: string): string {
return join(this.root, `${parseScope(scope)}.json.gz`);
}
private async load(roomId: string, scope: string): Promise<Board> {
const normalizedRoomId = parseMatrixRoomId(roomId);
const normalized = parseScope(scope);
const cached = this.boards.get(normalized);
if (cached) return cached;
const pending = this.loading.get(normalized);
if (pending) return pending;
const loading = (async (): Promise<Board> => {
try {
const compressed = await readFile(this.path(normalized));
const parsed = JSON.parse((await gunzipAsync(compressed)).toString("utf8")) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new TypeError("Invalid drawing file");
}
const record = parsed as Record<string, unknown>;
const snapshot = parseDrawingServerMessage({
type: "drawing.snapshot",
protocolVersion: 1,
width: BOTSU_DRAWING_SIZE,
height: BOTSU_DRAWING_SIZE,
revision: record.revision,
elements: record.elements,
});
if (snapshot.type !== "drawing.snapshot") throw new TypeError("Invalid drawing file");
return {
roomId: record.roomId === undefined ? undefined : parseMatrixRoomId(record.roomId),
resourceId:
record.resourceId === undefined
? undefined
: parseBotsuDrawingResourceId(record.resourceId),
revision: snapshot.revision,
elements: snapshot.elements,
recentMutations: parseRecentMutations(record.recentMutations, snapshot.revision),
generation: 0,
dirty: false,
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return {
roomId: normalizedRoomId,
resourceId: normalized,
revision: 0,
elements: [],
recentMutations: [],
generation: 0,
dirty: false,
};
}
throw error;
}
})();
this.loading.set(normalized, loading);
try {
const board = await loading;
this.boards.set(normalized, board);
return board;
} finally {
this.loading.delete(normalized);
}
}
private async board(roomId: string, scope: string): Promise<Board> {
const normalizedRoomId = parseMatrixRoomId(roomId);
const normalizedScope = parseScope(scope);
const board = await this.load(normalizedRoomId, normalizedScope);
let migrated = false;
if (board.roomId === undefined) {
board.roomId = normalizedRoomId;
migrated = true;
} else if (board.roomId !== normalizedRoomId) {
throw new Error("Drawing resource is bound to another Matrix room");
}
if (board.resourceId === undefined) {
board.resourceId = normalizedScope;
migrated = true;
} else if (board.resourceId !== normalizedScope) {
throw new Error("Drawing file resource does not match its capability");
}
if (migrated) {
board.generation += 1;
board.dirty = true;
await this.flush(normalizedScope);
}
return board;
}
private schedule(scope: string): void {
const normalized = parseScope(scope);
if (this.timers.has(normalized)) return;
const timer = setTimeout(() => {
this.timers.delete(normalized);
void this.flush(normalized).catch(() => this.schedule(normalized));
}, this.saveDelayMs);
timer.unref();
this.timers.set(normalized, timer);
}
async snapshot(roomIdOrScope: string, optionalScope?: string): Promise<DrawingSnapshot> {
const roomId = optionalScope === undefined ? "!legacy-drawing:botsu.net" : roomIdOrScope;
const scope = optionalScope ?? roomIdOrScope;
const board = await this.board(roomId, scope);
return {
type: "drawing.snapshot",
protocolVersion: 1,
width: BOTSU_DRAWING_SIZE,
height: BOTSU_DRAWING_SIZE,
revision: board.revision,
elements: board.elements.map((element) => structuredClone(element)),
};
}
private appliedMutation(board: Board, mutationId: string): AppliedMutation | undefined {
return board.recentMutations.find((entry) => entry.mutationId === mutationId);
}
private rememberMutation(board: Board, mutation: AppliedMutation): void {
board.recentMutations.push(mutation);
if (board.recentMutations.length > MAXIMUM_RECENT_MUTATIONS) board.recentMutations.shift();
board.generation += 1;
}
async upsert(
roomIdOrScope: string,
scopeOrInput: string | DrawingElement,
inputOrMutationId: DrawingElement | string,
optionalMutationId?: string,
expectedRevision?: number,
guard: () => boolean | Promise<boolean> = () => true
): Promise<DrawingMutationResult<DrawingRemoteUpsert>> {
const legacy = optionalMutationId === undefined;
const roomId = legacy ? "!legacy-drawing:botsu.net" : roomIdOrScope;
const scope = legacy ? roomIdOrScope : (scopeOrInput as string);
const input = (legacy ? scopeOrInput : inputOrMutationId) as DrawingElement;
const mutationId = (legacy ? inputOrMutationId : optionalMutationId) as string;
const parsed = parseDrawingClientMessage({
type: "drawing.upsert",
protocolVersion: 1,
mutationId,
...(expectedRevision === undefined ? {} : { expectedRevision }),
element: input,
});
if (parsed.type !== "drawing.upsert") throw new TypeError("Invalid drawing upsert");
const board = await this.board(roomId, scope);
if (!(await guard())) throw new Error("Drawing subscription changed before mutation");
const applied = this.appliedMutation(board, parsed.mutationId);
if (applied !== undefined) {
if (applied.type !== "upsert" || !isDeepStrictEqual(applied.element, parsed.element)) {
throw new Error("Drawing mutation replay does not match its original operation");
}
return mutationResult(
{
type: "drawing.upsert",
protocolVersion: 1,
revision: applied.revision,
mutationId: applied.mutationId,
element: structuredClone(applied.element),
},
true
);
}
if (parsed.expectedRevision !== undefined && parsed.expectedRevision !== board.revision) {
throw new Error("Drawing revision conflict");
}
const index = board.elements.findIndex((element) => element.id === parsed.element.id);
const previous = index >= 0 ? board.elements[index] : undefined;
if (index < 0) {
if (board.elements.length >= MAXIMUM_DRAWING_ELEMENTS) throw new Error("Drawing is full");
board.elements.push(parsed.element);
} else {
board.elements[index] = parsed.element;
}
if (
board.elements.filter((element) => element.kind === "fill").length >
MAXIMUM_DRAWING_FILL_ELEMENTS
) {
if (index < 0) board.elements.pop();
else if (previous) board.elements[index] = previous;
throw new Error("Drawing fill limit reached");
}
board.revision += 1;
this.rememberMutation(board, {
mutationId: parsed.mutationId,
revision: board.revision,
type: "upsert",
element: structuredClone(parsed.element),
});
board.dirty = true;
this.schedule(scope);
return mutationResult(
{
type: "drawing.upsert",
protocolVersion: 1,
revision: board.revision,
mutationId: parsed.mutationId,
element: structuredClone(parsed.element),
},
false
);
}
async remove(
roomIdOrScope: string,
scopeOrInputId: string,
inputIdOrMutationId: string,
optionalMutationId?: string,
expectedRevision?: number,
guard: () => boolean | Promise<boolean> = () => true
): Promise<DrawingMutationResult<DrawingRemoteRemove>> {
const legacy = optionalMutationId === undefined;
const roomId = legacy ? "!legacy-drawing:botsu.net" : roomIdOrScope;
const scope = legacy ? roomIdOrScope : scopeOrInputId;
const inputId = legacy ? scopeOrInputId : inputIdOrMutationId;
const mutationId = legacy ? inputIdOrMutationId : optionalMutationId;
const parsed = parseDrawingClientMessage({
type: "drawing.remove",
protocolVersion: 1,
mutationId,
...(expectedRevision === undefined ? {} : { expectedRevision }),
id: inputId,
});
if (parsed.type !== "drawing.remove") throw new TypeError("Invalid drawing remove");
const board = await this.board(roomId, scope);
if (!(await guard())) throw new Error("Drawing subscription changed before mutation");
const applied = this.appliedMutation(board, parsed.mutationId);
if (applied !== undefined) {
if (applied.type !== "remove" || applied.id !== parsed.id) {
throw new Error("Drawing mutation replay does not match its original operation");
}
return mutationResult(
{
type: "drawing.remove",
protocolVersion: 1,
revision: applied.revision,
mutationId: applied.mutationId,
id: applied.id,
},
true
);
}
if (parsed.expectedRevision !== undefined && parsed.expectedRevision !== board.revision) {
throw new Error("Drawing revision conflict");
}
const next = board.elements.filter((element) => element.id !== parsed.id);
if (next.length === board.elements.length) {
this.rememberMutation(board, {
mutationId: parsed.mutationId,
revision: board.revision,
type: "remove",
id: parsed.id,
});
board.dirty = true;
this.schedule(scope);
return mutationResult(
{
type: "drawing.remove",
protocolVersion: 1,
revision: board.revision,
mutationId: parsed.mutationId,
id: parsed.id,
},
false
);
}
board.elements = next;
board.revision += 1;
this.rememberMutation(board, {
mutationId: parsed.mutationId,
revision: board.revision,
type: "remove",
id: parsed.id,
});
board.dirty = true;
this.schedule(scope);
return mutationResult(
{
type: "drawing.remove",
protocolVersion: 1,
revision: board.revision,
mutationId: parsed.mutationId,
id: parsed.id,
},
false
);
}
private async flushOnce(normalized: string): Promise<void> {
const board = this.boards.get(normalized);
if (!board) return;
if (!board.dirty) return;
const persistedRevision = board.revision;
const persistedElements = structuredClone(board.elements);
const persistedMutations = structuredClone(board.recentMutations);
const persistedGeneration = board.generation;
await mkdir(this.root, { recursive: true });
const path = this.path(normalized);
const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
const compressed = await this.compress(
Buffer.from(
JSON.stringify({
roomId: board.roomId,
resourceId: board.resourceId,
revision: persistedRevision,
elements: persistedElements,
recentMutations: persistedMutations,
})
)
);
await writeFile(temporaryPath, compressed, { mode: 0o600 });
await rename(temporaryPath, path);
if (board.generation === persistedGeneration) board.dirty = false;
else this.schedule(normalized);
}
async flush(scope: string): Promise<void> {
const normalized = parseScope(scope);
const timer = this.timers.get(normalized);
if (timer) clearTimeout(timer);
this.timers.delete(normalized);
const previous = this.flushes.get(normalized) ?? Promise.resolve();
const operation = previous.catch(() => undefined).then(() => this.flushOnce(normalized));
this.flushes.set(normalized, operation);
try {
await operation;
} finally {
if (this.flushes.get(normalized) === operation) this.flushes.delete(normalized);
}
}
async flushAll(maximumAttempts = 3): Promise<void> {
let lastError: unknown;
for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
try {
await Promise.all([...this.boards.keys()].map((scope) => this.flush(scope)));
return;
} catch (error) {
lastError = error;
if (attempt < maximumAttempts) {
await new Promise<void>((resolve) => setTimeout(resolve, attempt * 25));
}
}
}
throw lastError;
}
}
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { createMatrixMembershipAuthorizer } from "./matrix-membership.ts";
const roomId = "!zlpDmLJtzRUYjIctHZ:botsu.net";
const userId = "@alice:botsu.net";
const resourceId = "drawing_123e4567-e89b-42d3-a456-426614174030";
test("authorizes only a current joined Matrix member through a parameterized read-only query", async () => {
const calls: Array<{ text: string; values: unknown[] }> = [];
let membership = "join";
const authorizer = createMatrixMembershipAuthorizer({
pool: {
query: async (text, values) => {
calls.push({ text, values });
return { rows: [{ allowed: membership === "join" }] };
},
end: async () => undefined,
},
});
assert.equal(await authorizer.canAccessDrawing(roomId, userId, resourceId), true);
membership = "leave";
assert.equal(await authorizer.canAccessDrawing(roomId, userId, resourceId), false);
membership = "";
assert.equal(await authorizer.canAccessDrawing(roomId, userId, resourceId), false);
assert.equal(calls.length, 3);
assert.deepEqual(calls[0]?.values, [roomId, userId, resourceId]);
assert.match(calls[0]?.text ?? "", /botsu_can_access_drawing/);
});
test("rejects malformed identifiers before querying Matrix storage", async () => {
let queried = false;
const authorizer = createMatrixMembershipAuthorizer({
pool: {
query: async () => {
queried = true;
return { rows: [] };
},
end: async () => undefined,
},
});
await assert.rejects(() =>
authorizer.canAccessDrawing("!room:botsu.net/path", userId, resourceId)
);
await assert.rejects(() =>
authorizer.canAccessDrawing(roomId, "alice:botsu.net", resourceId)
);
await assert.rejects(() => authorizer.canAccessDrawing(roomId, userId, "drawing_invalid"));
assert.equal(queried, false);
});
test("ships a least-privilege SQL policy for the Matrix drawing authorization function", async () => {
const sql = await readFile(
new URL("../sql/matrix-drawing-membership.sql", import.meta.url),
"utf8"
);
assert.match(sql, /SECURITY DEFINER/);
assert.match(sql, /SELECT count\(\*\)/);
assert.match(sql, /unique_drawing_state/);
assert.match(sql, /GRANT EXECUTE ON FUNCTION public\.botsu_can_access_drawing/);
assert.match(sql, /REVOKE SELECT ON TABLE current_state_events, event_json/);
assert.doesNotMatch(sql, /GRANT SELECT ON TABLE/);
});
@@ -0,0 +1,59 @@
import {
isValidMatrixUserId,
parseBotsuDrawingResourceId,
parseMatrixRoomId,
} from "@botsu/protocol";
import { Pool } from "pg";
type MembershipPool = {
query: (text: string, values: unknown[]) => Promise<{ rows: Array<{ allowed?: unknown }> }>;
end: () => Promise<void>;
};
type MatrixMembershipAuthorizerOptions = {
databaseUrl?: string;
pool?: MembershipPool;
};
export type MatrixMembershipAuthorizer = {
canAccessDrawing: (roomId: string, userId: string, resourceId: string) => Promise<boolean>;
close: () => Promise<void>;
};
const JOINED_MEMBERSHIP_QUERY = `
SELECT botsu_can_access_drawing($1, $2, $3) AS allowed
`;
export const createMatrixMembershipAuthorizer = (
options: MatrixMembershipAuthorizerOptions = {}
): MatrixMembershipAuthorizer => {
const databaseUrl = options.databaseUrl ?? process.env.MATRIX_MEMBERSHIP_DATABASE_URL;
if (!options.pool && !databaseUrl) {
throw new Error("Matrix membership database URL must be configured");
}
const pool: MembershipPool =
options.pool ??
new Pool({
connectionString: databaseUrl,
max: 2,
connectionTimeoutMillis: 3_000,
idleTimeoutMillis: 10_000,
statement_timeout: 3_000,
application_name: "botsu-presence-membership",
});
return {
async canAccessDrawing(roomId, userId, resourceId) {
const normalizedRoomId = parseMatrixRoomId(roomId);
if (!isValidMatrixUserId(userId)) throw new TypeError("Matrix user id is invalid");
const normalizedResourceId = parseBotsuDrawingResourceId(resourceId);
const result = await pool.query(JOINED_MEMBERSHIP_QUERY, [
normalizedRoomId,
userId,
normalizedResourceId,
]);
return result.rows.length === 1 && result.rows[0]?.allowed === true;
},
close: () => pool.end(),
};
};
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { BotsuMoodboardStore } from "./moodboard-store.ts";
import { createPresenceServer } from "./server.ts";
const TEST_ORIGIN = "https://test.botsu.net";
const AUTH_HEADERS = {
origin: TEST_ORIGIN,
authorization: "Bearer test-token-long-enough",
"content-type": "application/json",
};
const openService = async (context: { after: (callback: () => void | Promise<void>) => void }) => {
const root = await mkdtemp(join(tmpdir(), "botsu-moodboard-http-"));
const store = new BotsuMoodboardStore({ root, now: () => 1_725_000_000_000, createId: () => "mood_12345678" });
const service = createPresenceServer({
allowedOrigin: TEST_ORIGIN,
verifyOpenId: async () => ({ userId: "@alice:botsu.net", displayName: "Alice" }),
moodboardStore: store,
searchMoodboardImages: async () => ({
results: [{ title: "Béton", url: "https://example.org", img_src: "https://img.example.org/beton.jpg" }],
}),
getPinterestPins: async () => ({
source: "https://www.pinterest.com/pinterest/",
pins: [{ id: "1234", title: "Pin", imageUrl: "https://i.pinimg.com/564x/a.jpg" }],
}),
});
service.httpServer.listen(0, "127.0.0.1");
await once(service.httpServer, "listening");
context.after(() => service.close());
const address = service.httpServer.address();
assert.ok(address && typeof address === "object");
return `http://127.0.0.1:${address.port}`;
};
test("creates and lists authenticated shared Moodboard cards", async (context) => {
const base = await openService(context);
const created = await fetch(`${base}/presence/moodboard/cards`, {
method: "POST",
headers: AUTH_HEADERS,
body: JSON.stringify({ kind: "text", title: "Ambiance", text: "Calme et minéral" }),
});
assert.equal(created.status, 201);
assert.equal((await created.json() as { card: { creatorName: string } }).card.creatorName, "Alice");
const listed = await fetch(`${base}/presence/moodboard/cards?limit=24`, { headers: AUTH_HEADERS });
assert.equal(listed.status, 200);
assert.equal((await listed.json() as { cards: unknown[] }).cards.length, 1);
});
test("proxies member searches to private SearXNG without exposing it to the browser", async (context) => {
const base = await openService(context);
const response = await fetch(`${base}/presence/moodboard/search?q=beton`, { headers: AUTH_HEADERS });
assert.equal(response.status, 200);
const payload = await response.json() as { results: Array<{ title: string }> };
assert.equal(payload.results[0]?.title, "Béton");
});
test("rejects unauthenticated Moodboard access", async (context) => {
const base = await openService(context);
const response = await fetch(`${base}/presence/moodboard/cards`, { headers: { origin: TEST_ORIGIN } });
assert.equal(response.status, 401);
});
test("loads a public Pinterest source through the authenticated Moodboard proxy", async (context) => {
const base = await openService(context);
const source = encodeURIComponent("https://www.pinterest.com/pinterest/");
const response = await fetch(`${base}/presence/moodboard/pinterest?url=${source}`, { headers: AUTH_HEADERS });
assert.equal(response.status, 200);
const payload = await response.json() as { pins: Array<{ id: string }> };
assert.equal(payload.pins[0]?.id, "1234");
});
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fetchPinterestPins, parsePinterestSourceUrl } from "./moodboard-pinterest.ts";
test("accepts only public Pinterest profile and board URLs", () => {
assert.deepEqual(parsePinterestSourceUrl("https://www.pinterest.com/pinterest/"), {
kind: "user",
username: "pinterest",
});
assert.deepEqual(parsePinterestSourceUrl("https://pinterest.com/pinterest/official-news/"), {
kind: "board",
username: "pinterest",
board: "official-news",
});
assert.throws(() => parsePinterestSourceUrl("https://evil.example/pinterest/"), /Pinterest/i);
assert.throws(() => parsePinterestSourceUrl("https://www.pinterest.com/pin/123/"), /Pinterest/i);
});
test("loads public Pinterest pidgets and projects only bounded Pin fields", async () => {
let requested = "";
const result = await fetchPinterestPins("https://www.pinterest.com/pinterest/official-news/", {
fetchImpl: async (input) => {
requested = String(input);
return new Response(JSON.stringify({
status: "success",
data: {
pins: [{
id: "424605071136961057",
description: "Architecture &amp; idées &#8217;",
images: {
"236x": { width: 236, height: 354, url: "https://i.pinimg.com/236x/a.jpg" },
"564x": { width: 564, height: 846, url: "https://i.pinimg.com/564x/a.jpg" },
},
unwanted: "ignored",
}],
},
}), { status: 200, headers: { "content-type": "application/json" } });
},
});
assert.equal(requested, "https://widgets.pinterest.com/v3/pidgets/boards/pinterest/official-news/pins/");
assert.deepEqual(result, {
source: "https://www.pinterest.com/pinterest/official-news/",
pins: [{
id: "424605071136961057",
title: "Architecture & idées ",
sourceUrl: "https://www.pinterest.com/pin/424605071136961057/",
imageUrl: "https://i.pinimg.com/564x/a.jpg",
thumbnailUrl: "https://i.pinimg.com/236x/a.jpg",
width: 564,
height: 846,
}],
});
});
test("rejects non-JSON and oversized Pinterest responses", async () => {
await assert.rejects(
() => fetchPinterestPins("https://www.pinterest.com/pinterest/", {
fetchImpl: async () => new Response("html", { status: 200, headers: { "content-type": "text/html" } }),
}),
/Pinterest/i
);
await assert.rejects(
() => fetchPinterestPins("https://www.pinterest.com/pinterest/", {
fetchImpl: async () => new Response("x", { status: 200, headers: { "content-type": "application/json", "content-length": "3000001" } }),
}),
/Pinterest/i
);
});
@@ -0,0 +1,169 @@
export type PinterestSource =
| { kind: "user"; username: string }
| { kind: "board"; username: string; board: string };
export type PinterestPin = {
id: string;
title: string;
sourceUrl: string;
imageUrl: string;
thumbnailUrl: string;
width: number;
height: number;
};
export type PinterestPinsResult = { source: string; pins: PinterestPin[] };
type FetchPinterestOptions = { fetchImpl?: typeof fetch };
const MAXIMUM_PINTEREST_BYTES = 3_000_000;
const RESERVED_PATHS = new Set(["pin", "search", "ideas", "today", "business", "settings"]);
const SLUG = /^[a-z0-9][a-z0-9_-]{0,79}$/i;
const decodeEntities = (value: string): string =>
value
.replace(/&#(x?)([0-9a-f]+);/gi, (_match, hexadecimal: string, digits: string) => {
const codePoint = Number.parseInt(digits, hexadecimal ? 16 : 10);
return Number.isSafeInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff
? String.fromCodePoint(codePoint)
: '';
})
.replace(/&amp;/g, "&")
.replace(/&#39;|&apos;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
export const parsePinterestSourceUrl = (value: string): PinterestSource => {
let url: URL;
try {
url = new URL(value.trim());
} catch {
throw new TypeError("URL Pinterest invalide");
}
if (
url.protocol !== "https:" ||
(url.hostname !== "www.pinterest.com" && url.hostname !== "pinterest.com") ||
url.username ||
url.password ||
url.search ||
url.hash
) {
throw new TypeError("URL Pinterest invalide");
}
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length < 1 || parts.length > 2 || parts.some((part) => !SLUG.test(part))) {
throw new TypeError("URL Pinterest invalide");
}
const username = parts[0]!;
if (RESERVED_PATHS.has(username.toLocaleLowerCase("en-US"))) {
throw new TypeError("URL Pinterest invalide");
}
if (parts.length === 1) return { kind: "user", username };
return { kind: "board", username, board: parts[1]! };
};
const canonicalSourceUrl = (source: PinterestSource): string =>
source.kind === "user"
? `https://www.pinterest.com/${source.username}/`
: `https://www.pinterest.com/${source.username}/${source.board}/`;
const upstreamUrl = (source: PinterestSource): string =>
source.kind === "user"
? `https://widgets.pinterest.com/v3/pidgets/users/${encodeURIComponent(source.username)}/pins/`
: `https://widgets.pinterest.com/v3/pidgets/boards/${encodeURIComponent(source.username)}/${encodeURIComponent(source.board)}/pins/`;
const readBoundedJson = async (response: Response): Promise<unknown> => {
const contentType = response.headers.get("content-type")?.toLocaleLowerCase("en-US") ?? "";
const contentLength = Number.parseInt(response.headers.get("content-length") ?? "0", 10);
if (!response.ok || !contentType.includes("application/json") || contentLength > MAXIMUM_PINTEREST_BYTES) {
await response.body?.cancel().catch(() => undefined);
throw new Error("Pinterest indisponible");
}
if (!response.body) throw new Error("Pinterest indisponible");
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
if (received > MAXIMUM_PINTEREST_BYTES) throw new Error("Pinterest réponse trop grande");
chunks.push(value);
}
} catch (error) {
await reader.cancel().catch(() => undefined);
throw error;
}
const bytes = new Uint8Array(received);
let offset = 0;
chunks.forEach((chunk) => {
bytes.set(chunk, offset);
offset += chunk.byteLength;
});
try {
return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
} catch {
throw new Error("Pinterest réponse invalide");
}
};
const safeImage = (value: unknown): { url: string; width: number; height: number } | undefined => {
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
const record = value as Record<string, unknown>;
if (typeof record.url !== "string" || typeof record.width !== "number" || typeof record.height !== "number") return undefined;
let url: URL;
try {
url = new URL(record.url);
} catch {
return undefined;
}
if (url.protocol !== "https:" || url.hostname !== "i.pinimg.com") return undefined;
if (!Number.isFinite(record.width) || !Number.isFinite(record.height) || record.width < 1 || record.height < 1) return undefined;
return { url: url.toString(), width: Math.round(record.width), height: Math.round(record.height) };
};
export const fetchPinterestPins = async (
sourceUrl: string,
options: FetchPinterestOptions = {}
): Promise<PinterestPinsResult> => {
const source = parsePinterestSourceUrl(sourceUrl);
const response = await (options.fetchImpl ?? fetch)(upstreamUrl(source), {
headers: { accept: "application/json", "user-agent": "BOTSU Moodboard/1.0" },
signal: AbortSignal.timeout(8_000),
});
const payload = await readBoundedJson(response);
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) throw new Error("Pinterest réponse invalide");
const record = payload as Record<string, unknown>;
const data = record.data;
if (record.status !== "success" || typeof data !== "object" || data === null || Array.isArray(data)) {
throw new Error("Pinterest source introuvable");
}
const rawPins = (data as { pins?: unknown }).pins;
if (!Array.isArray(rawPins)) throw new Error("Pinterest réponse invalide");
const pins: PinterestPin[] = [];
rawPins.slice(0, 50).forEach((raw) => {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return;
const pin = raw as Record<string, unknown>;
if (typeof pin.id !== "string" || !/^[0-9]{4,32}$/.test(pin.id)) return;
const images = typeof pin.images === "object" && pin.images !== null && !Array.isArray(pin.images)
? pin.images as Record<string, unknown>
: undefined;
const full = safeImage(images?.["564x"] ?? images?.["736x"] ?? images?.["236x"]);
const thumbnail = safeImage(images?.["236x"] ?? images?.["237x"] ?? images?.["564x"]);
if (!full || !thumbnail) return;
const description = typeof pin.description === "string" ? decodeEntities(pin.description).trim().replace(/\s+/g, " ") : "";
const title = (description || `Pin ${pin.id}`).slice(0, 240);
pins.push({
id: pin.id,
title,
sourceUrl: `https://www.pinterest.com/pin/${pin.id}/`,
imageUrl: full.url,
thumbnailUrl: thumbnail.url,
width: full.width,
height: full.height,
});
});
return { source: canonicalSourceUrl(source), pins };
};
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { searchSearxngImages } from "./moodboard-search.ts";
test("queries the private SearXNG image category through the configured internal endpoint", async () => {
let requested = "";
const result = (await searchSearxngImages("architecture brutaliste", {
baseUrl: "http://searxng:8080",
fetchImpl: async (input) => {
requested = String(input);
return new Response(JSON.stringify({ results: [{ title: "A", img_src: "https://img/a.jpg" }] }), {
status: 200,
headers: { "content-type": "application/json" },
});
},
})) as { results: unknown[] };
const url = new URL(requested);
assert.equal(url.origin, "http://searxng:8080");
assert.equal(url.pathname, "/search");
assert.equal(url.searchParams.get("q"), "architecture brutaliste");
assert.equal(url.searchParams.get("categories"), "images");
assert.equal(url.searchParams.get("format"), "json");
assert.equal(result.results.length, 1);
});
test("bounds blank and excessively long searches", async () => {
await assert.rejects(() => searchSearxngImages(" ", { baseUrl: "http://searxng:8080" }), /recherche/i);
await assert.rejects(
() => searchSearxngImages("x".repeat(201), { baseUrl: "http://searxng:8080" }),
/recherche/i
);
});
+29
View File
@@ -0,0 +1,29 @@
export type MoodboardSearchOptions = {
baseUrl: string;
fetchImpl?: typeof fetch;
};
export const searchSearxngImages = async (
query: string,
options: MoodboardSearchOptions
): Promise<unknown> => {
const normalized = query.trim().replace(/\s+/g, " ");
if (!normalized || normalized.length > 200) throw new TypeError("Recherche invalide");
const base = new URL(options.baseUrl);
if (base.protocol !== "http:" && base.protocol !== "https:") {
throw new TypeError("Endpoint de recherche invalide");
}
const target = new URL("search", base.href.endsWith("/") ? base : `${base.href}/`);
target.searchParams.set("q", normalized);
target.searchParams.set("categories", "images");
target.searchParams.set("format", "json");
target.searchParams.set("language", "fr-FR");
target.searchParams.set("safesearch", "1");
const response = await (options.fetchImpl ?? fetch)(target, {
headers: { accept: "application/json" },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`SearXNG ${response.status}`);
return response.json() as Promise<unknown>;
};
@@ -0,0 +1,81 @@
import assert from "node:assert/strict";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { BotsuMoodboardStore } from "./moodboard-store.ts";
const createStore = async () => {
const root = await mkdtemp(join(tmpdir(), "botsu-moodboard-"));
let now = 1_725_000_000_000;
let sequence = 0;
return {
store: new BotsuMoodboardStore({
root,
now: () => now,
createId: () => `mood_${String(++sequence).padStart(8, "0")}`,
}),
tick: () => {
now += 1_000;
},
};
};
test("creates shared cards and lists newest first with cursor pagination", async () => {
const { store, tick } = await createStore();
await store.create({
creatorId: "@alice:botsu.net",
creatorName: "Alice",
draft: { kind: "text", title: "Béton", text: "brutalisme calme" },
});
tick();
await store.create({
creatorId: "@bob:botsu.net",
creatorName: "Bob",
draft: { kind: "color", title: "Violet", color: "#7C5CFF" },
});
const first = await store.list({ limit: 1 });
assert.equal(first.cards[0]?.title, "Violet");
assert.equal(first.nextCursor, "1");
const second = await store.list({ limit: 1, cursor: first.nextCursor });
assert.equal(second.cards[0]?.title, "Béton");
assert.equal(second.nextCursor, undefined);
});
test("filters the shared board accent-insensitively", async () => {
const { store } = await createStore();
await store.create({
creatorId: "@alice:botsu.net",
creatorName: "Alice",
draft: { kind: "text", title: "Béton", text: "brutalisme calme" },
});
await store.create({
creatorId: "@bob:botsu.net",
creatorName: "Bob",
draft: { kind: "link", title: "Typographie", url: "https://example.org/fonts" },
});
const page = await store.list({ limit: 20, query: "beton" });
assert.deepEqual(page.cards.map((card) => card.title), ["Béton"]);
});
test("rejects oversized embedded files before writing", async () => {
const { store } = await createStore();
await assert.rejects(
() =>
store.create({
creatorId: "@alice:botsu.net",
creatorName: "Alice",
draft: {
kind: "file",
title: "Archive",
fileName: "archive.bin",
mimeType: "application/octet-stream",
dataUrl: `data:application/octet-stream;base64,${"A".repeat(2_800_001)}`,
},
}),
/fichier/i
);
});
+165
View File
@@ -0,0 +1,165 @@
import { randomUUID } from "node:crypto";
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
export type MoodboardDraft =
| { kind: "image"; title: string; url: string; sourceUrl?: string }
| { kind: "color"; title: string; color: string }
| { kind: "text"; title: string; text: string }
| { kind: "file"; title: string; fileName: string; mimeType: string; dataUrl: string }
| { kind: "link"; title: string; url: string };
export type MoodboardCard = MoodboardDraft & {
version: 1;
id: string;
creatorId: string;
creatorName: string;
createdAt: number;
};
export type BotsuMoodboardStoreOptions = {
root: string;
now?: () => number;
createId?: () => string;
};
type CreateInput = { creatorId: string; creatorName: string; draft: MoodboardDraft };
type ListInput = { limit: number; cursor?: string; query?: string };
type ListResult = { cards: MoodboardCard[]; nextCursor?: string };
const cleanText = (value: unknown, label: string, max: number): string => {
if (typeof value !== "string") throw new TypeError(`${label} invalide`);
const result = value.trim().replace(/\s+/g, " ");
if (!result || result.length > max) throw new TypeError(`${label} invalide`);
return result;
};
const cleanUrl = (value: unknown, label: string): string => {
const url = new URL(cleanText(value, label, 2048));
if (url.protocol !== "http:" && url.protocol !== "https:") throw new TypeError(`${label} invalide`);
return url.toString();
};
const cleanDataUrl = (value: unknown): string => {
if (typeof value !== "string" || value.length > 2_800_000) throw new TypeError("Fichier invalide");
if (!/^data:[a-z0-9.+-]+\/[a-z0-9.+-]+;base64,[a-z0-9+/]+={0,2}$/i.test(value)) {
throw new TypeError("Fichier invalide");
}
return value;
};
const parseDraft = (input: MoodboardDraft): MoodboardDraft => {
const title = cleanText(input.title, "Titre", 160);
if (input.kind === "image") {
const base = { kind: "image" as const, title, url: cleanUrl(input.url, "Lien image") };
return input.sourceUrl ? { ...base, sourceUrl: cleanUrl(input.sourceUrl, "Lien source") } : base;
}
if (input.kind === "color") {
if (!/^#[0-9a-f]{6}$/i.test(input.color)) throw new TypeError("Couleur invalide");
return { kind: "color", title, color: input.color.toUpperCase() };
}
if (input.kind === "text") return { kind: "text", title, text: cleanText(input.text, "Texte", 5000) };
if (input.kind === "link") return { kind: "link", title, url: cleanUrl(input.url, "Lien") };
if (input.kind === "file") {
return {
kind: "file",
title,
fileName: cleanText(input.fileName, "Nom du fichier", 180),
mimeType: cleanText(input.mimeType, "Type du fichier", 120),
dataUrl: cleanDataUrl(input.dataUrl),
};
}
throw new TypeError("Type de carte invalide");
};
const parseStoredCard = (input: unknown): MoodboardCard => {
if (typeof input !== "object" || input === null || Array.isArray(input)) throw new TypeError("Carte invalide");
const record = input as Record<string, unknown>;
const draft = parseDraft(record as MoodboardDraft);
if (record.version !== 1 || typeof record.id !== "string" || !/^mood_[a-z0-9_-]{8,64}$/i.test(record.id)) {
throw new TypeError("Carte invalide");
}
if (typeof record.createdAt !== "number" || !Number.isSafeInteger(record.createdAt)) throw new TypeError("Carte invalide");
return {
version: 1,
id: record.id,
creatorId: cleanText(record.creatorId, "Auteur", 255),
creatorName: cleanText(record.creatorName, "Nom auteur", 160),
createdAt: record.createdAt,
...draft,
};
};
const writeJsonAtomic = async (path: string, value: unknown): Promise<void> => {
await mkdir(dirname(path), { recursive: true });
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
await writeFile(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600 });
await rename(temporary, path);
};
const searchable = (card: MoodboardCard): string => {
const extras = card.kind === "text"
? card.text
: card.kind === "file"
? `${card.fileName} ${card.mimeType}`
: card.kind === "image" || card.kind === "link"
? card.url
: card.color;
return `${card.title} ${extras}`.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLocaleLowerCase("fr-FR");
};
export class BotsuMoodboardStore {
private readonly root: string;
private readonly now: () => number;
private readonly createId: () => string;
constructor(options: BotsuMoodboardStoreOptions) {
this.root = options.root;
this.now = options.now ?? Date.now;
this.createId = options.createId ?? (() => `mood_${randomUUID().replace(/-/g, "")}`);
}
private cardPath(id: string): string {
if (!/^mood_[a-z0-9_-]{8,64}$/i.test(id)) throw new TypeError("Identifiant invalide");
return join(this.root, "cards", `${id}.json`);
}
async create(input: CreateInput): Promise<MoodboardCard> {
const card = parseStoredCard({
version: 1,
id: this.createId(),
creatorId: input.creatorId,
creatorName: input.creatorName,
createdAt: this.now(),
...parseDraft(input.draft),
});
await writeJsonAtomic(this.cardPath(card.id), card);
return card;
}
async list(input: ListInput): Promise<ListResult> {
const limit = Number.isInteger(input.limit) ? Math.min(Math.max(input.limit, 1), 60) : 24;
const offset = input.cursor === undefined ? 0 : Number.parseInt(input.cursor, 10);
if (!Number.isSafeInteger(offset) || offset < 0) throw new TypeError("Curseur invalide");
let names: string[] = [];
try {
names = (await readdir(join(this.root, "cards"))).filter((name) => /^mood_[a-z0-9_-]{8,64}\.json$/i.test(name));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const cards: MoodboardCard[] = [];
for (const name of names) {
try {
cards.push(parseStoredCard(JSON.parse(await readFile(join(this.root, "cards", name), "utf8")) as unknown));
} catch {
// A corrupt card is isolated instead of taking down the shared API.
}
}
cards.sort((a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id));
const query = input.query?.trim().normalize("NFD").replace(/\p{Diacritic}/gu, "").toLocaleLowerCase("fr-FR");
const filtered = query ? cards.filter((card) => searchable(card).includes(query)) : cards;
const page = filtered.slice(offset, offset + limit);
const nextOffset = offset + page.length;
return nextOffset < filtered.length ? { cards: page, nextCursor: String(nextOffset) } : { cards: page };
}
}
+294 -3
View File
@@ -1,10 +1,18 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import WebSocket from "ws";
import { BotsuDrawingStore } from "./drawing-store.ts";
import { PresenceRegistry } from "./registry.ts";
const TEST_ORIGIN = "https://test.botsu.net";
const DRAWING_SCOPE_A = "drawing_123e4567-e89b-42d3-a456-426614174030";
const DRAWING_SCOPE_B = "drawing_123e4567-e89b-42d3-a456-426614174031";
const DRAWING_ROOM_A = "!zlpDmLJtzRUYjIctHZ:botsu.net";
const DRAWING_ROOM_B = "!rVlckMQQtaFrNxyckA:botsu.net";
const nextJson = <T>(socket: WebSocket): Promise<T> =>
new Promise((resolve, reject) => {
@@ -936,9 +944,9 @@ test("scopes pixel canvases by resource join for drawing rooms", async (context)
};
const [alice, bob, carol] = await Promise.all([
connect("alice", "room_a"),
connect("bob", "room_a"),
connect("carol", "room_b"),
connect("alice", DRAWING_SCOPE_A),
connect("bob", DRAWING_SCOPE_A),
connect("carol", DRAWING_SCOPE_B),
]);
await new Promise((resolve) => setTimeout(resolve, 20));
@@ -974,3 +982,286 @@ test("scopes pixel canvases by resource join for drawing rooms", async (context)
});
assert.equal(await carolPatch, undefined);
});
test("streams semantic drawing elements only inside one drawing room", async (context) => {
const { createPresenceServer } = await import("./server.ts");
const root = await mkdtemp(join(tmpdir(), "botsu-drawing-server-"));
const joined = new Set([
`${DRAWING_ROOM_A}|@alice:botsu.net`,
`${DRAWING_ROOM_A}|@bob:botsu.net`,
`${DRAWING_ROOM_B}|@carol:botsu.net`,
]);
const service = createPresenceServer({
allowedOrigin: TEST_ORIGIN,
drawingStore: new BotsuDrawingStore({ root, saveDelayMs: 10 }),
verifyOpenId: async (token) => ({
userId: `@${token.accessToken.split("-")[0]}:botsu.net`,
displayName: token.accessToken.split("-")[0] ?? "Member",
}),
drawingMembershipAuthorizer: {
canAccessDrawing: async (roomId, userId, resourceId) =>
joined.has(`${roomId}|${userId}`) &&
((roomId === DRAWING_ROOM_A && resourceId === DRAWING_SCOPE_A) ||
(roomId === DRAWING_ROOM_B && resourceId === DRAWING_SCOPE_B)),
close: async () => undefined,
},
});
service.httpServer.listen(0, "127.0.0.1");
await once(service.httpServer, "listening");
context.after(() => service.close());
const address = service.httpServer.address();
assert.ok(address && typeof address === "object");
const connect = async (name: string, resourceId: string) => {
const socket = new WebSocket(`ws://127.0.0.1:${address.port}/ws`, {
origin: TEST_ORIGIN,
});
await once(socket, "open");
context.after(() => socket.close());
const ready = nextJsonOfType(socket, "presence.ready");
socket.send(
JSON.stringify({
type: "presence.hello",
protocolVersion: 1,
appId: "client",
openIdToken: {
accessToken: `${name}-openid-token`,
matrixServerName: "botsu.net",
expiresIn: 300,
},
})
);
await ready;
socket.send(
JSON.stringify({
type: "presence.join",
protocolVersion: 1,
visibility: "resource",
workspaceId: "drawing",
resourceId,
})
);
return socket;
};
const [alice, bob, carol] = await Promise.all([
connect("alice", DRAWING_SCOPE_A),
connect("bob", DRAWING_SCOPE_A),
connect("carol", DRAWING_SCOPE_B),
]);
const snapshots = [alice, bob, carol].map((socket) =>
nextJsonOfType<{ revision: number }>(socket, "drawing.snapshot")
);
alice.send(
JSON.stringify({ type: "drawing.subscribe", protocolVersion: 1, roomId: DRAWING_ROOM_A })
);
bob.send(
JSON.stringify({ type: "drawing.subscribe", protocolVersion: 1, roomId: DRAWING_ROOM_A })
);
carol.send(
JSON.stringify({ type: "drawing.subscribe", protocolVersion: 1, roomId: DRAWING_ROOM_B })
);
assert.deepEqual((await Promise.all(snapshots)).map((snapshot) => snapshot.revision), [0, 0, 0]);
const element = {
id: "draw_123e4567-e89b-42d3-a456-426614174000",
kind: "stroke",
mode: "paint",
color: "#123456",
size: 5,
points: [
{ x: 10, y: 20 },
{ x: 16, y: 24 },
],
};
const aliceInitialUpdate = nextJsonOfType(alice, "drawing.upsert");
const bobUpdate = nextJsonOfType(bob, "drawing.upsert");
const carolUpdate = Promise.race([
nextJsonOfType(carol, "drawing.upsert"),
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 150)),
]);
const mutationId = "mut_123e4567-e89b-42d3-a456-426614174032";
alice.send(
JSON.stringify({
type: "drawing.upsert",
protocolVersion: 1,
mutationId,
expectedRevision: 0,
element,
})
);
const expectedInitialUpdate = {
type: "drawing.upsert",
protocolVersion: 1,
revision: 1,
mutationId,
element,
};
assert.deepEqual(await aliceInitialUpdate, expectedInitialUpdate);
assert.deepEqual(await bobUpdate, expectedInitialUpdate);
assert.equal(await carolUpdate, undefined);
const replayAck = nextJsonOfType(alice, "drawing.upsert");
const bobReplay = Promise.race([
nextJsonOfType(bob, "drawing.upsert"),
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 100)),
]);
alice.send(
JSON.stringify({
type: "drawing.upsert",
protocolVersion: 1,
mutationId,
expectedRevision: 0,
element,
})
);
assert.deepEqual(await replayAck, expectedInitialUpdate);
assert.equal(await bobReplay, undefined);
const mismatchedReplay = nextJsonOfType<{ code: string }>(alice, "drawing.error");
alice.send(
JSON.stringify({
type: "drawing.upsert",
protocolVersion: 1,
mutationId,
expectedRevision: 0,
element: { ...element, color: "#ffffff" },
})
);
assert.equal((await mismatchedReplay).code, "rejected");
joined.delete(`${DRAWING_ROOM_A}|@bob:botsu.net`);
const bobAfterLeave = Promise.race([
nextJsonOfType(bob, "drawing.upsert"),
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 150)),
]);
const aliceUpdate = nextJsonOfType(alice, "drawing.upsert");
alice.send(
JSON.stringify({
type: "drawing.upsert",
protocolVersion: 1,
mutationId: "mut_123e4567-e89b-42d3-a456-426614174033",
expectedRevision: 1,
element: { ...element, color: "#abcdef" },
})
);
await aliceUpdate;
assert.equal(await bobAfterLeave, undefined);
const rejected = nextJsonOfType<{ code: string }>(bob, "drawing.error");
bob.send(
JSON.stringify({
type: "drawing.upsert",
protocolVersion: 1,
mutationId: "mut_123e4567-e89b-42d3-a456-426614174034",
expectedRevision: 2,
element: { ...element, color: "#fedcba" },
})
);
assert.equal((await rejected).code, "rejected");
});
test("does not send a delayed drawing snapshot after the socket joins another room", async (context) => {
const { createPresenceServer } = await import("./server.ts");
const root = await mkdtemp(join(tmpdir(), "botsu-drawing-scope-race-"));
let markStarted!: () => void;
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
class DelayedDrawingStore extends BotsuDrawingStore {
override async snapshot(roomId: string, scope?: string) {
if (scope === DRAWING_SCOPE_A) {
markStarted();
await gate;
}
return super.snapshot(roomId, scope);
}
}
const service = createPresenceServer({
allowedOrigin: TEST_ORIGIN,
drawingStore: new DelayedDrawingStore({ root }),
verifyOpenId: async () => ({ userId: "@alice:botsu.net", displayName: "Alice" }),
drawingMembershipAuthorizer: {
canAccessDrawing: async () => true,
close: async () => undefined,
},
});
service.httpServer.listen(0, "127.0.0.1");
await once(service.httpServer, "listening");
context.after(() => service.close());
const address = service.httpServer.address();
assert.ok(address && typeof address === "object");
const socket = new WebSocket(`ws://127.0.0.1:${address.port}/ws`, { origin: TEST_ORIGIN });
await once(socket, "open");
context.after(() => socket.close());
const ready = nextJsonOfType(socket, "presence.ready");
socket.send(
JSON.stringify({
type: "presence.hello",
protocolVersion: 1,
appId: "client",
openIdToken: {
accessToken: "alice-openid-token",
matrixServerName: "botsu.net",
expiresIn: 300,
},
})
);
await ready;
socket.send(
JSON.stringify({
type: "presence.join",
protocolVersion: 1,
visibility: "resource",
workspaceId: "drawing",
resourceId: DRAWING_SCOPE_A,
})
);
socket.send(
JSON.stringify({ type: "drawing.subscribe", protocolVersion: 1, roomId: DRAWING_ROOM_A })
);
await started;
socket.send(
JSON.stringify({
type: "presence.join",
protocolVersion: 1,
visibility: "resource",
workspaceId: "drawing",
resourceId: DRAWING_SCOPE_B,
})
);
const leakedSnapshot = Promise.race([
nextJsonOfType(socket, "drawing.snapshot"),
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 100)),
]);
await new Promise<void>((resolve) => setTimeout(resolve, 25));
release();
assert.equal(await leakedSnapshot, undefined);
});
test("closes the Matrix authorizer even when the terminal drawing flush fails", async () => {
const { createPresenceServer } = await import("./server.ts");
const root = await mkdtemp(join(tmpdir(), "botsu-drawing-shutdown-"));
let authorizerClosed = false;
class FailingFlushStore extends BotsuDrawingStore {
override async flushAll(): Promise<void> {
throw new Error("terminal flush failure");
}
}
const service = createPresenceServer({
allowedOrigin: TEST_ORIGIN,
drawingStore: new FailingFlushStore({ root }),
drawingMembershipAuthorizer: {
canAccessDrawing: async () => true,
close: async () => {
authorizerClosed = true;
},
},
});
await assert.rejects(() => service.close(), /Presence shutdown failed/);
assert.equal(authorizerClosed, true);
});
+426 -25
View File
@@ -8,6 +8,8 @@ import {
import { pathToFileURL } from "node:url";
import {
MAXIMUM_PRESENCE_SERVER_MESSAGE_BYTES,
parseBotsuDrawingResourceId,
parseDrawingClientMessage,
parseDocumentClientMessage,
parseBotsuDocumentObjectIconId,
parseBotsuDocumentObjectId,
@@ -18,13 +20,18 @@ import {
parseTcgPaginationParams,
type PresenceOpenIdToken,
} from "@botsu/protocol";
import { WebSocket, WebSocketServer } from "ws";
import { WebSocket, WebSocketServer, type RawData } from "ws";
import {
verifyMatrixOpenId,
type VerifiedMatrixIdentity,
} from "./matrix-auth.ts";
import { PresenceRegistry } from "./registry.ts";
import { PixelCanvas } from "./pixel-canvas.ts";
import { BotsuDrawingStore } from "./drawing-store.ts";
import {
createMatrixMembershipAuthorizer,
type MatrixMembershipAuthorizer,
} from "./matrix-membership.ts";
import { BotsuDocumentObjectStore } from "./document-store.ts";
import { BotsuCookieStore } from "./cookie-store.ts";
import { TcgCatalogStore } from "./tcg-catalog-store.ts";
@@ -32,6 +39,9 @@ import { TcgCardStore } from "./tcg-card-store.ts";
import { TcgBoosterStore } from "./tcg-booster-store.ts";
import { TcgEconomyLogger } from "./tcg-economy-log.ts";
import { loadTcgConfig, type TcgConfig } from "./tcg-config.ts";
import { BotsuMoodboardStore, type MoodboardDraft } from "./moodboard-store.ts";
import { searchSearxngImages } from "./moodboard-search.ts";
import { fetchPinterestPins } from "./moodboard-pinterest.ts";
const HEARTBEAT_INTERVAL_MS = 15_000;
const SESSION_TIMEOUT_MS = 45_000;
@@ -43,6 +53,7 @@ const PIXEL_CANVAS_BROADCAST_INTERVAL_MS = 100;
const CANVAS_SNAPSHOT_BROADCAST_INTERVAL_MS = 250;
const MAXIMUM_CANVAS_SNAPSHOTS_PER_INTERVAL = 5;
const MAXIMUM_HTTP_JSON_BYTES = 256 * 1024;
const MAXIMUM_MOODBOARD_JSON_BYTES = 3 * 1024 * 1024;
const MAXIMUM_GODOT_DEBUG_SESSIONS = 32;
const MAXIMUM_GODOT_DEBUG_PAYLOAD_BYTES = 8 * 1024 * 1024;
const MAXIMUM_GODOT_DEBUG_QUEUE_BYTES = 8 * 1024 * 1024;
@@ -61,6 +72,8 @@ type PresenceServerOptions = {
) => Promise<VerifiedMatrixIdentity>;
registry?: PresenceRegistry;
pixelCanvas?: PixelCanvas;
drawingStore?: BotsuDrawingStore;
drawingMembershipAuthorizer?: MatrixMembershipAuthorizer;
documentStore?: BotsuDocumentObjectStore;
cookieStore?: BotsuCookieStore;
canvasSnapshotBroadcastIntervalMs?: number;
@@ -71,6 +84,9 @@ type PresenceServerOptions = {
tcgBoosterStore?: TcgBoosterStore;
tcgEconomyLogger?: TcgEconomyLogger;
tcgAdminIds?: ReadonlySet<string>;
moodboardStore?: BotsuMoodboardStore;
searchMoodboardImages?: (query: string) => Promise<unknown>;
getPinterestPins?: (sourceUrl: string) => Promise<unknown>;
};
type SocketState = {
@@ -81,6 +97,10 @@ type SocketState = {
canvasSubscribed: boolean;
canvasSubscriptionPending: boolean;
canvasScope: string;
drawingScope?: string;
drawingSubscribedScope?: string;
drawingSubscribedRoomId?: string;
drawingGeneration: number;
documentSubscriptions: Set<string>;
messageCount: number;
rateWindowStartedAt: number;
@@ -187,6 +207,21 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
}
return canvas;
};
const drawingStore =
options.drawingStore ??
new BotsuDrawingStore({
root: process.env.BOTSU_DRAWING_STORE_ROOT ?? "/data/botsu-drawings",
});
const drawingMembershipAuthorizer =
options.drawingMembershipAuthorizer ??
(process.env.MATRIX_MEMBERSHIP_DATABASE_URL
? createMatrixMembershipAuthorizer()
: {
canAccessDrawing: async () => {
throw new Error("Matrix membership authorization is not configured");
},
close: async () => undefined,
});
const documentStore =
options.documentStore ??
new BotsuDocumentObjectStore({
@@ -223,6 +258,16 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
config: tcgConfig,
});
const tcgAdminIds = options.tcgAdminIds ?? new Set<string>();
const moodboardStore = options.moodboardStore ?? new BotsuMoodboardStore({
root: process.env.BOTSU_MOODBOARD_STORE_ROOT ?? "/data/botsu-moodboard",
});
const searchMoodboardImages =
options.searchMoodboardImages ??
((query: string) =>
searchSearxngImages(query, {
baseUrl: process.env.BOTSU_SEARXNG_URL ?? "http://192.168.1.12:8081",
}));
const getPinterestPins = options.getPinterestPins ?? fetchPinterestPins;
// Load catalog index on startup (non-blocking)
void tcgCatalogStore.load().catch(() => undefined);
const canvasSnapshotBroadcastIntervalMs =
@@ -241,6 +286,8 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
}
const sockets = new Set<WebSocket>();
const states = new WeakMap<WebSocket, SocketState>();
const inFlightSocketMessages = new Set<Promise<void>>();
let acceptingSocketMessages = true;
const canvasSnapshotQueue = new Set<WebSocket>();
let snapshotTimer: ReturnType<typeof setTimeout> | undefined;
let pixelCanvasTimer: ReturnType<typeof setTimeout> | undefined;
@@ -259,14 +306,17 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
response.end(JSON.stringify(value));
};
const readRequestJson = async (request: IncomingMessage): Promise<Record<string, unknown>> =>
const readRequestJson = async (
request: IncomingMessage,
maximumBytes = MAXIMUM_HTTP_JSON_BYTES
): Promise<Record<string, unknown>> =>
new Promise((resolve, reject) => {
let received = 0;
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk: string) => {
received += Buffer.byteLength(chunk, "utf8");
if (received > MAXIMUM_HTTP_JSON_BYTES) {
if (received > maximumBytes) {
reject(new Error("HTTP JSON body exceeds maximum size"));
request.destroy();
return;
@@ -372,6 +422,68 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
}
};
const handleMoodboardHttpRequest = async (
request: IncomingMessage,
response: ServerResponse
): Promise<boolean> => {
const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
const moodboardPath = requestUrl.pathname.startsWith("/presence/moodboard")
? requestUrl.pathname.slice("/presence".length)
: requestUrl.pathname;
if (!moodboardPath.startsWith("/moodboard")) return false;
let identity: VerifiedMatrixIdentity;
try {
identity = await authenticateHttpRequest(request);
} catch {
sendJsonResponse(response, 401, { error: "unauthorized" });
return true;
}
try {
if (moodboardPath === "/moodboard/cards" && request.method === "GET") {
const parsedLimit = Number.parseInt(requestUrl.searchParams.get("limit") ?? "24", 10);
const cursor = requestUrl.searchParams.get("cursor") ?? undefined;
const query = requestUrl.searchParams.get("q") ?? undefined;
const listInput: { limit: number; cursor?: string; query?: string } = {
limit: Number.isFinite(parsedLimit) ? parsedLimit : 24,
};
if (cursor !== undefined) listInput.cursor = cursor;
if (query !== undefined) listInput.query = query;
sendJsonResponse(response, 200, await moodboardStore.list(listInput));
return true;
}
if (moodboardPath === "/moodboard/cards" && request.method === "POST") {
const body = await readRequestJson(request, MAXIMUM_MOODBOARD_JSON_BYTES);
const card = await moodboardStore.create({
creatorId: identity.userId,
creatorName: identity.displayName,
draft: body as unknown as MoodboardDraft,
});
sendJsonResponse(response, 201, { card });
return true;
}
if (moodboardPath === "/moodboard/search" && request.method === "GET") {
const query = requestUrl.searchParams.get("q") ?? "";
sendJsonResponse(response, 200, await searchMoodboardImages(query));
return true;
}
if (moodboardPath === "/moodboard/pinterest" && request.method === "GET") {
const sourceUrl = requestUrl.searchParams.get("url") ?? "";
sendJsonResponse(response, 200, await getPinterestPins(sourceUrl));
return true;
}
sendJsonResponse(response, 405, { error: "method_not_allowed" });
return true;
} catch (error) {
const message = (error as Error).message;
sendJsonResponse(response, /SearXNG/i.test(message) ? 502 : 400, {
error: /SearXNG/i.test(message) ? "search_unavailable" : "invalid_request",
});
return true;
}
};
const handleCookieHttpRequest = async (
request: IncomingMessage,
response: ServerResponse
@@ -836,6 +948,11 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
};
const httpServer: HttpServer = createServer((request, response) => {
if (!acceptingSocketMessages) {
response.writeHead(503, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "shutting_down" }));
return;
}
if (request.method === "GET" && request.url === "/health") {
response.writeHead(200, {
"content-type": "application/json",
@@ -846,6 +963,8 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
}
void handleDocumentHttpRequest(request, response).then(async (handled) => {
if (handled) return;
const moodboardHandled = await handleMoodboardHttpRequest(request, response);
if (moodboardHandled) return;
const cookieHandled = await handleCookieHttpRequest(request, response);
if (cookieHandled) return;
const tcgHandled = await handleTcgHttpRequest(request, response);
@@ -859,7 +978,7 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
});
const webSocketServer = new WebSocketServer({
noServer: true,
maxPayload: 16 * 1024,
maxPayload: 64 * 1024,
perMessageDeflate: false,
});
const godotDebugWebSocketServer = new WebSocketServer({
@@ -975,6 +1094,10 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
};
httpServer.on("upgrade", (request, socket, head) => {
if (!acceptingSocketMessages) {
socket.destroy();
return;
}
const godotDebugEndpoint = parseGodotDebugEndpoint(request.url);
if (godotDebugEndpoint) {
const protocols = String(request.headers["sec-websocket-protocol"] ?? "")
@@ -1088,6 +1211,7 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
canvasSubscribed: false,
canvasSubscriptionPending: false,
canvasScope: "public",
drawingGeneration: 0,
documentSubscriptions: new Set(),
messageCount: 0,
rateWindowStartedAt: Date.now(),
@@ -1099,19 +1223,13 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
}, authenticationTimeoutMs);
authenticationTimer.unref();
socket.on("message", async (raw, isBinary) => {
const handleMessage = async (
raw: RawData,
isBinary: boolean,
drawingGeneration: number
): Promise<void> => {
const state = states.get(socket);
if (!state) return;
const now = Date.now();
if (now - state.rateWindowStartedAt >= 10_000) {
state.rateWindowStartedAt = now;
state.messageCount = 0;
}
state.messageCount += 1;
if (state.messageCount > 100) {
socket.close(1008, "Rate limit exceeded");
return;
}
if (isBinary) {
socket.close(1003, "Text messages required");
return;
@@ -1121,11 +1239,16 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
const parsedJson = JSON.parse(raw.toString()) as unknown;
let message:
| ReturnType<typeof parsePresenceClientMessage>
| ReturnType<typeof parseDocumentClientMessage>;
| ReturnType<typeof parseDocumentClientMessage>
| ReturnType<typeof parseDrawingClientMessage>;
try {
message = parsePresenceClientMessage(parsedJson);
} catch {
message = parseDocumentClientMessage(parsedJson);
try {
message = parseDocumentClientMessage(parsedJson);
} catch {
message = parseDrawingClientMessage(parsedJson);
}
}
if (state.sessionId === undefined) {
if (message.type !== "presence.hello" || state.authenticating) {
@@ -1153,10 +1276,20 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
if (message.type === "presence.join") {
state.joined = message.visibility === "app" || message.visibility === "resource";
state.canvasScope =
message.visibility === "resource" && message.workspaceId === "drawing" && message.resourceId
? message.resourceId
: "public";
delete state.drawingScope;
delete state.drawingSubscribedScope;
delete state.drawingSubscribedRoomId;
if (
message.visibility === "resource" &&
message.workspaceId === "drawing" &&
message.resourceId
) {
const scope = parseBotsuDrawingResourceId(message.resourceId);
state.canvasScope = scope;
state.drawingScope = scope;
} else {
state.canvasScope = "public";
}
if (message.visibility === "resource") {
registry.heartbeat(state.sessionId);
} else if (registry.join(state.sessionId, message)) {
@@ -1184,6 +1317,179 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
scheduleSnapshot();
return;
}
if (message.type === "drawing.subscribe") {
const scope = state.drawingScope;
const userId = state.userId;
if (!state.joined || !scope || !userId) {
throw new Error("Drawing room join is required");
}
if (
!(await drawingMembershipAuthorizer.canAccessDrawing(message.roomId, userId, scope))
) {
throw new Error("Current Matrix room membership is required");
}
if (
state.drawingGeneration !== drawingGeneration ||
!state.joined ||
state.drawingScope !== scope ||
state.userId !== userId
) return;
const snapshot = await drawingStore.snapshot(message.roomId, scope);
const stillAuthorized = await drawingMembershipAuthorizer.canAccessDrawing(
message.roomId,
userId,
scope
);
if (
!stillAuthorized ||
state.drawingGeneration !== drawingGeneration ||
!state.joined ||
state.drawingScope !== scope ||
state.userId !== userId ||
socket.readyState !== WebSocket.OPEN
) return;
if (sendPresenceJson(socket, snapshot)) {
state.drawingSubscribedScope = scope;
state.drawingSubscribedRoomId = message.roomId;
}
return;
}
if (message.type === "drawing.unsubscribe") {
delete state.drawingSubscribedScope;
delete state.drawingSubscribedRoomId;
return;
}
if (message.type === "drawing.upsert" || message.type === "drawing.remove") {
const scope = state.drawingSubscribedScope;
const roomId = state.drawingSubscribedRoomId;
const userId = state.userId;
if (
state.drawingGeneration !== drawingGeneration ||
!state.joined ||
!scope ||
!roomId ||
!userId ||
message.expectedRevision === undefined ||
state.drawingScope !== scope
) {
throw new Error("Drawing subscription is required");
}
let currentMember = false;
try {
currentMember = await drawingMembershipAuthorizer.canAccessDrawing(
roomId,
userId,
scope
);
} catch {
currentMember = false;
}
if (!currentMember) {
delete state.drawingSubscribedScope;
delete state.drawingSubscribedRoomId;
sendPresenceJson(socket, {
type: "drawing.error",
protocolVersion: 1,
mutationId: message.mutationId,
code: "rejected",
});
return;
}
const subscriptionIsCurrent = (): boolean =>
state.drawingGeneration === drawingGeneration &&
state.joined &&
state.userId === userId &&
state.drawingScope === scope &&
state.drawingSubscribedScope === scope &&
state.drawingSubscribedRoomId === roomId &&
socket.readyState === WebSocket.OPEN;
const mutationIsAuthorized = async (): Promise<boolean> => {
if (!subscriptionIsCurrent()) return false;
try {
if (!(await drawingMembershipAuthorizer.canAccessDrawing(roomId, userId, scope))) {
return false;
}
} catch {
return false;
}
return subscriptionIsCurrent();
};
if (!subscriptionIsCurrent()) return;
let mutationResult;
try {
mutationResult =
message.type === "drawing.upsert"
? await drawingStore.upsert(
roomId,
scope,
message.element,
message.mutationId,
message.expectedRevision,
mutationIsAuthorized
)
: await drawingStore.remove(
roomId,
scope,
message.id,
message.mutationId,
message.expectedRevision,
mutationIsAuthorized
);
} catch {
sendPresenceJson(socket, {
type: "drawing.error",
protocolVersion: 1,
mutationId: message.mutationId,
code: "rejected",
});
return;
}
if (!subscriptionIsCurrent()) return;
const update = mutationResult.update;
if (mutationResult.replay) {
sendPresenceJson(socket, update);
return;
}
await Promise.all([...sockets].map(async (candidate) => {
const candidateState = states.get(candidate);
if (
!candidateState?.joined ||
candidateState.drawingSubscribedScope !== scope ||
candidateState.drawingScope !== scope ||
!candidateState.drawingSubscribedRoomId ||
!candidateState.userId
) {
return;
}
const candidateGeneration = candidateState.drawingGeneration;
const candidateRoomId = candidateState.drawingSubscribedRoomId;
const candidateUserId = candidateState.userId;
let candidateMember = false;
try {
candidateMember = await drawingMembershipAuthorizer.canAccessDrawing(
candidateRoomId,
candidateUserId,
scope
);
} catch {
candidateMember = false;
}
if (!candidateMember) {
return;
}
if (
candidate.readyState !== WebSocket.OPEN ||
candidateState.drawingGeneration !== candidateGeneration ||
!candidateState.joined ||
candidateState.userId !== candidateUserId ||
candidateState.drawingScope !== scope ||
candidateState.drawingSubscribedScope !== scope ||
candidateState.drawingSubscribedRoomId !== candidateRoomId
) return;
sendPresenceJson(candidate, update);
}));
return;
}
if (message.type === "canvas.subscribe") {
if (!state.joined) throw new Error("Presence join is required");
if (state.canvasSubscribed || state.canvasSubscriptionPending) return;
@@ -1437,12 +1743,56 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
message: "Invalid presence message",
});
}
};
let messageQueue = Promise.resolve();
socket.on("message", (raw, isBinary) => {
if (!acceptingSocketMessages) return;
const state = states.get(socket);
if (!state) return;
const now = Date.now();
if (now - state.rateWindowStartedAt >= 10_000) {
state.rateWindowStartedAt = now;
state.messageCount = 0;
}
state.messageCount += 1;
if (state.messageCount > 100) {
socket.close(1008, "Rate limit exceeded");
return;
}
if (!isBinary) {
try {
const type = (JSON.parse(raw.toString()) as { type?: unknown }).type;
if (
type === "presence.join" ||
type === "drawing.subscribe" ||
type === "drawing.unsubscribe"
) {
state.drawingGeneration += 1;
}
} catch {
// The serialized parser reports malformed JSON.
}
}
const generation = state.drawingGeneration;
const operation = messageQueue.then(() => handleMessage(raw, isBinary, generation));
messageQueue = operation.catch(() => undefined);
inFlightSocketMessages.add(operation);
void operation.then(
() => inFlightSocketMessages.delete(operation),
() => inFlightSocketMessages.delete(operation)
);
});
socket.on("close", () => {
clearTimeout(authenticationTimer);
sockets.delete(socket);
canvasSnapshotQueue.delete(socket);
const socketState = states.get(socket);
if (socketState) {
delete socketState.drawingScope;
delete socketState.drawingSubscribedScope;
}
const sessionId = states.get(socket)?.sessionId;
if (sessionId && registry.disconnect(sessionId)) scheduleSnapshot();
states.get(socket)?.documentSubscriptions.clear();
@@ -1458,6 +1808,8 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
if (!state?.sessionId || !expiredIds.has(state.sessionId)) return;
canvasSnapshotQueue.delete(socket);
state.canvasSubscriptionPending = false;
delete state.drawingScope;
delete state.drawingSubscribedScope;
delete state.sessionId;
delete state.userId;
state.documentSubscriptions.clear();
@@ -1483,7 +1835,25 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
}, HEARTBEAT_INTERVAL_MS);
godotDebugExpirationTimer.unref();
const close = (): void => {
const close = async (): Promise<void> => {
acceptingSocketMessages = false;
const serverClosures = [
new Promise<void>((resolve, reject) =>
webSocketServer.close((error) => error ? reject(error) : resolve())
),
new Promise<void>((resolve, reject) =>
godotDebugWebSocketServer.close((error) => error ? reject(error) : resolve())
),
new Promise<void>((resolve, reject) => {
httpServer.close((error) => {
if (error && (error as NodeJS.ErrnoException).code !== "ERR_SERVER_NOT_RUNNING") {
reject(error);
} else {
resolve();
}
});
}),
];
clearInterval(expirationTimer);
clearInterval(godotDebugExpirationTimer);
if (snapshotTimer !== undefined) clearTimeout(snapshotTimer);
@@ -1496,9 +1866,26 @@ export const createPresenceServer = (options: PresenceServerOptions = {}) => {
session.game?.close(1001, "Server shutting down");
}
godotDebugSessions.clear();
webSocketServer.close();
godotDebugWebSocketServer.close();
httpServer.close();
const errors: unknown[] = [];
const inFlightResults = await Promise.allSettled([...inFlightSocketMessages]);
for (const result of inFlightResults) {
if (result.status === "rejected") errors.push(result.reason);
}
try {
await drawingStore.flushAll();
} catch (error) {
errors.push(error);
}
try {
await drawingMembershipAuthorizer.close();
} catch (error) {
errors.push(error);
}
const serverResults = await Promise.allSettled(serverClosures);
for (const result of serverResults) {
if (result.status === "rejected") errors.push(result.reason);
}
if (errors.length > 0) throw new AggregateError(errors, "Presence shutdown failed");
};
return { httpServer, close, registry, pixelCanvas: defaultPixelCanvas, expireStaleSessions };
@@ -1514,4 +1901,18 @@ if (entrypoint && import.meta.url === pathToFileURL(entrypoint).href) {
service.httpServer.listen(port, "0.0.0.0", () => {
process.stdout.write(`BOTSU Presence listening on ${port}\n`);
});
let shuttingDown = false;
const shutdown = (): void => {
if (shuttingDown) return;
shuttingDown = true;
void service.close().then(
() => process.exit(0),
(error) => {
process.stderr.write(`BOTSU Presence shutdown failed: ${String(error)}\n`);
process.exit(1);
}
);
};
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);
}
+177
View File
@@ -11775,11 +11775,13 @@
"version": "0.0.0",
"dependencies": {
"@botsu/protocol": "file:../../packages/protocol",
"pg": "^8.22.0",
"ws": "8.21.1",
"yjs": "^13.6.27"
},
"devDependencies": {
"@types/node": "22.19.11",
"@types/pg": "^8.21.0",
"@types/ws": "8.18.1",
"typescript": "5.9.3"
},
@@ -11945,6 +11947,28 @@
"yjs": "^13.5.38"
}
},
"node_modules/@types/node": {
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/pg": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -12056,6 +12080,134 @@
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.15.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
@@ -12201,6 +12353,15 @@
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"dev": true,
@@ -12213,12 +12374,28 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y-prosemirror": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz",
+1 -1
View File
@@ -9,7 +9,7 @@
".": "./src/index.ts"
},
"scripts": {
"test": "node --experimental-strip-types --test src/profile.test.ts src/presence.test.ts src/matrix-workspace.test.ts src/document-object.test.ts src/cookies.test.ts src/watch-room.test.ts",
"test": "node --experimental-strip-types --test src/profile.test.ts src/presence.test.ts src/drawing.test.ts src/matrix-workspace.test.ts src/document-object.test.ts src/cookies.test.ts src/watch-room.test.ts",
"typecheck": "tsc -p tsconfig.json"
},
"devDependencies": {
+229
View File
@@ -0,0 +1,229 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
MAXIMUM_DRAWING_FILL_RUNS,
createBotsuDrawingResourceId,
parseDrawingClientMessage,
parseDrawingReference,
parseDrawingServerMessage,
type DrawingElement,
} from "./drawing.ts";
const mutationId = "mut_123e4567-e89b-42d3-a456-426614174001";
const roomId = "!zlpDmLJtzRUYjIctHZ:botsu.net";
const stroke: DrawingElement = {
id: "draw_123e4567-e89b-42d3-a456-426614174000",
kind: "stroke",
mode: "paint",
color: "#123456",
size: 8,
points: [
{ x: 10, y: 20 },
{ x: 14, y: 24 },
],
};
test("parses bounded drawing subscriptions and semantic upserts", () => {
assert.deepEqual(
parseDrawingClientMessage({ type: "drawing.subscribe", protocolVersion: 1, roomId }),
{ type: "drawing.subscribe", protocolVersion: 1, roomId }
);
assert.deepEqual(
parseDrawingClientMessage({
type: "drawing.upsert",
protocolVersion: 1,
mutationId,
element: stroke,
}),
{ type: "drawing.upsert", protocolVersion: 1, mutationId, element: stroke }
);
assert.deepEqual(
parseDrawingClientMessage({
type: "drawing.remove",
protocolVersion: 1,
mutationId,
id: stroke.id,
}),
{ type: "drawing.remove", protocolVersion: 1, mutationId, id: stroke.id }
);
});
test("requires an exact bounded Matrix room id for drawing subscriptions", () => {
[
undefined,
"room",
"!room",
"!room:botsu.net/path",
"!room:botsu.net?token=secret",
`!${"a".repeat(256)}:botsu.net`,
].forEach((invalidRoomId) =>
assert.throws(() =>
parseDrawingClientMessage({
type: "drawing.subscribe",
protocolVersion: 1,
roomId: invalidRoomId,
})
)
);
assert.throws(() =>
parseDrawingClientMessage({
type: "drawing.subscribe",
protocolVersion: 1,
roomId,
accessToken: "forbidden",
})
);
});
test("parses revisioned drawing snapshots and updates", () => {
assert.deepEqual(
parseDrawingServerMessage({
type: "drawing.snapshot",
protocolVersion: 1,
width: 1024,
height: 1024,
revision: 3,
elements: [stroke],
}),
{
type: "drawing.snapshot",
protocolVersion: 1,
width: 1024,
height: 1024,
revision: 3,
elements: [stroke],
}
);
assert.equal(
parseDrawingServerMessage({
type: "drawing.upsert",
protocolVersion: 1,
revision: 4,
mutationId,
element: stroke,
}).type,
"drawing.upsert"
);
});
test("parses a mutation-correlated drawing rejection", () => {
assert.deepEqual(
parseDrawingServerMessage({
type: "drawing.error",
protocolVersion: 1,
mutationId,
code: "rejected",
}),
{
type: "drawing.error",
protocolVersion: 1,
mutationId,
code: "rejected",
}
);
});
test("rejects pixel-amplifying or unsafe drawing payloads", () => {
const parseUpsert = (element: unknown) =>
parseDrawingClientMessage({
type: "drawing.upsert",
protocolVersion: 1,
mutationId,
element,
});
assert.throws(() => parseUpsert({ ...stroke, color: "red" }), /color/i);
assert.throws(
() => parseUpsert({ ...stroke, points: Array.from({ length: 129 }, (_, x) => ({ x, y: 0 })) }),
/points/i
);
assert.throws(() => parseUpsert({ ...stroke, points: [{ x: 1024, y: 0 }] }), /point/i);
assert.throws(() => parseUpsert({ ...stroke, unexpected: true }), /unexpected|allowed/i);
assert.throws(
() =>
parseDrawingServerMessage({
type: "drawing.snapshot",
protocolVersion: 1,
width: 1024,
height: 1024,
revision: 0,
elements: Array.from({ length: 5001 }, (_, index) => ({
...stroke,
id: `draw_123e4567-e89b-42d3-a456-${String(index).padStart(12, "0")}`,
})),
}),
/elements/i
);
});
test("creates and parses an unguessable Matrix-state drawing capability", () => {
const resourceId = createBotsuDrawingResourceId("123e4567-e89b-42d3-a456-426614174002");
assert.equal(resourceId, "drawing_123e4567-e89b-42d3-a456-426614174002");
assert.deepEqual(parseDrawingReference({ version: 1, resourceId }), {
version: 1,
resourceId,
});
assert.throws(
() => parseDrawingReference({ version: 1, resourceId: "drawing_predictable-room-id" }),
/resource/i
);
});
test("bounds bucket fills as raster spans instead of replaying flood fill", () => {
const fill: DrawingElement = {
id: "draw_123e4567-e89b-42d3-a456-426614174003",
kind: "fill",
color: "#abcdef",
runs: [
{ y: 4, startX: 5, endX: 10 },
{ y: 5, startX: 5, endX: 10 },
],
};
assert.doesNotThrow(() =>
parseDrawingClientMessage({
type: "drawing.upsert",
protocolVersion: 1,
mutationId,
element: fill,
})
);
assert.throws(
() =>
parseDrawingClientMessage({
type: "drawing.upsert",
protocolVersion: 1,
mutationId,
element: {
...fill,
runs: Array.from({ length: 4097 }, (_, y) => ({
y: y % 1024,
startX: 0,
endX: 0,
})),
},
}),
/runs/i
);
});
test("keeps a maximum drawing fill upsert below the websocket input budget", () => {
const message = {
type: "drawing.upsert",
protocolVersion: 1,
mutationId,
element: {
id: "draw_123e4567-e89b-42d3-a456-426614174004",
kind: "fill",
color: "#123456",
runs: Array.from({ length: MAXIMUM_DRAWING_FILL_RUNS }, (_, y) => ({
y,
startX: 0,
endX: 1023,
})),
},
};
assert.equal(parseDrawingClientMessage(message).type, "drawing.upsert");
assert.ok(new TextEncoder().encode(JSON.stringify(message)).byteLength < 64 * 1024);
});
+482
View File
@@ -0,0 +1,482 @@
import { isValidMatrixServerName } from './profile.ts';
export const BOTSU_DRAWING_SIZE = 1024 as const;
export const MAXIMUM_DRAWING_ELEMENTS = 512;
export const MAXIMUM_DRAWING_POINTS = 128;
export const MAXIMUM_DRAWING_FILL_ELEMENTS = 16;
export const MAXIMUM_DRAWING_FILL_RUNS = 1_024;
export type DrawingPoint = { x: number; y: number };
export type DrawingFillRun = { y: number; startX: number; endX: number };
export type DrawingReference = { version: 1; resourceId: string };
export type DrawingFont = 'site' | 'inter' | 'velvelyne' | 'minecraft' | 'monospace';
export type DrawingShape =
| 'line'
| 'rectangle'
| 'rectangle-filled'
| 'circle'
| 'circle-filled'
| 'polygon';
export type DrawingStrokeElement = {
id: string;
kind: 'stroke';
mode: 'paint' | 'erase';
color: string;
size: number;
points: DrawingPoint[];
};
export type DrawingShapeElement = {
id: string;
kind: 'shape';
shape: DrawingShape;
color: string;
size: number;
points: DrawingPoint[];
};
export type DrawingTextElement = {
id: string;
kind: 'text';
color: string;
font: DrawingFont;
size: number;
x: number;
y: number;
text: string;
};
export type DrawingFillElement = {
id: string;
kind: 'fill';
color: string;
runs: DrawingFillRun[];
};
export type DrawingElement =
| DrawingStrokeElement
| DrawingShapeElement
| DrawingTextElement
| DrawingFillElement;
export type DrawingSubscribe = { type: 'drawing.subscribe'; protocolVersion: 1; roomId: string };
export type DrawingUnsubscribe = { type: 'drawing.unsubscribe'; protocolVersion: 1 };
export type DrawingUpsert = {
type: 'drawing.upsert';
protocolVersion: 1;
mutationId: string;
expectedRevision?: number;
element: DrawingElement;
};
export type DrawingRemove = {
type: 'drawing.remove';
protocolVersion: 1;
mutationId: string;
expectedRevision?: number;
id: string;
};
export type DrawingClientMessage =
| DrawingSubscribe
| DrawingUnsubscribe
| DrawingUpsert
| DrawingRemove;
export type DrawingSnapshot = {
type: 'drawing.snapshot';
protocolVersion: 1;
width: 1024;
height: 1024;
revision: number;
elements: DrawingElement[];
};
export type DrawingRemoteUpsert = Omit<DrawingUpsert, 'expectedRevision'> & { revision: number };
export type DrawingRemoteRemove = Omit<DrawingRemove, 'expectedRevision'> & { revision: number };
export type DrawingError = {
type: 'drawing.error';
protocolVersion: 1;
mutationId: string;
code: 'rejected';
};
export type DrawingServerMessage =
| DrawingSnapshot
| DrawingRemoteUpsert
| DrawingRemoteRemove
| DrawingError;
type UnknownRecord = Record<string, unknown>;
const snapshotRecord = (value: unknown, keys: readonly string[], field: string): UnknownRecord => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new TypeError(`${field} must be an object`);
}
const prototype = Object.getPrototypeOf(value) as unknown;
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError(`${field} must be a plain object`);
}
const descriptors = Object.getOwnPropertyDescriptors(value);
const ownKeys = Reflect.ownKeys(descriptors);
const unexpected = ownKeys.find(
(key) => typeof key !== 'string' || !keys.includes(key)
);
if (unexpected !== undefined) {
throw new TypeError(`${field}.${String(unexpected)} is not allowed`);
}
const output: UnknownRecord = {};
ownKeys.forEach((key) => {
if (typeof key !== 'string') return;
const descriptor = descriptors[key];
if (!descriptor?.enumerable || !('value' in descriptor)) {
throw new TypeError(`${field}.${key} is invalid`);
}
output[key] = descriptor.value;
});
return output;
};
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
export const createBotsuDrawingResourceId = (uuid: string): string => {
if (!UUID_V4_PATTERN.test(uuid)) throw new TypeError('drawing resource uuid is invalid');
return `drawing_${uuid}`;
};
export const parseBotsuDrawingResourceId = (value: unknown): string => {
if (typeof value !== 'string' || !/^drawing_[0-9a-f-]{36}$/.test(value)) {
throw new TypeError('drawing resource id is invalid');
}
createBotsuDrawingResourceId(value.slice('drawing_'.length));
return value;
};
export const parseDrawingReference = (value: unknown): DrawingReference => {
const reference = snapshotRecord(value, ['version', 'resourceId'], 'drawing reference');
if (reference.version !== 1) throw new TypeError('drawing reference version is invalid');
return { version: 1, resourceId: parseBotsuDrawingResourceId(reference.resourceId) };
};
export const parseDrawingMutationId = (value: unknown): string => {
if (typeof value !== 'string' || !/^mut_[0-9a-f-]{36}$/.test(value)) {
throw new TypeError('drawing mutation id is invalid');
}
const uuid = value.slice('mut_'.length);
if (!UUID_V4_PATTERN.test(uuid)) throw new TypeError('drawing mutation id is invalid');
return value;
};
export const parseMatrixRoomId = (value: unknown): string => {
if (typeof value !== 'string' || value.length > 255 || value[0] !== '!') {
throw new TypeError('Matrix room id is invalid');
}
const separator = value.indexOf(':', 1);
if (separator < 2) throw new TypeError('Matrix room id is invalid');
const localpart = value.slice(1, separator);
const serverName = value.slice(separator + 1);
if (!/^[A-Za-z0-9._=+/-]+$/.test(localpart) || !isValidMatrixServerName(serverName)) {
throw new TypeError('Matrix room id is invalid');
}
return value;
};
const parseRevision = (value: unknown, field: string): number => {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
throw new TypeError(`${field} is invalid`);
}
return value;
};
export const parseDrawingElementId = (value: unknown): string => {
if (
typeof value !== 'string' ||
!/^draw_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)
) {
throw new TypeError('drawing element id is invalid');
}
return value;
};
const parseColor = (value: unknown): string => {
if (typeof value !== 'string' || !/^#[0-9a-f]{6}$/.test(value)) {
throw new TypeError('drawing element color is invalid');
}
return value;
};
const parseCoordinate = (value: unknown, field: string): number => {
if (
typeof value !== 'number' ||
!Number.isInteger(value) ||
value < 0 ||
value >= BOTSU_DRAWING_SIZE
) {
throw new TypeError(`${field} is invalid`);
}
return value;
};
const parseSize = (value: unknown, maximum = 128): number => {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > maximum) {
throw new TypeError('drawing element size is invalid');
}
return value;
};
const parsePoint = (value: unknown, index: number): DrawingPoint => {
const point = snapshotRecord(value, ['x', 'y'], `drawing element point ${index}`);
return {
x: parseCoordinate(point.x, `drawing element point ${index}.x`),
y: parseCoordinate(point.y, `drawing element point ${index}.y`),
};
};
const parsePoints = (value: unknown, minimum: number, maximum = MAXIMUM_DRAWING_POINTS): DrawingPoint[] => {
if (!Array.isArray(value) || value.length < minimum || value.length > maximum) {
throw new TypeError('drawing element points are invalid');
}
const descriptors = Object.getOwnPropertyDescriptors(value);
if (Reflect.ownKeys(descriptors).length !== value.length + 1) {
throw new TypeError('drawing element points are invalid');
}
return Array.from({ length: value.length }, (_, index) => {
const descriptor = descriptors[String(index)];
if (!descriptor?.enumerable || !('value' in descriptor)) {
throw new TypeError('drawing element points are invalid');
}
return parsePoint(descriptor.value, index);
});
};
const parseFillRuns = (value: unknown): DrawingFillRun[] => {
if (!Array.isArray(value) || value.length < 1 || value.length > MAXIMUM_DRAWING_FILL_RUNS) {
throw new TypeError('drawing fill runs are invalid');
}
const descriptors = Object.getOwnPropertyDescriptors(value);
if (Reflect.ownKeys(descriptors).length !== value.length + 1) {
throw new TypeError('drawing fill runs are invalid');
}
let previous: DrawingFillRun | undefined;
return Array.from({ length: value.length }, (_, index) => {
const descriptor = descriptors[String(index)];
if (!descriptor?.enumerable || !('value' in descriptor)) {
throw new TypeError('drawing fill runs are invalid');
}
const record = snapshotRecord(
descriptor.value,
['y', 'startX', 'endX'],
`drawing fill run ${index}`
);
const run: DrawingFillRun = {
y: parseCoordinate(record.y, `drawing fill run ${index}.y`),
startX: parseCoordinate(record.startX, `drawing fill run ${index}.startX`),
endX: parseCoordinate(record.endX, `drawing fill run ${index}.endX`),
};
if (
run.startX > run.endX ||
(previous !== undefined &&
(run.y < previous.y ||
(run.y === previous.y && run.startX <= previous.endX)))
) {
throw new TypeError('drawing fill runs must be ordered and non-overlapping');
}
previous = run;
return run;
});
};
export const parseDrawingElement = (value: unknown): DrawingElement => {
const base = snapshotRecord(
value,
['id', 'kind', 'mode', 'shape', 'color', 'size', 'points', 'font', 'x', 'y', 'text', 'runs'],
'drawing element'
);
const id = parseDrawingElementId(base.id);
if (base.kind === 'stroke') {
if (base.mode !== 'paint' && base.mode !== 'erase') {
throw new TypeError('drawing element mode is invalid');
}
const exact = snapshotRecord(value, ['id', 'kind', 'mode', 'color', 'size', 'points'], 'drawing stroke');
return {
id,
kind: 'stroke',
mode: exact.mode as 'paint' | 'erase',
color: parseColor(exact.color),
size: parseSize(exact.size, 64),
points: parsePoints(exact.points, 1),
};
}
if (base.kind === 'shape') {
const exact = snapshotRecord(value, ['id', 'kind', 'shape', 'color', 'size', 'points'], 'drawing shape');
const shapes: DrawingShape[] = [
'line',
'rectangle',
'rectangle-filled',
'circle',
'circle-filled',
'polygon',
];
if (typeof exact.shape !== 'string' || !shapes.includes(exact.shape as DrawingShape)) {
throw new TypeError('drawing element shape is invalid');
}
const shape = exact.shape as DrawingShape;
return {
id,
kind: 'shape',
shape,
color: parseColor(exact.color),
size: parseSize(exact.size, 64),
points: parsePoints(exact.points, shape === 'polygon' ? 2 : 2, shape === 'polygon' ? 64 : 2),
};
}
if (base.kind === 'text') {
const exact = snapshotRecord(value, ['id', 'kind', 'color', 'font', 'size', 'x', 'y', 'text'], 'drawing text');
const fonts: DrawingFont[] = ['site', 'inter', 'velvelyne', 'minecraft', 'monospace'];
if (typeof exact.font !== 'string' || !fonts.includes(exact.font as DrawingFont)) {
throw new TypeError('drawing element font is invalid');
}
if (typeof exact.text !== 'string' || exact.text.trim().length < 1 || exact.text.length > 512) {
throw new TypeError('drawing element text is invalid');
}
return {
id,
kind: 'text',
color: parseColor(exact.color),
font: exact.font as DrawingFont,
size: parseSize(exact.size),
x: parseCoordinate(exact.x, 'drawing text.x'),
y: parseCoordinate(exact.y, 'drawing text.y'),
text: exact.text,
};
}
if (base.kind === 'fill') {
const exact = snapshotRecord(value, ['id', 'kind', 'color', 'runs'], 'drawing fill');
return {
id,
kind: 'fill',
color: parseColor(exact.color),
runs: parseFillRuns(exact.runs),
};
}
throw new TypeError('drawing element kind is invalid');
};
const parseElements = (value: unknown): DrawingElement[] => {
if (!Array.isArray(value) || value.length > MAXIMUM_DRAWING_ELEMENTS) {
throw new TypeError('drawing snapshot elements are invalid');
}
const descriptors = Object.getOwnPropertyDescriptors(value);
if (Reflect.ownKeys(descriptors).length !== value.length + 1) {
throw new TypeError('drawing snapshot elements are invalid');
}
const ids = new Set<string>();
const elements = Array.from({ length: value.length }, (_, index) => {
const descriptor = descriptors[String(index)];
if (!descriptor?.enumerable || !('value' in descriptor)) {
throw new TypeError('drawing snapshot elements are invalid');
}
const element = parseDrawingElement(descriptor.value);
if (ids.has(element.id)) throw new TypeError('drawing snapshot element ids must be unique');
ids.add(element.id);
return element;
});
if (elements.filter((element) => element.kind === 'fill').length > MAXIMUM_DRAWING_FILL_ELEMENTS) {
throw new TypeError('drawing snapshot contains too many fills');
}
return elements;
};
export const parseDrawingClientMessage = (value: unknown): DrawingClientMessage => {
const message = snapshotRecord(
value,
['type', 'protocolVersion', 'roomId', 'mutationId', 'expectedRevision', 'element', 'id'],
'DrawingClientMessage'
);
if (message.protocolVersion !== 1) throw new TypeError('drawing protocolVersion must be 1');
if (message.type === 'drawing.subscribe') {
snapshotRecord(value, ['type', 'protocolVersion', 'roomId'], String(message.type));
return { type: message.type, protocolVersion: 1, roomId: parseMatrixRoomId(message.roomId) };
}
if (message.type === 'drawing.unsubscribe') {
snapshotRecord(value, ['type', 'protocolVersion'], String(message.type));
return { type: message.type, protocolVersion: 1 };
}
if (message.type === 'drawing.upsert') {
snapshotRecord(value, ['type', 'protocolVersion', 'mutationId', 'expectedRevision', 'element'], 'drawing.upsert');
return {
type: 'drawing.upsert',
protocolVersion: 1,
mutationId: parseDrawingMutationId(message.mutationId),
...(message.expectedRevision === undefined
? {}
: { expectedRevision: parseRevision(message.expectedRevision, 'drawing expected revision') }),
element: parseDrawingElement(message.element),
};
}
if (message.type === 'drawing.remove') {
snapshotRecord(value, ['type', 'protocolVersion', 'mutationId', 'expectedRevision', 'id'], 'drawing.remove');
return {
type: 'drawing.remove',
protocolVersion: 1,
mutationId: parseDrawingMutationId(message.mutationId),
...(message.expectedRevision === undefined
? {}
: { expectedRevision: parseRevision(message.expectedRevision, 'drawing expected revision') }),
id: parseDrawingElementId(message.id),
};
}
throw new TypeError('DrawingClientMessage.type is unsupported');
};
export const parseDrawingServerMessage = (value: unknown): DrawingServerMessage => {
const message = snapshotRecord(
value,
['type', 'protocolVersion', 'width', 'height', 'revision', 'mutationId', 'elements', 'element', 'id', 'code'],
'DrawingServerMessage'
);
if (message.protocolVersion !== 1) throw new TypeError('drawing protocolVersion must be 1');
if (message.type === 'drawing.error') {
snapshotRecord(value, ['type', 'protocolVersion', 'mutationId', 'code'], 'drawing.error');
if (message.code !== 'rejected') throw new TypeError('drawing error code is invalid');
return {
type: 'drawing.error',
protocolVersion: 1,
mutationId: parseDrawingMutationId(message.mutationId),
code: 'rejected',
};
}
const revision = parseRevision(message.revision, 'drawing revision');
if (message.type === 'drawing.snapshot') {
snapshotRecord(value, ['type', 'protocolVersion', 'width', 'height', 'revision', 'elements'], 'drawing.snapshot');
if (message.width !== BOTSU_DRAWING_SIZE || message.height !== BOTSU_DRAWING_SIZE) {
throw new TypeError('drawing snapshot dimensions are invalid');
}
return {
type: 'drawing.snapshot',
protocolVersion: 1,
width: BOTSU_DRAWING_SIZE,
height: BOTSU_DRAWING_SIZE,
revision,
elements: parseElements(message.elements),
};
}
if (message.type === 'drawing.upsert') {
snapshotRecord(value, ['type', 'protocolVersion', 'revision', 'mutationId', 'element'], 'drawing.upsert');
return {
type: 'drawing.upsert',
protocolVersion: 1,
revision,
mutationId: parseDrawingMutationId(message.mutationId),
element: parseDrawingElement(message.element),
};
}
if (message.type === 'drawing.remove') {
snapshotRecord(value, ['type', 'protocolVersion', 'revision', 'mutationId', 'id'], 'drawing.remove');
return {
type: 'drawing.remove',
protocolVersion: 1,
revision,
mutationId: parseDrawingMutationId(message.mutationId),
id: parseDrawingElementId(message.id),
};
}
throw new TypeError('DrawingServerMessage.type is unsupported');
};
+37
View File
@@ -43,6 +43,43 @@ export {
type PixelCanvasUnsubscribe,
} from "./presence.ts";
export {
BOTSU_DRAWING_SIZE,
MAXIMUM_DRAWING_ELEMENTS,
MAXIMUM_DRAWING_FILL_ELEMENTS,
MAXIMUM_DRAWING_FILL_RUNS,
MAXIMUM_DRAWING_POINTS,
createBotsuDrawingResourceId,
parseBotsuDrawingResourceId,
parseDrawingClientMessage,
parseDrawingElement,
parseDrawingElementId,
parseDrawingMutationId,
parseDrawingReference,
parseDrawingServerMessage,
parseMatrixRoomId,
type DrawingClientMessage,
type DrawingElement,
type DrawingError,
type DrawingFillElement,
type DrawingFillRun,
type DrawingFont,
type DrawingPoint,
type DrawingReference,
type DrawingRemoteRemove,
type DrawingRemoteUpsert,
type DrawingRemove,
type DrawingServerMessage,
type DrawingShape,
type DrawingShapeElement,
type DrawingSnapshot,
type DrawingStrokeElement,
type DrawingSubscribe,
type DrawingTextElement,
type DrawingUnsubscribe,
type DrawingUpsert,
} from "./drawing.ts";
export {
BOTSU_WORKSPACE_EVENT_TYPE,
BOTSU_WORKSPACE_SCHEMA_VERSION,