Files
app/src/pages/tldraw/multiplayerUser.tsx
T
kcar fedbd903ff
app-ci-deploy / test-build-deploy (push) Has been cancelled
fix: centralize app API URL fallbacks
2026-05-28 19:26:00 +01:00

280 lines
9.2 KiB
TypeScript

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<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<div
style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.1)',
}}
>
<div
style={{
padding: '20px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
}}
>
{message}
</div>
</div>
);
}
/**
* 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<Editor | null>(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 <TlsyncStatusOverlay message={`Connecting to room: ${roomId}...`} />;
}
return (
<div
style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<Tldraw
user={editorUser}
store={store.store}
onMount={(editor) => {
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={() => []}
/>
</div>
);
}
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 <TlsyncStatusOverlay message={`Failed to connect to collaboration server: ${tlsyncError}`} />;
}
if (!tlsyncToken) {
return <TlsyncStatusOverlay message="Connecting to collaboration server..." />;
}
return <TldrawCanvas tlsyncToken={tlsyncToken} roomId={roomId} />;
}