diff --git a/apps/client/package.json b/apps/client/package.json index 75084f15..d4ad6bb5 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -66,6 +66,7 @@ "eslint-plugin-playwright": "^0.15.3", "happy-dom": "^10.9.0", "jsdom": "^22.1.0", + "msw": "*", "resize-observer-polyfill": "^1.5.1", "vite-plugin-pwa": "^0.16.4", "vitest-canvas-mock": "^0.3.2" diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index ffab125e..06667711 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -1,134 +1,92 @@ -import { useEffect, useLayoutEffect } from 'react'; +import { useEffect } from 'react'; import { LOCAL_STORAGE_KEY, - appState, - libraryState, - PAGE_URL_SEARCH_PARAM_KEY, LOCAL_STORAGE_LIBRARY_KEY, + PAGE_URL_SEARCH_PARAM_KEY, } from '@/constants/app'; -import { useAppDispatch, useAppStore } from '@/stores/hooks'; -import { canvasActions } from '@/stores/slices/canvas'; +import { useAppDispatch } from '@/stores/hooks'; +import { canvasActions } from '@/services/canvas/slice'; import { storage } from '@/utils/storage'; import MainLayout from './components/Layout/MainLayout/MainLayout'; import { useWebSocket } from './contexts/websocket'; -import useWSMessage from './hooks/useWSMessage'; -import useUrlSearchParams from './hooks/useUrlSearchParams/useUrlSearchParams'; -import { historyActions } from './stores/reducers/history'; -import { collaborationActions } from './stores/slices/collaboration'; -import { libraryActions } from './stores/slices/library'; +import { libraryActions } from '@/services/library/slice'; +import { + addCollabActionsListeners, + subscribeToIncomingCollabMessages, +} from './services/collaboration/listeners'; +import useParam from './hooks/useParam/useParam'; +import api from './services/api'; +import { useNotifications } from './contexts/notifications'; import type { Library, AppState } from '@/constants/app'; const App = () => { - const params = useUrlSearchParams(); + const roomId = useParam(PAGE_URL_SEARCH_PARAM_KEY); + const ws = useWebSocket(); - const store = useAppStore(); + const { addNotification } = useNotifications(); const dispatch = useAppDispatch(); - useWSMessage(ws.connection, (message) => { - const { type, data } = message; + useEffect(() => { + if (!roomId) return; - switch (type) { - case 'user-joined': { - dispatch(collaborationActions.addUser(data)); - break; - } - case 'user-change': { - dispatch(collaborationActions.updateUser(data)); - break; - } - case 'user-left': { - dispatch(collaborationActions.removeUser(data)); - break; - } - case 'nodes-set': { - dispatch(canvasActions.setNodes(data)); - break; - } - case 'nodes-add': { - dispatch(canvasActions.addNodes(data)); - break; - } - case 'draft-finish': { - dispatch(canvasActions.addNodes([data.node])); - break; - } - case 'nodes-update': { - dispatch(canvasActions.updateNodes(data)); - break; - } - case 'nodes-delete': { - dispatch(canvasActions.deleteNodes(data)); - break; - } - case 'nodes-move-to-start': { - dispatch(canvasActions.moveNodesToStart(data)); - break; - } - case 'nodes-move-to-end': { - dispatch(canvasActions.moveNodesToEnd(data)); - break; - } - case 'nodes-move-forward': { - dispatch(canvasActions.moveNodesForward(data)); - break; - } - case 'nodes-move-backward': { - dispatch(canvasActions.moveNodesBackward(data)); - break; - } - case 'draft-text-update': { - const textNode = store - .getState() - .canvas.present.nodes.find( - (node) => node.nodeProps.id === data.nodeId, - ); - - if (textNode) { - dispatch( - canvasActions.updateNodes([{ ...textNode, text: data.text }]), - ); - } - break; - } - case 'history-change': { - const action = historyActions[data.action]; - dispatch(action()); + const [request, abortController] = api.getPage(roomId); + + request + .then(({ page }) => { + dispatch(canvasActions.setNodes(page.nodes, { broadcast: false })); + addNotification({ + title: 'Live collaboration', + description: 'You are connected', + type: 'success', + }); + }) + .catch(() => { + addNotification({ + title: 'Live collaboration', + description: 'Disconnected', + type: 'info', + }); + }); + + return () => { + if (abortController.signal) { + abortController.abort(); } + }; + }, [roomId, dispatch, addNotification]); + + useEffect(() => { + if (!ws.isConnected || !roomId) { + return; } - }); - useLayoutEffect(() => { - const pageId = params[PAGE_URL_SEARCH_PARAM_KEY]; + const subscribers = subscribeToIncomingCollabMessages(ws, dispatch); + const unsubscribe = dispatch(addCollabActionsListeners(ws, roomId)); - if (ws.isConnected || pageId) { + return () => { + subscribers.forEach((unsubscribe) => unsubscribe()); + unsubscribe({ cancelActive: true }); + }; + }, [ws, roomId, dispatch]); + + useEffect(() => { + if (ws.isConnected || ws.isConnecting || roomId) { return; } - const state = storage.get(LOCAL_STORAGE_KEY); + const storedCanvasState = storage.get(LOCAL_STORAGE_KEY); - if (state) { - try { - appState.parse(state); - } catch (error) { - return; - } - - dispatch(canvasActions.set(state.page)); + if (storedCanvasState) { + dispatch(canvasActions.set(storedCanvasState.page)); } - }, [ws, params, dispatch]); + }, [ws, roomId, dispatch]); useEffect(() => { - const libraryFromStorage = storage.get(LOCAL_STORAGE_LIBRARY_KEY); - - if (libraryFromStorage) { - try { - libraryState.parse(libraryFromStorage); - } catch (error) { - return; - } + const storedLibrary = storage.get(LOCAL_STORAGE_LIBRARY_KEY); - dispatch(libraryActions.init(libraryFromStorage)); + if (storedLibrary) { + dispatch(libraryActions.set(storedLibrary)); } }, [dispatch]); diff --git a/apps/client/src/__tests__/App.test.tsx b/apps/client/src/__tests__/App.test.tsx index 12da856d..0f9d84b8 100644 --- a/apps/client/src/__tests__/App.test.tsx +++ b/apps/client/src/__tests__/App.test.tsx @@ -1,10 +1,54 @@ import { renderWithProviders } from '@/test/test-utils'; import App from '@/App'; +import { mockGetPageResponse } from '@/test/mocks/handlers'; +import { screen, waitFor } from '@testing-library/react'; +import { PAGE_URL_SEARCH_PARAM_KEY } from '@/constants/app'; +import { setSearchParam } from '@/test/browser-mocks'; +import { nodesGenerator, stateGenerator } from '@/test/data-generators'; describe('App', () => { + afterEach(() => { + Object.defineProperty(window, 'location', window.location); + }); + it('mounts without crashing', () => { const { container } = renderWithProviders(); expect(container).toBeInTheDocument(); }); + + it('sets canvas state from fetched data when in collab mode', async () => { + setSearchParam(PAGE_URL_SEARCH_PARAM_KEY, mockGetPageResponse.page.id); + + const { store } = renderWithProviders(); + + await waitFor(() => { + const { nodes } = store.getState().canvas.present; + + expect(nodes).toEqual(mockGetPageResponse.page.nodes); + }); + }); + + it('calls share this page', async () => { + Object.defineProperty(window.location, 'reload', { + value: vi.fn(), + }); + + const { user } = renderWithProviders(, { + preloadedState: stateGenerator({ + canvas: { + present: { + nodes: nodesGenerator(6), + }, + }, + }), + }); + + await user.click(screen.getByText(/Share/i)); + await user.click(screen.getByText(/Share this page/i)); + + await waitFor(() => { + expect(screen.getByTestId(/loader/i)).toBeInTheDocument(); + }); + }); }); diff --git a/apps/client/src/__tests__/shortcuts.test.tsx b/apps/client/src/__tests__/shortcuts.test.tsx index f9315464..abacda25 100644 --- a/apps/client/src/__tests__/shortcuts.test.tsx +++ b/apps/client/src/__tests__/shortcuts.test.tsx @@ -6,7 +6,7 @@ import { } from '@/test/test-utils'; import { TOOLS } from '@/constants/panels/tools'; import { nodesGenerator, stateGenerator } from '@/test/data-generators'; -import { canvasActions } from '@/stores/slices/canvas'; +import { canvasActions } from '@/services/canvas/slice'; import { mapNodesIds } from '@/utils/node'; import { historyActions } from '@/stores/reducers/history'; diff --git a/apps/client/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx b/apps/client/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx index 555966eb..8b20cbef 100644 --- a/apps/client/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx +++ b/apps/client/src/components/Canvas/DrawingCanvas/DrawingCanvas.tsx @@ -10,15 +10,15 @@ import { } from 'react'; import { Stage } from 'react-konva'; import { useWebSocket } from '@/contexts/websocket'; -import { useAppDispatch, useAppSelector, useAppStore } from '@/stores/hooks'; +import { useAppDispatch, useAppSelector } from '@/stores/hooks'; import { canvasActions, selectConfig, selectSelectedNodesIds, selectToolType, -} from '@/stores/slices/canvas'; -import { selectMyUser } from '@/stores/slices/collaboration'; -import { createNode, isValidNode, mapNodesIds } from '@/utils/node'; +} from '@/services/canvas/slice'; +import { selectThisUser } from '@/services/collaboration/slice'; +import { createNode, mapNodesIds } from '@/utils/node'; import BackgroundLayer from '../Layers/BackgroundLayer'; import { getCursorStyle, @@ -30,12 +30,11 @@ import { import useRefValue from '@/hooks/useRefValue/useRefValue'; import { calculateStageZoomRelativeToPoint } from './helpers/zoom'; import { throttleFn } from '@/utils/timed'; -import { WS_THROTTLE_MS } from '@/constants/app'; -import usePageMutation from '@/hooks/usePageMutation'; import useDrafts from '@/hooks/useDrafts'; import NodesLayer from '../Layers/NodesLayer'; import SelectRect from '../SelectRect'; import MainLayer from './MainLayer'; +import Drafts from '../Node/Drafts'; import { getNormalizedInvertedRect } from '@/utils/position'; import type { DrawPosition } from './helpers/draw'; import type { IRect } from 'konva/lib/types'; @@ -76,18 +75,12 @@ const DrawingCanvas = forwardRef( const stageConfig = useAppSelector(selectConfig); const toolType = useAppSelector(selectToolType); const selectedNodesIds = useAppSelector(selectSelectedNodesIds); - const userId = useAppSelector(selectMyUser); - - const store = useAppStore(); + const thisUser = useAppSelector(selectThisUser); const ws = useWebSocket(); - const { updatePage } = usePageMutation(); - const dispatch = useAppDispatch(); - const nodeDrafts = drafts.map(({ node }) => node); - const cursorStyle = useMemo(() => { return getCursorStyle(toolType, draggingStage, drawing); }, [toolType, drawing, draggingStage]); @@ -102,13 +95,6 @@ const DrawingCanvas = forwardRef( const isLayerListening = !isHandTool && !drawing && isSelectTool; const hasSelectedNodes = intersectedNodesIds.length > 0; - // Required for throttleFn to work - // eslint-disable-next-line react-hooks/exhaustive-deps - const throttledSendWSMessage = useCallback( - throttleFn(ws.send, WS_THROTTLE_MS), - [ws], - ); - // eslint-disable-next-line react-hooks/exhaustive-deps const throttledOnNodesSelect = useCallback(throttleFn(onNodesSelect, 50), [ onNodesSelect, @@ -122,11 +108,6 @@ const DrawingCanvas = forwardRef( throttledOnNodesSelect(intersectedNodesIds); }, [intersectedNodesIds, throttledOnNodesSelect]); - const handlePageUpdate = useCallback(() => { - const currentNodes = store.getState().canvas.present.nodes; - updatePage({ nodes: currentNodes }); - }, [store, updatePage]); - const updateBackgroundRectPosition = useCallback( (stageRect: IRect, stageScale: number) => { if (!backgroundRef.current) return; @@ -142,7 +123,7 @@ const DrawingCanvas = forwardRef( (childrenNodes: ReturnType, position: Point) => { if (!selectRef.current) return; - const selectRect = selectRef.current?.getClientRect(); + const selectRect = selectRef.current.getClientRect(); const nodesIntersectedWithSelectRect = getIntersectingNodes( childrenNodes, @@ -155,28 +136,38 @@ const DrawingCanvas = forwardRef( setIntersectedNodesIds(nodesIds); - if (ws.isConnected && userId) { - throttledSendWSMessage({ - type: 'user-move', - data: { id: userId, position }, - }); + if (ws.isConnected && thisUser) { + ws.send({ type: 'user-move', data: { id: thisUser.id, position } }); } }, - [ws.isConnected, userId, selectRef, throttledSendWSMessage], + [ws, thisUser, selectRef], ); - const handleCreateDraft = useCallback( + const broadcastPointerPosition = useCallback( + (stage: Konva.Stage) => { + if (ws.isConnected && thisUser) { + const position = getRelativePointerPosition(stage); + ws.send( + { type: 'user-move', data: { id: thisUser.id, position } }, + true, + ); + } + }, + [ws, thisUser], + ); + + const handleDraftCreate = useCallback( (toolType: NodeType, position: Point) => { const node = createNode(toolType, position); - setDrafts({ type: 'add', payload: { node } }); + setDrafts({ type: 'create', payload: { node } }); setActiveDraftId(node.nodeProps.id); - if (ws.isConnected && userId) { + if (ws.isConnected) { ws.send({ type: 'draft-create', data: { node } }); } }, - [ws, userId, setActiveDraftId, setDrafts], + [ws, setActiveDraftId, setDrafts], ); const handleDraftDraw = useCallback( @@ -187,14 +178,38 @@ const DrawingCanvas = forwardRef( setDrafts({ type: 'draw', payload: { position, nodeId } }); - if (ws.isConnected && userId) { - throttledSendWSMessage({ - type: 'draft-draw', - data: { userId, position, nodeId }, + if (ws.isConnected && thisUser) { + ws.send( + { + type: 'draft-draw', + data: { userId: thisUser.id, position, nodeId }, + }, + true, + ); + } + }, + [ws, thisUser, activeDraftId, setDrafts], + ); + + const handleDraftTextChange = useCallback( + (node: NodeObject) => { + if (ws.isConnected && thisUser) { + ws.send({ + type: 'draft-update', + data: { node, userId: thisUser.id }, }); } }, - [ws, userId, activeDraftId, setDrafts, throttledSendWSMessage], + [ws, thisUser], + ); + + const handleDraftDelete = useCallback( + (node: NodeObject) => { + if (!drawing) { + setDrafts({ type: 'finish', payload: { nodeId: node.nodeProps.id } }); + } + }, + [drawing, setDrafts], ); const handleDraftFinish = useCallback( @@ -205,34 +220,29 @@ const DrawingCanvas = forwardRef( node.type !== 'draw' && node.type !== 'laser'; setDrafts({ - type: shouldKeepDraft ? 'finish-keep' : 'finish', - payload: { nodeId: node.nodeProps.id }, + type: 'finish', + payload: { nodeId: node.nodeProps.id, keep: shouldKeepDraft }, }); - setActiveDraftId(null); - const nodeIsValid = isValidNode(node); + setActiveDraftId(null); if (shouldResetToolType) { dispatch(canvasActions.setToolType('select')); - nodeIsValid && - dispatch(canvasActions.setSelectedNodesIds([node.nodeProps.id])); + dispatch(canvasActions.setSelectedNodesIds([node.nodeProps.id])); } if (isNodeSavable) { dispatch(canvasActions.addNodes([node])); } - if (ws.isConnected && userId && nodeIsValid) { - const messageType = shouldKeepDraft - ? 'draft-finish-keep' - : 'draft-finish'; - - ws.send({ type: messageType, data: { node, userId } }); - - isNodeSavable && handlePageUpdate(); + if (ws.isConnected && thisUser) { + ws.send({ + type: 'draft-finish', + data: { node, userId: thisUser.id, keep: shouldKeepDraft }, + }); } }, - [ws, userId, handlePageUpdate, dispatch, setDrafts, setActiveDraftId], + [ws, thisUser, dispatch, setDrafts, setActiveDraftId], ); const handleNodePress = useCallback( @@ -269,16 +279,19 @@ const DrawingCanvas = forwardRef( event.evt.stopPropagation(); - const currentPoint = getRelativePointerPosition(stage); + const pointerPosition = getRelativePointerPosition(stage); - setDrawingPosition({ start: currentPoint, current: currentPoint }); + setDrawingPosition({ + start: pointerPosition, + current: pointerPosition, + }); if (toolType !== 'text') { setDrawing(true); } if (toolType !== 'select') { - handleCreateDraft(toolType, currentPoint); + handleDraftCreate(toolType, pointerPosition); } if (hasSelectedNodes) { @@ -288,7 +301,7 @@ const DrawingCanvas = forwardRef( [ toolType, hasSelectedNodes, - handleCreateDraft, + handleDraftCreate, setDrawingPosition, handleNodePress, ], @@ -298,21 +311,26 @@ const DrawingCanvas = forwardRef( (event: KonvaEventObject) => { const stage = event.target.getStage(); - if (!stage || !drawing || toolType === 'hand' || toolType === 'text') { + if (!stage || toolType === 'hand') { return; } - const currentPoint = getRelativePointerPosition(stage); + const pointerPosition = getRelativePointerPosition(stage); + + if (!drawing) { + broadcastPointerPosition(stage); + return; + } setDrawingPosition((prevPosition) => { - return { start: prevPosition.start, current: currentPoint }; + return { start: prevPosition.start, current: pointerPosition }; }); if (toolType === 'select') { const layer = getMainLayer(stage); const children = getLayerNodes(layer); - return handleSelectDraw(children, currentPoint); + return handleSelectDraw(children, pointerPosition); } handleDraftDraw(drawingPosition.current); @@ -321,6 +339,7 @@ const DrawingCanvas = forwardRef( drawing, toolType, drawingPosition, + broadcastPointerPosition, handleDraftDraw, handleSelectDraw, setDrawingPosition, @@ -387,15 +406,17 @@ const DrawingCanvas = forwardRef( const handleStageDragMove = useCallback( (event: KonvaEventObject) => { - if (event.target !== event.target.getStage()) return; + if (event.target !== event.target.getStage()) { + broadcastPointerPosition(event.target.getStage() as Konva.Stage); + return; + } const stage = event.target; - const stageRect = { ...stage.getPosition(), ...stage.getSize() }; updateBackgroundRectPosition(stageRect, stage.scaleX()); }, - [updateBackgroundRectPosition], + [broadcastPointerPosition, updateBackgroundRectPosition], ); const handleStageDragEnd = useCallback( @@ -404,8 +425,7 @@ const DrawingCanvas = forwardRef( return; } - const stage = event.target; - const position = stage.getPosition(); + const position = event.target.getPosition(); dispatch(canvasActions.setStageConfig({ position })); setDraggingStage(false); @@ -426,22 +446,17 @@ const DrawingCanvas = forwardRef( const handleNodesChange = useCallback( (nodes: NodeObject[]) => { dispatch(canvasActions.updateNodes(nodes)); - - if (ws.isConnected) { - ws.send({ type: 'nodes-update', data: nodes }); - handlePageUpdate(); - } }, - [ws, handlePageUpdate, dispatch], + [dispatch], ); - const handleNodeDelete = useCallback( + const handleNodeTextChange = useCallback( (node: NodeObject) => { - if (!drawing) { - setDrafts({ type: 'finish', payload: { nodeId: node.nodeProps.id } }); + if (ws.isConnected) { + ws.send({ type: 'nodes-update', data: [node] }); } }, - [drawing, setDrafts], + [ws], ); const handleLibraryItemDrop = useCallback( @@ -449,25 +464,20 @@ const DrawingCanvas = forwardRef( dispatch(canvasActions.addNodes(nodes)); dispatch(canvasActions.setSelectedNodesIds(mapNodesIds(nodes))); dispatch(canvasActions.setToolType('select')); - - if (ws.isConnected) { - ws.send({ type: 'nodes-add', data: nodes }); - handlePageUpdate(); - } }, - [ws, handlePageUpdate, dispatch], + [dispatch], ); const handleLibraryItemDragOver = useCallback( (position: Point) => { - if (ws.isConnected && userId) { - throttledSendWSMessage({ - type: 'user-move', - data: { id: userId, position }, - }); + if (ws.isConnected && thisUser) { + ws.send( + { type: 'user-move', data: { id: thisUser.id, position } }, + true, + ); } }, - [ws, userId, throttledSendWSMessage], + [ws, thisUser], ); return ( @@ -500,18 +510,19 @@ const DrawingCanvas = forwardRef( + {ws.isConnected && ( - + )} {isSelectRectActive && ( diff --git a/apps/client/src/components/Canvas/DrawingCanvas/MainLayer.tsx b/apps/client/src/components/Canvas/DrawingCanvas/MainLayer.tsx index 81137514..a99c6c77 100644 --- a/apps/client/src/components/Canvas/DrawingCanvas/MainLayer.tsx +++ b/apps/client/src/components/Canvas/DrawingCanvas/MainLayer.tsx @@ -1,6 +1,6 @@ import { useCallback, useRef } from 'react'; import { Layer } from 'react-konva'; -import useEvent from '@/hooks/useEvent'; +import useEvent from '@/hooks/useEvent/useEvent'; import { getUnregisteredPointerPosition } from './helpers/stage'; import { safeJSONParse } from '@/utils/object'; import { duplicateNodesAtPosition } from '@/utils/node'; diff --git a/apps/client/src/components/Canvas/Layers/BackgroundLayer.tsx b/apps/client/src/components/Canvas/Layers/BackgroundLayer.tsx index d8a47f3d..a34c0fff 100644 --- a/apps/client/src/components/Canvas/Layers/BackgroundLayer.tsx +++ b/apps/client/src/components/Canvas/Layers/BackgroundLayer.tsx @@ -11,7 +11,7 @@ type Props = { ref: RefObject; }; -const BackgroundLayer = forwardRef( +const Background = forwardRef( ({ scale, rect }, ref) => { const themeColors = useThemeColors(); @@ -32,6 +32,6 @@ const BackgroundLayer = forwardRef( }, ); -BackgroundLayer.displayName = 'BackgroundLayer'; +Background.displayName = 'BackgroundLayer'; -export default memo(BackgroundLayer); +export default memo(Background); diff --git a/apps/client/src/components/Canvas/Layers/CollaborationLayer.tsx b/apps/client/src/components/Canvas/Layers/CollaborationLayer.tsx index 9b0b8a5a..3d304ded 100644 --- a/apps/client/src/components/Canvas/Layers/CollaborationLayer.tsx +++ b/apps/client/src/components/Canvas/Layers/CollaborationLayer.tsx @@ -1,116 +1,65 @@ import { memo, useCallback, useEffect, useState } from 'react'; -import { WS_THROTTLE_MS } from '@/constants/app'; import { useWebSocket } from '@/contexts/websocket'; -import useWSMessage from '@/hooks/useWSMessage'; import { useAppSelector } from '@/stores/hooks'; -import { selectUsers, selectMyUser } from '@/stores/slices/collaboration'; -import { throttleFn } from '@/utils/timed'; +import { selectCollaborators } from '@/services/collaboration/slice'; import NodeDraft from '../Node/NodeDraft'; import UserCursor from '../UserCursor'; import useDrafts from '@/hooks/useDrafts'; import { noop } from '@/utils/is'; import type { NodeObject, Point } from 'shared'; -import type Konva from 'konva'; -import type { ForwardedRef } from 'react'; type Props = { stageScale: number; - stageRef: ForwardedRef; - isDrawing: boolean; }; type UserPosition = { [id: string]: Point }; -const Collaboration = ({ stageScale, stageRef, isDrawing }: Props) => { +const CollaborationLayer = ({ stageScale }: Props) => { const [userPositions, setUserPositions] = useState({}); - const [drafts, dispatchDrafts] = useDrafts(); - - const nodeDrafts = drafts.map(({ node }) => node); - - const room = useAppSelector(selectUsers); - const thisUserId = useAppSelector(selectMyUser); + const [drafts, setDrafts] = useDrafts(); + const collaborators = useAppSelector(selectCollaborators); const ws = useWebSocket(); - const users = room.map((user) => { - if (user.id in userPositions) { - return { ...user, position: userPositions[user.id] }; - } - return null; - }); - useEffect(() => { - if ( - typeof stageRef === 'function' || - !stageRef?.current || - !ws.isConnected || - !thisUserId - ) { + if (!ws.isConnected) { return; } - const stage = stageRef.current; - const container = stage.container(); - - const handlePointerMove = throttleFn(() => { - if (isDrawing) { - return; - } - - const { x, y } = stage.getRelativePointerPosition() ?? { x: 0, y: 0 }; - - ws.send({ - type: 'user-move', - data: { id: thisUserId, position: [x, y] }, + ws.subscribe('user-move', ({ id, position }) => { + setUserPositions((prevPositions) => ({ + ...prevPositions, + [id]: position, + })); + }); + ws.subscribe('draft-create', ({ node }) => { + setDrafts({ type: 'create', payload: { node } }); + }); + ws.subscribe('draft-draw', ({ nodeId, position, userId }) => { + setDrafts({ type: 'draw', payload: { nodeId, position } }); + setUserPositions((prevPositions) => ({ + ...prevPositions, + [userId]: position.current, + })); + }); + ws.subscribe('draft-update', ({ node }) => { + setDrafts({ type: 'update', payload: { node } }); + }); + ws.subscribe('draft-finish', ({ node, keep }) => { + setDrafts({ + type: 'finish', + payload: { nodeId: node.nodeProps.id, keep }, }); - }, WS_THROTTLE_MS); - - container.addEventListener('pointermove', handlePointerMove); + }); return () => { - container.removeEventListener('pointermove', handlePointerMove); + ws.unsubscribe('user-move'); + ws.unsubscribe('draft-create'); + ws.unsubscribe('draft-draw'); + ws.unsubscribe('draft-update'); + ws.unsubscribe('draft-finish'); }; - }, [stageRef, ws, thisUserId, isDrawing]); - - useWSMessage(ws.connection, ({ type, data }) => { - switch (type) { - case 'draft-create': { - dispatchDrafts({ type: 'add', payload: { node: data.node } }); - break; - } - case 'draft-draw': { - const { nodeId, position, userId } = data; - - dispatchDrafts({ type: 'draw', payload: { nodeId, position } }); - setUserPositions((prevPositions) => ({ - ...prevPositions, - [userId]: position.current, - })); - break; - } - case 'draft-finish': { - dispatchDrafts({ - type: 'finish', - payload: { nodeId: data.node.nodeProps.id }, - }); - break; - } - case 'draft-finish-keep': { - dispatchDrafts({ - type: 'finish-keep', - payload: { nodeId: data.node.nodeProps.id }, - }); - break; - } - case 'user-move': { - setUserPositions((prevPositions) => ({ - ...prevPositions, - [data.id]: data.position, - })); - break; - } - } - }); + }, [ws, setDrafts]); const handleNodeDelete = useCallback( (node: NodeObject) => { @@ -120,18 +69,18 @@ const Collaboration = ({ stageScale, stageRef, isDrawing }: Props) => { ); if (!isDrawing) { - dispatchDrafts({ + setDrafts({ type: 'finish', payload: { nodeId: node.nodeProps.id }, }); } }, - [drafts, dispatchDrafts], + [drafts, setDrafts], ); return ( <> - {nodeDrafts.map((node) => { + {drafts.map(({ node }) => { return ( { /> ); })} - {users.map((user) => { + {collaborators.map((user) => { + const position = userPositions[user.id]; + + if (!position) return null; + return ( - user && ( - - ) + ); })} ); }; -export default memo(Collaboration); +export default memo(CollaborationLayer); diff --git a/apps/client/src/components/Canvas/Layers/NodesLayer.tsx b/apps/client/src/components/Canvas/Layers/NodesLayer.tsx index 5b80d41b..64d6f2ad 100644 --- a/apps/client/src/components/Canvas/Layers/NodesLayer.tsx +++ b/apps/client/src/components/Canvas/Layers/NodesLayer.tsx @@ -1,30 +1,20 @@ import { useCallback, useMemo } from 'react'; import Nodes from '../Node/Nodes'; import NodeGroupTransformer from '../Transformer/NodeGroupTransformer'; -import NodeDraft from '../Node/NodeDraft'; import useFontFaceObserver from '@/hooks/useFontFaceObserver'; import { TEXT } from '@/constants/shape'; import { useAppSelector } from '@/stores/hooks'; -import { selectNodes } from '@/stores/slices/canvas'; +import { selectNodes } from '@/services/canvas/slice'; import type { NodeObject } from 'shared'; +import type { NodeComponentProps } from '../Node/Node'; type Props = { selectedNodesIds: string[]; stageScale: number; - nodeDrafts: NodeObject[]; onNodesChange: (nodes: NodeObject[]) => void; - onNodeDraftFinish: (node: NodeObject) => void; - onNodesDelete: (node: NodeObject) => void; -}; +} & Pick; -const NodesLayer = ({ - selectedNodesIds, - stageScale, - nodeDrafts, - onNodesChange, - onNodeDraftFinish, - onNodesDelete, -}: Props) => { +const NodesLayer = ({ selectedNodesIds, stageScale, onNodesChange, onTextChange }: Props) => { const nodes = useAppSelector(selectNodes); /* @@ -69,18 +59,8 @@ const NodesLayer = ({ selectedNodeId={selectedNodeId} stageScale={stageScale} onNodeChange={handleNodeChange} + onTextChange={onTextChange} /> - {nodeDrafts.map((node) => { - return ( - - ); - })} {selectedMultipleNodes ? ( ; + +const Drafts = ({ + drafts, + stageScale, + onNodeChange, + onNodeDelete, + onTextChange, +}: Props) => { + return ( + <> + {drafts.map(({ node }) => { + return ( + + ); + })} + + ); +}; + +export default memo(Drafts); diff --git a/apps/client/src/components/Canvas/Node/Node.tsx b/apps/client/src/components/Canvas/Node/Node.tsx index 4c840638..32754cbb 100644 --- a/apps/client/src/components/Canvas/Node/Node.tsx +++ b/apps/client/src/components/Canvas/Node/Node.tsx @@ -12,6 +12,7 @@ export type NodeComponentProps = { stageScale: number; onNodeChange: (node: NodeObject) => void; onNodeDelete?: (node: NodeObject) => void; + onTextChange?: (node: NodeObject) => void; }; const elements = { diff --git a/apps/client/src/components/Canvas/Node/NodeDraft.tsx b/apps/client/src/components/Canvas/Node/NodeDraft.tsx index 0b921a5d..428fe5c1 100644 --- a/apps/client/src/components/Canvas/Node/NodeDraft.tsx +++ b/apps/client/src/components/Canvas/Node/NodeDraft.tsx @@ -3,16 +3,8 @@ import { type NodeComponentProps } from './Node'; type Props = Omit; -const NodeDraft = ({ node, stageScale, onNodeChange, onNodeDelete }: Props) => { - return ( - - ); +const NodeDraft = (props: Props) => { + return ; }; export default NodeDraft; diff --git a/apps/client/src/components/Canvas/Node/Nodes.tsx b/apps/client/src/components/Canvas/Node/Nodes.tsx index fa791bc9..8a5ce01d 100644 --- a/apps/client/src/components/Canvas/Node/Nodes.tsx +++ b/apps/client/src/components/Canvas/Node/Nodes.tsx @@ -5,9 +5,9 @@ import type { NodeObject } from 'shared'; type Props = { nodes: NodeObject[]; selectedNodeId: string | null; -} & Pick; +} & Pick; -const Nodes = ({ nodes, selectedNodeId, stageScale, onNodeChange }: Props) => { +const Nodes = ({ nodes, selectedNodeId, stageScale, onNodeChange, onTextChange }: Props) => { return ( <> {nodes.map((node) => { @@ -18,6 +18,7 @@ const Nodes = ({ nodes, selectedNodeId, stageScale, onNodeChange }: Props) => { selected={selectedNodeId === node.nodeProps.id} stageScale={stageScale} onNodeChange={onNodeChange} + onTextChange={onTextChange} /> ); })} diff --git a/apps/client/src/components/Canvas/Shapes/EditableText/EditableText.tsx b/apps/client/src/components/Canvas/Shapes/EditableText/EditableText.tsx index 865807a7..03404f8f 100644 --- a/apps/client/src/components/Canvas/Shapes/EditableText/EditableText.tsx +++ b/apps/client/src/components/Canvas/Shapes/EditableText/EditableText.tsx @@ -1,11 +1,8 @@ import { useEffect, useState } from 'react'; -import type { NodeComponentProps } from '@/components/Canvas/Node/Node'; -import { useWebSocket } from '@/contexts/websocket'; import EditableTextInput from './EditableTextInput'; import ResizableText from './ResizableText'; +import type { NodeComponentProps } from '@/components/Canvas/Node/Node'; import type { KonvaEventObject } from 'konva/lib/Node'; -import { useAppSelector } from '@/stores/hooks'; -import { selectMyUser } from '@/stores/slices/collaboration'; export type OnTextSaveArgs = { text: string; @@ -18,12 +15,10 @@ const EditableText = ({ selected, stageScale, onNodeChange, + onTextChange, }: NodeComponentProps<'text'>) => { const [editing, setEditing] = useState(false); - const ws = useWebSocket(); - const thisUserId = useAppSelector(selectMyUser); - useEffect(() => { if (!node.text) { setEditing(true); @@ -38,22 +33,15 @@ const EditableText = ({ onNodeChange({ ...node, text, - nodeProps: { - ...node.nodeProps, - width, - height, - }, + nodeProps: { ...node.nodeProps, width, height }, }); setEditing(false); }; const handleTextUpdate = (text: string) => { - if (ws.isConnected && thisUserId) { - ws.send({ - type: 'draft-text-update', - data: { nodeId: node.nodeProps.id, text, userId: thisUserId }, - }); + if (onTextChange) { + onTextChange({ ...node, text }); } }; diff --git a/apps/client/src/components/Canvas/Shapes/LaserDrawable/LaserDrawable.tsx b/apps/client/src/components/Canvas/Shapes/LaserDrawable/LaserDrawable.tsx index cbde2783..e61f2b90 100644 --- a/apps/client/src/components/Canvas/Shapes/LaserDrawable/LaserDrawable.tsx +++ b/apps/client/src/components/Canvas/Shapes/LaserDrawable/LaserDrawable.tsx @@ -9,7 +9,11 @@ import { now } from '@/utils/is'; import type { NodeComponentProps } from '@/components/Canvas/Node/Node'; import type Konva from 'konva'; -const LaserDrawable = ({ node, onNodeDelete }: NodeComponentProps<'laser'>) => { +const LaserDrawable = ({ + node, + stageScale, + onNodeDelete, +}: NodeComponentProps<'laser'>) => { const [path, setPath] = useState(node.nodeProps.points ?? []); const [lastDrawTime, setLastDrawTime] = useRefValue(now()); @@ -91,7 +95,8 @@ const LaserDrawable = ({ node, onNodeDelete }: NodeComponentProps<'laser'>) => { controlPoint[1], ); - ctx.lineWidth = (1 - pointIndex / path.length) * LASER.WIDTH; + ctx.lineWidth = + ((1 - pointIndex / path.length) * LASER.WIDTH) / stageScale; ctx.stroke(); } diff --git a/apps/client/src/components/Canvas/Transformer/NodeGroupTransformer.tsx b/apps/client/src/components/Canvas/Transformer/NodeGroupTransformer.tsx index 3f079cab..be5efd1d 100644 --- a/apps/client/src/components/Canvas/Transformer/NodeGroupTransformer.tsx +++ b/apps/client/src/components/Canvas/Transformer/NodeGroupTransformer.tsx @@ -1,13 +1,14 @@ -import type Konva from 'konva'; import { useCallback } from 'react'; import { Group } from 'react-konva'; -import type { NodeObject } from 'shared'; import Node from '@/components/Canvas/Node/Node'; import useForceUpdate from '@/hooks/useForceUpdate/useForceUpdate'; import useTransformer from '@/hooks/useTransformer'; import { getPointsAbsolutePosition } from '@/utils/position'; import NodeTransformer from './NodeTransformer'; import { mapNodesIds } from '@/utils/node'; +import { noop } from '@/utils/is'; +import type Konva from 'konva'; +import type { NodeObject } from 'shared'; type Props = { nodes: NodeObject[]; @@ -15,9 +16,6 @@ type Props = { onDragEnd: (nodes: NodeObject[]) => void; }; -// eslint-disable-next-line @typescript-eslint/no-empty-function -const noop = () => {}; - const NodeGroupTransformer = ({ nodes, stageScale, onDragEnd }: Props) => { // Solves the issue when a nested group inside a tranformer is not updated properly when changed // Forces transformer to rerender with the updated nodes diff --git a/apps/client/src/components/ContextMenu/ContextMenu.tsx b/apps/client/src/components/ContextMenu/ContextMenu.tsx index 9c4adaa3..918a24d3 100644 --- a/apps/client/src/components/ContextMenu/ContextMenu.tsx +++ b/apps/client/src/components/ContextMenu/ContextMenu.tsx @@ -1,15 +1,12 @@ import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'; import { type PropsWithChildren, type ReactNode, useCallback } from 'react'; -import { type WSMessage } from 'shared'; import Divider from '@/components/Elements/Divider/Divider'; import Kbd from '@/components/Elements/Kbd/Kbd'; -import { useWebSocket } from '@/contexts/websocket'; import { useAppDispatch, useAppSelector, useAppStore } from '@/stores/hooks'; -import { canvasActions, selectSelectedNodesIds } from '@/stores/slices/canvas'; -import * as Styled from './ContextMenu.styled'; -import usePageMutation from '@/hooks/usePageMutation'; +import { canvasActions, selectSelectedNodesIds } from '@/services/canvas/slice'; +import { libraryActions } from '@/services/library/slice'; import { duplicateNodesToRight, mapNodesIds } from '@/utils/node'; -import { libraryActions } from '@/stores/slices/library'; +import * as Styled from './ContextMenu.styled'; export type ContextMenuType = 'node-menu' | 'canvas-menu'; @@ -20,15 +17,6 @@ type RootProps = PropsWithChildren<{ type TriggerProps = ContextMenuPrimitive.ContextMenuTriggerProps; -type NodesMenuWSMessageType = Extract< - WSMessage['type'], - | 'nodes-move-to-start' - | 'nodes-move-to-end' - | 'nodes-move-backward' - | 'nodes-move-forward' - | 'nodes-delete' ->; - type NodesMenuActionKey = Extract< keyof typeof canvasActions, | 'moveNodesToStart' @@ -38,20 +26,7 @@ type NodesMenuActionKey = Extract< | 'deleteNodes' >; -const wsNodesActionMap: Record = { - moveNodesToStart: 'nodes-move-to-start', - moveNodesToEnd: 'nodes-move-to-end', - moveNodesBackward: 'nodes-move-backward', - moveNodesForward: 'nodes-move-forward', - deleteNodes: 'nodes-delete', -}; - const CanvasMenu = () => { - const store = useAppStore(); - const ws = useWebSocket(); - - const { updatePage } = usePageMutation(); - const dispatch = useAppDispatch(); const handleSelectAll = useCallback(() => { @@ -61,18 +36,7 @@ const CanvasMenu = () => { const handlePaste = useCallback(() => { dispatch(canvasActions.pasteNodes()); - if (ws.isConnected) { - const { selectedNodesIds, nodes } = store.getState().canvas.present; - - const pastedNodes = nodes.filter( - ({ nodeProps }) => nodeProps.id in selectedNodesIds, - ); - - ws.send({ type: 'nodes-add', data: pastedNodes }); - - updatePage({ nodes }); - } - }, [ws, store, updatePage, dispatch]); + }, [dispatch]); return ( <> @@ -90,9 +54,6 @@ const CanvasMenu = () => { const NodeMenu = () => { const store = useAppStore(); const selectedNodesIds = useAppSelector(selectSelectedNodesIds); - const ws = useWebSocket(); - - const { updatePage } = usePageMutation(); const dispatch = useAppDispatch(); @@ -101,15 +62,6 @@ const NodeMenu = () => { const action = canvasActions[actionKey]; dispatch(action(nodesIds)); - - if (ws.isConnected) { - const messageType = wsNodesActionMap[actionKey]; - - ws.send({ type: messageType, data: nodesIds }); - - const currentNodes = store.getState().canvas.present.nodes; - updatePage({ nodes: currentNodes }); - } }; const handleNodesDuplicate = () => { @@ -124,14 +76,6 @@ const NodeMenu = () => { dispatch(canvasActions.addNodes(duplicatedNodes)); dispatch(canvasActions.setSelectedNodesIds(duplicatedNodesIds)); - - if (ws.isConnected) { - ws.send({ type: 'nodes-add', data: duplicatedNodes }); - - const currentNodes = store.getState().canvas.present.nodes; - - updatePage({ nodes: currentNodes }); - } }; const handleSelectNone = () => { diff --git a/apps/client/src/components/Elements/Icon/ExtraLarge/ExtraLarge.tsx b/apps/client/src/components/Elements/Icon/ExtraLarge/ExtraLarge.tsx index 9f0d9214..c561999d 100644 --- a/apps/client/src/components/Elements/Icon/ExtraLarge/ExtraLarge.tsx +++ b/apps/client/src/components/Elements/Icon/ExtraLarge/ExtraLarge.tsx @@ -5,8 +5,8 @@ import type { IconBaseProps } from 'react-icons'; const ExtraLarge = (props: IconBaseProps) => { return ( - - + + ); }; diff --git a/apps/client/src/components/Elements/Loader/Loader.tsx b/apps/client/src/components/Elements/Loader/Loader.tsx index 16c4ab08..e86a582e 100644 --- a/apps/client/src/components/Elements/Loader/Loader.tsx +++ b/apps/client/src/components/Elements/Loader/Loader.tsx @@ -5,7 +5,7 @@ type Props = PropsWithChildren<(typeof Styled.Container)['defaultProps']>; const Loader = ({ children, ...restProps }: Props) => { return ( - + {children} diff --git a/apps/client/src/components/Elements/ShapesThumbnail/ShapesThumbnail.tsx b/apps/client/src/components/Elements/ShapesThumbnail/ShapesThumbnail.tsx index dd7ac944..639ee755 100644 --- a/apps/client/src/components/Elements/ShapesThumbnail/ShapesThumbnail.tsx +++ b/apps/client/src/components/Elements/ShapesThumbnail/ShapesThumbnail.tsx @@ -7,7 +7,7 @@ import { getNodesMinMaxPoints, } from '@/utils/position'; import useForceUpdate from '@/hooks/useForceUpdate/useForceUpdate'; -import useEvent from '@/hooks/useEvent'; +import useEvent from '@/hooks/useEvent/useEvent'; import * as Styled from './ShapesThumbnail.styled'; import type { NodeObject, ThemeColorValue } from 'shared'; import type Konva from 'konva'; diff --git a/apps/client/src/components/Layout/MainLayout/MainLayout.tsx b/apps/client/src/components/Layout/MainLayout/MainLayout.tsx index 4cbefcc0..2d07734f 100644 --- a/apps/client/src/components/Layout/MainLayout/MainLayout.tsx +++ b/apps/client/src/components/Layout/MainLayout/MainLayout.tsx @@ -1,28 +1,24 @@ -import type Konva from 'konva'; import { Suspense, lazy, useCallback, useRef, useState } from 'react'; +import ContextMenu from '@/components/ContextMenu/ContextMenu'; +import Panels from '@/components/Panels/Panels'; +import Loader from '@/components/Elements/Loader/Loader'; +import { useAppDispatch, useAppStore } from '@/stores/hooks'; +import useAutoFocus from '@/hooks/useAutoFocus/useAutoFocus'; +import useWindowSize from '@/hooks/useWindowSize/useWindowSize'; +import { useWebSocket } from '@/contexts/websocket'; import { getIntersectingNodes, getLayerNodes, getMainLayer, getPointerRect, } from '@/components/Canvas/DrawingCanvas/helpers/stage'; -import ContextMenu from '@/components/ContextMenu/ContextMenu'; -import Panels from '@/components/Panels/Panels'; -import { useAppDispatch, useAppStore } from '@/stores/hooks'; -import { canvasActions } from '@/stores/slices/canvas'; -import * as Styled from './MainLayout.styled'; -import useWindowSize from '@/hooks/useWindowSize/useWindowSize'; +import { duplicateNodesToRight, mapNodesIds } from '@/utils/node'; +import { historyActions } from '@/stores/reducers/history'; +import { canvasActions } from '@/services/canvas/slice'; import { TOOLS } from '@/constants/panels/tools'; import { KEYS } from '@/constants/keys'; -import { duplicateNodesToRight, mapNodesIds } from '@/utils/node'; -import { - type HistoryActionKey, - historyActions, -} from '@/stores/reducers/history'; -import usePageMutation from '@/hooks/usePageMutation'; -import { useWebSocket } from '@/contexts/websocket'; -import useAutoFocus from '@/hooks/useAutoFocus/useAutoFocus'; -import Loader from '@/components/Elements/Loader/Loader'; +import * as Styled from './MainLayout.styled'; +import type Konva from 'konva'; const DrawingCanvas = lazy( () => import('@/components/Canvas/DrawingCanvas/DrawingCanvas'), @@ -32,15 +28,13 @@ const MainLayout = () => { const [selectedNodesIds, setSelectedNodesIds] = useState([]); const store = useAppStore(); + const windowSize = useWindowSize(); + const ws = useWebSocket(); const containerRef = useAutoFocus(); const stageRef = useRef(null); - const windowSize = useWindowSize(); - const ws = useWebSocket(); - const dispatch = useAppDispatch(); - const { updatePage } = usePageMutation(); const handleNodesSelect = useCallback((nodesIds: string[]) => { setSelectedNodesIds(nodesIds); @@ -63,14 +57,13 @@ const MainLayout = () => { break; } case KEYS.Z: { - const actionKey: HistoryActionKey = shiftKey ? 'redo' : 'undo'; + if (ws.isConnected) { + return; + } + const actionKey = shiftKey ? 'redo' : 'undo'; const action = historyActions[actionKey]; dispatch(action()); - - if (ws.isConnected) { - ws.send({ type: 'history-change', data: { action: actionKey } }); - } break; } case KEYS.D: { @@ -86,14 +79,6 @@ const MainLayout = () => { dispatch(canvasActions.addNodes(duplicatedNodes)); dispatch(canvasActions.setSelectedNodesIds(duplicatedNodesIds)); - - if (ws.isConnected) { - ws.send({ type: 'nodes-add', data: duplicatedNodes }); - - const currentNodes = store.getState().canvas.present.nodes; - - updatePage({ nodes: currentNodes }); - } break; } case KEYS.C: { @@ -102,18 +87,6 @@ const MainLayout = () => { } case KEYS.V: { dispatch(canvasActions.pasteNodes()); - - if (ws.isConnected) { - const currentState = store.getState().canvas.present; - - const pastedNodes = currentState.nodes.filter(({ nodeProps }) => { - return nodeProps.id in currentState.selectedNodesIds; - }); - - ws.send({ type: 'nodes-add', data: pastedNodes }); - - updatePage({ nodes }); - } break; } } @@ -125,35 +98,21 @@ const MainLayout = () => { if (key === KEYS.DELETE) { dispatch(canvasActions.deleteNodes(selectedNodesIds)); - - if (ws.isConnected) { - ws.send({ - type: 'nodes-delete', - data: selectedNodesIds, - }); - - updatePage({ nodes }); - } return; } const toolTypeObj = TOOLS.find((tool) => tool.key === lowerCaseKey); toolTypeObj && dispatch(canvasActions.setToolType(toolTypeObj.value)); }, - [ws, store, updatePage, dispatch], + [ws, store, dispatch], ); const handleContextMenuOpen = useCallback( (open: boolean) => { const stage = stageRef.current; + const pointerPosition = stage?.getPointerPosition(); - if (!stage || !open) { - return; - } - - const pointerPosition = stage.getPointerPosition(); - - if (!pointerPosition) { + if (!stage || !pointerPosition || !open) { return; } diff --git a/apps/client/src/components/Library/LibraryDrawer/LibraryDrawer.tsx b/apps/client/src/components/Library/LibraryDrawer/LibraryDrawer.tsx index 9ccfb114..8996a2fa 100644 --- a/apps/client/src/components/Library/LibraryDrawer/LibraryDrawer.tsx +++ b/apps/client/src/components/Library/LibraryDrawer/LibraryDrawer.tsx @@ -7,11 +7,9 @@ import Divider from '@/components/Elements/Divider/Divider'; import Text from '@/components/Elements/Text/Text'; import Button from '@/components/Elements/Button/Button'; import useThemeColors from '@/hooks/useThemeColors'; -import usePageMutation from '@/hooks/usePageMutation'; -import { useWebSocket } from '@/contexts/websocket'; -import { useAppDispatch, useAppSelector, useAppStore } from '@/stores/hooks'; -import { libraryActions } from '@/stores/slices/library'; -import { canvasActions, selectConfig } from '@/stores/slices/canvas'; +import { useAppDispatch, useAppSelector } from '@/stores/hooks'; +import { libraryActions } from '@/services/library/slice'; +import { canvasActions, selectConfig } from '@/services/canvas/slice'; import { duplicateNodesAtCenter, mapNodesIds } from '@/utils/node'; import * as Styled from './LibraryDrawer.styled'; import type { LibraryItem } from '@/constants/app'; @@ -27,18 +25,15 @@ const LibraryDrawer = ({ items }: Props) => { {}, ); - const store = useAppStore(); const stageConfig = useAppSelector(selectConfig); const themeColors = useThemeColors(); - const ws = useWebSocket(); const selectedItemsCount = Object.keys(selectedItemsIds).length; const hasItems = Boolean(items.length); const hasSelectedItems = Boolean(selectedItemsCount); const dispatch = useAppDispatch(); - const { updatePage } = usePageMutation(); const handleRemoveSelectedItems = () => { dispatch(libraryActions.removeItems(Object.keys(selectedItemsIds))); @@ -66,14 +61,6 @@ const LibraryDrawer = ({ items }: Props) => { dispatch(canvasActions.addNodes(duplicatedNodes)); dispatch(canvasActions.setSelectedNodesIds(duplicatedNodesIds)); dispatch(canvasActions.setToolType('select')); - - if (ws.isConnected) { - ws.send({ type: 'nodes-add', data: duplicatedNodes }); - - const currentNodes = store.getState().canvas.present.nodes; - - updatePage({ nodes: currentNodes }); - } }; const isItemChecked = (id: LibraryItem['id']) => id in selectedItemsIds; diff --git a/apps/client/src/components/Panels/Panels.tsx b/apps/client/src/components/Panels/Panels.tsx index 29de2f06..52f3d73a 100644 --- a/apps/client/src/components/Panels/Panels.tsx +++ b/apps/client/src/components/Panels/Panels.tsx @@ -9,27 +9,26 @@ import { selectHistory, selectNodes, selectToolType, -} from '@/stores/slices/canvas'; -import { collaborationActions } from '@/stores/slices/collaboration'; +} from '@/services/canvas/slice'; +import { collaborationActions } from '@/services/collaboration/slice'; import { downloadDataUrlAsFile, importProject } from '@/utils/file'; import ControlPanel, { type ControlActionKey, } from './ControlPanel/ControlPanel'; import MenuPanel, { type MenuKey } from './MenuPanel/MenuPanel'; -import * as Styled from './Panels.styled'; import SharePanel from './SharePanel/SharePanel'; import StylePanel from './StylePanel/StylePanel'; import ToolsPanel from './ToolsPanel/ToolsPanel'; import ZoomPanel from './ZoomPanel/ZoomPanel'; import LibraryDrawer from '../Library/LibraryDrawer/LibraryDrawer'; -import usePageMutation from '@/hooks/usePageMutation'; import { PROJECT_FILE_EXT, PROJECT_FILE_NAME } from '@/constants/app'; import { historyActions } from '@/stores/reducers/history'; -import { selectLibrary } from '@/stores/slices/library'; -import { type MenuPanelActionType } from '@/constants/panels/menu'; -import { type ToolType } from '@/constants/panels/tools'; +import { selectLibrary } from '@/services/library/slice'; import { calculateCenterPoint } from '@/utils/position'; import { calculateStageZoomRelativeToPoint } from '../Canvas/DrawingCanvas/helpers/zoom'; +import * as Styled from './Panels.styled'; +import { type MenuPanelActionType } from '@/constants/panels/menu'; +import { type ToolType } from '@/constants/panels/tools'; import type Konva from 'konva'; import type { NodeStyle, User } from 'shared'; import type { ZoomAction } from '@/constants/panels/zoom'; @@ -45,23 +44,20 @@ const Panels = ({ selectedNodesIds, stageRef }: Props) => { const store = useAppStore(); const ws = useWebSocket(); - const { updatePage } = usePageMutation(); - const stageConfig = useAppSelector(selectConfig); const toolType = useAppSelector(selectToolType); const nodes = useAppSelector(selectNodes); const library = useAppSelector(selectLibrary); const { past, future } = useAppSelector(selectHistory); - const { online } = useNetworkState(); const modal = useModal(); - const isHandTool = toolType === 'hand'; - const dispatch = useAppDispatch(); + const isHandTool = toolType === 'hand'; + const selectedNodes = useMemo(() => { const nodesIds = new Set(selectedNodesIds); @@ -74,11 +70,11 @@ const Panels = ({ selectedNodesIds, stageRef }: Props) => { const enabledControls = useMemo(() => { return { - undo: Boolean(past.length), - redo: Boolean(future.length), + undo: Boolean(past.length) && !ws.isConnected, + redo: Boolean(future.length) && !ws.isConnected, deleteSelectedNodes: Boolean(selectedNodesIds.length), }; - }, [past, future, selectedNodesIds]); + }, [ws, past, future, selectedNodesIds]); const disabledMenuItems = useMemo((): MenuKey[] | null => { if (ws.isConnected) { @@ -94,13 +90,8 @@ const Panels = ({ selectedNodesIds, stageRef }: Props) => { [dispatch], ); - const handleUpdatePage = useCallback(() => { - const currentNodes = store.getState().canvas.present.nodes; - updatePage({ nodes: currentNodes }); - }, [store, updatePage]); - const handleStyleChange = useCallback( - (style: Partial, updateAsync = true) => { + (style: Partial) => { const { nodes, selectedNodesIds } = store.getState().canvas.present; const updatedNodes = nodes @@ -110,14 +101,8 @@ const Panels = ({ selectedNodesIds, stageRef }: Props) => { }); dispatch(canvasActions.updateNodes(updatedNodes)); - - if (ws.isConnected) { - ws.send({ type: 'nodes-update', data: updatedNodes }); - - updateAsync && handleUpdatePage(); - } }, - [ws, store, handleUpdatePage, dispatch], + [store, dispatch], ); const handleMenuAction = useCallback( @@ -191,41 +176,22 @@ const Panels = ({ selectedNodesIds, stageRef }: Props) => { (actionType: ControlActionKey) => { if (actionType === 'deleteNodes') { dispatch(canvasActions.deleteNodes(selectedNodesIds)); - - if (ws.isConnected) { - ws.send({ - type: 'nodes-delete', - data: selectedNodesIds, - }); - } return; } - if (actionType === 'undo' || actionType === 'redo') { + if (!ws.isConnected && (actionType === 'undo' || actionType === 'redo')) { const action = historyActions[actionType]; - dispatch(action()); - - if (ws.isConnected) { - const historyAction = actionType === 'redo' ? 'redo' : 'undo'; - ws.send({ type: 'history-change', data: { action: historyAction } }); - - handleUpdatePage(); - } } }, - [ws, selectedNodesIds, dispatch, handleUpdatePage], + [ws, selectedNodesIds, dispatch], ); const handleUserChange = useCallback( (user: User) => { - if (ws.isConnected) { - ws.send({ type: 'user-change', data: user }); - - dispatch(collaborationActions.updateUser(user)); - } + dispatch(collaborationActions.updateUser(user)); }, - [ws, dispatch], + [dispatch], ); return ( @@ -242,7 +208,6 @@ const Panels = ({ selectedNodesIds, stageRef }: Props) => { isActive={isStylePanelActive} onStyleChange={handleStyleChange} /> - {ws.isConnected && ( diff --git a/apps/client/src/components/Panels/SharePanel/SharePanel.tsx b/apps/client/src/components/Panels/SharePanel/SharePanel.tsx index e3c20af8..2a316772 100644 --- a/apps/client/src/components/Panels/SharePanel/SharePanel.tsx +++ b/apps/client/src/components/Panels/SharePanel/SharePanel.tsx @@ -1,26 +1,27 @@ -import * as PopoverPrimitive from '@radix-ui/react-popover'; -import { memo } from 'react'; -import type { QRCodeRequestBody, QRCodeResponse } from 'shared'; -import useFetch from '@/hooks/useFetch'; +import { memo, useState } from 'react'; +import api from '@/services/api'; import ShareablePageContent from './ShareablePageContent'; import SharedPageContent from './SharedPageContent'; import * as Styled from './SharePanel.styled'; +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import type { QRCodeResponse } from 'shared'; type Props = { isPageShared: boolean; }; const SharePanel = ({ isPageShared }: Props) => { - const [{ data, error }, getQRCode] = useFetch< - QRCodeResponse, - QRCodeRequestBody - >('/qrcode', { method: 'POST' }, true); - - const url = window.location.href; + const [qrCode, setQRCode] = useState(null); + const [error, setError] = useState(null); const handlePopoverOpen = (open: boolean) => { - if (isPageShared && !data && open) { - getQRCode({ url }); + if (isPageShared && open && !qrCode) { + const url = window.location.href; + + setError(null); + + const [request] = api.makeQRCode({ url }); + request.then(setQRCode).catch(setError); } }; @@ -30,7 +31,7 @@ const SharePanel = ({ isPageShared }: Props) => { {isPageShared ? ( - + ) : ( )} diff --git a/apps/client/src/components/Panels/SharePanel/ShareablePageContent.tsx b/apps/client/src/components/Panels/SharePanel/ShareablePageContent.tsx index 3c53a4e3..2deabdc2 100644 --- a/apps/client/src/components/Panels/SharePanel/ShareablePageContent.tsx +++ b/apps/client/src/components/Panels/SharePanel/ShareablePageContent.tsx @@ -1,47 +1,45 @@ -import { useEffect } from 'react'; -import type { SharePageRequestBody, SharePageResponse } from 'shared'; +import { useState } from 'react'; import Button from '@/components/Elements/Button/Button'; import Divider from '@/components/Elements/Divider/Divider'; import Loader from '@/components/Elements/Loader/Loader'; +import Icon from '@/components/Elements/Icon/Icon'; +import api from '@/services/api'; import { PAGE_URL_SEARCH_PARAM_KEY } from '@/constants/app'; -import useFetch from '@/hooks/useFetch'; import { useAppSelector } from '@/stores/hooks'; -import { selectNodes, selectConfig } from '@/stores/slices/canvas'; +import { selectNodes, selectConfig } from '@/services/canvas/slice'; import { urlSearchParam } from '@/utils/url'; import * as Styled from './SharePanel.styled'; -import Icon from '@/components/Elements/Icon/Icon'; const SharablePageContent = () => { - const [{ data, status }, sharePage] = useFetch< - SharePageResponse, - SharePageRequestBody - >( - '/p', - { - method: 'POST', - }, - true, - ); + const [loading, setLoading] = useState(false); const nodes = useAppSelector(selectNodes); const stageConfig = useAppSelector(selectConfig); - useEffect(() => { - if (data?.id) { - const updatedURL = urlSearchParam.set(PAGE_URL_SEARCH_PARAM_KEY, data.id); - - window.history.pushState({}, '', updatedURL); - window.location.reload(); - return; - } - }, [data]); - const handlePageShare = () => { if (!nodes.length) { return; } - sharePage({ page: { nodes, stageConfig } }); + setLoading(true); + + const [request] = api.sharePage({ + page: { nodes, stageConfig }, + }); + + request + .then((data) => { + if (data?.id) { + const updatedURL = urlSearchParam.set( + PAGE_URL_SEARCH_PARAM_KEY, + data.id, + ); + + window.history.pushState({}, '', updatedURL); + window.location.reload(); + } + }) + .catch(() => setLoading(false)); }; return ( @@ -53,12 +51,8 @@ const SharablePageContent = () => { disabled={!nodes.length} onClick={handlePageShare} > - {status === 'idle' && } - {status === 'loading' || status === 'success' ? ( - - ) : ( - 'Share this page' - )} + {!loading && } + {loading ? : 'Share this page'} diff --git a/apps/client/src/components/Panels/SharePanel/__tests__/SharePanel.test.tsx b/apps/client/src/components/Panels/SharePanel/__tests__/SharePanel.test.tsx new file mode 100644 index 00000000..e81ebcdf --- /dev/null +++ b/apps/client/src/components/Panels/SharePanel/__tests__/SharePanel.test.tsx @@ -0,0 +1,25 @@ +import { screen, waitFor } from '@testing-library/react'; +import { renderWithProviders } from '@/test/test-utils'; +import SharePanel from '../SharePanel'; + +describe('SharePanel', () => { + it('displays shareable content if canvas is not shared', async () => { + const { user } = renderWithProviders(); + + // open share panel + await user.click(screen.getByText(/Share/i)); + + expect(screen.getByText(/Share this page/i)).toBeInTheDocument(); + }); + + it('displays qrcode if the canvas is shared', async () => { + const { user } = renderWithProviders(); + + // open share panel + await user.click(screen.getByText(/Share/i)); + + await waitFor(() => { + expect(screen.getByTestId(/qr-code/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/client/src/components/Panels/StylePanel/StylePanel.tsx b/apps/client/src/components/Panels/StylePanel/StylePanel.tsx index 9201ee1c..c5b01abb 100644 --- a/apps/client/src/components/Panels/StylePanel/StylePanel.tsx +++ b/apps/client/src/components/Panels/StylePanel/StylePanel.tsx @@ -20,10 +20,7 @@ import FillSection from './FillSection'; export type StylePanelProps = { selectedNodes: NodeObject[]; isActive: boolean; - onStyleChange: ( - updatedStyle: Partial, - updateAsync?: boolean, - ) => void; + onStyleChange: (updatedStyle: Partial) => void; }; const StylePanel = ({ @@ -63,8 +60,8 @@ const StylePanel = ({ const selectedNodesTypes = selectedNodes.map(({ type }) => type); const onlyTextNodes = selectedNodesTypes.every((type) => type === 'text'); - - if(onlyTextNodes) { + + if (onlyTextNodes) { return { line: false, size: true }; } @@ -95,9 +92,9 @@ const StylePanel = ({ onStyleChange({ size }); }; - const handleOpacitySelect = (value: number, commit = false) => { + const handleOpacitySelect = (value: number) => { const clampedOpacity = clamp(value, [OPACITY.minValue, OPACITY.maxValue]); - onStyleChange({ opacity: clampedOpacity }, commit); + onStyleChange({ opacity: clampedOpacity }); }; const handleOpacityChange = (value: number) => { @@ -105,7 +102,7 @@ const StylePanel = ({ }; const handleOpacityCommit = (value: number) => { - handleOpacitySelect(value, true); + handleOpacitySelect(value); }; const handleFillChange = (value: NodeFill) => { diff --git a/apps/client/src/components/Panels/UsersPanel/UsersPanel.tsx b/apps/client/src/components/Panels/UsersPanel/UsersPanel.tsx index 8fabd194..7909293d 100644 --- a/apps/client/src/components/Panels/UsersPanel/UsersPanel.tsx +++ b/apps/client/src/components/Panels/UsersPanel/UsersPanel.tsx @@ -7,7 +7,7 @@ import TextInput from '@/components/Elements/TextInput/TextInput'; import { USER } from '@/constants/app'; import { KEYS } from '@/constants/keys'; import { useAppSelector } from '@/stores/hooks'; -import { selectMyUser, selectUsers } from '@/stores/slices/collaboration'; +import { selectThisUser, selectCollaborators } from '@/services/collaboration/slice'; import * as Styled from './UsersPanel.styled'; import Icon from '@/components/Elements/Icon/Icon'; @@ -18,13 +18,11 @@ type Props = { const UsersPanel = ({ onUserChange }: Props) => { const [isEditing, setIsEditing] = useState(false); - const users = useAppSelector(selectUsers); - const userId = useAppSelector(selectMyUser); + const collaborators = useAppSelector(selectCollaborators); + const thisUser = useAppSelector(selectThisUser); const inputRef = useRef(null); - const currentUser = users.find((user) => user.id === userId); - useEffect(() => { if (isEditing && inputRef.current) { inputRef.current.focus(); @@ -32,16 +30,16 @@ const UsersPanel = ({ onUserChange }: Props) => { }, [isEditing, inputRef]); const handleColorSelect = (color: User['color']) => { - if (currentUser) { - onUserChange({ ...currentUser, color }); + if (thisUser) { + onUserChange({ ...thisUser, color }); } }; const handleNameChange = () => { const value = inputRef.current?.value; - if (currentUser && value && currentUser.name !== value) { - onUserChange({ ...currentUser, name: value }); + if (thisUser && value && thisUser.name !== value) { + onUserChange({ ...thisUser, name: value }); } setIsEditing(!isEditing); @@ -55,7 +53,7 @@ const UsersPanel = ({ onUserChange }: Props) => { } }; - if (!currentUser) { + if (!thisUser) { return null; } @@ -68,8 +66,8 @@ const UsersPanel = ({ onUserChange }: Props) => { - {users.map((user) => { - const isCurrentUser = user.id === userId; + {[thisUser, ...collaborators].map((user) => { + const isCurrentUser = user.id === thisUser.id; const color = colors[user.color]; return ( @@ -90,7 +88,7 @@ const UsersPanel = ({ onUserChange }: Props) => { alignOffset={-16} > diff --git a/apps/client/src/components/QRCode/QRCode.tsx b/apps/client/src/components/QRCode/QRCode.tsx index 7337373b..249dfe15 100644 --- a/apps/client/src/components/QRCode/QRCode.tsx +++ b/apps/client/src/components/QRCode/QRCode.tsx @@ -5,7 +5,12 @@ type Props = { }; const QRCode = ({ dataUrl }: Props) => { - return ; + return ( + + ); }; export default QRCode; diff --git a/apps/client/src/constants/panels/control.ts b/apps/client/src/constants/panels/control.ts index be7d5ada..f3e6ecdf 100644 --- a/apps/client/src/constants/panels/control.ts +++ b/apps/client/src/constants/panels/control.ts @@ -1,5 +1,5 @@ import type { HistoryActionKey } from '@/stores/reducers/history'; -import { type canvasActions } from '@/stores/slices/canvas'; +import { type canvasActions } from '@/services/canvas/slice'; import type { ShortcutKeyCombo } from '@/constants/index'; type Control = ShortcutKeyCombo; diff --git a/apps/client/src/contexts/__tests__/notifications.test.tsx b/apps/client/src/contexts/__tests__/notifications.test.tsx index 5818938c..f41804ca 100644 --- a/apps/client/src/contexts/__tests__/notifications.test.tsx +++ b/apps/client/src/contexts/__tests__/notifications.test.tsx @@ -14,23 +14,19 @@ describe('notifications context', () => { ]; await act(async () => { - result.current.add(notifications[0]); + result.current.addNotification(notifications[0]); }); await act(async () => { - result.current.add(notifications[1]); + result.current.addNotification(notifications[1]); }); - const [notification1, notification2] = [...result.current.list.values()]; - - expect(result.current.list.size).toBe(2); - expect(notification1).toEqual(notifications[0]); - expect(notification2).toEqual(notifications[1]); - expect(screen.getAllByTestId(/toast/)).toHaveLength(2); }); it.skip('removes notifications', async () => { + vi.useFakeTimers(); + const { result } = renderHook(() => useNotifications(), { wrapper: NotificationsProvider, }); @@ -41,26 +37,14 @@ describe('notifications context', () => { ]; await act(async () => { - result.current.add(notifications[0]); - result.current.add(notifications[1]); + result.current.addNotification(notifications[0]); + result.current.addNotification(notifications[1]); }); - const listEntries = [...result.current.list.entries()]; - - await act(async () => { - result.current.remove(listEntries[1][0]); - }); - - expect(result.current.list.size).toBe(1); - expect(listEntries[1][1]).toEqual(notifications[1]); - - await act(async () => { - result.current.remove(listEntries[0][0]); - }); + expect(screen.getAllByTestId(/toast/)).toHaveLength(2); - expect(result.current.list.size).toBe(0); - expect(listEntries[0][1]).toEqual(notifications[0]); + await vi.advanceTimersByTimeAsync(5000); - expect(screen.getAllByTestId(/toast/)).toHaveLength(0); + expect(screen.getAllByTestId(/toast/)).toHaveLength(1); }); }); diff --git a/apps/client/src/contexts/modal.tsx b/apps/client/src/contexts/modal.tsx index 23b98c5a..2596d928 100644 --- a/apps/client/src/contexts/modal.tsx +++ b/apps/client/src/contexts/modal.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import type { PropsWithChildren } from 'react'; import useDisclosure from '@/hooks/useDisclosure/useDisclosure'; import Dialog from '@/components/Elements/Dialog/Dialog'; import { createContext } from './createContext'; @@ -19,7 +18,7 @@ type ModalContent = { export const [ModalContext, useModal] = createContext('Modal'); -export const ModalProvider = ({ children }: PropsWithChildren) => { +export const ModalProvider = ({ children }: React.PropsWithChildren) => { const [opened, { open, close }] = useDisclosure(); const [content, setContent] = useState({ title: '', diff --git a/apps/client/src/contexts/notifications.tsx b/apps/client/src/contexts/notifications.tsx index 2022db21..73ed5fb9 100644 --- a/apps/client/src/contexts/notifications.tsx +++ b/apps/client/src/contexts/notifications.tsx @@ -1,5 +1,5 @@ +import { useState, useCallback } from 'react'; import { Provider as ToastProvider } from '@radix-ui/react-toast'; -import { type PropsWithChildren, useState, useCallback } from 'react'; import Toast from '@/components/Elements/Toast/Toast'; import { createContext } from './createContext'; @@ -9,21 +9,22 @@ export type Notification = { type: 'error' | 'info' | 'success' | 'warning'; }; +type NotificationsMap = Map; + type NotificationContextValue = { - add: (args: Notification) => void; - remove: (id: string) => void; - list: Map; + addNotification: (notification: Notification) => void, + removeNotification: (id: string) => void }; export const [NotificationsContext, useNotifications] = - createContext('Notification'); + createContext('Notifications'); -export const NotificationsProvider = ({ children }: PropsWithChildren) => { - const [notifications, setNotifications] = useState( - new Map(), +export const NotificationsProvider = ({ children }: React.PropsWithChildren) => { + const [notifications, setNotifications] = useState( + new Map(), ); - const handleNotificationAdd = useCallback((notification: Notification) => { + const addNotification = useCallback((notification: Notification) => { setNotifications((prev) => { const newMap = new Map(prev); newMap.set(String(Date.now()), notification); @@ -31,7 +32,7 @@ export const NotificationsProvider = ({ children }: PropsWithChildren) => { }); }, []); - const handleNotificationRemove = useCallback((id: string) => { + const removeNotification = useCallback((id: string) => { setNotifications((prev) => { const newMap = new Map(prev); newMap.delete(id); @@ -40,13 +41,7 @@ export const NotificationsProvider = ({ children }: PropsWithChildren) => { }, []); return ( - + {children} {Array.from(notifications).map(([id, notification]) => { @@ -55,7 +50,7 @@ export const NotificationsProvider = ({ children }: PropsWithChildren) => { key={id} title={notification.title} description={notification.description} - onOpenChange={(open) => !open && handleNotificationRemove(id)} + onOpenChange={(open) => !open && removeNotification(id)} data-testid="toast" /> ); diff --git a/apps/client/src/contexts/theme.tsx b/apps/client/src/contexts/theme.tsx index 7fa26751..3044146c 100644 --- a/apps/client/src/contexts/theme.tsx +++ b/apps/client/src/contexts/theme.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import useEvent from '@/hooks/useEvent/useEvent'; import { LOCAL_STORAGE_THEME_KEY } from '@/constants/app'; import { storage } from '@/utils/storage'; import { createContext } from './createContext'; @@ -30,23 +31,20 @@ export const ThemeProvider = ({ children }: React.PropsWithChildren) => { } }, []); - useEffect(() => { - if (storage.get(LOCAL_STORAGE_THEME_KEY)) { - return; - } + const darkColorScheme = useMemo(() => prefersDarkColorScheme(), []); - const darkColorScheme = prefersDarkColorScheme(); + const handleColorSchemeChange = useCallback( + (event: MediaQueryListEvent) => { + if (storage.get(LOCAL_STORAGE_THEME_KEY)) { + return; + } - const handleColorSchemeChange = (event: MediaQueryListEvent) => { handleThemeChange(event.matches ? 'dark' : 'default', false); - }; - - darkColorScheme.addEventListener('change', handleColorSchemeChange); + }, + [handleThemeChange], + ); - return () => { - darkColorScheme.removeEventListener('change', handleColorSchemeChange); - }; - }, [handleThemeChange]); + useEvent('change', handleColorSchemeChange, darkColorScheme); return ( diff --git a/apps/client/src/contexts/websocket.tsx b/apps/client/src/contexts/websocket.tsx index 7855b5df..2d011862 100644 --- a/apps/client/src/contexts/websocket.tsx +++ b/apps/client/src/contexts/websocket.tsx @@ -1,141 +1,153 @@ -import { useCallback, useEffect, useState } from 'react'; -import type { PropsWithChildren } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { type WSMessage, WSMessageUtil } from 'shared'; import { BASE_WS_URL, BASE_WS_URL_DEV, IS_PROD, PAGE_URL_SEARCH_PARAM_KEY, + WS_THROTTLE_MS, } from '@/constants/app'; -import { useAppDispatch } from '@/stores/hooks'; -import { historyActions } from '@/stores/reducers/history'; -import { canvasActions } from '@/stores/slices/canvas'; -import { collaborationActions } from '@/stores/slices/collaboration'; import { urlSearchParam } from '@/utils/url'; -import { useModal } from './modal'; -import { useNotifications } from './notifications'; -import useUrlSearchParams from '@/hooks/useUrlSearchParams/useUrlSearchParams'; import { createContext } from './createContext'; - -type WebSocketContextValue = { - connection: WebSocket | null; +import { throttleFn } from '@/utils/timed'; + +type SubscribeFn = ( + type: T, + handler: SubscriberHandler, +) => () => ReturnType; +type UnsubscribeFn = (type: SubscriberKey) => void; +type SendFn = (message: WSMessage, throttle?: boolean) => void; +type SubscriberKey = WSMessage['type']; +type SubscriberHandler = ( + data: Extract['data'], +) => void; +type Subscribers = Record< + T, + SubscriberHandler +>; +export type WebSocketContextValue = { isConnected: boolean; isConnecting: boolean; - send: (message: WSMessage) => void; + isDisconnected: boolean; + send: SendFn; + subscribe: SubscribeFn; + unsubscribe: UnsubscribeFn; }; - type WSStatus = 'idle' | 'connecting' | 'connected' | 'disconnected'; +type WebSocketProviderProps = { + roomId: string | null; + children: React.ReactNode; +}; + +let startedConnecting = false; -const serverErrorCodes = [1011]; const wsBaseUrl = IS_PROD ? BASE_WS_URL : BASE_WS_URL_DEV; -let attemptedConnection = false; +// eslint-disable-next-line @typescript-eslint/naming-convention +const _sendWSMessage = (message: WSMessage, webSocket: WebSocket) => { + const serializedMessage = WSMessageUtil.serialize(message); + + if (serializedMessage) { + webSocket.send(serializedMessage); + } +}; + +// eslint-disable-next-line @typescript-eslint/naming-convention +const _throttledSendWSMessage = throttleFn(_sendWSMessage, WS_THROTTLE_MS); export const [WebSocketContext, useWebSocket] = createContext('WebSocket'); -export const WebSocketProvider = ({ children }: PropsWithChildren) => { - const [webSocket, setWebSocket] = useState(null); +export const WebSocketProvider = ({ + roomId, + children, +}: WebSocketProviderProps) => { const [status, setStatus] = useState('idle'); - const params = useUrlSearchParams(); + const webSocket = useRef(null); + const subscribers = useRef>({}); - const dispatch = useAppDispatch(); + useEffect(() => { + if (!roomId) { + webSocket.current?.close(); + startedConnecting = false; + return; + } - const modal = useModal(); - const notifications = useNotifications(); + function createWebSocketConnection(url: URL | string) { + webSocket.current = new WebSocket(url); - useEffect(() => { - const pageId = params[PAGE_URL_SEARCH_PARAM_KEY]; + setStatus('connecting'); - if (attemptedConnection || !pageId) { - return; + webSocket.current.onmessage = (event) => { + const message = WSMessageUtil.deserialize(event.data); + + if (message && message.type in subscribers.current) { + const handler = subscribers.current[message.type]; + + handler && handler(message.data); + } + }; + + webSocket.current.onopen = () => setStatus('connected'); + webSocket.current.onclose = () => setStatus('disconnected'); + webSocket.current.onerror = () => setStatus('disconnected'); } - const url = urlSearchParam.set( - 'id', - pageId, - `${wsBaseUrl}/${PAGE_URL_SEARCH_PARAM_KEY}`, - ); - - const webSocket = new WebSocket(url); - - const onMessage = (event: MessageEvent) => { - const message = WSMessageUtil.deserialize(event.data); - - if (message?.type === 'room-joined') { - notifications.add({ - title: 'Live collaboration', - description: 'You are connected', - type: 'success', - }); - - dispatch( - collaborationActions.init({ - userId: message.data.userId, - users: message.data.users, - }), - ); - dispatch(canvasActions.setNodes(message.data.nodes)); - dispatch(historyActions.reset()); - } - }; - - const onOpen = () => { - setStatus('connected'); - setWebSocket(webSocket); - }; - - const onClose = (event: CloseEvent) => { - setStatus('disconnected'); - - if (serverErrorCodes.includes(event.code)) { - modal.open({ title: 'Error', description: event.reason }); - } else { - modal.open({ - title: 'Live collaboration', - description: 'Disconnected', - }); - } - }; - - const onError = () => { - notifications.add({ - title: 'Connection', - description: 'Something went wrong', - type: 'error', - }); - setStatus('disconnected'); - }; - - webSocket.addEventListener('message', onMessage); - webSocket.addEventListener('open', onOpen); - webSocket.addEventListener('close', onClose); - webSocket.addEventListener('error', onError); - - attemptedConnection = true; - }, [modal, params, notifications, dispatch]); - - const send = useCallback( - (message: WSMessage) => { - if (webSocket && webSocket.readyState === webSocket.OPEN) { - const serializedMessage = WSMessageUtil.serialize(message); - serializedMessage && webSocket.send(serializedMessage); - } + if (!startedConnecting) { + const url = createWSUrl(roomId); + + createWebSocketConnection(url); + + startedConnecting = true; + } + }, [roomId]); + + const unsubscribe = useCallback((type) => { + delete subscribers.current[type]; + }, []); + + const subscribe = useCallback( + (type, handler) => { + subscribers.current[type] = handler as never; + + return () => unsubscribe(type); }, - [webSocket], + [unsubscribe], ); + const send = useCallback((message, throttle) => { + if (!webSocket.current) { + return; + } + + if (throttle) { + _throttledSendWSMessage(message, webSocket.current); + } else { + _sendWSMessage(message, webSocket.current); + } + }, []); + return ( {children} ); }; + +function createWSUrl(roomId: string) { + return urlSearchParam.set( + 'id', + roomId, + `${wsBaseUrl}/${PAGE_URL_SEARCH_PARAM_KEY}`, + ); +} diff --git a/apps/client/src/hooks/useDrafts.ts b/apps/client/src/hooks/useDrafts.ts index 857a39d6..f21b0be4 100644 --- a/apps/client/src/hooks/useDrafts.ts +++ b/apps/client/src/hooks/useDrafts.ts @@ -8,24 +8,26 @@ type DispatchAction> = { payload: P; }; -type Add = DispatchAction<'add', { node: NodeObject }>; +type Create = DispatchAction<'create', { node: NodeObject }>; type Draw = DispatchAction<'draw', { nodeId: string; position: DrawPosition }>; -type Finish = DispatchAction<'finish', { nodeId: string }>; -type FinishAndKeep = DispatchAction<'finish-keep', { nodeId: string }>; +type Update = DispatchAction<'update', { node: NodeObject; }>; +type Finish = DispatchAction<'finish', { nodeId: string; keep?: boolean }>; -type NodeDraftAction = Add | Draw | Finish | FinishAndKeep; +type NodeDraftAction = Create | Draw | Update | Finish; -type NodeDrafts = { - [nodeId: string]: { node: NodeObject; drawing: boolean }; +type Drafts = { + [nodeId: string]: Draft; }; +export type Draft = { node: NodeObject; drawing: boolean }; + function useDrafts() { - const [drafts, setDrafts] = useState({}); + const [drafts, setDrafts] = useState({}); const dispatchAction = useCallback(({ type, payload }: NodeDraftAction) => { setDrafts((prevDrafts) => { switch (type) { - case 'add': { + case 'create': { const { node } = payload; const newDraftNode = { drawing: true, node }; @@ -46,26 +48,34 @@ function useDrafts() { return prevDrafts; } - case 'finish': { - const { nodeId } = payload; + case 'update': { + const { node } = payload; - if (nodeId in prevDrafts) { - const draftsCopy = { ...prevDrafts }; + if (node.nodeProps.id in prevDrafts) { + const prevDraft = prevDrafts[node.nodeProps.id]; - delete draftsCopy[nodeId]; + const updatedDraft = { drawing: prevDraft.drawing, node }; - return draftsCopy; + return { ...prevDrafts, [node.nodeProps.id]: updatedDraft }; } + return prevDrafts; } - case 'finish-keep': { - const { nodeId } = payload; + case 'finish': { + const { nodeId, keep } = payload; if (nodeId in prevDrafts) { - const { node } = prevDrafts[nodeId]; - const updatedDraft = { node, drawing: false }; + const draftsCopy = { ...prevDrafts }; + + if (keep) { + const { node } = prevDrafts[nodeId]; + const updatedDraft = { node, drawing: false }; + + return { ...draftsCopy, [nodeId]: updatedDraft }; + } - return { ...prevDrafts, [nodeId]: updatedDraft }; + delete draftsCopy[nodeId]; + return draftsCopy; } return prevDrafts; } @@ -74,7 +84,7 @@ function useDrafts() { } }); }, []); - + return [Object.values(drafts), dispatchAction] as const; } diff --git a/apps/client/src/hooks/useEvent.ts b/apps/client/src/hooks/useEvent.ts deleted file mode 100644 index 14cf8a02..00000000 --- a/apps/client/src/hooks/useEvent.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useEffect } from 'react'; -import { isBrowser } from '@/utils/is'; - -const defaultTarget = isBrowser ? window : null; - -function useEvent( - name: K, - handler?: (event: HTMLElementEventMap[K]) => void, - element: EventTarget | undefined | null = defaultTarget, - options?: { - eventOptions?: EventListenerOptions; - deps?: unknown[]; - }, -) { - useEffect(() => { - if (!element) return; - - element.addEventListener( - name, - handler as EventListenerOrEventListenerObject, - options?.eventOptions, - ); - - return () => { - element.removeEventListener( - name, - handler as EventListenerOrEventListenerObject, - options?.eventOptions, - ); - }; - }, [name, handler, element, JSON.stringify(options)]); -} - -export default useEvent; diff --git a/apps/client/src/hooks/useEvent/useEvent.test.ts b/apps/client/src/hooks/useEvent/useEvent.test.ts new file mode 100644 index 00000000..8fd31a8f --- /dev/null +++ b/apps/client/src/hooks/useEvent/useEvent.test.ts @@ -0,0 +1,109 @@ +import { fireEvent, renderHook } from '@testing-library/react'; +import useEvent from './useEvent'; + +describe('useEvent', () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + it('calls the event listener when the event is triggered', async () => { + const element = document.createElement('button'); + const handler = vi.fn(); + + renderHook(() => useEvent('click', handler, element)); + + fireEvent.click(element); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should bind/unbind the event listener to the window object if no element is provided', async () => { + const spyAddEventListener = vi.spyOn(window, 'addEventListener'); + const spyRemoveEventListener = vi.spyOn(window, 'removeEventListener'); + + const handler = vi.fn(); + const options = undefined; + + const { unmount } = renderHook(() => useEvent('click', handler)); + + expect(spyAddEventListener).toHaveBeenCalledTimes(1); + expect(spyAddEventListener).toHaveBeenCalledWith('click', handler, options); + + unmount(); + + expect(spyRemoveEventListener).toHaveBeenCalledTimes(1); + expect(spyRemoveEventListener).toHaveBeenCalledWith( + 'click', + handler, + options, + ); + }); + + it('should bind/unbind the event listener if element is provided', async () => { + const element = document.createElement('button'); + const spyAddEventListener = vi.spyOn(element, 'addEventListener'); + const spyRemoveEventListener = vi.spyOn(element, 'removeEventListener'); + + const handler = vi.fn(); + const options = undefined; + + const { unmount } = renderHook(() => + useEvent('click', handler, element, options), + ); + + expect(spyAddEventListener).toHaveBeenCalledTimes(1); + expect(spyAddEventListener).toHaveBeenCalledWith('click', handler, options); + + unmount(); + + expect(spyRemoveEventListener).toHaveBeenCalledTimes(1); + expect(spyRemoveEventListener).toHaveBeenCalledWith( + 'click', + handler, + options, + ); + }); + + it('should not bind the event listener if handler is undefined', async () => { + const element = document.createElement('button'); + const spyAddEventListener = vi.spyOn(element, 'addEventListener'); + const spyRemoveEventListener = vi.spyOn(element, 'removeEventListener'); + + const handler = undefined; + + const { unmount } = renderHook(() => useEvent('click', handler, element)); + + expect(spyAddEventListener).toHaveBeenCalledTimes(0); + + unmount(); + + expect(spyRemoveEventListener).toHaveBeenCalledTimes(0); + }); + + it('should pass options to the event listener', async () => { + const element = document.createElement('button'); + const spyAddEventListener = vi.spyOn(element, 'addEventListener'); + const spyRemoveEventListener = vi.spyOn(element, 'removeEventListener'); + + const handler = vi.fn(); + const options = { + capture: true, + passive: true, + once: true, + }; + + const { unmount } = renderHook(() => + useEvent('click', handler, element, { eventOptions: options }), + ); + + expect(spyAddEventListener).toHaveBeenCalledWith('click', handler, options); + + unmount(); + + expect(spyRemoveEventListener).toHaveBeenCalledWith( + 'click', + handler, + options, + ); + }); +}); diff --git a/apps/client/src/hooks/useEvent/useEvent.ts b/apps/client/src/hooks/useEvent/useEvent.ts new file mode 100644 index 00000000..9b570c80 --- /dev/null +++ b/apps/client/src/hooks/useEvent/useEvent.ts @@ -0,0 +1,48 @@ +import { isBrowser } from '@/utils/is'; +import { useEffect, useRef } from 'react'; + +type EventsMap = HTMLElementEventMap & + WindowEventMap & + DocumentEventMap & + MediaQueryListEventMap & + WebSocketEventMap; + +type HandlerElements = + | Document + | HTMLElement + | MediaQueryList + | Window + | WebSocket; + +const defaultTarget = isBrowser ? window : null; + +function useEvent( + name: K, + handler: ((event: EventsMap[K]) => void) | undefined, + element: HandlerElements | null = defaultTarget, + options?: { + eventOptions?: AddEventListenerOptions; + deps?: unknown[]; + }, +) { + const savedHandler = useRef(handler); + + useEffect(() => { + savedHandler.current = handler; + }, [handler]); + + useEffect(() => { + if (!element || !savedHandler.current) return; + + const listener = savedHandler.current as EventListenerOrEventListenerObject; + const eventOptions = options?.eventOptions; + + element.addEventListener(name, listener, eventOptions); + + return () => { + element.removeEventListener(name, listener, eventOptions); + }; + }, [name, element, JSON.stringify(options)]); +} + +export default useEvent; diff --git a/apps/client/src/hooks/useFetch.ts b/apps/client/src/hooks/useFetch.ts deleted file mode 100644 index 242aab23..00000000 --- a/apps/client/src/hooks/useFetch.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { BASE_URL, BASE_URL_DEV, IS_PROD } from '@/constants/app'; - -export type UseFetchConfig = { - method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; -}; - -type UseFetchStatus = 'idle' | 'loading' | 'success' | 'error'; -type UseFetchReturn = [ - { data: Data | null; error: string | null; status: UseFetchStatus }, - (body: Body) => Promise, -]; - -const defaultConfig: RequestInit = { - headers: { - 'Content-Type': 'application/json', - }, -}; - -const baseUrl = IS_PROD ? BASE_URL : BASE_URL_DEV; - -let fetched = false; - -function useFetch( - url: string, - config?: UseFetchConfig, - skip = false, -): UseFetchReturn { - const [data, setData] = useState(null); - const [error, setError] = useState(null); - const [status, setStatus] = useState('idle'); - - const execute = useCallback( - async (body?: Body) => { - setError(null); - setStatus('loading'); - - try { - const response = await window.fetch(`${baseUrl}${url}`, { - ...defaultConfig, - ...config, - body: body && JSON.stringify(body), - }); - - const { data, error } = await response.json(); - - if (!response.ok) { - const errorMessage = error.message || response.statusText; - throw new Error(errorMessage); - } - - setStatus('success'); - setData(data); - } catch (error) { - setStatus('error'); - - if (error instanceof Error) { - setError(error.message); - return; - } - - setError(String(error)); - } - }, - [url, config], - ); - - useEffect(() => { - if (skip || fetched) { - return; - } - - const fetchData = async () => { - await execute(); - fetched = true; - }; - - fetchData(); - - return () => { - setData(null); - setError(null); - setStatus('idle'); - }; - }, [url, config, skip]); - - return [{ status, data, error }, execute]; -} - -export default useFetch; diff --git a/apps/client/src/hooks/useNetworkState/useNetworkState.ts b/apps/client/src/hooks/useNetworkState/useNetworkState.ts index 1ccc4c1b..023e28de 100644 --- a/apps/client/src/hooks/useNetworkState/useNetworkState.ts +++ b/apps/client/src/hooks/useNetworkState/useNetworkState.ts @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; +import useEvent from '@/hooks/useEvent/useEvent'; type NetworkState = { online?: boolean; @@ -6,6 +7,8 @@ type NetworkState = { const isNavigator = typeof navigator !== 'undefined'; +const eventOptions = { passive: true }; + function getConnectionState(): NetworkState { return { online: isNavigator ? navigator.onLine : undefined, @@ -15,19 +18,12 @@ function getConnectionState(): NetworkState { function useNetworkState() { const [state, setState] = useState(getConnectionState()); - useEffect(() => { - const handleStateChange = () => { - setState(getConnectionState()); - }; - - window.addEventListener('online', handleStateChange, { passive: true }); - window.addEventListener('offline', handleStateChange, { passive: true }); + const handleStateChange = useCallback(() => { + setState(getConnectionState()); + }, []); - return () => { - window.removeEventListener('online', handleStateChange); - window.removeEventListener('offline', handleStateChange); - }; - }); + useEvent('online', handleStateChange, window, { eventOptions }); + useEvent('offline', handleStateChange, window, { eventOptions }); return state; } diff --git a/apps/client/src/hooks/usePageMutation.ts b/apps/client/src/hooks/usePageMutation.ts deleted file mode 100644 index e2776839..00000000 --- a/apps/client/src/hooks/usePageMutation.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { UpdatePageRequestBody, UpdatePageResponse } from 'shared'; -import useFetch, { type UseFetchConfig } from './useFetch'; -import { useEffect } from 'react'; -import { useNotifications } from '@/contexts/notifications'; -import useUrlSearchParams from './useUrlSearchParams/useUrlSearchParams'; -import { PAGE_URL_SEARCH_PARAM_KEY } from '@/constants/app'; - -const options: UseFetchConfig = { - method: 'PATCH', -}; - -function usePageMutation() { - const params = useUrlSearchParams(); - - const pageId = params[PAGE_URL_SEARCH_PARAM_KEY]; - const url = `/p/${pageId}`; - - const [{ error }, updatePage] = useFetch< - UpdatePageResponse, - UpdatePageRequestBody - >(url, options, true); - - const notifications = useNotifications(); - - useEffect(() => { - if (error) { - notifications.add({ - title: 'Error', - description: 'Failed to update the drawing', - type: 'error', - }); - } - }, [error, notifications]); - - return { updatePage }; -} - -export default usePageMutation; diff --git a/apps/client/src/hooks/useParam/useParam.test.ts b/apps/client/src/hooks/useParam/useParam.test.ts new file mode 100644 index 00000000..8eab65b5 --- /dev/null +++ b/apps/client/src/hooks/useParam/useParam.test.ts @@ -0,0 +1,34 @@ +import { fireEvent, renderHook, waitFor } from '@testing-library/react'; +import useParam from './useParam'; + +describe('useParam', () => { + it('returns correct param value', () => { + vi.stubGlobal('location', { search: '?page=1' }); + + const { result } = renderHook(() => useParam('page')); + + expect(result.current).toEqual('1'); + }); + + it('returns updated param value when url changes', async () => { + vi.stubGlobal('location', { search: '?page=1' }); + + const { result } = renderHook(() => useParam('page')); + + expect(result.current).toEqual('1'); + + vi.stubGlobal('location', { search: '?page=2' }); + + fireEvent.popState(window); + + await waitFor(() => expect(result.current).toEqual('2')); + }); + + it('returns null if search string is empty', () => { + vi.stubGlobal('location', { search: '' }); + + const { result } = renderHook(() => useParam('page')); + + expect(result.current).toEqual(null); + }); +}); diff --git a/apps/client/src/hooks/useParam/useParam.ts b/apps/client/src/hooks/useParam/useParam.ts new file mode 100644 index 00000000..5e919944 --- /dev/null +++ b/apps/client/src/hooks/useParam/useParam.ts @@ -0,0 +1,21 @@ +import { useCallback, useState } from 'react'; +import useEvent from '@/hooks/useEvent/useEvent'; + +const getSearchParam = (name: string) => { + const searchParams = new URLSearchParams(window.location.search); + return searchParams.get(name); +}; + +function useParam(name: string) { + const [param, setParam] = useState(getSearchParam(name)); + + const handlePopState = useCallback(() => { + setParam(getSearchParam(name)); + }, []); + + useEvent('popstate', handlePopState); + + return param; +} + +export default useParam; diff --git a/apps/client/src/hooks/useUrlSearchParams/useUrlSearchParams.test.ts b/apps/client/src/hooks/useUrlSearchParams/useUrlSearchParams.test.ts deleted file mode 100644 index 216f3caf..00000000 --- a/apps/client/src/hooks/useUrlSearchParams/useUrlSearchParams.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import useUrlSearchParams from './useUrlSearchParams'; - -describe('useUrlSearchParams', () => { - it('returns correct params object', async () => { - vi.stubGlobal('location', { search: '?param1=value1¶m2=value2' }); - - const { result } = renderHook(() => useUrlSearchParams()); - - expect(result.current).toEqual({ param1: 'value1', param2: 'value2' }); - }); - - it('returns empty object if search string is empty', async () => { - vi.stubGlobal('location', { search: '' }); - - const { result } = renderHook(() => useUrlSearchParams()); - - expect(result.current).toEqual({}); - }); -}); diff --git a/apps/client/src/hooks/useUrlSearchParams/useUrlSearchParams.ts b/apps/client/src/hooks/useUrlSearchParams/useUrlSearchParams.ts deleted file mode 100644 index 75776dfa..00000000 --- a/apps/client/src/hooks/useUrlSearchParams/useUrlSearchParams.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useEffect, useState } from 'react'; - -type Params = ReturnType; - -const getSearchParams = (search: string) => { - const params = new URLSearchParams(search); - return Object.fromEntries(params); -}; - -function useUrlSearchParams(): Params { - const [params, setParams] = useState(getSearchParams(window.location.search)); - - useEffect(() => { - const handleHistoryChange = () => { - const params = getSearchParams(window.location.search); - setParams(params); - }; - - window.addEventListener('popstate', handleHistoryChange, { - passive: true, - }); - - return () => { - window.removeEventListener('popstate', handleHistoryChange); - }; - }, []); - - return params; -} - -export default useUrlSearchParams; diff --git a/apps/client/src/hooks/useWSMessage.ts b/apps/client/src/hooks/useWSMessage.ts deleted file mode 100644 index a9dfb6f0..00000000 --- a/apps/client/src/hooks/useWSMessage.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useEffect } from 'react'; -import { type WSMessage, WSMessageUtil } from 'shared'; - -function useWSMessage( - connection: WebSocket | null | undefined, - onMessage: (message: WSMessage) => void, - deps: unknown[] = [], -) { - useEffect(() => { - if (!connection) { - return; - } - - const listener = (event: MessageEvent) => { - const message = WSMessageUtil.deserialize(event.data); - - if (message?.data && message?.type) { - onMessage(message); - } - }; - - connection.addEventListener('message', listener); - - return () => { - connection.removeEventListener('message', listener); - }; - }, [connection, onMessage, ...deps]); -} - -export default useWSMessage; diff --git a/apps/client/src/providers/app.tsx b/apps/client/src/providers/app.tsx index dc4cbde3..2abf1225 100644 --- a/apps/client/src/providers/app.tsx +++ b/apps/client/src/providers/app.tsx @@ -4,21 +4,25 @@ import { NotificationsProvider } from '@/contexts/notifications'; import { WebSocketProvider } from '@/contexts/websocket'; import { store } from '@/stores/store'; import { ThemeProvider } from '@/contexts/theme'; +import useParam from '@/hooks/useParam/useParam'; +import { PAGE_URL_SEARCH_PARAM_KEY } from '@/constants/app'; type Props = { children: React.ReactNode; }; export const AppProvider = ({ children }: Props) => { + const roomId = useParam(PAGE_URL_SEARCH_PARAM_KEY); + return ( - - - - - {children} - - - - + + + + + {children} + + + + ); }; diff --git a/apps/client/src/services/api.ts b/apps/client/src/services/api.ts new file mode 100644 index 00000000..f7f74b9c --- /dev/null +++ b/apps/client/src/services/api.ts @@ -0,0 +1,74 @@ +import { BASE_URL, BASE_URL_DEV, IS_PROD } from '@/constants/app'; +import type { + GetPageResponse, + QRCodeRequestBody, + QRCodeResponse, + SharePageRequestBody, + SharePageResponse, + UpdatePageRequestBody, + UpdatePageResponse, +} from 'shared'; + +const baseUrl = IS_PROD ? BASE_URL : BASE_URL_DEV; + +class HTTPError extends Error {} + +const createQuery = ( + baseUrl: RequestInfo | URL = '', + baseInit?: RequestInit, +) => { + return (url: RequestInfo | URL, init?: RequestInit) => { + const controller = new AbortController(); + const signal = controller.signal; + + const result = fetch(`${baseUrl}${url}`, { + ...baseInit, + ...init, + signal, + }).then((res) => { + if (!res.ok) { + throw new HTTPError(res.statusText, { cause: res }); + } + + return res.json() as Promise; + }); + + return [result, controller] as const; + }; +}; + +const query = createQuery(baseUrl, { + headers: { + 'Content-Type': 'application/json', + }, +}); + +const makeRequest = (method: RequestInit['method']) => { + return >( + url: RequestInfo | URL, + body: TBody, + ) => { + return query(url, { method, body: JSON.stringify(body) }); + }; +}; + +const api = { + get: query, + post: makeRequest('POST'), + patch: makeRequest('PATCH'), +}; + +export default { + getPage: (pageId: string) => { + return api.get(`/p/${pageId}`); + }, + updatePage: (pageId: string, body: UpdatePageRequestBody) => { + return api.patch(`/p/${pageId}`, body); + }, + sharePage: (body: SharePageRequestBody) => { + return api.post('/p', body); + }, + makeQRCode: (body: QRCodeRequestBody) => { + return api.post('/qrcode', body); + }, +}; diff --git a/apps/client/src/stores/slices/__tests__/canvas.test.ts b/apps/client/src/services/canvas/__tests__/slice.test.ts similarity index 99% rename from apps/client/src/stores/slices/__tests__/canvas.test.ts rename to apps/client/src/services/canvas/__tests__/slice.test.ts index 5b6f0014..40d0e553 100644 --- a/apps/client/src/stores/slices/__tests__/canvas.test.ts +++ b/apps/client/src/services/canvas/__tests__/slice.test.ts @@ -2,7 +2,7 @@ import reducer, { type CanvasSliceState, canvasActions, initialState, -} from '../canvas'; +} from '../slice'; import { nodesGenerator } from '@/test/data-generators'; import type { AppState } from '@/constants/app'; import type { NodeObject, StageConfig } from 'shared'; diff --git a/apps/client/src/services/canvas/listeners.ts b/apps/client/src/services/canvas/listeners.ts new file mode 100644 index 00000000..c1c773fc --- /dev/null +++ b/apps/client/src/services/canvas/listeners.ts @@ -0,0 +1,45 @@ +import { isAnyOf } from '@reduxjs/toolkit'; +import { historyActions } from '@/stores/reducers/history'; +import { canvasActions } from '@/services/canvas/slice'; +import { storage } from '@/utils/storage'; +import { LOCAL_STORAGE_KEY } from '@/constants/app'; +import { collaborationActions } from '@/services/collaboration/slice'; +import type { AppStartListening } from '@/stores/middlewares/listenerMiddleware'; +import type { AppState } from '@/constants/app'; + +export const addCanvasListener = (startListening: AppStartListening) => { + const unsubscribe = startListening({ + matcher: isAnyOf( + historyActions.undo, + historyActions.redo, + canvasActions.addNodes, + canvasActions.updateNodes, + canvasActions.setNodes, + canvasActions.deleteNodes, + canvasActions.pasteNodes, + canvasActions.moveNodesToEnd, + canvasActions.moveNodesBackward, + canvasActions.moveNodesForward, + canvasActions.moveNodesToStart, + canvasActions.setSelectedNodesIds, + canvasActions.setStageConfig, + canvasActions.setToolType, + canvasActions.selectAllNodes, + ), + effect: (_, listenerApi) => { + const canvasState = listenerApi.getState().canvas.present; + + storage.set(LOCAL_STORAGE_KEY, { page: canvasState }); + }, + }); + + startListening({ + matcher: collaborationActions.init.match, + effect: (_, listenerApi) => { + unsubscribe({ cancelActive: true }); + + listenerApi.dispatch(historyActions.reset()); + listenerApi.unsubscribe(); + }, + }); +}; diff --git a/apps/client/src/stores/slices/canvas.ts b/apps/client/src/services/canvas/slice.ts similarity index 55% rename from apps/client/src/stores/slices/canvas.ts rename to apps/client/src/services/canvas/slice.ts index d0992f60..b7ac3104 100644 --- a/apps/client/src/stores/slices/canvas.ts +++ b/apps/client/src/services/canvas/slice.ts @@ -1,10 +1,10 @@ import { createSlice, isAnyOf } from '@reduxjs/toolkit'; import { makeNodesCopy, reorderNodes, isValidNode } from '@/utils/node'; import { getCanvasCenteredPositionRelativeToNodes } from '@/utils/position'; -import { historyActions } from '../reducers/history'; +import { historyActions } from '../../stores/reducers/history'; import type { NodeObject } from 'shared'; import type { AppState } from '@/constants/app'; -import type { RootState } from '../store'; +import type { RootState } from '../../stores/store'; import type { PayloadAction } from '@reduxjs/toolkit'; export type CanvasSliceState = { @@ -25,6 +25,18 @@ export const initialState: CanvasSliceState = { copiedNodes: null, }; +export type ActionMeta = { + receivedFromWS?: boolean; + broadcast?: boolean; +}; + +export const prepareMeta = ( + payload: T = undefined as T, + meta?: ActionMeta, +) => { + return { payload, meta }; +}; + export const canvasSlice = createSlice({ name: 'canvas', initialState, @@ -37,67 +49,96 @@ export const canvasSlice = createSlice({ state.stageConfig = stageConfig; state.toolType = toolType; }, - setNodes: (state, action: PayloadAction) => { - state.nodes = action.payload; + setNodes: { + reducer: (state, action: PayloadAction) => { + state.nodes = action.payload; + }, + prepare: prepareMeta, }, - addNodes: (state, action: PayloadAction) => { - const nodesToAdd = action.payload; - - if (nodesToAdd.every(isValidNode)) { - state.nodes.push(...nodesToAdd); - } + addNodes: { + reducer: (state, action: PayloadAction) => { + const nodesToAdd = action.payload; + + if (nodesToAdd.every(isValidNode)) { + state.nodes.push(...nodesToAdd); + } + }, + prepare: prepareMeta, }, - updateNodes: (state, action: PayloadAction) => { - const updatedNodesMap = new Map( - action.payload.map((node) => [node.nodeProps.id, node]), - ); + updateNodes: { + reducer: (state, action: PayloadAction) => { + const updatedNodesMap = new Map( + action.payload.map((node) => [node.nodeProps.id, node]), + ); - const updatedNodes = state.nodes.map((node) => { - const updatedNode = updatedNodesMap.get(node.nodeProps.id); + const updatedNodes = state.nodes.map((node) => { + const updatedNode = updatedNodesMap.get(node.nodeProps.id); - return updatedNode ?? node; - }); + return updatedNode ?? node; + }); - state.nodes = updatedNodes.filter(isValidNode); + state.nodes = updatedNodes.filter(isValidNode); + }, + prepare: prepareMeta, }, - deleteNodes: (state, action: PayloadAction) => { - const nodesIds = new Set(action.payload); + deleteNodes: { + reducer: (state, action: PayloadAction) => { + const nodesIds = new Set(action.payload); - state.nodes = state.nodes.filter( - (node) => !nodesIds.has(node.nodeProps.id), - ); + state.nodes = state.nodes.filter( + (node) => !nodesIds.has(node.nodeProps.id), + ); + }, + prepare: prepareMeta, }, - moveNodesToStart: (state, action: PayloadAction) => { - state.nodes = reorderNodes(action.payload, state.nodes).toStart(); + moveNodesToStart: { + reducer: (state, action: PayloadAction) => { + state.nodes = reorderNodes(action.payload, state.nodes).toStart(); + }, + prepare: prepareMeta, }, - moveNodesForward: (state, action: PayloadAction) => { - state.nodes = reorderNodes(action.payload, state.nodes).forward(); + moveNodesForward: { + reducer: (state, action: PayloadAction) => { + state.nodes = reorderNodes(action.payload, state.nodes).forward(); + }, + prepare: prepareMeta, }, - moveNodesBackward: (state, action: PayloadAction) => { - state.nodes = reorderNodes(action.payload, state.nodes).backward(); + moveNodesBackward: { + reducer: (state, action: PayloadAction) => { + state.nodes = reorderNodes(action.payload, state.nodes).backward(); + }, + prepare: prepareMeta, }, - moveNodesToEnd: (state, action: PayloadAction) => { - state.nodes = reorderNodes(action.payload, state.nodes).toEnd(); + moveNodesToEnd: { + reducer: (state, action: PayloadAction) => { + state.nodes = reorderNodes(action.payload, state.nodes).toEnd(); + }, + prepare: prepareMeta, }, copyNodes: (state) => { const { nodes, selectedNodesIds } = state; - state.copiedNodes = nodes.filter( - ({ nodeProps }) => nodeProps.id in selectedNodesIds, + const selectedNodes = nodes.filter( + (node) => node.nodeProps.id in selectedNodesIds, ); + + state.copiedNodes = makeNodesCopy(selectedNodes); }, - pasteNodes: (state) => { - if (state.copiedNodes) { - const duplicatedNodes = makeNodesCopy(state.copiedNodes); + pasteNodes: { + reducer: (state) => { + const { copiedNodes } = state; - state.nodes.push(...duplicatedNodes); + if (!copiedNodes) { + return; + } + state.nodes.push(...copiedNodes); state.selectedNodesIds = Object.fromEntries( - duplicatedNodes.map(({ nodeProps }) => [nodeProps.id, true]), + copiedNodes.map(({ nodeProps }) => [nodeProps.id, true]), ); - state.copiedNodes = null; - } + }, + prepare: prepareMeta, }, setToolType: ( state, diff --git a/apps/client/src/stores/slices/__tests__/collaboration.test.ts b/apps/client/src/services/collaboration/__tests__/slice.test.ts similarity index 64% rename from apps/client/src/stores/slices/__tests__/collaboration.test.ts rename to apps/client/src/services/collaboration/__tests__/slice.test.ts index fafd34bd..e3ec9224 100644 --- a/apps/client/src/stores/slices/__tests__/collaboration.test.ts +++ b/apps/client/src/services/collaboration/__tests__/slice.test.ts @@ -1,31 +1,31 @@ -import reducer, { collaborationActions, initialState } from '../collaboration'; +import reducer, { collaborationActions, initialState } from '../slice'; import { usersGenerator } from '@/test/data-generators'; -import type { CollaborationSliceState } from '../collaboration'; +import type { CollaborationSliceState } from '../slice'; import type { User } from 'shared'; describe('collaboration slice', () => { it('initializes correctly', () => { - const users: CollaborationSliceState['users'] = usersGenerator(4); - const userId = users[0].id; + const collaborators = usersGenerator(4); + const thisUser = collaborators[0]; const state = reducer( undefined, - collaborationActions.init({ userId, users }), + collaborationActions.init({ thisUser, collaborators }), ); - expect(state).toEqual({ ...initialState, userId, users }); + expect(state).toEqual({ ...initialState, thisUser, collaborators }); }); it('adds a user', () => { - const users = usersGenerator(2); - const userId = users[0].id; + const collaborators = usersGenerator(4); + const thisUser = collaborators[0]; const userToAdd = usersGenerator(1)[0]; const previousState: CollaborationSliceState = { ...initialState, - userId, - users, + thisUser, + collaborators, }; const state = reducer( @@ -35,20 +35,20 @@ describe('collaboration slice', () => { expect(state).toEqual({ ...previousState, - users: expect.arrayContaining([userToAdd]), + collaborators: expect.arrayContaining([userToAdd]), }); }); it('updates a user', () => { - const users = usersGenerator(3); + const collaborators = usersGenerator(3); const updatedUser: User = { - ...users[0], + ...collaborators[0], name: 'New name', color: 'gray500', }; - const previousState: CollaborationSliceState = { ...initialState, users }; + const previousState: CollaborationSliceState = { ...initialState, collaborators }; const state = reducer( previousState, @@ -57,15 +57,15 @@ describe('collaboration slice', () => { expect(state).toEqual({ ...previousState, - users: [updatedUser, users[1], users[2]], + collaborators: [updatedUser, collaborators[1], collaborators[2]], }); }); it('does not error or update a user if the user is not in the state', () => { - const users = usersGenerator(3); + const collaborators = usersGenerator(3); const userNotInState = usersGenerator(1)[0]; - const previousState: CollaborationSliceState = { ...initialState, users }; + const previousState: CollaborationSliceState = { ...initialState, collaborators }; const state = reducer( previousState, @@ -76,10 +76,10 @@ describe('collaboration slice', () => { }); it('removes a user', () => { - const users = usersGenerator(3); - const userToRemove = users[0]; + const collaborators = usersGenerator(3); + const userToRemove = collaborators[0]; - const previousState: CollaborationSliceState = { ...initialState, users }; + const previousState: CollaborationSliceState = { ...initialState, collaborators }; const state = reducer( previousState, @@ -88,7 +88,7 @@ describe('collaboration slice', () => { expect(state).toEqual({ ...previousState, - users: expect.not.arrayContaining([userToRemove]), + collaborators: expect.not.arrayContaining([userToRemove]), }); }); }); diff --git a/apps/client/src/services/collaboration/listeners.ts b/apps/client/src/services/collaboration/listeners.ts new file mode 100644 index 00000000..b80adccc --- /dev/null +++ b/apps/client/src/services/collaboration/listeners.ts @@ -0,0 +1,133 @@ +import { addAppListener } from '@/stores/middlewares/listenerMiddleware'; +import { isAnyOf } from '@reduxjs/toolkit'; +import { collaborationActions } from './slice'; +import { canvasActions } from '../canvas/slice'; +import api from '@/services/api'; +import type { WebSocketContextValue } from '@/contexts/websocket'; +import type { AppDispatch } from '@/stores/store'; +import type { ActionMeta } from '../canvas/slice'; + +/** + * subscribe to collaborrative actions and send messages + */ +export const addCollabActionsListeners = ( + ws: WebSocketContextValue, + roomId: string, +) => { + const matcher = isAnyOf( + collaborationActions.updateUser, + canvasActions.addNodes, + canvasActions.updateNodes, + canvasActions.deleteNodes, + canvasActions.moveNodesBackward, + canvasActions.moveNodesForward, + canvasActions.moveNodesToEnd, + canvasActions.moveNodesToStart, + canvasActions.setNodes, + canvasActions.pasteNodes, + ); + + return addAppListener({ + matcher, + effect: (action, listenerApi) => { + if (action.meta?.receivedFromWS || action.meta?.broadcast === false) { + return; + } + + const state = listenerApi.getState().canvas.present; + + if (!collaborationActions.updateUser.match(action)) { + api.updatePage(roomId, { nodes: state.nodes }); + } + + switch (action.type) { + case 'canvas/addNodes': + ws.send({ type: 'nodes-add', data: action.payload }); + break; + case 'canvas/updateNodes': + ws.send({ type: 'nodes-update', data: action.payload }); + break; + case 'canvas/deleteNodes': + ws.send({ type: 'nodes-delete', data: action.payload }); + break; + case 'canvas/moveNodesBackward': + ws.send({ type: 'nodes-move-backward', data: action.payload }); + break; + case 'canvas/moveNodesForward': + ws.send({ type: 'nodes-move-forward', data: action.payload }); + break; + case 'canvas/moveNodesToEnd': + ws.send({ type: 'nodes-move-to-end', data: action.payload }); + break; + case 'canvas/moveNodesToStart': + ws.send({ type: 'nodes-move-to-start', data: action.payload }); + break; + case 'canvas/setNodes': + ws.send({ type: 'nodes-set', data: action.payload }); + break; + case 'canvas/pasteNodes': { + const pasteNodes = state.nodes.filter( + (node) => node.nodeProps.id in state.selectedNodesIds, + ); + + ws.send({ type: 'nodes-add', data: pasteNodes }); + break; + } + case 'collaboration/updateUser': + ws.send({ type: 'user-change', data: action.payload }); + break; + } + }, + }); +}; + +/** + * subscribe to incoming ws messages and dispatch actions + */ +export const subscribeToIncomingCollabMessages = ( + ws: WebSocketContextValue, + dispatch: AppDispatch, +) => { + const actionMeta: ActionMeta = { receivedFromWS: true }; + + const subscribers = [ + ws.subscribe('room-joined', (data) => { + dispatch(collaborationActions.init(data)); + }), + ws.subscribe('user-joined', (data) => + dispatch(collaborationActions.addUser(data)), + ), + ws.subscribe('user-change', (data) => + dispatch(collaborationActions.updateUser(data, actionMeta)), + ), + ws.subscribe('user-left', (data) => + dispatch(collaborationActions.removeUser(data)), + ), + ws.subscribe('nodes-set', (data) => + dispatch(canvasActions.setNodes(data, actionMeta)), + ), + ws.subscribe('nodes-add', (data) => + dispatch(canvasActions.addNodes(data, actionMeta)), + ), + ws.subscribe('nodes-update', (data) => + dispatch(canvasActions.updateNodes(data, actionMeta)), + ), + ws.subscribe('nodes-delete', (data) => + dispatch(canvasActions.deleteNodes(data, actionMeta)), + ), + ws.subscribe('nodes-move-to-start', (data) => + dispatch(canvasActions.moveNodesToStart(data, actionMeta)), + ), + ws.subscribe('nodes-move-to-end', (data) => + dispatch(canvasActions.moveNodesToEnd(data, actionMeta)), + ), + ws.subscribe('nodes-move-forward', (data) => + dispatch(canvasActions.moveNodesForward(data, actionMeta)), + ), + ws.subscribe('nodes-move-backward', (data) => + dispatch(canvasActions.moveNodesBackward(data, actionMeta)), + ), + ] as const; + + return subscribers; +}; diff --git a/apps/client/src/services/collaboration/slice.ts b/apps/client/src/services/collaboration/slice.ts new file mode 100644 index 00000000..c372847c --- /dev/null +++ b/apps/client/src/services/collaboration/slice.ts @@ -0,0 +1,60 @@ +import { createSlice } from '@reduxjs/toolkit'; +import type { User } from 'shared'; +import type { RootState } from '@/stores/store'; +import type { PayloadAction } from '@reduxjs/toolkit'; +import { prepareMeta } from '../canvas/slice'; + +export type CollaborationSliceState = { + thisUser: User | null; + collaborators: User[]; +}; + +export const initialState: CollaborationSliceState = { + thisUser: null, + collaborators: [], +}; + +export const collaborationSlice = createSlice({ + name: 'collaboration', + initialState, + reducers: { + init: ( + state, + action: PayloadAction<{ collaborators: User[]; thisUser: User }>, + ) => { + state.thisUser = action.payload.thisUser; + state.collaborators = action.payload.collaborators; + }, + addUser: (state, action: PayloadAction) => { + state.collaborators.push(action.payload); + }, + updateUser: { + reducer: (state, action: PayloadAction) => { + const index = state.collaborators.findIndex( + (u) => u.id === action.payload.id, + ); + + if (index !== -1) { + state.collaborators[index] = { + ...state.collaborators[index], + ...action.payload, + }; + } + }, + prepare: prepareMeta, + }, + removeUser: (state, action: PayloadAction<{ id: string }>) => { + state.collaborators = state.collaborators.filter( + (u) => u.id !== action.payload.id, + ); + }, + }, +}); + +export const selectThisUser = (state: RootState) => + state.collaboration.thisUser; +export const selectCollaborators = (state: RootState) => + state.collaboration.collaborators; + +export const collaborationActions = collaborationSlice.actions; +export default collaborationSlice.reducer; diff --git a/apps/client/src/stores/slices/__tests__/library.test.ts b/apps/client/src/services/library/__tests__/slice.test.ts similarity index 80% rename from apps/client/src/stores/slices/__tests__/library.test.ts rename to apps/client/src/services/library/__tests__/slice.test.ts index 8f622b62..1707fc8e 100644 --- a/apps/client/src/stores/slices/__tests__/library.test.ts +++ b/apps/client/src/services/library/__tests__/slice.test.ts @@ -1,4 +1,4 @@ -import reducer, { initialState, libraryActions } from '../library'; +import reducer, { initialState, libraryActions } from '../slice'; import { libraryGenerator, nodesGenerator } from '@/test/data-generators'; describe('library slice', () => { @@ -6,14 +6,6 @@ describe('library slice', () => { expect(reducer(undefined, { type: undefined })).toEqual(initialState); }); - it('initializes the state', () => { - const stateToSet = libraryGenerator(8); - - const state = reducer(undefined, libraryActions.init(stateToSet)); - - expect(state).toEqual({ ...initialState, ...stateToSet }); - }); - it('adds item to the library', () => { const nodesToAdd = nodesGenerator(8); diff --git a/apps/client/src/services/library/listeners.ts b/apps/client/src/services/library/listeners.ts new file mode 100644 index 00000000..5589088a --- /dev/null +++ b/apps/client/src/services/library/listeners.ts @@ -0,0 +1,20 @@ +import { isAnyOf } from '@reduxjs/toolkit'; +import { libraryActions } from './slice'; +import { storage } from '@/utils/storage'; +import { LOCAL_STORAGE_LIBRARY_KEY } from '@/constants/app'; +import type { Library } from '@/constants/app'; +import type { AppStartListening } from '@/stores/middlewares/listenerMiddleware'; + +export const addLibraryListener = (startListening: AppStartListening) => { + startListening({ + matcher: isAnyOf( + libraryActions.addItem.match, + libraryActions.removeItems.match, + ), + effect: (_, listenerApi) => { + const libraryState = listenerApi.getState().library; + + storage.set(LOCAL_STORAGE_LIBRARY_KEY, libraryState); + }, + }); +}; diff --git a/apps/client/src/stores/slices/library.ts b/apps/client/src/services/library/slice.ts similarity index 82% rename from apps/client/src/stores/slices/library.ts rename to apps/client/src/services/library/slice.ts index 610bab7f..69743da8 100644 --- a/apps/client/src/stores/slices/library.ts +++ b/apps/client/src/services/library/slice.ts @@ -1,7 +1,7 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { v4 as uuid } from 'uuid'; import type { NodeObject } from 'shared'; -import type { RootState } from '../store'; +import type { RootState } from '@/stores/store'; import type { Library, LibraryItem } from '@/constants/app'; export type LibrarySliceState = Library; @@ -14,7 +14,7 @@ export const librarySlice = createSlice({ name: 'library', initialState, reducers: { - init: (_, action: PayloadAction) => { + set: (_, action: PayloadAction) => { return action.payload; }, addItem: (state, action: PayloadAction) => { @@ -27,8 +27,8 @@ export const librarySlice = createSlice({ state.items.push(newLibraryItem); }, removeItems: (state, action: PayloadAction) => { - state.items = state.items.filter((item) => - !action.payload.includes(item.id), + state.items = state.items.filter( + (item) => !action.payload.includes(item.id), ); }, }, diff --git a/apps/client/src/stores/middlewares/listenerMiddleware.ts b/apps/client/src/stores/middlewares/listenerMiddleware.ts index 3aeef960..fd8a4ba5 100644 --- a/apps/client/src/stores/middlewares/listenerMiddleware.ts +++ b/apps/client/src/stores/middlewares/listenerMiddleware.ts @@ -1,21 +1,12 @@ -import { - createListenerMiddleware, - addListener, - isAnyOf, -} from '@reduxjs/toolkit'; -import { LOCAL_STORAGE_KEY, LOCAL_STORAGE_LIBRARY_KEY } from '@/constants/app'; -import { storage } from '@/utils/storage'; -import { historyActions } from '../reducers/history'; -import { canvasActions } from '../slices/canvas'; -import { collaborationActions } from '../slices/collaboration'; -import { libraryActions } from '../slices/library'; +import { createListenerMiddleware, addListener } from '@reduxjs/toolkit'; import type { RootState, AppDispatch } from '../store'; -import type { AppState, Library } from '@/constants/app'; import type { TypedAddListener, TypedStartListening, TypedStopListening, } from '@reduxjs/toolkit'; +import { addLibraryListener } from '@/services/library/listeners'; +import { addCanvasListener } from '@/services/canvas/listeners'; export type AppStartListening = TypedStartListening; export type AppStopListening = TypedStopListening; @@ -33,45 +24,5 @@ export const addAppListener = addListener as TypedAddListener< AppDispatch >; -export const ACTIONS_TO_LISTEN = [ - historyActions.undo, - historyActions.redo, - canvasActions.addNodes, - canvasActions.updateNodes, - canvasActions.setNodes, - canvasActions.deleteNodes, - canvasActions.moveNodesToEnd, - canvasActions.moveNodesBackward, - canvasActions.moveNodesForward, - canvasActions.moveNodesToStart, - canvasActions.setSelectedNodesIds, - canvasActions.setStageConfig, - canvasActions.setToolType, - collaborationActions.init, - libraryActions.addItem, - libraryActions.removeItems, -]; - -startAppListening({ - matcher: isAnyOf(...ACTIONS_TO_LISTEN), - effect: (action, listenerApi) => { - if (collaborationActions.init.match(action)) { - listenerApi.unsubscribe(); - return; - } - - if ( - libraryActions.addItem.match(action) || - libraryActions.removeItems.match(action) - ) { - const libraryState = listenerApi.getState().library; - - storage.set(LOCAL_STORAGE_LIBRARY_KEY, libraryState); - return; - } - - const canvasState = listenerApi.getState().canvas.present; - - storage.set(LOCAL_STORAGE_KEY, { page: canvasState }); - }, -}); +addLibraryListener(startAppListening); +addCanvasListener(startAppListening); diff --git a/apps/client/src/stores/reducers/__tests__/history.test.ts b/apps/client/src/stores/reducers/__tests__/history.test.ts index 7cd96c51..7c686c08 100644 --- a/apps/client/src/stores/reducers/__tests__/history.test.ts +++ b/apps/client/src/stores/reducers/__tests__/history.test.ts @@ -1,7 +1,7 @@ import reducer, { type CanvasHistoryState, historyActions } from '../history'; import canvasReducer, { initialState as initialCanvasState, -} from '@/stores/slices/canvas'; +} from '@/services/canvas/slice'; import { nodesGenerator } from '@/test/data-generators'; describe('history reducer', () => { diff --git a/apps/client/src/stores/reducers/history.ts b/apps/client/src/stores/reducers/history.ts index 343f77b4..d3266342 100644 --- a/apps/client/src/stores/reducers/history.ts +++ b/apps/client/src/stores/reducers/history.ts @@ -1,6 +1,6 @@ import { type Action, createAction, type Reducer } from '@reduxjs/toolkit'; -import { initialState as initialCanvasState } from '../slices/canvas'; -import type { CanvasActionType, CanvasSliceState } from '../slices/canvas'; +import { initialState as initialCanvasState } from '../../services/canvas/slice'; +import type { CanvasActionType, CanvasSliceState } from '../../services/canvas/slice'; export type CanvasHistoryState = { past: CanvasSliceState[]; diff --git a/apps/client/src/stores/slices/collaboration.ts b/apps/client/src/stores/slices/collaboration.ts deleted file mode 100644 index 1da13c37..00000000 --- a/apps/client/src/stores/slices/collaboration.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; -import type { User } from 'shared'; -import { type RootState } from '../store'; - -export type CollaborationSliceState = { - userId: string | null; - users: User[]; -}; - -export const initialState: CollaborationSliceState = { - userId: null, - users: [], -}; - -export const collaborationSlice = createSlice({ - name: 'collaboration', - initialState, - reducers: { - init: (state, action: PayloadAction<{ users: User[]; userId: string }>) => { - state.userId = action.payload.userId; - state.users = action.payload.users; - }, - addUser: (state, action: PayloadAction) => { - state.users.push(action.payload); - }, - updateUser: ( - state, - action: PayloadAction & { id: string }>, - ) => { - const index = state.users.findIndex((u) => u.id === action.payload.id); - - if (index !== -1) { - state.users[index] = { ...state.users[index], ...action.payload }; - } - }, - removeUser: (state, action: PayloadAction<{ id: string }>) => { - state.users = state.users.filter((u) => u.id !== action.payload.id); - }, - }, -}); - -export const selectMyUser = (state: RootState) => state.collaboration.userId; -export const selectUsers = (state: RootState) => state.collaboration.users; - -export const collaborationActions = collaborationSlice.actions; -export default collaborationSlice.reducer; diff --git a/apps/client/src/stores/store.ts b/apps/client/src/stores/store.ts index b1fc29d7..7b0e88a7 100644 --- a/apps/client/src/stores/store.ts +++ b/apps/client/src/stores/store.ts @@ -1,15 +1,15 @@ import { configureStore } from '@reduxjs/toolkit'; import { listenerMiddleware } from './middlewares/listenerMiddleware'; import historyReducer from './reducers/history'; -import canvas from './slices/canvas'; -import collaboration from './slices/collaboration'; -import library from './slices/library'; +import canvas from '../services/canvas/slice'; +import collaboration from '../services/collaboration/slice'; +import library from '@/services/library/slice'; export const store = configureStore({ reducer: { canvas: historyReducer(canvas), collaboration, - library + library, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware().prepend(listenerMiddleware.middleware), diff --git a/apps/client/src/test/browser-mocks.ts b/apps/client/src/test/browser-mocks.ts index 417c0fa9..6d7ce9ac 100644 --- a/apps/client/src/test/browser-mocks.ts +++ b/apps/client/src/test/browser-mocks.ts @@ -1,23 +1,68 @@ -export const matchMedia = vi.fn().mockImplementation((query) => ({ - matches: false, - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), -})); - -export const localStorage = (() => { - let storage: Record = {}; - - return { - getItem: (key: string) => storage[key], - setItem: (key: string, value: string) => (storage[key] = value), - clear: () => (storage = {}), - removeItem: (key: string) => delete storage[key], - length: Object.keys(storage).length, - key: (index: number) => Object.keys(storage)[index] || null, - }; -})(); +import { urlSearchParam } from '@/utils/url'; +import ResizeObserverPolyfill from 'resize-observer-polyfill'; + +// matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), // deprecated + removeListener: vi.fn(), // deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +// localStorage +Object.defineProperty(window, 'localStorage', { + value: vi.fn().mockImplementation(() => { + let storage: Record = {}; + + return { + getItem: (key: string) => storage[key], + setItem: (key: string, value: string) => (storage[key] = value), + clear: () => (storage = {}), + removeItem: (key: string) => delete storage[key], + length: Object.keys(storage).length, + key: (index: number) => Object.keys(storage)[index] || null, + }; + })(), +}); + +/** + * implement missing DragEvent in jsdom + * https://github.com/jsdom/jsdom/issues/2913 + */ +export class DragEvent extends MouseEvent { + public clientX: number; + public clientY: number; + + constructor(type: string, params: PointerEventInit = {}) { + super(type, params); + this.clientX = params.clientX ?? 0; + this.clientY = params.clientY ?? 0; + } +} + +global.DragEvent = + global.DragEvent ?? (DragEvent as typeof globalThis.PointerEvent); + +// fonts load +Object.defineProperty(document, 'fonts', { + value: { ready: Promise.resolve({}) }, + configurable: true, +}); + +// resizeObserver +global.ResizeObserver = ResizeObserverPolyfill; + +export function setSearchParam(key: string, value: string) { + Object.defineProperty(window, 'location', { + value: { + search: urlSearchParam.set(key, value).search, + }, + }); +} diff --git a/apps/client/src/test/mocks/handlers.ts b/apps/client/src/test/mocks/handlers.ts new file mode 100644 index 00000000..7ea25d2a --- /dev/null +++ b/apps/client/src/test/mocks/handlers.ts @@ -0,0 +1,45 @@ +import { HttpResponse, http, delay } from 'msw'; +import { v4 as uuid } from 'uuid'; +import { nodesGenerator, stateGenerator } from '../data-generators'; +import { BASE_URL_DEV } from '@/constants/app'; +import type { + GetPageResponse, + QRCodeResponse, + SharePageResponse, +} from 'shared'; + +const mockPageId = uuid(); + +export const mockGetPageResponse: GetPageResponse = { + page: { + nodes: nodesGenerator(5), + stageConfig: stateGenerator({}).canvas.present.stageConfig, + id: mockPageId, + }, +}; + +export const mockSharePageResponse: SharePageResponse = { + id: mockPageId, +}; + +export const mockQRCodeResponse: QRCodeResponse = { + dataUrl: 'data:image/png', +}; + +export const handlers = [ + http.get(`${BASE_URL_DEV}/p/*`, async () => { + await delay(150); + + return HttpResponse.json(mockGetPageResponse); + }), + http.post(`${BASE_URL_DEV}/p`, async () => { + await delay(150); + + return HttpResponse.json(mockSharePageResponse); + }), + http.post(`${BASE_URL_DEV}/qrcode`, async () => { + await delay(150); + + return HttpResponse.json(mockQRCodeResponse); + }), +]; diff --git a/apps/client/src/test/mocks/server.ts b/apps/client/src/test/mocks/server.ts new file mode 100644 index 00000000..e52fee0a --- /dev/null +++ b/apps/client/src/test/mocks/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from 'msw/node'; +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); diff --git a/apps/client/src/test/setup.ts b/apps/client/src/test/setup.ts index 41c0057b..0c0994b6 100644 --- a/apps/client/src/test/setup.ts +++ b/apps/client/src/test/setup.ts @@ -1,15 +1,20 @@ import * as matchers from '@testing-library/jest-dom/matchers'; import { cleanup } from '@testing-library/react'; -import ResizeObserverPolyfill from 'resize-observer-polyfill'; +import './browser-mocks'; import 'vitest-canvas-mock'; -import { localStorage, matchMedia } from './browser-mocks'; +import { server } from './mocks/server'; expect.extend(matchers); +beforeAll(() => server.listen()); + afterEach(() => { cleanup(); + server.resetHandlers(); }); +afterAll(() => server.close()); + /** * temporary fix for tests that use jest-canvas-mock * otherwise throws error @@ -17,33 +22,3 @@ afterEach(() => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore globalThis.jest = vi; - -global.ResizeObserver = ResizeObserverPolyfill; -global.matchMedia = matchMedia; -global.localStorage = localStorage; - -/** - * mock document.fonts load - */ -Object.defineProperty(document, 'fonts', { - value: { ready: Promise.resolve({}) }, - configurable: true, -}); - -/** - * implement missing DragEvent in jsdom - * https://github.com/jsdom/jsdom/issues/2913 - */ -class DragEvent extends MouseEvent { - public clientX: number; - public clientY: number; - - constructor(type: string, params: PointerEventInit = {}) { - super(type, params); - this.clientX = params.clientX ?? 0; - this.clientY = params.clientY ?? 0; - } -} - -global.DragEvent = - global.DragEvent ?? (DragEvent as typeof globalThis.PointerEvent); diff --git a/apps/client/src/test/test-utils.tsx b/apps/client/src/test/test-utils.tsx index dee9535f..b7637589 100644 --- a/apps/client/src/test/test-utils.tsx +++ b/apps/client/src/test/test-utils.tsx @@ -4,20 +4,22 @@ import { Provider as StoreProvider } from 'react-redux'; import userEvent from '@testing-library/user-event'; import canvasReducer, { initialState as initialCanvasState, -} from '@/stores/slices/canvas'; +} from '@/services/canvas/slice'; import historyReducer, { type CanvasHistoryState, } from '@/stores/reducers/history'; import collabReducer, { initialState as initialCollabState, -} from '@/stores/slices/collaboration'; +} from '@/services/collaboration/slice'; import libraryReducer, { initialState as initialLibraryState, -} from '@/stores/slices/library'; +} from '@/services/library/slice'; import { WebSocketProvider } from '@/contexts/websocket'; import { ThemeProvider } from '@/contexts/theme'; import { NotificationsProvider } from '@/contexts/notifications'; import { ModalProvider } from '@/contexts/modal'; +import { PAGE_URL_SEARCH_PARAM_KEY } from '@/constants/app'; +import { urlSearchParam } from '@/utils/url'; import type { PropsWithChildren } from 'react'; import type { PreloadedState } from '@reduxjs/toolkit'; import type { RootState } from '@/stores/store'; @@ -70,16 +72,18 @@ export function renderWithProviders( function Wrapper({ children, }: PropsWithChildren<{ children: React.ReactNode }>) { + const roomId = urlSearchParam.get(PAGE_URL_SEARCH_PARAM_KEY); + return ( - - - - - {children} - - - - + + + + + {children} + + + + ); } diff --git a/apps/client/src/utils/storage.ts b/apps/client/src/utils/storage.ts index 7db8cfff..4d142e9e 100644 --- a/apps/client/src/utils/storage.ts +++ b/apps/client/src/utils/storage.ts @@ -1,10 +1,9 @@ -const serializer = JSON.stringify; -const deserializer = JSON.parse; +import { safeJSONParse } from './object'; export const storage = { set: (key: string, value: T) => { try { - window.localStorage.setItem(key, serializer(value)); + window.localStorage.setItem(key, JSON.stringify(value)); } catch (error) { // Empty } @@ -14,12 +13,8 @@ export const storage = { throw new Error('Key must be provided'); } - try { - const item = window.localStorage.getItem(key); + const item = window.localStorage.getItem(key); - return item ? (deserializer(item) as T) : null; - } catch (error) { - return null; - } + return item ? (safeJSONParse(item) as T) : null; }, }; diff --git a/apps/server/src/features/Collaboration/controller.ts b/apps/server/src/features/Collaboration/controller.ts index 6fab549d..9d4b3baa 100644 --- a/apps/server/src/features/Collaboration/controller.ts +++ b/apps/server/src/features/Collaboration/controller.ts @@ -2,7 +2,7 @@ import type { IncomingMessage } from 'http'; import { type WSMessage, WSMessageUtil } from 'shared'; import type { RawData } from 'ws'; import { type PatchedWebSocket } from '@/services/websocket'; -import { findPage, getUnusedUserColor } from './helpers'; +import { createUsername, findPage, getUnusedUserColor } from './helpers'; import { CollabRoom, CollabUser } from './models'; const rooms = new Map>(); @@ -29,14 +29,18 @@ export async function initCollabConnection( } const userColor = getUnusedUserColor(room.users); - const user = new CollabUser('New User', userColor, ws); + const username = createUsername(room); + + const user = new CollabUser(username, userColor, ws); room.addUser(user); rooms.set(room.id, room); + const collaborators = room.getCollaborators(user.id); + const roomJoinedMessage = WSMessageUtil.serialize({ type: 'room-joined', - data: { users: room.users, userId: user.id, nodes: page.nodes }, + data: { collaborators, thisUser: user }, } as WSMessage); roomJoinedMessage && ws.send(roomJoinedMessage); @@ -50,20 +54,12 @@ export async function initCollabConnection( userJoinedMessage && room.broadcast(user.id, userJoinedMessage); } - ws.on('message', (rawMessage) => onMessage(rawMessage, user, room)); - ws.on('close', () => onClose(ws, user, room)); + ws.on('message', (rawMessage) => receiveMessage(rawMessage, user, room)); + ws.on('close', () => leaveRoom(user, room)); + ws.on('error', (error) => console.error(error.message)); } -export function onClose( - ws: PatchedWebSocket, - user: CollabUser, - room: CollabRoom, -) { - leaveRoom(user, room); - ws.terminate(); -} - -export function onMessage( +export function receiveMessage( rawMessage: RawData, user: CollabUser, room: CollabRoom, @@ -79,22 +75,20 @@ export function onMessage( room.updateUser(deserializedMessage.data); } - if (room.hasMultipleUsers()) { - room.broadcast(user.id, message); - } + room.broadcast(user.id, message); } export function leaveRoom(user: CollabUser, room: CollabRoom) { room.removeUser(user.id); - if (room.isEmpty()) { - return rooms.delete(room.id); - } - const message = WSMessageUtil.serialize({ type: 'user-left', data: { id: user.id }, } as WSMessage); message && room.broadcast(user.id, message); + + if (room.isEmpty()) { + return rooms.delete(room.id); + } } diff --git a/apps/server/src/features/Collaboration/helpers.ts b/apps/server/src/features/Collaboration/helpers.ts index 16cb256b..02e146a2 100644 --- a/apps/server/src/features/Collaboration/helpers.ts +++ b/apps/server/src/features/Collaboration/helpers.ts @@ -1,7 +1,7 @@ import type { User } from 'shared'; import * as queries from '@/features/Page/queries/index'; import { COLORS, DEFAULT_COLOR } from './constants'; -import type { CollabUser } from './models'; +import type { CollabRoom, CollabUser } from './models'; export async function findPage(id: string) { try { @@ -19,3 +19,9 @@ export function getUnusedUserColor(users: CollabUser[]): User['color'] { const usedColors = new Set(users.map((user) => user.color)); return COLORS.find((color) => !usedColors.has(color)) || DEFAULT_COLOR; } + +export function createUsername(room: CollabRoom) { + const suffix = room.hasMultipleUsers() ? ` ${room.users.length}` : ''; + + return `New User${suffix}`; +} diff --git a/apps/server/src/features/Collaboration/models.ts b/apps/server/src/features/Collaboration/models.ts index e43c59d4..8dade535 100644 --- a/apps/server/src/features/Collaboration/models.ts +++ b/apps/server/src/features/Collaboration/models.ts @@ -30,6 +30,10 @@ export class CollabRoom implements Room { this.users = this.users.filter((user) => user.id !== id); } + getCollaborators(userId: string) { + return this.users.filter((user) => user.id !== userId); + } + broadcast(broadcasterId: string, message: string) { this.users.forEach((user) => { const ws = user.getWS(); diff --git a/apps/server/src/features/Page/__tests__/routes.test.ts b/apps/server/src/features/Page/__tests__/routes.test.ts index f9c45b21..7e90f50a 100644 --- a/apps/server/src/features/Page/__tests__/routes.test.ts +++ b/apps/server/src/features/Page/__tests__/routes.test.ts @@ -53,11 +53,11 @@ describe('db queries', () => { const postResponse = await request(app).post('/p').send(mockPage); const response = await request(app) - .patch(`/p/${postResponse.body.data.id}`) + .patch(`/p/${postResponse.body.id}`) .send({ nodes: mockPage.page.nodes }); expect(response.status).toBe(200); - expect(typeof response.body.data.id).toBe('string'); + expect(typeof response.body.id).toBe('string'); }); }); @@ -73,7 +73,7 @@ describe('db queries', () => { const response = await request(app).post('/p').send(mockPage); expect(response.status).toBe(200); - expect(typeof response.body.data.id).toBe('string'); + expect(typeof response.body.id).toBe('string'); }); }); @@ -88,19 +88,19 @@ describe('db queries', () => { const postResponse = await request(app).post('/p').send(mockPage); const getResponse = await request(app) - .get(`/p/${postResponse.body.data.id}`) + .get(`/p/${postResponse.body.id}`) .send(); const returnedPage: GetPageResponse = { page: { ...mockPage.page, - id: postResponse.body.data.id, + id: postResponse.body.id, }, }; expect(postResponse.statusCode).toBe(200); expect(getResponse.status).toBe(200); - expect(getResponse.body.data).toMatchObject(returnedPage); + expect(getResponse.body).toMatchObject(returnedPage); }); }); }); diff --git a/apps/server/src/features/QRCode/__tests__/routes.test.ts b/apps/server/src/features/QRCode/__tests__/routes.test.ts index c8a976d4..887d589d 100644 --- a/apps/server/src/features/QRCode/__tests__/routes.test.ts +++ b/apps/server/src/features/QRCode/__tests__/routes.test.ts @@ -8,7 +8,7 @@ describe('POST /qrcode', () => { const response = await request(app).post('/qrcode').send({ url }); expect(response.status).toBe(200); - expect(response.body.data).toHaveProperty('dataUrl'); - expect(typeof response.body.data.dataUrl).toBe('string'); + expect(response.body).toHaveProperty('dataUrl'); + expect(typeof response.body.dataUrl).toBe('string'); }); }); diff --git a/apps/server/src/loaders/route/route.test.ts b/apps/server/src/loaders/route/route.test.ts index a5af6277..22d2f260 100644 --- a/apps/server/src/loaders/route/route.test.ts +++ b/apps/server/src/loaders/route/route.test.ts @@ -13,7 +13,7 @@ describe('loadRoute', () => { await loadRouteHandler(req, res, next); expect(handler).toHaveBeenCalledWith(req, res, next); - expect(res.json).toHaveBeenCalledWith({ data: 42 }); + expect(res.json).toHaveBeenCalledWith(42); }); it('should return an error if the handler throws', async () => { diff --git a/apps/server/src/loaders/route/route.ts b/apps/server/src/loaders/route/route.ts index b0f846e6..c6d275be 100644 --- a/apps/server/src/loaders/route/route.ts +++ b/apps/server/src/loaders/route/route.ts @@ -11,7 +11,7 @@ export const loadRoute = (handler: LoadRouteHandler) => { try { const response = await handler(req, res, next); - return res.json({ data: response }); + return res.json(response); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { console.error(error.message, error.statusCode); diff --git a/package.json b/package.json index bb5737c5..e18a0dbd 100644 --- a/package.json +++ b/package.json @@ -27,11 +27,12 @@ }, "devDependencies": { "@types/react": "^18.0.31", + "@vitest/coverage-v8": "^0.34.2", "eslint": "^8.38.0", + "msw": "^2.0.11", "prettier": "^2.8.7", "typescript": "^4.9.5", "vite": "^4.4.9", - "vitest": "^0.34.1", - "@vitest/coverage-v8": "^0.34.2" + "vitest": "^0.34.1" } } diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 471a3ed8..3b634195 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -9,7 +9,6 @@ import type { UpdatePageResponse, } from '../schemas/page'; import type { QRCodeRequestBody, QRCodeResponse } from '../schemas/qrcode'; -import type { BadRequestError } from '../utils/errors'; import type { defaultTheme, darkTheme } from '../design/theme'; const { nodeProps, style, type } = Node.shape; @@ -48,9 +47,4 @@ export type UpdatePageResponse = z.infer; export type QRCodeRequestBody = z.infer; export type QRCodeResponse = z.infer; -export type ServerResponse = { - error?: typeof BadRequestError; - data?: T; -}; - export * from './ws'; diff --git a/packages/shared/src/types/ws.ts b/packages/shared/src/types/ws.ts index 6de015d4..09ad81df 100644 --- a/packages/shared/src/types/ws.ts +++ b/packages/shared/src/types/ws.ts @@ -19,7 +19,7 @@ type Message = { type RoomJoined = Message< 'room-joined', - { userId: string; users: User[]; nodes: NodeObject[] } + { thisUser: User; collaborators: User[] } >; type UserJoined = Message<'user-joined', User>; type UserLeft = Message<'user-left', { id: string }>; @@ -42,19 +42,14 @@ type DraftDraw = Message< position: { start: Point; current: Point }; } >; -type DraftFinish = Message< - 'draft-finish', - { userId: string; node: NodeObject } ->; -type DraftFinishAndKeep = Message< - 'draft-finish-keep', +type DraftUpdate = Message< + 'draft-update', { userId: string; node: NodeObject } >; -type TextDraftUpdate = Message< - 'draft-text-update', - { userId: string; nodeId: string; text: string } +type DraftFinish = Message< + 'draft-finish', + { userId: string; node: NodeObject; keep?: boolean } >; -type HistoryChange = Message<'history-change', { action: 'undo' | 'redo' }>; export type WSMessage = | RoomJoined @@ -72,7 +67,5 @@ export type WSMessage = | NodesMoveBackward | DraftCreate | DraftDraw - | DraftFinishAndKeep - | DraftFinish - | TextDraftUpdate - | HistoryChange; + | DraftUpdate + | DraftFinish; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f66cc055..6e21147d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: eslint: specifier: ^8.38.0 version: 8.54.0 + msw: + specifier: ^2.0.11 + version: 2.0.11(typescript@4.9.5) prettier: specifier: ^2.8.7 version: 2.8.8 @@ -184,6 +187,9 @@ importers: jsdom: specifier: ^22.1.0 version: 22.1.0(canvas@2.11.2) + msw: + specifier: '*' + version: 2.0.11(typescript@4.9.5) resize-observer-polyfill: specifier: ^1.5.1 version: 1.5.1 @@ -1651,6 +1657,24 @@ packages: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true + /@bundled-es-modules/cookie@2.0.0: + resolution: {integrity: sha512-Or6YHg/kamKHpxULAdSqhGqnWFneIXu1NKvvfBBzKGwpVsYuFIQ5aBPHDnnoR3ghW1nvSkALd+EF9iMtY7Vjxw==} + dependencies: + cookie: 0.5.0 + dev: true + + /@bundled-es-modules/js-levenshtein@2.0.1: + resolution: {integrity: sha512-DERMS3yfbAljKsQc0U2wcqGKUWpdFjwqWuoMugEJlqBnKO180/n+4SR/J8MRDt1AN48X1ovgoD9KrdVXcaa3Rg==} + dependencies: + js-levenshtein: 1.1.6 + dev: true + + /@bundled-es-modules/statuses@1.0.1: + resolution: {integrity: sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==} + dependencies: + statuses: 2.0.1 + dev: true + /@esbuild/android-arm64@0.18.20: resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} engines: {node: '>=12'} @@ -2020,6 +2044,23 @@ packages: - supports-color dev: true + /@mswjs/cookies@1.1.0: + resolution: {integrity: sha512-0ZcCVQxifZmhwNBoQIrystCb+2sWBY2Zw8lpfJBPCHGCA/HWqehITeCRVIv4VMy8MPlaHo2w2pTHFV2pFfqKPw==} + engines: {node: '>=18'} + dev: true + + /@mswjs/interceptors@0.25.13: + resolution: {integrity: sha512-xfjR81WwXPHwhDbqJRHlxYmboJuiSaIKpP4I5TJVFl/EmByOU13jOBT9hmEnxcjR3jvFYoqoNKt7MM9uqerj9A==} + engines: {node: '>=18'} + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.2 + strict-event-emitter: 0.5.1 + dev: true + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2038,6 +2079,21 @@ packages: '@nodelib/fs.scandir': 2.1.5 fastq: 1.15.0 + /@open-draft/deferred-promise@2.2.0: + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + dev: true + + /@open-draft/logger@0.3.0: + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.2 + dev: true + + /@open-draft/until@2.1.0: + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + dev: true + /@playwright/test@1.40.0: resolution: {integrity: sha512-PdW+kn4eV99iP5gxWNSDQCbhMaDVej+RXL5xr6t04nbKLCBwYtA046t7ofoczHOm8u6c+45hpDKQVZqtqwkeQg==} engines: {node: '>=16'} @@ -3118,6 +3174,10 @@ packages: dependencies: '@types/node': 18.18.11 + /@types/cookie@0.4.1: + resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} + dev: true + /@types/cookiejar@2.1.5: resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} dev: true @@ -3209,6 +3269,10 @@ packages: pretty-format: 29.7.0 dev: true + /@types/js-levenshtein@1.1.3: + resolution: {integrity: sha512-jd+Q+sD20Qfu9e2aEXogiO3vpOC1PYJOUdyN9gvs4Qrvkg4wF43L5OhqrPeokdv8TL0/mXoYfpkcoGZMNN2pkQ==} + dev: true + /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true @@ -3302,6 +3366,10 @@ packages: resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} dev: true + /@types/statuses@2.0.4: + resolution: {integrity: sha512-eqNDvZsCNY49OAXB0Firg/Sc2BgoWsntsLUdybGFOhAfCD6QJ2n9HXUIHGqt5qjrxmMv4wS8WLAw43ZkKcJ8Pw==} + dev: true + /@types/superagent@4.1.22: resolution: {integrity: sha512-GMaOrnnUsjChvH8zlzdDPARRXky8bU3E8xsU/fOclgqsINekbwDu1+wzJzJaGzZP91SGpOutf5Te5pm5M/qCWg==} dependencies: @@ -3769,6 +3837,13 @@ packages: uri-js: 4.4.1 dev: true + /ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.21.3 + dev: true + /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -4013,10 +4088,22 @@ packages: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} dev: true + /base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + dev: true + /binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} + /bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + dev: true + /body-parser@1.20.1: resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -4076,6 +4163,13 @@ packages: engines: {node: '>=4'} dev: false + /buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: true + /builtin-modules@3.3.0: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} @@ -4168,6 +4262,10 @@ packages: supports-color: 7.2.0 dev: true + /chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + dev: true + /check-error@1.0.3: resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} dependencies: @@ -4203,6 +4301,23 @@ packages: engines: {node: '>=8'} dev: true + /cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + dependencies: + restore-cursor: 3.1.0 + dev: true + + /cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + dev: true + + /cli-width@3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + dev: true + /cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} dependencies: @@ -4211,6 +4326,20 @@ packages: wrap-ansi: 6.2.0 dev: false + /cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + dev: true + + /clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + dev: true + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -4317,7 +4446,6 @@ packages: /cookie@0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} - dev: false /cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} @@ -4479,6 +4607,12 @@ packages: engines: {node: '>=0.10.0'} dev: true + /defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + dependencies: + clone: 1.0.4 + dev: true + /define-data-property@1.1.1: resolution: {integrity: sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==} engines: {node: '>= 0.4'} @@ -5164,6 +5298,15 @@ packages: - supports-color dev: false + /external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + dev: true + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true @@ -5195,6 +5338,13 @@ packages: dependencies: reusify: 1.0.4 + /figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + dependencies: + escape-string-regexp: 1.0.5 + dev: true + /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -5388,7 +5538,6 @@ packages: /get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - dev: false /get-func-name@2.0.2: resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} @@ -5496,6 +5645,11 @@ packages: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} dev: true + /graphql@16.8.1: + resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + dev: true + /happy-dom@10.11.2: resolution: {integrity: sha512-rzgmLjLkhyaOdFEyU8CWXzbgyCyM7wJHLqhaoeEVSTyur1fjcUaiNTHx+D4CPaLvx16tGy+SBPd9TVnP/kzL3w==} dependencies: @@ -5551,6 +5705,10 @@ packages: dependencies: function-bind: 1.1.2 + /headers-polyfill@4.0.2: + resolution: {integrity: sha512-EWGTfnTqAO2L/j5HZgoM/3z82L7necsJ0pO9Tp0X1wil3PDLrkypTBRgVO2ExehEEvUycejZD3FuRaXpZZc3kw==} + dev: true + /helmet@6.2.0: resolution: {integrity: sha512-DWlwuXLLqbrIOltR6tFQXShj/+7Cyp0gLi6uAb8qMdFh/YBBFbKSgQ6nbXmScYd8emMctuthmgIa7tUfo9Rtyg==} engines: {node: '>=14.0.0'} @@ -5614,7 +5772,6 @@ packages: engines: {node: '>=0.10.0'} dependencies: safer-buffer: 2.1.2 - dev: false /iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} @@ -5627,6 +5784,10 @@ packages: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} dev: true + /ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + dev: true + /ignore-by-default@1.0.1: resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} dev: true @@ -5667,6 +5828,27 @@ packages: /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + /inquirer@8.2.6: + resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} + engines: {node: '>=12.0.0'} + dependencies: + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + external-editor: 3.1.0 + figures: 3.2.0 + lodash: 4.17.21 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.8.1 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 6.2.0 + dev: true + /internal-slot@1.0.6: resolution: {integrity: sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==} engines: {node: '>= 0.4'} @@ -5775,6 +5957,11 @@ packages: dependencies: is-extglob: 2.1.1 + /is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + dev: true + /is-map@2.0.2: resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==} dev: true @@ -5788,6 +5975,10 @@ packages: engines: {node: '>= 0.4'} dev: true + /is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + dev: true + /is-number-object@1.0.7: resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} engines: {node: '>= 0.4'} @@ -5866,6 +6057,11 @@ packages: which-typed-array: 1.1.13 dev: true + /is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + dev: true + /is-weakmap@2.0.1: resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==} dev: true @@ -6031,6 +6227,11 @@ packages: supports-color: 8.1.1 dev: true + /js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + dev: true + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -6240,6 +6441,14 @@ packages: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} dev: true + /log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + dev: true + /loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -6351,6 +6560,11 @@ packages: hasBin: true dev: true + /mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + dev: true + /mimic-response@2.1.0: resolution: {integrity: sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==} engines: {node: '>=8'} @@ -6430,6 +6644,45 @@ packages: /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + /msw@2.0.11(typescript@4.9.5): + resolution: {integrity: sha512-dAXFS2DxZX0uFqMPhS3oUAu8S/5IQ5qKKSwtXl3/dMTeML0C8JfSvbeWtowYg6pu4Iehgp5L/pHLrlIcG++y/A==} + engines: {node: '>=18'} + hasBin: true + requiresBuild: true + peerDependencies: + typescript: '>= 4.7.x <= 5.2.x' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@bundled-es-modules/cookie': 2.0.0 + '@bundled-es-modules/js-levenshtein': 2.0.1 + '@bundled-es-modules/statuses': 1.0.1 + '@mswjs/cookies': 1.1.0 + '@mswjs/interceptors': 0.25.13 + '@open-draft/until': 2.1.0 + '@types/cookie': 0.4.1 + '@types/js-levenshtein': 1.1.3 + '@types/statuses': 2.0.4 + chalk: 4.1.2 + chokidar: 3.5.3 + graphql: 16.8.1 + headers-polyfill: 4.0.2 + inquirer: 8.2.6 + is-node-process: 1.2.0 + js-levenshtein: 1.1.6 + outvariant: 1.4.2 + path-to-regexp: 6.2.1 + strict-event-emitter: 0.5.1 + type-fest: 2.19.0 + typescript: 4.9.5 + yargs: 17.7.2 + dev: true + + /mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + dev: true + /mylas@2.1.13: resolution: {integrity: sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==} engines: {node: '>=12.0.0'} @@ -6622,6 +6875,13 @@ packages: wrappy: 1.0.2 dev: true + /onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + dependencies: + mimic-fn: 2.1.0 + dev: true + /optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -6634,6 +6894,30 @@ packages: type-check: 0.4.0 dev: true + /ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + dev: true + + /os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + dev: true + + /outvariant@1.4.2: + resolution: {integrity: sha512-Ou3dJ6bA/UJ5GVHxah4LnqDwZRwAmWxrG3wtrHrbGnP4RnLCtA64A4F+ae7Y8ww660JaddSoArUR5HjipWSHAQ==} + dev: true + /p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -6730,6 +7014,10 @@ packages: resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} dev: false + /path-to-regexp@6.2.1: + resolution: {integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==} + dev: true + /path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -7319,7 +7607,6 @@ packages: /require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - dev: false /require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} @@ -7369,6 +7656,14 @@ packages: supports-preserve-symlinks-flag: 1.0.0 dev: true + /restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + dev: true + /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -7413,11 +7708,22 @@ packages: resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==} dev: true + /run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + dev: true + /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: queue-microtask: 1.2.3 + /rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + dependencies: + tslib: 2.6.2 + dev: true + /safe-array-concat@1.0.1: resolution: {integrity: sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==} engines: {node: '>=0.4'} @@ -7668,7 +7974,6 @@ packages: /statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} - dev: false /std-env@3.5.0: resolution: {integrity: sha512-JGUEaALvL0Mf6JCfYnJOTcobY+Nc7sG/TemDRBqCA0wEr4DER7zDchaaixTlmOxAjG1uRJmX82EQcxwTQTkqVA==} @@ -7681,6 +7986,10 @@ packages: internal-slot: 1.0.6 dev: true + /strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + dev: true + /string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -7915,6 +8224,10 @@ packages: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true + /through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + dev: true + /tinybench@2.5.1: resolution: {integrity: sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==} dev: true @@ -7929,6 +8242,13 @@ packages: engines: {node: '>=14.0.0'} dev: true + /tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + dependencies: + os-tmpdir: 1.0.2 + dev: true + /to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} @@ -8006,7 +8326,6 @@ packages: /tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} - dev: false /tsutils@3.21.0(typescript@4.9.5): resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} @@ -8051,6 +8370,16 @@ packages: engines: {node: '>=10'} dev: true + /type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + dev: true + + /type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + dev: true + /type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -8440,6 +8769,12 @@ packages: graceful-fs: 4.2.11 dev: true + /wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + dependencies: + defaults: 1.0.4 + dev: true + /webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} dev: true @@ -8761,7 +9096,15 @@ packages: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 - dev: false + + /wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: true /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -8797,6 +9140,11 @@ packages: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} dev: false + /y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + dev: true + /yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} dev: true @@ -8813,6 +9161,11 @@ packages: decamelize: 1.2.0 dev: false + /yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + dev: true + /yargs@15.4.1: resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} engines: {node: '>=8'} @@ -8830,6 +9183,19 @@ packages: yargs-parser: 18.1.3 dev: false + /yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + dependencies: + cliui: 8.0.1 + escalade: 3.1.1 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + dev: true + /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'}