feat: expand BOTSU creative rooms and apps
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 l’instant. 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);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 n’a pas pu être synchronisé.');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (card: StoryboardCard) => {
|
||||
setError('');
|
||||
try {
|
||||
await sendCard({ ...card, deleted: true, updatedAt: Date.now() });
|
||||
} catch {
|
||||
setError('La carte n’a 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 s’ouvre.',
|
||||
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']
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'/);
|
||||
});
|
||||
@@ -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). L’adresse est enregistrée dans l’état Matrix du salon :
|
||||
n’utilisez pas d’URL contenant un secret.
|
||||
MP4, WebM, HLS, YouTube et Twitch. Pour Jellyfin, seul l’identifiant 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"/
|
||||
);
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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=/);
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+9
@@ -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
@@ -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);
|
||||
})()
|
||||
);
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+23
-151
@@ -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
@@ -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" />
|
||||
|
||||
@@ -70,6 +70,10 @@ const copyFiles = {
|
||||
src: 'vendor/voxelier/sanctuary-assets',
|
||||
dest: '',
|
||||
},
|
||||
{
|
||||
src: 'vendor/twitch/embed-v1.js',
|
||||
dest: 'vendor/twitch',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Generated
+160
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 & idées ’",
|
||||
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(/&/g, "&")
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/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
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Generated
+177
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user