121 lines
3.5 KiB
TypeScript
121 lines
3.5 KiB
TypeScript
/**
|
|
* useOraclePage — Page hydration, branch state, component projection
|
|
* Owns the canonical page state including revision tracking and optimistic updates.
|
|
*/
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import type { CanvasPage, CanvasComponent, OracleWSMessage } from '../types/canvas';
|
|
import { fetchCanvasPage, connectPageSocket } from '../lib/oracleApiClient';
|
|
|
|
export interface OraclePageState {
|
|
page: CanvasPage | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
isConnected: boolean;
|
|
// Actions
|
|
refresh: () => Promise<void>;
|
|
optimisticallyAppendComponent: (comp: CanvasComponent) => void;
|
|
applyRevision: (headRevision: number, components: CanvasComponent[]) => void;
|
|
}
|
|
|
|
export function useOraclePage(pageId: string | null): OraclePageState {
|
|
const [page, setPage] = useState<CanvasPage | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isConnected, setIsConnected] = useState(false);
|
|
const disconnectRef = useRef<(() => void) | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
if (!pageId) {
|
|
setPage(null);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await fetchCanvasPage(pageId);
|
|
setPage(data);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load page');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [pageId]);
|
|
|
|
// Connect WebSocket
|
|
useEffect(() => {
|
|
if (!pageId) {
|
|
setIsConnected(false);
|
|
return () => undefined;
|
|
}
|
|
const disconnect = connectPageSocket(pageId, {
|
|
onMessage: (msg: OracleWSMessage) => handleWSMessage(msg),
|
|
onReconnect: () => void load(),
|
|
onClose: () => setIsConnected(false),
|
|
});
|
|
disconnectRef.current = disconnect;
|
|
setIsConnected(true);
|
|
return () => {
|
|
disconnect();
|
|
disconnectRef.current = null;
|
|
};
|
|
}, [pageId, load]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Handle WS messages
|
|
function handleWSMessage(msg: OracleWSMessage) {
|
|
if (msg.type === 'oracle.page.revision.committed') {
|
|
const { headRevision, components } = msg.payload as {
|
|
headRevision: number;
|
|
components: CanvasComponent[];
|
|
};
|
|
applyRevision(headRevision, components);
|
|
} else if (msg.type === 'oracle.presence.updated') {
|
|
setPage((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
presence: {
|
|
activeViewers: (msg.payload.activeViewers as number) ?? prev.presence.activeViewers,
|
|
activeEditors: (msg.payload.activeEditors as number) ?? prev.presence.activeEditors,
|
|
lastPresenceAt: msg.timestamp,
|
|
},
|
|
}
|
|
: prev,
|
|
);
|
|
}
|
|
}
|
|
|
|
const optimisticallyAppendComponent = useCallback((comp: CanvasComponent) => {
|
|
setPage((prev) => {
|
|
if (!prev) return prev;
|
|
// Prevent duplicate (idempotent insert)
|
|
if (prev.components.some((c) => c.componentId === comp.componentId)) return prev;
|
|
return { ...prev, components: [...prev.components, comp] };
|
|
});
|
|
}, []);
|
|
|
|
const applyRevision = useCallback(
|
|
(headRevision: number, components: CanvasComponent[]) => {
|
|
setPage((prev) =>
|
|
prev ? { ...prev, headRevision, components } : prev,
|
|
);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
return {
|
|
page,
|
|
isLoading,
|
|
error,
|
|
isConnected,
|
|
refresh: load,
|
|
optimisticallyAppendComponent,
|
|
applyRevision,
|
|
};
|
|
}
|