import { useState, useSyncExternalStore } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { useNavigation, useNavigationActions } from "./navigationContext"; import { Info, LogOut, Settings, Shield, UserPlus, type LucideIcon } from "lucide-react"; import { api, type Me } from "../lib/api"; import { pinOnNextPage } from "../lib/activeAccount"; import * as e2ee from "../lib/e2ee"; import { useLiveQuery } from "../lib/useLiveQuery"; import { getUnreadCount, subscribe } from "../lib/notifications"; import type { Page } from "../lib/urlState"; import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules"; import { useScrollFade } from "../lib/useScrollFade"; import { type LangCode } from "../i18n"; import { Button } from "./ui/Button"; import AvatarImg from "./ui/Avatar"; import Tooltip from "./ui/Tooltip"; import LanguageSwitcher from "./LanguageSwitcher"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; // Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar // dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account // actions sit at the bottom in a popover. export default function NavSidebar({ me, onOpenAbout, onChangeLanguage, language, }: { me: Me; onOpenAbout: () => void; onChangeLanguage: (code: LangCode) => void; language: LangCode; }) { const { t } = useTranslation(); const { page } = useNavigation(); const { setPage } = useNavigationActions(); // The rail is ALWAYS a thin icon-only column flush against the left edge. There is no // expanded/pinned mode anymore — instead each icon reveals a floating name label on hover or // keyboard focus (a rest-to-show flyout via Tooltip), so the names are one glance away without the // rail ever growing. const [acctOpen, setAcctOpen] = useState(false); // The account panel holds mixed content with its own tab order (profile, account switches, two // actions), so it is a `dialog` popover, not a roving menu. It portals out of the nav — whose // backdrop-filter would otherwise trap fixed positioning + stacking and let clicks fall through // to the controls behind it — and anchors to the RIGHT of the button, bottom-aligned, as its // hand-rolled placement did. Dismissal (outside press, topmost-only Escape, focus return) now // comes from the platform instead of a pair of document listeners. const acctPop = useAnchoredPopover({ open: acctOpen, onOpenChange: setAcctOpen, mode: "dialog", role: "dialog", placement: "right-end", offset: 8, }); async function logout() { // Signs THIS tab's active account out of the browser wallet (server is per-tab aware); then // drops this tab's override and reloads onto whatever account remains the default. try { await api.logout(); } catch { /* clear locally and reload regardless */ } // Forget this device's E2EE private key on sign-out: it's otherwise persisted in IndexedDB // and auto-unlocks on the next visit, so on a shared machine the conversations would stay // decryptable after logout. Re-entering the message passphrase is required next time. try { await e2ee.clearDevice(me.id); } catch { /* IndexedDB unavailable (e.g. private mode) — never let it block the logout + reload */ } pinOnNextPage(null); location.reload(); } // Accounts that have signed in on this browser (loaded only while the popover is open). const accountsQuery = useQuery({ queryKey: qk.accounts(), queryFn: api.accounts, enabled: acctOpen, }); const otherAccounts = (accountsQuery.data ?? []).filter((a) => !a.active); function switchTo(id: number) { // Per-tab switch: point THIS tab at the account and reload; other tabs keep their own // identity. No server round-trip — req() sends the account header (validated against the // browser wallet server-side), so the cookie's default account is left untouched. The pin moves with // the reload, not before it: the page going away still unloads as the account it was built for. pinOnNextPage(id); // Drop the previous account's in-module sub-view/overlay before the reload (which would // otherwise preserve history.state across the swap). Otherwise the new account lands on a // stale sub-view — e.g. an open chat thread whose partnerId belongs to the OLD identity // (often the new account's own id → an empty self-thread). Start at the module root instead. const st = { ...(window.history.state || {}) }; delete st._sub; delete st._ov; window.history.replaceState(st, ""); location.reload(); // cleanest way to reload all per-user state for the new account } // Durable, server-backed unread count for the inbox badge. Polled live (pauses when the // tab is hidden); coexists with the client-side transient bell at the bottom of the rail. const unreadQuery = useLiveQuery(qk.notifUnread(), api.notificationUnreadCount, { intervalMs: 30000, }); // One indicator for both layers: durable server notifications + the client-side transient // events (the former separate bell is now folded into the inbox page). const clientUnread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount); const unread = (unreadQuery.data?.count ?? 0) + clientUnread; // Direct messages are their own module with their own unread badge (demo can't message). const msgUnreadQuery = useLiveQuery(qk.messageUnread(), api.messageUnreadCount, { intervalMs: 30000, enabled: !me.is_demo, }); const msgUnread = msgUnreadQuery.data?.count ?? 0; // Download center badge = items still working (queued/running/paused). Reuses the same // lightweight per-user index the feed cards poll, so no extra request shape. const dlIndexQuery = useLiveQuery(qk.downloadIndex(), api.downloadIndex, { intervalMs: 30000, enabled: !me.is_demo, }); const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length; // Scroll-affordance for the module list: hidden scrollbar + top/bottom edge fade (shared hook, // also used by the side panels) so a high-zoom overflow scrolls without a scrollbar. const listFade = useScrollFade(); type NavItem = { page: Page; icon: LucideIcon; label: string; badge?: number }; // Per-page presentation (icon/label/badge). WHICH pages appear and in what order — and each // module's icon + label — come from the shared module registry (the same source the header's // ◀/▶ stepper uses), so the two never drift and a new/gated module updates both at once. The // badge NUMBERS are the only nav-only concern (they need the live polling queries above). const BADGE: Partial> = { notifications: unread, messages: msgUnread, downloads: dlActive, }; const META = (p: Page): Omit => ({ icon: moduleDef(p).icon, label: t(moduleLabelKey[p]), badge: BADGE[p], }); const sys = new Set(SYSTEM_PAGES); const order = moduleOrder(me); // User-facing content modules vs. admin/system modules, separated by a divider in the rail. const userItems: NavItem[] = order .filter((p) => !sys.has(p)) .map((p) => ({ page: p, ...META(p) })); const systemItems: NavItem[] = order .filter((p) => sys.has(p)) .map((p) => ({ page: p, ...META(p) })); const rowBase = "selectable w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; const name = me.display_name ?? me.email.split("@")[0]; // Role is surfaced as a plain title tooltip on the account row (no visual badge). Demo isn't a // real role (it's a flag on a `user` row), so surface it first. const roleKey: "admin" | "user" | "demo" = me.is_demo ? "demo" : me.role === "admin" ? "admin" : "user"; const roleLabel = t(`nav.role.${roleKey}`); const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => { const active = page === p; // On the active row the background is the accent colour, so a same-accent badge would be // red-on-red. Invert it there (light pill, accent-coloured number) for clean contrast. const badgeColor = active ? "bg-accent-fg text-accent" : "bg-accent text-accent-fg"; const show = !!badge && badge > 0; return ( // The name rides in a right-side flyout on hover (rest-to-show), so the rail stays icon-only. ); }; return ( // Slim icon rail, flush against the left viewport edge (only top/bottom margin), never grows. // The slot is exactly the card's width so the filter tab/bar docks flush against its right edge.
); }