From ed3f934b61f17d86788189c1e5816c0d6ea1e22f Mon Sep 17 00:00:00 2001 From: BOTSU Date: Sat, 8 Aug 2026 20:31:07 +0200 Subject: [PATCH] feat: expand BOTSU creative rooms and apps --- apps/client/package.json | 3 +- .../create-room/CreateRoomTypeSelector.tsx | 25 + .../src/app/components/create-room/types.ts | 1 + .../src/app/components/create-room/utils.ts | 19 +- .../app/features/create-room/CreateRoom.tsx | 10 +- .../features/create-room/CreateRoomModal.tsx | 2 + .../src/app/features/room/CallChatView.tsx | 4 +- apps/client/src/app/features/room/Room.tsx | 130 ++- .../src/app/features/room/RoomViewHeader.tsx | 46 +- apps/client/src/app/pages/Router.tsx | 3 + apps/client/src/app/pages/paths.ts | 1 + apps/client/src/app/state/callEmbed.ts | 2 + apps/client/src/app/utils/room.ts | 12 +- apps/client/src/botsu/apps/catalog.test.ts | 33 +- apps/client/src/botsu/apps/catalog.ts | 16 + .../src/botsu/drawing/BotsuDrawingCanvas.tsx | 404 ++++++++ .../botsu/drawing/BotsuDrawingRoomView.tsx | 9 +- .../botsu/drawing/BotsuDrawingTransport.tsx | 101 +- .../src/botsu/drawing/DrawingContext.tsx | 32 + .../src/botsu/drawing/DrawingRoomToolbar.tsx | 154 ++- .../src/botsu/drawing/DrawingToolContext.tsx | 65 +- .../src/botsu/drawing/drawing-bridge.test.ts | 236 +++++ .../src/botsu/drawing/drawing-bridge.ts | 230 +++++ .../src/botsu/drawing/drawing-export.test.ts | 21 + .../src/botsu/drawing/drawing-export.ts | 97 ++ .../src/botsu/drawing/drawing-model.test.ts | 106 ++ .../client/src/botsu/drawing/drawing-model.ts | 214 ++++ .../src/botsu/drawing/drawing-ui.test.ts | 89 ++ apps/client/src/botsu/drawing/drawing.css | 126 ++- .../src/botsu/moodboard/BotsuMoodboard.tsx | 363 +++++++ .../src/botsu/moodboard/controller.test.ts | 82 ++ apps/client/src/botsu/moodboard/controller.ts | 112 +++ apps/client/src/botsu/moodboard/index.ts | 1 + apps/client/src/botsu/moodboard/model.test.ts | 72 ++ apps/client/src/botsu/moodboard/model.ts | 134 +++ .../src/botsu/moodboard/moodboard-ui.test.ts | 33 + apps/client/src/botsu/moodboard/moodboard.css | 289 ++++++ .../src/botsu/presence/socket-message.test.ts | 25 + .../src/botsu/presence/socket-message.ts | 10 +- apps/client/src/botsu/shell/BotsuFrame.tsx | 1 + apps/client/src/botsu/shell/BotsuLauncher.tsx | 4 +- apps/client/src/botsu/shell/BotsuNav.tsx | 6 +- apps/client/src/botsu/shell/layout.test.ts | 14 +- .../src/botsu/start/BotsuPixelCanvas.tsx | 3 +- .../client/src/botsu/start/BotsuStartPage.tsx | 4 +- .../storyboard/BotsuStoryboardRoomView.tsx | 310 ++++++ apps/client/src/botsu/storyboard/index.ts | 2 + .../client/src/botsu/storyboard/model.test.ts | 112 +++ apps/client/src/botsu/storyboard/model.ts | 141 +++ .../botsu/storyboard/storyboard-ui.test.ts | 50 + .../src/botsu/storyboard/storyboard.css | 283 ++++++ .../src/botsu/voxel/BotsuVoxelRoomView.css | 195 +++- .../src/botsu/voxel/BotsuVoxelRoomView.tsx | 60 +- .../src/botsu/voxel/VoxelEditorContext.tsx | 256 +++++ .../src/botsu/voxel/VoxelMaterialsDrawer.tsx | 187 ++++ .../src/botsu/voxel/VoxelRoomToolbar.tsx | 159 +++ apps/client/src/botsu/voxel/index.ts | 3 + .../src/botsu/voxel/voxel-color.test.ts | 24 + apps/client/src/botsu/voxel/voxel-color.ts | 40 + apps/client/src/botsu/voxel/voxel-ui.test.ts | 77 ++ .../src/botsu/watch/BotsuWatchRoomView.tsx | 705 ++++++++++---- .../src/botsu/watch/WatchEmbedPlayer.tsx | 268 ++++++ apps/client/src/botsu/watch/jellyfin.test.ts | 236 +++++ apps/client/src/botsu/watch/jellyfin.ts | 340 +++++++ apps/client/src/botsu/watch/providers.test.ts | 77 ++ apps/client/src/botsu/watch/providers.ts | 126 +++ apps/client/src/botsu/watch/watch-room.css | 308 +++--- apps/client/src/botsu/watch/watch-ui.test.ts | 68 ++ apps/client/src/types/matrix/room.ts | 3 + apps/client/tsconfig.botsu-ui.json | 3 + apps/client/tsconfig.botsu.json | 6 + apps/client/vendor/twitch/README.md | 9 + apps/client/vendor/twitch/embed-v1.js | 910 ++++++++++++++++++ .../vendor/voxelier/assets/index-B558jG0T.js | 2 +- .../client/vendor/voxelier/botsu-embedded.css | 174 +--- apps/client/vendor/voxelier/index.html | 22 + apps/client/vite.config.js | 4 + apps/presence-api/package-lock.json | 160 +++ apps/presence-api/package.json | 2 + .../sql/matrix-drawing-membership.sql | 48 + apps/presence-api/src/drawing-store.test.ts | 214 ++++ apps/presence-api/src/drawing-store.ts | 478 +++++++++ .../src/matrix-membership.test.ts | 67 ++ apps/presence-api/src/matrix-membership.ts | 59 ++ apps/presence-api/src/moodboard-http.test.ts | 77 ++ .../src/moodboard-pinterest.test.ts | 70 ++ apps/presence-api/src/moodboard-pinterest.ts | 169 ++++ .../presence-api/src/moodboard-search.test.ts | 34 + apps/presence-api/src/moodboard-search.ts | 29 + apps/presence-api/src/moodboard-store.test.ts | 81 ++ apps/presence-api/src/moodboard-store.ts | 165 ++++ apps/presence-api/src/server.test.ts | 297 +++++- apps/presence-api/src/server.ts | 451 ++++++++- package-lock.json | 177 ++++ packages/protocol/package.json | 2 +- packages/protocol/src/drawing.test.ts | 229 +++++ packages/protocol/src/drawing.ts | 482 ++++++++++ packages/protocol/src/index.ts | 37 + 98 files changed, 11197 insertions(+), 635 deletions(-) create mode 100644 apps/client/src/botsu/drawing/BotsuDrawingCanvas.tsx create mode 100644 apps/client/src/botsu/drawing/DrawingContext.tsx create mode 100644 apps/client/src/botsu/drawing/drawing-bridge.test.ts create mode 100644 apps/client/src/botsu/drawing/drawing-bridge.ts create mode 100644 apps/client/src/botsu/drawing/drawing-export.test.ts create mode 100644 apps/client/src/botsu/drawing/drawing-export.ts create mode 100644 apps/client/src/botsu/drawing/drawing-model.test.ts create mode 100644 apps/client/src/botsu/drawing/drawing-model.ts create mode 100644 apps/client/src/botsu/drawing/drawing-ui.test.ts create mode 100644 apps/client/src/botsu/moodboard/BotsuMoodboard.tsx create mode 100644 apps/client/src/botsu/moodboard/controller.test.ts create mode 100644 apps/client/src/botsu/moodboard/controller.ts create mode 100644 apps/client/src/botsu/moodboard/index.ts create mode 100644 apps/client/src/botsu/moodboard/model.test.ts create mode 100644 apps/client/src/botsu/moodboard/model.ts create mode 100644 apps/client/src/botsu/moodboard/moodboard-ui.test.ts create mode 100644 apps/client/src/botsu/moodboard/moodboard.css create mode 100644 apps/client/src/botsu/storyboard/BotsuStoryboardRoomView.tsx create mode 100644 apps/client/src/botsu/storyboard/index.ts create mode 100644 apps/client/src/botsu/storyboard/model.test.ts create mode 100644 apps/client/src/botsu/storyboard/model.ts create mode 100644 apps/client/src/botsu/storyboard/storyboard-ui.test.ts create mode 100644 apps/client/src/botsu/storyboard/storyboard.css create mode 100644 apps/client/src/botsu/voxel/VoxelEditorContext.tsx create mode 100644 apps/client/src/botsu/voxel/VoxelMaterialsDrawer.tsx create mode 100644 apps/client/src/botsu/voxel/VoxelRoomToolbar.tsx create mode 100644 apps/client/src/botsu/voxel/voxel-color.test.ts create mode 100644 apps/client/src/botsu/voxel/voxel-color.ts create mode 100644 apps/client/src/botsu/voxel/voxel-ui.test.ts create mode 100644 apps/client/src/botsu/watch/WatchEmbedPlayer.tsx create mode 100644 apps/client/src/botsu/watch/jellyfin.test.ts create mode 100644 apps/client/src/botsu/watch/jellyfin.ts create mode 100644 apps/client/src/botsu/watch/providers.test.ts create mode 100644 apps/client/src/botsu/watch/providers.ts create mode 100644 apps/client/src/botsu/watch/watch-ui.test.ts create mode 100644 apps/client/vendor/twitch/README.md create mode 100644 apps/client/vendor/twitch/embed-v1.js create mode 100644 apps/presence-api/sql/matrix-drawing-membership.sql create mode 100644 apps/presence-api/src/drawing-store.test.ts create mode 100644 apps/presence-api/src/drawing-store.ts create mode 100644 apps/presence-api/src/matrix-membership.test.ts create mode 100644 apps/presence-api/src/matrix-membership.ts create mode 100644 apps/presence-api/src/moodboard-http.test.ts create mode 100644 apps/presence-api/src/moodboard-pinterest.test.ts create mode 100644 apps/presence-api/src/moodboard-pinterest.ts create mode 100644 apps/presence-api/src/moodboard-search.test.ts create mode 100644 apps/presence-api/src/moodboard-search.ts create mode 100644 apps/presence-api/src/moodboard-store.test.ts create mode 100644 apps/presence-api/src/moodboard-store.ts create mode 100644 packages/protocol/src/drawing.test.ts create mode 100644 packages/protocol/src/drawing.ts diff --git a/apps/client/package.json b/apps/client/package.json index e1c2235..6a800e0 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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", diff --git a/apps/client/src/app/components/create-room/CreateRoomTypeSelector.tsx b/apps/client/src/app/components/create-room/CreateRoomTypeSelector.tsx index dc00612..64ed9b3 100644 --- a/apps/client/src/app/components/create-room/CreateRoomTypeSelector.tsx +++ b/apps/client/src/app/components/create-room/CreateRoomTypeSelector.tsx @@ -170,6 +170,31 @@ export function CreateRoomTypeSelector({ + onSelect(CreateRoomType.StoryboardRoom)} + disabled={disabled} + > + } + after={value === CreateRoomType.StoryboardRoom && } + > + + + Story Room + + + - Scènes, dialogues, images et sons à réarranger ensemble. + + + + ); } diff --git a/apps/client/src/app/components/create-room/types.ts b/apps/client/src/app/components/create-room/types.ts index 764e665..09c7a88 100644 --- a/apps/client/src/app/components/create-room/types.ts +++ b/apps/client/src/app/components/create-room/types.ts @@ -5,6 +5,7 @@ export enum CreateRoomType { DrawingRoom = 'drawing', VoxelRoom = 'voxel', WatchRoom = 'watch', + StoryboardRoom = 'storyboard', } export enum CreateRoomAccess { diff --git a/apps/client/src/app/components/create-room/utils.ts b/apps/client/src/app/components/create-room/utils.ts index cdb6f0a..644916b 100644 --- a/apps/client/src/app/components/create-room/utils.ts +++ b/apps/client/src/app/components/create-room/utils.ts @@ -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 => { 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, }; diff --git a/apps/client/src/app/features/create-room/CreateRoom.tsx b/apps/client/src/app/features/create-room/CreateRoom.tsx index 6d36e08..d8329a5 100644 --- a/apps/client/src/app/features/create-room/CreateRoom.tsx +++ b/apps/client/src/app/features/create-room/CreateRoom.tsx @@ -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 { diff --git a/apps/client/src/app/features/create-room/CreateRoomModal.tsx b/apps/client/src/app/features/create-room/CreateRoomModal.tsx index 5d224d3..333dad0 100644 --- a/apps/client/src/app/features/create-room/CreateRoomModal.tsx +++ b/apps/client/src/app/features/create-room/CreateRoomModal.tsx @@ -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'} diff --git a/apps/client/src/app/features/room/CallChatView.tsx b/apps/client/src/app/features/room/CallChatView.tsx index 6c32f4a..1911002 100644 --- a/apps/client/src/app/features/room/CallChatView.tsx +++ b/apps/client/src/app/features/room/CallChatView.tsx @@ -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 ( + + + + ); +} + +function VoxelRoomContent({ room }: { room: MatrixRoom }) { + const screenSize = useScreenSizeContext(); + const { materialsOpen } = useVoxelEditor(); + return ( + <> + + + + + + + + + + ); +} 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 ( @@ -76,7 +134,12 @@ export function Room() { )} - {!callView && !documentView && !drawingView && !voxelView && !watchView && ( + {!callView && + !documentView && + !drawingView && + !voxelView && + !watchView && + !storyboardView && ( @@ -96,27 +159,53 @@ export function Room() { )} {!callView && watchView && ( - + - + )} - {!callView && voxelView && ( + {!callView && watchView && watchChat && ( + <> + {screenSize === ScreenSize.Desktop && ( + + )} + setWatchChat(false)} /> + + )} + {!callView && storyboardView && ( - - - + + )} + {!callView && voxelView && ( + + + + )} {!callView && drawingView && ( - + - + @@ -124,7 +213,16 @@ export function Room() { - + + )} + + {!callView && drawingView && drawingChat && ( + <> + {screenSize === ScreenSize.Desktop && ( + + )} + setDrawingChat(false)} /> + )} {callView && chat && ( diff --git a/apps/client/src/app/features/room/RoomViewHeader.tsx b/apps/client/src/app/features/room/RoomViewHeader.tsx index 1d0bd57..ac141d6 100644 --- a/apps/client/src/app/features/room/RoomViewHeader.tsx +++ b/apps/client/src/app/features/room/RoomViewHeader.tsx @@ -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 ( + + {watchChat ? 'Fermer la discussion' : 'Ouvrir la discussion'} + + } + > + {(triggerRef) => ( + setWatchChat(!watchChat)} + > + + + )} + + ); +} + export function RoomViewHeader({ callView }: { callView?: boolean }) { const navigate = useNavigate(); const mx = useMatrixClient(); @@ -544,6 +575,8 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { ) : isDrawingRoom(room) ? ( + ) : isVoxelRoom(room) ? ( + ) : ( <> )} - {!room.isCallRoom() && !isDocumentRoom(room) && !isDrawingRoom(room) && livekitSupported && rtcSupported && hasCallPermission && ( - - )} + {!room.isCallRoom() && + !isDocumentRoom(room) && + !isDrawingRoom(room) && + !isVoxelRoom(room) && + livekitSupported && + rtcSupported && + hasCallPermission && } + {isWatchRoom(room) && } {screenSize === ScreenSize.Desktop && ( { const { hashRouter } = clientConfig; @@ -196,6 +198,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/client/src/app/pages/paths.ts b/apps/client/src/app/pages/paths.ts index 709d807..9cb5094 100644 --- a/apps/client/src/app/pages/paths.ts +++ b/apps/client/src/app/pages/paths.ts @@ -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}`; diff --git a/apps/client/src/app/state/callEmbed.ts b/apps/client/src/app/state/callEmbed.ts index a9f8930..3893459 100644 --- a/apps/client/src/app/state/callEmbed.ts +++ b/apps/client/src/app/state/callEmbed.ts @@ -18,3 +18,5 @@ export const callEmbedAtom = atom(false); +export const drawingChatAtom = atom(false); +export const watchChatAtom = atom(false); diff --git a/apps/client/src/app/utils/room.ts b/apps/client/src/app/utils/room.ts index 2b89817..40d32fa 100644 --- a/apps/client/src/app/utils/room.ts +++ b/apps/client/src/app/utils/room.ts @@ -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 || diff --git a/apps/client/src/botsu/apps/catalog.test.ts b/apps/client/src/botsu/apps/catalog.test.ts index c426f74..2d10a11 100644 --- a/apps/client/src/botsu/apps/catalog.test.ts +++ b/apps/client/src/botsu/apps/catalog.test.ts @@ -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'); diff --git a/apps/client/src/botsu/apps/catalog.ts b/apps/client/src/botsu/apps/catalog.ts index 3c76136..9f5dcfd 100644 --- a/apps/client/src/botsu/apps/catalog.ts +++ b/apps/client/src/botsu/apps/catalog.ts @@ -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(['dessin', 'voxel', 'cinema', 'radio']); + export const getVisibleApps = ( roles: ReadonlySet, apps: readonly BotsuApp[] = botsuApps ): readonly BotsuApp[] => apps.filter((app) => app.roles.some((role) => roles.has(role))); +export const getBotsuApplicationApps = ( + roles: ReadonlySet, + 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); diff --git a/apps/client/src/botsu/drawing/BotsuDrawingCanvas.tsx b/apps/client/src/botsu/drawing/BotsuDrawingCanvas.tsx new file mode 100644 index 0000000..7b15607 --- /dev/null +++ b/apps/client/src/botsu/drawing/BotsuDrawingCanvas.tsx @@ -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> = { + rectangle: 'rectangle', + 'rectangle-filled': 'rectangle-filled', + circle: 'circle', + 'circle-filled': 'circle-filled', + line: 'line', +}; + +export function BotsuDrawingCanvas() { + const canvasRef = useRef(null); + const activeRef = useRef(); + const dragRef = useRef<{ element: DrawingElement; start: DrawingPoint }>(); + const polygonRef = useRef([]); + const lastPublishRef = useRef(0); + const [preview, setPreview] = useState(); + const [selectedId, setSelectedId] = useState(); + 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): 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): 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): 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): 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 ( +
+
+ + {status} +
+ +
+ ); +} diff --git a/apps/client/src/botsu/drawing/BotsuDrawingRoomView.tsx b/apps/client/src/botsu/drawing/BotsuDrawingRoomView.tsx index 55542db..4898985 100644 --- a/apps/client/src/botsu/drawing/BotsuDrawingRoomView.tsx +++ b/apps/client/src/botsu/drawing/BotsuDrawingRoomView.tsx @@ -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 ( -
+
- +
); diff --git a/apps/client/src/botsu/drawing/BotsuDrawingTransport.tsx b/apps/client/src/botsu/drawing/BotsuDrawingTransport.tsx index 3dfcc9a..981c475 100644 --- a/apps/client/src/botsu/drawing/BotsuDrawingTransport.tsx +++ b/apps/client/src/botsu/drawing/BotsuDrawingTransport.tsx @@ -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(); + 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; } diff --git a/apps/client/src/botsu/drawing/DrawingContext.tsx b/apps/client/src/botsu/drawing/DrawingContext.tsx new file mode 100644 index 0000000..729896f --- /dev/null +++ b/apps/client/src/botsu/drawing/DrawingContext.tsx @@ -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(undefined); + +export function DrawingProvider({ children }: React.PropsWithChildren) { + const bridgeRef = useRef(); + if (!bridgeRef.current) bridgeRef.current = createDrawingBridge(); + return {children}; +} + +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(bridge.getState()); + useEffect(() => bridge.activate(setState), [bridge]); + return { state, upsert: bridge.upsert, remove: bridge.remove }; +}; diff --git a/apps/client/src/botsu/drawing/DrawingRoomToolbar.tsx b/apps/client/src/botsu/drawing/DrawingRoomToolbar.tsx index 272de77..b2a4e3a 100644 --- a/apps/client/src/botsu/drawing/DrawingRoomToolbar.tsx +++ b/apps/client/src/botsu/drawing/DrawingRoomToolbar.tsx @@ -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: