File
Blob: src/client/components/active-page/canonical.tsx
| 1 | import { useEffect, type ReactNode } from "react"; |
| 2 | import { useParams } from "@tanstack/react-router"; |
| 3 | import { CanonicalPageMentionSurface } from "@/client/components/page-mention/canonical-surface"; |
| 4 | import { usePageAccessMode } from "@/client/stores/workspace-replica"; |
| 5 | import { replicaCommands } from "@/client/stores/db/workspace-replica"; |
| 6 | import { useCanonicalPageContext } from "@/client/components/workspace/use-canonical-page-context"; |
| 7 | import { ActivePageProvider } from "@/client/components/active-page/provider"; |
| 8 | import { useActivePageActions, useActivePageSync } from "@/client/components/active-page/use-active-page"; |
| 9 | import { parseDocMessage } from "@/shared/doc-messages"; |
| 10 | import type { ActivePageAccess } from "@/client/lib/active-page-model"; |
| 11 | import type { Page } from "@/shared/types"; |
| 12 | |
| 13 | /** |
| 14 | * Canonical boundary: mounts the shared ActivePageProvider with canonical |
| 15 | * inputs, wires replica mutators as cache side-effects, and listens for |
| 16 | * real-time metadata updates on the active doc's WebSocket. |
| 17 | */ |
| 18 | export function CanonicalActivePageBoundary({ children }: { children: ReactNode }) { |
| 19 | const params = useParams({ strict: false }) as { workspaceSlug: string; pageId: string }; |
| 20 | const { workspaceId: effectiveWorkspaceId, accessMode, workspaceRole, currentPageMeta } = useCanonicalPageContext(); |
| 21 | // Role flows from the workspace replica so restricted-ancestor loading does |
| 22 | // not depend on the `/members` fetch (which is empty on shared-surface |
| 23 | // replicas and scoped to self on guest surfaces). |
| 24 | const role = workspaceRole; |
| 25 | |
| 26 | const cachedAccess = usePageAccessMode(params.pageId); |
| 27 | |
| 28 | const onLivePageLoaded = (page: Page, access: ActivePageAccess) => { |
| 29 | if (!effectiveWorkspaceId) return; |
| 30 | void replicaCommands.upsertPage(effectiveWorkspaceId, page); |
| 31 | void replicaCommands.upsertPageAccess(page.id, access.mode); |
| 32 | }; |
| 33 | |
| 34 | const onEvict = (id: string) => { |
| 35 | if (!effectiveWorkspaceId) return; |
| 36 | void replicaCommands.removePage(effectiveWorkspaceId, id); |
| 37 | }; |
| 38 | |
| 39 | return ( |
| 40 | <ActivePageProvider |
| 41 | surface="canonical" |
| 42 | workspaceId={effectiveWorkspaceId} |
| 43 | pageId={params.pageId} |
| 44 | accessMode={accessMode} |
| 45 | role={role} |
| 46 | cachedPageMeta={currentPageMeta} |
| 47 | cachedAccess={cachedAccess} |
| 48 | shareToken={null} |
| 49 | onLivePageLoaded={onLivePageLoaded} |
| 50 | onEvict={onEvict} |
| 51 | > |
| 52 | <CanonicalMetadataListener /> |
| 53 | <CanonicalPageMentionSurface>{children}</CanonicalPageMentionSurface> |
| 54 | </ActivePageProvider> |
| 55 | ); |
| 56 | } |
| 57 | |
| 58 | function CanonicalMetadataListener() { |
| 59 | const { syncProvider } = useActivePageSync(); |
| 60 | const { patchPage } = useActivePageActions(); |
| 61 | const { workspace } = useCanonicalPageContext(); |
| 62 | const workspaceId = workspace?.id ?? null; |
| 63 | |
| 64 | useEffect(() => { |
| 65 | if (!syncProvider || !workspaceId) return; |
| 66 | const handler = (message: string) => { |
| 67 | const msg = parseDocMessage(message); |
| 68 | if (msg?.type === "page-metadata-updated") { |
| 69 | patchPage({ icon: msg.icon, coverUrl: msg.cover_url }); |
| 70 | void replicaCommands.patchPage(workspaceId, msg.pageId, { |
| 71 | icon: msg.icon, |
| 72 | cover_url: msg.cover_url, |
| 73 | }); |
| 74 | } |
| 75 | }; |
| 76 | syncProvider.on("custom-message", handler); |
| 77 | return () => syncProvider.off("custom-message", handler); |
| 78 | }, [patchPage, syncProvider, workspaceId]); |
| 79 | return null; |
| 80 | } |