import { lazy, Suspense, useCallback, useEffect, useReducer, useRef, useState } from "react"; import { ContentSwapLayer } from "@chopin/editor/content-swap"; import { documentsPath, parseChildDocumentPath, parseDocumentPath, } from "@chopin/protocol/document-url"; import * as Api from "./api"; import { childCloseAction, childFocusTransition, childHistoryState } from "./child-history"; import { documentRouteIdentity, transitionDocumentRoute } from "./document-route-swap"; import { motionContract } from "./motion-contract"; import { motionImmediately } from "./motion-input"; import { WorkspaceNotice } from "./workspace-notice"; import { NavigationShell, useNavigationDocument } from "./navigation-shell"; import type { ReactNode } from "react"; import type { ResearchOpener } from "@chopin/editor"; import type { ChildFocusEvent, ChildFocusState, ChildFocusToken } from "./child-history"; import type { DocumentRouteIdentity, DocumentRouteIdentitySource, DocumentRouteSwap as DocumentRouteSwapState, } from "./document-route-swap"; import type { WorkspacePresentation } from "./workspace-model"; let ChannelWorkspace = lazy(() => import("./channel-workspace")); let DocumentWorkspaceHost = lazy(() => import("./document-workspace-host")); export type HostedWorkspaceProps = { room: string; handle: string; label: string; slug: string; updatedAt: string; descriptionRevision: number; description?: string; repository: Api.Repository; canEdit: boolean; canManage: boolean; archivedAt?: string; agent?: boolean; userId: string; onMetadataChanged?: ( metadata: Pick< Api.Channel, | "archivedAt" | "description" | "descriptionRevision" | "slug" | "title" | "updatedAt" >, ) => void; presentation: WorkspacePresentation; }; export type HostedRoute = | DocumentRouteIdentitySource | { page: "repositories" } | { page: "repository"; owner: string; repository: string } | { page: "missing" }; type DocumentRoute = DocumentRouteIdentitySource; export type ChannelSource = Extract; type DocumentSource = DocumentRoute; type DocumentRouteRequest = { immediately: boolean; key: DocumentRouteIdentity; routeKey: DocumentRouteIdentity; source: DocumentSource; }; export type DocumentRouteResolution = { canonicalPath: string; channel: Api.Channel; routeKey: DocumentRouteIdentity; }; type DocumentRouteLayer = DocumentRouteSwapState< DocumentRouteRequest, DocumentRouteResolution >["current"]; function keyedDocumentRoute( route: DocumentRoute, immediately: boolean, alias?: DocumentRouteIdentity, ): DocumentRouteRequest { let routeKey = documentRouteIdentity(route); let key = alias ?? (route.page === "child" ? documentRouteIdentity({ owner: route.owner, page: "document", repository: route.repository, slug: route.parentSlug, }) : documentRouteIdentity(route)); return { immediately, key, routeKey, source: route, }; } function decoded(value: string): string | undefined { try { return decodeURIComponent(value); } catch { return undefined; } } export function hostedRoute(pathname: string): HostedRoute { if (pathname === "/" || pathname === "") return { page: "repositories" }; let child = parseChildDocumentPath(pathname); if (child) return { page: "child", ...child }; let document = parseDocumentPath(pathname); if (document?.slug) { return { page: "document", owner: document.owner, repository: document.repository, slug: document.slug, }; } if (document) return { page: "repository", ...document }; let repository = /^\/repositories\/([^/]+)\/([^/]+)\/?$/.exec(pathname); if (repository) { let owner = decoded(repository[1]!); let name = decoded(repository[2]!); if (owner && name) return { page: "repository", owner, repository: name }; } let channel = /^\/channels\/([0-9a-f-]{36})\/?$/i.exec(pathname); if (channel) return { page: "channel", id: channel[1]!.toLowerCase() }; return { page: "missing" }; } export function retryableChannelFailure(error: unknown): boolean { return !(error instanceof Api.ApiError) || error.status === 408 || error.status === 429 || error.status >= 500; } function Loading({ label = "Loading" }: { label?: string }) { return (
{label}
); } function repositoryHref(repository: { owner: string; name: string }): string { return documentsPath(repository.owner, repository.name); } function failureCopy( error: unknown, channel: { title?: string; slug?: string } | undefined, repository: Pick | undefined, ): { body: string; title: string } { let status = error instanceof Api.ApiError ? error.status : undefined; let where = repository ? ` in ${repository.fullName}` : ""; if (status === 404) { let name = channel?.title ?? channel?.slug; return { title: "Document not found", body: `We couldn't find ${name ? `"${name}"` : "this document"}${where}. ` + "It may have been renamed or deleted.", }; } if (status === 401 || status === 403) { return { title: repository ? `You don't have access to ${repository.fullName}` : "No access", body: "Ask a repository admin to give you access.", }; } return { title: "Couldn't open this document", body: "Check your connection and try again.", }; } function Failure( { channel, error, onRetry, repository, }: { channel?: { title?: string; slug?: string }; error: unknown; onRetry?: () => void; repository?: Pick; }, ) { let { body, title } = failureCopy(error, channel, repository); let denied = error instanceof Api.ApiError && (error.status === 401 || error.status === 403); return ( {onRetry && ( )} {repository && !denied && ( Open {repository.fullName} )} {(!repository || denied) && !onRetry && ( Go to Chopin )} } body={body} title={title} /> ); } function DocumentRouteSwap( { agent, onCanonicalPath, onChildClose, onChildClosing, onParentRestored, route, user, }: { agent: boolean; onCanonicalPath: (pathname: string) => void; onChildClose: (parentId: string, parentPath: string, opener?: ResearchOpener) => void; onChildClosing: (parentId: string, parentPath: string) => ChildFocusToken; onParentRestored: (token: ChildFocusToken) => void; route: DocumentRoute; user: Api.User; }, ) { let aliases = useRef(new Map()); let routeKey = documentRouteIdentity(route); let requested = keyedDocumentRoute(route, motionImmediately(), aliases.current.get(routeKey)); let [state, dispatch] = useReducer( transitionDocumentRoute, { current: requested }, ); let requestedRoute = useRef({ key: requested.key, routeKey: requested.routeKey }); requestedRoute.current = { key: requested.key, routeKey: requested.routeKey }; let presentedRequest = state.pending ?? state.current; if (requested.key !== presentedRequest.key || requested.routeKey !== presentedRequest.routeKey) { dispatch({ route: requested, type: "requested" }); } let layers = [state.previous, state.current, state.pending].filter( (layer): layer is DocumentRouteLayer => layer !== undefined, ); let motion = motionContract("route-swap"); let { onDocumentLoaded, onDocumentRouteSettled } = useNavigationDocument(); let ready = useCallback(( key: DocumentRouteIdentity, resolution?: DocumentRouteResolution, ) => { if (resolution) aliases.current.set(resolution.routeKey, key); dispatch({ key, resolution, type: "ready" }); if (requestedRoute.current.key === key) { onDocumentRouteSettled(requestedRoute.current.routeKey); } }, [onDocumentRouteSettled]); let channelResolved = useCallback((key: DocumentRouteIdentity, pathname: string) => { if (requestedRoute.current.key === key) onCanonicalPath(pathname); }, [onCanonicalPath]); let metadataPath = useCallback(( key: DocumentRouteIdentity, metadataRouteKey: DocumentRouteIdentity, pathname: string, ) => { let authority = requestedRoute.current; if (key !== authority.key || metadataRouteKey !== authority.routeKey) return; let canonicalRoute = hostedRoute(pathname); if (canonicalRoute.page === "document" || canonicalRoute.page === "child") { aliases.current.set(documentRouteIdentity(canonicalRoute), key); } onCanonicalPath(pathname); }, [onCanonicalPath]); let published = useRef(undefined); useEffect(() => { let resolution = state.current.resolution; // An outgoing route that resolves after a newer request must not canonicalize over it. if (!resolution || state.pending || published.current === resolution) return; published.current = resolution; onCanonicalPath(resolution.canonicalPath); void onDocumentLoaded(resolution.channel, resolution.routeKey); }, [ onCanonicalPath, onDocumentLoaded, state.current.key, state.current.resolution, state.pending, ]); return (
{layers.map(layer => { let source = layer.source; return ( dispatch({ key: layer.key, type: "closed" }) : undefined} staged={layer !== state.previous && (layer === state.pending || !!state.previous)} > {source.page === "document" || source.page === "child" ? ( }> ) : ( }> )} ); })}
); } export function HostedApp( { agent, user }: { agent: boolean; user: Api.User }, ) { let [route, setRoute] = useState(() => hostedRoute(location.pathname)); let [navigationRevision, setNavigationRevision] = useState(0); let hostedRouteRef = useRef(route); hostedRouteRef.current = route; let childOpener = useRef(undefined); let childFocus = useRef({ generation: 0 }); let childFocusFrame = useRef(undefined); let cancelChildFocusFrame = useCallback(() => { if (childFocusFrame.current !== undefined) cancelAnimationFrame(childFocusFrame.current); childFocusFrame.current = undefined; }, []); let moveChildFocus = useCallback((event: ChildFocusEvent) => { let current = childFocus.current; let next = childFocusTransition(current, event); if (next !== current && !next.attempt) cancelChildFocusFrame(); childFocus.current = next; return next; }, [cancelChildFocusFrame]); let childRouteChanged = useCallback((pathname: string) => { moveChildFocus({ type: "route", pathname }); }, [moveChildFocus]); let navigate = useCallback(( destination: string, options: { opener?: ResearchOpener; replace?: boolean } = {}, ) => { let target = new URL(destination, location.href); if (target.origin !== location.origin) { moveChildFocus({ type: "cancel" }); location.assign(target.href); return; } let next = `${target.pathname}${target.search}${target.hash}`; let current = `${location.pathname}${location.search}${location.hash}`; if (next === current) return; setNavigationRevision(value => value + 1); childRouteChanged(target.pathname); let nextRoute = hostedRoute(target.pathname); if (options.replace) history.replaceState(history.state, "", next); else { let currentRoute = hostedRouteRef.current; let siblingChild = nextRoute.page === "child" && currentRoute.page === "child" && nextRoute.owner.toLocaleLowerCase() === currentRoute.owner.toLocaleLowerCase() && nextRoute.repository.toLocaleLowerCase() === currentRoute.repository.toLocaleLowerCase() && nextRoute.parentSlug === currentRoute.parentSlug; let inAppChild = nextRoute.page === "child" && currentRoute.page === "document" && nextRoute.owner.toLocaleLowerCase() === currentRoute.owner.toLocaleLowerCase() && nextRoute.repository.toLocaleLowerCase() === currentRoute.repository.toLocaleLowerCase() && nextRoute.parentSlug === currentRoute.slug; if (siblingChild || inAppChild) { let active = document.activeElement; childOpener.current = options.opener ?? { current: active instanceof HTMLElement ? active : null }; } if (siblingChild) history.replaceState(history.state, "", next); else if (inAppChild) { history.pushState( childHistoryState(history.state, current), "", next, ); } else history.pushState(null, "", next); } setRoute(nextRoute); }, [childRouteChanged, moveChildFocus]); let canonicalPath = useCallback((pathname: string) => { if (location.pathname === pathname) return; childRouteChanged(pathname); history.replaceState( history.state, "", `${pathname}${location.search}${location.hash}`, ); setRoute(hostedRoute(pathname)); }, [childRouteChanged]); let beginChildClosing = useCallback((parentId: string, parentPath: string) => { cancelChildFocusFrame(); let current = childFocus.current; let next = moveChildFocus({ type: "begin", opener: childOpener.current, parentId, parentPath, }); let started = next !== current; if (started) childOpener.current = undefined; return { started, token: { generation: next.generation, parentId } }; }, [cancelChildFocusFrame, moveChildFocus]); let closeChild = useCallback((parentId: string, parentPath: string, opener?: ResearchOpener) => { // A provenance link returns focus to the parent's research card instead of the original opener. if (opener) childOpener.current = opener; let closing = beginChildClosing(parentId, parentPath); if (!closing.started) return; let action = childCloseAction(history.state, parentPath); if (action.type === "back") history.back(); else navigate(action.destination, { replace: true }); }, [beginChildClosing, navigate]); let childClosing = useCallback((parentId: string, parentPath: string): ChildFocusToken => { return beginChildClosing(parentId, parentPath).token; }, [beginChildClosing]); let restoreParentFocus = useCallback((token: ChildFocusToken) => { let current = childFocus.current; let next = childFocusTransition(current, { type: "restore", token }); if (next === current || next.attempt?.phase !== "deferred") return; childFocus.current = next; let attempt = next.attempt; cancelChildFocusFrame(); childFocusFrame.current = requestAnimationFrame(() => { childFocusFrame.current = undefined; let latest = childFocus.current.attempt; if ( latest?.generation !== attempt.generation || latest.parentId !== attempt.parentId || latest.phase !== "deferred" || location.pathname !== attempt.parentPath ) return; let parent = document.querySelector( `[data-workspace-room="${CSS.escape(attempt.parentId)}"]`, ); if (!parent?.isConnected || parent.closest("[inert]")) { moveChildFocus({ type: "cancel" }); return; } let opener = attempt.opener?.current; let target = opener; if (!target?.isConnected || target.closest("[inert]")) { target = parent.querySelector(`[data-document-view="plan"] h2`); } moveChildFocus({ type: "finish", token }); if (target?.isConnected && !target.closest("[inert]")) { target.focus({ preventScroll: true }); // An opener the reader clicked is already in view; a provenance target may not be. if (target === opener) target.scrollIntoView({ block: "nearest" }); } }); }, [cancelChildFocusFrame, moveChildFocus]); useEffect(() => { let changed = () => { setNavigationRevision(value => value + 1); childRouteChanged(location.pathname); setRoute(hostedRoute(location.pathname)); }; window.addEventListener("popstate", changed); return () => { window.removeEventListener("popstate", changed); moveChildFocus({ type: "cancel" }); cancelChildFocusFrame(); }; }, [cancelChildFocusFrame, childRouteChanged, moveChildFocus]); let workspace: ReactNode; switch (route.page) { case "missing": workspace = ( Go to Chopin} body="This page doesn't exist." title="Page not found" /> ); break; case "repositories": case "repository": break; case "document": case "child": case "channel": workspace = ( ); break; } return ( {workspace} ); } export { Failure as HostedFailure, Loading as HostedLoading };