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 { displayName, roleKey } from "../lib/api/account"; 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, moduleRailKey, SYSTEM_PAGES, } from "../lib/modules"; import { useScrollFade } from "../lib/useScrollFade"; import { type LangCode } from "../i18n"; import AvatarImg from "./ui/Avatar"; import LanguageSwitcher from "./LanguageSwitcher"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; import { NavRail, NavRailAction, NavRailDivider, NavRailItem, NavRailList, RAIL_ROW, } from "./ui/NavRail"; import { usePrefs } from "./prefsContext"; // 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 { railLabels } = usePrefs(); const name = displayName(me); // Role is surfaced as a plain title tooltip on the account row (no visual badge). const roleLabel = t(`nav.role.${roleKey(me)}`); const renderItem = ({ page: p, icon, label, badge }: NavItem) => ( setPage(p)} /> ); return ( // Slim rail (B7), flush against the left viewport edge (only top/bottom margin), never grows — // labels on or off. The slot is exactly the rail's width so the filter tab/bar docks flush // against its right edge.
{userItems.map(renderItem)} {systemItems.length > 0 && } {systemItems.map(renderItem)}
setPage("settings")} />
{name}
{me.email}
{/* The address Google answers with, when it isn't the one above. /api/me sends it only when the two differ, so for most accounts this line never exists — and for someone who registered as `me+siftlode@gmail.com` it answers the question their sign-in screen would otherwise leave open: which address gets me in? A text label rather than a brand glyph, since the app draws one icon set (lucide) and a generic mail icon here would say nothing. */} {me.google_email && (
Google · {me.google_email}
)}
{me.role === "admin" && (
{t("header.account.admin")}
)} {otherAccounts.length > 0 && (
{otherAccounts.map((a) => ( switchTo(a.id)} title={t("header.account.switchTo", { name: a.display_name ?? a.email })} className="selectable w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition touch-target" > {displayName(a)} {a.email} ))}
)}
{ // Adding an account switches THIS tab to it: drop the tab's pin so that on // return from Google it adopts the freshly-added account (the new default) // and pins that. Other tabs keep their own pinned account. pinOnNextPage(null); window.location.href = "/auth/login"; }} className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target" > {t("header.account.addAccount")} {t("header.account.signOut")}
); }