import { useCallback, useMemo, useState, type ReactNode } from "react"; import { LS, getAccountRaw, setAccountRaw } from "../lib/storage"; import { useUrlMirror } from "../lib/useUrlMirror"; import { useNavigation, useNavigationActions } from "./navigationContext"; import { ALL_FILES, FilesActionsContext, FilesStateContext, parseItem, parseView, viewParam, type FilesActions, type FilesArrival, type FilesFocus, type FilesState, type FilesView, } from "./filesContext"; // Owns the Files module's shared state — the header search term and the view the rail picked (every // file, shared with me, or a folder) — so the header's search box, the FilesRail and the Files page // read it via hooks. Shaped like PlaylistsProvider: split State + Actions contexts, composed in // main.tsx around . // // The search is ephemeral (resets on reload, like the other list filters); the view is remembered per // account and mirrored to `?folder=` on the Files page, so a reload or a bookmark opens the same folder. // A `?folder=` in the address wins over the remembered one. A folder that no longer exists is the // page's to fall back from (it knows the folder list). // // An arrival at one file (`?item=`, or `openFile` from a notice) is an INTENT the page consumes once it // has the file in view, the way the channel manager's focusChannel is: the consumed marker lives here, // because the page chunk remounts on every navigation and would otherwise re-apply it. Until then the // address carries it, so a reload mid-arrival still lands on the file. function initialView(): FilesView { return ( parseView(new URLSearchParams(window.location.search).get("folder")) ?? parseView(getAccountRaw(LS.filesView)) ?? ALL_FILES ); } function initialFocus(): FilesFocus | null { const id = parseItem(new URLSearchParams(window.location.search).get("item")); return id === null ? null : { id, token: 1 }; } export function FilesProvider({ children }: { children: ReactNode }) { const [search, setSearchState] = useState(""); const [view, setViewState] = useState(initialView); const [focusRaw, setFocusRaw] = useState(initialFocus); const [focusApplied, setFocusApplied] = useState(0); const setSearch = useCallback((q: string) => setSearchState(q), []); const setView = useCallback((next: FilesView) => { setViewState(next); setAccountRaw(LS.filesView, viewParam(next)); }, []); const { page } = useNavigation(); const { setPage } = useNavigationActions(); const openFile = useCallback( (id: number, folderId: number | null) => { setView(folderId === null ? ALL_FILES : { kind: "folder", id: folderId }); // A search that hides the file would leave nothing to arrive at. setSearchState(""); setFocusRaw((prev) => ({ id, token: (prev?.token ?? 0) + 1 })); setPage("files"); }, [setView, setPage], ); // What the page found. Held HERE, and set only once the page has mounted and looked: the table and // the grid follow a reveal that CHANGES under them, never one they mount with (a remount must not // move a page nobody asked to move), so an arrival decided during the page's first render would be // one they never turn to. const [arrivedRaw, setArrivedRaw] = useState(null); const consumeFocus = useCallback( (at: string | null) => { setFocusApplied(focusRaw?.token ?? 0); setArrivedRaw(focusRaw && at !== null ? { ...focusRaw, view: at } : null); }, [focusRaw], ); // Only ON the Files page, and only until the page has used it. const focus = page === "files" && focusRaw && focusRaw.token !== focusApplied ? focusRaw : null; // Leaving the page ends the arrival: back on it later, nothing is rung or turned to. if (page !== "files" && arrivedRaw) setArrivedRaw(null); const arrived = page === "files" ? arrivedRaw : null; useUrlMirror({ folder: page === "files" ? viewParam(view) : null, item: focus ? String(focus.id) : null, }); const state = useMemo( () => ({ search, view, focus, arrived }), [search, view, focus, arrived], ); const actions = useMemo( () => ({ setSearch, setView, openFile, consumeFocus }), [setSearch, setView, openFile, consumeFocus], ); return ( {children} ); }