import { lazy, type ReactNode, Suspense, useRef, useState } from "react"; import { Link, useLocation, useNavigate, useSearchParams } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { useAuth } from "../hooks/useAuth.ts"; import { useWS } from "../hooks/useWS.ts"; import { NotificationBell } from "./NotificationBell.tsx"; import { ChatButton } from "./ChatButton.tsx"; import { UserMenu } from "./UserMenu.tsx"; import { SearchBar } from "./SearchBar.tsx"; import { SITE_EMOJI, SITE_NAME } from "../hooks/useDocumentTitle.ts"; const DumpCreateModal = lazy(() => import("./DumpCreateModal.tsx").then((m) => ({ default: m.DumpCreateModal })) ); /** Carried in a search entry's history state: the history index of the page * the search started from, so leaving search returns there in one step * however many searches (or result tabs) came in between. */ interface SearchEntryState { searchExit?: number; } /** React Router's index of the current history entry — 0 for the first page * of the session, which has nothing in the app to go back to. */ function historyIndex(): number { return (window.history.state as { idx?: number } | null)?.idx ?? 0; } export function AppHeader( { centerSlot, disableNew, initialDumpUrl }: { centerSlot?: ReactNode; disableNew?: boolean; initialDumpUrl?: string; }, ) { const { user } = useAuth(); const { wsStatus, wsErrorMessage } = useWS(); const navigate = useNavigate(); const location = useLocation(); const [searchParams] = useSearchParams(); // The results page and each of its tabs (/search/~/users…). const isSearchPage = location.pathname === "/search" || location.pathname.startsWith("/search/"); const query = isSearchPage ? searchParams.get("q") ?? "" : ""; // On the results page the search stays open, holding the query it shows. const [searchOpen, setSearchOpen] = useState(false); const searchExpanded = isSearchPage || searchOpen; const [draft, setDraft] = useState(query); const [prevQuery, setPrevQuery] = useState(query); if (prevQuery !== query) { setPrevQuery(query); setDraft(query); } const [createModalOpen, setCreateModalOpen] = useState(!!initialDumpUrl); const searchToggleRef = useRef(null); function submitSearch(q: string) { const here = location.state as SearchEntryState | null; // A search from elsewhere remembers that page; one from the results page // passes on what it was given, or — for results reached by a link — // the page before them. Results that opened the session have nowhere to // return to, so a new search just replaces them. const searchExit = !isSearchPage ? historyIndex() : here?.searchExit ?? (historyIndex() > 0 ? historyIndex() - 1 : null); const to = `/search?q=${encodeURIComponent(q)}`; if (searchExit === null) navigate(to, { replace: true }); else navigate(to, { state: { searchExit } satisfies SearchEntryState }); } function closeSearch() { if (isSearchPage) { const exit = (location.state as SearchEntryState | null)?.searchExit; if (exit !== undefined) navigate(exit - historyIndex()); else if (historyIndex() > 0) navigate(-1); else navigate("/"); } else { setSearchOpen(false); setDraft(""); searchToggleRef.current?.focus(); } } // Escape leaves an opened search, but only reverts an edited query on the // results page — leaving that page is the ✕'s job. function escapeSearch() { if (isSearchPage) setDraft(query); else closeSearch(); } function handleSearchToggle() { if (searchExpanded) closeSearch(); else setSearchOpen(true); } const searchBarProps = { expanded: searchExpanded, value: draft, onValueChange: setDraft, onSubmit: submitSearch, onCancel: escapeSearch, }; return ( <>
{SITE_EMOJI} {" "} {SITE_NAME}
{centerSlot}
{ /* Stays mounted so the row can slide open and shut; `inert` keeps the collapsed input out of the tab order. */ }
{wsStatus === "disconnected" && wsErrorMessage && (
Live updates unavailable. {" "} {wsErrorMessage}
)} {createModalOpen && ( setCreateModalOpen(false)} initialUrl={initialDumpUrl} /> )} ); }