import { useEffect, useRef, useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { Tldraw, Editor, useTldrawUser, DEFAULT_SUPPORTED_IMAGE_TYPES, DEFAULT_SUPPORT_VIDEO_TYPES, } from '@tldraw/tldraw'; import { useSync } from '@tldraw/sync'; // App context import { useAuth } from '../../contexts/AuthContext'; import { useTLDraw } from '../../contexts/TLDrawContext'; import { useNeoInstitute } from '../../contexts/NeoInstituteContext'; // Tldraw services import { multiplayerOptions } from '../../services/tldraw/optionsService'; import { PresentationService } from '../../services/tldraw/presentationService'; import { createSyncConnectionOptions, handleExternalAsset } from '../../services/tldraw/syncService'; // Tldraw utils import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides'; import { customAssets } from '../../utils/tldraw/assets'; import { multiplayerTools } from '../../utils/tldraw/tools'; import { allShapeUtils } from '../../utils/tldraw/shapes'; import { customSchema } from '../../utils/tldraw/schemas'; import { allBindingUtils } from '../../utils/tldraw/bindings'; import { multiplayerEmbeds } from '../../utils/tldraw/embeds'; // Layout import { HEADER_HEIGHT } from '../../pages/Layout'; import { TLSYNC_URL } from '../../config/apiConfig'; // Styles import '../../utils/tldraw/tldraw.css'; // App debug import { logger } from '../../debugConfig'; const SYNC_WORKER_URL = TLSYNC_URL; const apiBase = (import.meta.env.VITE_API_BASE as string) || ''; /** * Fetches a short-lived TLSync token from the API using the current Supabase session. * Returns { token, error } — exactly one will be non-null. */ function useTlsyncToken(accessToken: string | null) { const [token, setToken] = useState(null); const [error, setError] = useState(null); useEffect(() => { if (!accessToken) return; let cancelled = false; logger.debug('multiplayer-page', '🔑 Fetching TLSync token from API'); fetch(`${apiBase}/api/tlsync/token`, { headers: { Authorization: `Bearer ${accessToken}` }, }) .then((r) => { if (!r.ok) throw new Error(`Token request failed: ${r.status}`); return r.json(); }) .then((data) => { if (!cancelled) { logger.debug('multiplayer-page', '✅ TLSync token received', { expiresIn: data.expires_in }); setToken(data.token); } }) .catch((err) => { if (!cancelled) { logger.error('multiplayer-page', '❌ Failed to fetch TLSync token', { err: err?.message }); setError(err?.message || 'Unknown error'); } }); return () => { cancelled = true; }; }, [accessToken]); return { token, error }; } /** * Loading / error overlay shown while the TLSync token is being fetched. */ function TlsyncStatusOverlay({ message }: { message: string }) { return (
{message}
); } /** * Inner component that owns the useSync hook. * Rendered only once a valid TLSync token has been fetched from the API. */ function TldrawCanvas({ tlsyncToken, roomId, }: { tlsyncToken: string; roomId: string; }) { const { user } = useAuth(); const { tldrawPreferences, setTldrawPreferences, initializePreferences, presentationMode, } = useTLDraw(); const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute(); const editorRef = useRef(null); const userInfo = useMemo( () => ({ id: user?.id ?? '', name: user?.display_name ?? user?.email?.split('@')[0] ?? 'Anonymous User', color: tldrawPreferences?.color ?? `hsl(${Math.random() * 360}, 70%, 50%)`, }), [user?.id, user?.display_name, user?.email, tldrawPreferences?.color], ); const editorUser = useTldrawUser({ userPreferences: { id: userInfo.id, name: userInfo.name, color: userInfo.color, locale: tldrawPreferences?.locale, colorScheme: tldrawPreferences?.colorScheme, animationSpeed: tldrawPreferences?.animationSpeed, isSnapMode: tldrawPreferences?.isSnapMode, }, setUserPreferences: setTldrawPreferences, }); const connectionOptions = useMemo( () => createSyncConnectionOptions({ userId: userInfo.id, displayName: userInfo.name, color: userInfo.color, roomId, baseUrl: SYNC_WORKER_URL, token: tlsyncToken, }), [userInfo, roomId, tlsyncToken], ); const store = useSync({ ...connectionOptions, schema: customSchema, shapeUtils: allShapeUtils, bindingUtils: allBindingUtils, userInfo: { id: userInfo.id, name: userInfo.name, color: userInfo.color, }, }); useEffect(() => { logger.info('multiplayer-page', `🔄 Connection status changed: ${store.status}`, { status: store.status, roomId: connectionOptions.roomId, }); }, [store.status, connectionOptions.roomId]); useEffect(() => { if (user?.id && !tldrawPreferences) { logger.info('multiplayer-page', '🔄 Initializing preferences'); initializePreferences(user.id); } }, [user?.id, tldrawPreferences, initializePreferences]); useEffect(() => { if (presentationMode && editorRef.current) { const editor = editorRef.current; const presentationService = new PresentationService(editor); const cleanup = presentationService.startPresentationMode(); return () => { presentationService.stopPresentationMode(); cleanup(); }; } }, [presentationMode]); const uiOverrides = useMemo(() => getUiOverrides(presentationMode), [presentationMode]); const uiComponents = useMemo(() => getUiComponents(presentationMode), [presentationMode]); if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) { return ; } return (
{ editorRef.current = editor; editor.registerExternalAssetHandler('url', async ({ url }: { url: string }) => { return handleExternalAsset(SYNC_WORKER_URL, url); }); }} options={multiplayerOptions} embeds={multiplayerEmbeds} tools={multiplayerTools} shapeUtils={allShapeUtils} bindingUtils={allBindingUtils} overrides={uiOverrides} components={uiComponents} assetUrls={customAssets} autoFocus={true} hideUi={false} acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES} acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES} maxImageDimension={Infinity} maxAssetSize={100 * 1024 * 1024} renderDebugMenuItems={() => []} />
); } export default function TldrawMultiUser() { const { user, accessToken } = useAuth(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const roomId = searchParams.get('room') || 'multiplayer'; const { token: tlsyncToken, error: tlsyncError } = useTlsyncToken(accessToken); // Redirect unauthenticated users useEffect(() => { if (!user) { navigate('/'); } }, [user, navigate]); if (!user) { return null; } if (tlsyncError) { return ; } if (!tlsyncToken) { return ; } return ; }