import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; import { Activity, BarChart3, Bell, Clapperboard, Download, Home, Info, ListVideo, LogOut, MessageSquare, Pin, PinOff, ScrollText, Settings, Shield, SlidersHorizontal, Users, Tv, UserPlus, } from "lucide-react"; import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api"; import * as e2ee from "../lib/e2ee"; import { useLiveQuery } from "../lib/useLiveQuery"; import { getUnreadCount, subscribe } from "../lib/notifications"; import type { Page } from "../lib/urlState"; import { moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules"; import { useScrollFade } from "../lib/useScrollFade"; import { useHoverFocusWithin } from "../lib/useHoverFocusWithin"; import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; import LanguageSwitcher from "./LanguageSwitcher"; // Primary app navigation: a collapsible left rail with icon+label entries (Design C). The // modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes // a thin icon-only rail. Account actions sit at the bottom in a popover. export default function NavSidebar({ me, onOpenAbout, onChangeLanguage, language, collapsed, onToggleCollapse, }: { me: Me; onOpenAbout: () => void; onChangeLanguage: (code: LangCode) => void; language: LangCode; // Collapse state is owned by App (persisted to the user's preferences); the rail just reflects // it and asks App to flip it. collapsed: boolean; onToggleCollapse: () => void; }) { const { t } = useTranslation(); const { page } = useNavigation(); const { setPage } = useNavigationActions(); // When unpinned, the rail sits slim (icons only) and peeks open into a labelled overlay on hover // or keyboard focus WITHOUT pushing content. Pinned keeps it expanded in-flow like before. // `collapsed` (persisted per-account) now means "unpinned". const peek = useHoverFocusWithin(); const pinned = !collapsed; const expanded = pinned || peek.active; const slim = !expanded; // visual: icon-only rail const [acctOpen, setAcctOpen] = useState(false); const acctBtnRef = useRef(null); const acctPanelRef = useRef(null); // Popover position (fixed, viewport coords). It's portaled to so it escapes the // nav's backdrop-filter, which would otherwise trap fixed positioning + stacking and let // clicks fall through to the controls behind it. const [acctPos, setAcctPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 }); function toggleAccount() { if (!acctOpen) { const r = acctBtnRef.current?.getBoundingClientRect(); if (r) setAcctPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom }); } setAcctOpen((o) => !o); } // Dismiss on outside click / Escape via document listeners — NOT a full-screen backdrop // div, which (sitting between the popover and the page) would break the popover's // backdrop-filter and make it look solid. useEffect(() => { if (!acctOpen) return; function onDoc(e: MouseEvent) { const t = e.target as Node; if (acctPanelRef.current?.contains(t) || acctBtnRef.current?.contains(t)) return; setAcctOpen(false); } function onKey(e: KeyboardEvent) { if (e.key === "Escape") setAcctOpen(false); } document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); }; }, [acctOpen]); 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 */ } clearActiveAccount(); location.reload(); } // Accounts that have signed in on this browser (loaded only while the popover is open). const accountsQuery = useQuery({ queryKey: ["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. setActiveAccount(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( ["notif-unread"], 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( ["message-unread"], 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(["download-index"], 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: typeof Home; label: string; badge?: number }; // Per-page presentation (icon/label/badge). WHICH pages appear and in what order comes from the // shared moduleOrder(me) — the same source the header's ◀/▶ stepper uses — so the two never drift // and a new/gated module updates both at once. Badges live here (nav-only concern). // Icon + badge per page; the label comes from the shared moduleLabelKey so the rail and the // header's ModuleName pill always read the same name. const ICON: Record = { feed: Home, channels: Tv, playlists: ListVideo, plex: Clapperboard, notifications: Bell, messages: MessageSquare, downloads: Download, stats: BarChart3, scheduler: Activity, config: SlidersHorizontal, users: Users, audit: ScrollText, settings: Settings, }; const BADGE: Partial> = { notifications: unread, messages: msgUnread, downloads: dlActive, }; const META = (p: Page): Omit => ({ icon: ICON[p], 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 = "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 ( ); }; return ( // Layout slot: reserves only the slim footprint when unpinned, so the expanded rail (below) // overlays content instead of pushing it. Pinned reserves the full width (in-flow).
); }