- F12 (owner: A): SwitchRow, the whole settings row toggles; a Config boolean toggles from its title and hint (a label pointed at the switch). - F14 (owner: B): the form dropdown trigger wears the field frame (see-through, the >= 3:1 edge); labelled pills keep the faint edge and the accent-edge hover. DESIGN says so, with log rows. - F6: ~15 bg-card / bg-border / opacity hovers onto the B3 state layer; a lint rule (HOVER_SWAP) refuses the swap, contract-tested. - F10: bare 14px icon links onto IconLink / LinkButton; a local lint rule refuses a glyph-only <a>. - F13: reason-disabled controls are aria-disabled (Users, Channels backfill, Discovery, Scheduler runs, Config tests); FocusSafeButton now swallows the click of a caller's aria-disabled; the Config tests say why while dirty (testDirty, HU/EN). - F5/F11: every control measured live at 24 (mouse) and 44 (coarse) across every page; touch-target is a utility that wins over a caller's min-h-6; link variants carry it too; SegmentedToggle, Tabs, NavRail, menu rows, PriorityStepper, sliders, CheckRow, InnerInput, card links and the rest. - F9: DismissibleChip's label reaches the chip's 24; the Combobox chips are DismissibleChips. - F15: the "+N" / "More..." open state worded as it renders.
334 lines
15 KiB
TypeScript
334 lines
15 KiB
TypeScript
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 { IconButton } from "./ui/IconButton";
|
|
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, 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<Record<Page, number>> = {
|
|
notifications: unread,
|
|
messages: msgUnread,
|
|
downloads: dlActive,
|
|
};
|
|
const META = (p: Page): Omit<NavItem, "page"> => ({
|
|
icon: moduleDef(p).icon,
|
|
label: t(moduleLabelKey[p]),
|
|
badge: BADGE[p],
|
|
});
|
|
const sys = new Set<Page>(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) => (
|
|
<NavRailItem
|
|
key={p}
|
|
testId={`nav-${p}`}
|
|
icon={icon}
|
|
label={label}
|
|
shortLabel={t(moduleRailKey[p])}
|
|
showLabel={railLabels}
|
|
active={page === p}
|
|
badge={badge}
|
|
onClick={() => 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.
|
|
<div className="relative shrink-0 w-15">
|
|
<NavRail label={t("nav.primary")} className="absolute top-0 bottom-0 left-0 z-rail my-3">
|
|
<NavRailList ref={listFade.ref} style={listFade.style}>
|
|
{userItems.map(renderItem)}
|
|
{systemItems.length > 0 && <NavRailDivider />}
|
|
{systemItems.map(renderItem)}
|
|
</NavRailList>
|
|
|
|
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
|
|
<div className="flex flex-col items-center justify-center gap-2 pb-2 mb-1 border-b border-border">
|
|
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
|
|
<IconButton
|
|
label={t("header.account.about")}
|
|
size="md"
|
|
variant="ghost"
|
|
onClick={onOpenAbout}
|
|
>
|
|
<Info className="w-5 h-5" />
|
|
</IconButton>
|
|
<IconButton
|
|
label={t("header.account.signOut")}
|
|
size="md"
|
|
variant="ghost"
|
|
onClick={logout}
|
|
>
|
|
<LogOut className="w-5 h-5" />
|
|
</IconButton>
|
|
</div>
|
|
<NavRailItem
|
|
testId="nav-settings"
|
|
icon={Settings}
|
|
label={t("header.account.settings")}
|
|
shortLabel={t(moduleRailKey.settings)}
|
|
showLabel={railLabels}
|
|
active={page === "settings"}
|
|
onClick={() => setPage("settings")}
|
|
/>
|
|
|
|
<div>
|
|
<Pressable
|
|
ref={acctPop.refs.setReference}
|
|
{...acctPop.getReferenceProps()}
|
|
title={`${name} · ${roleLabel}`}
|
|
aria-label={`${name} · ${roleLabel}`}
|
|
className={`${RAIL_ROW} h-9 text-muted state-layer hover:text-fg`}
|
|
>
|
|
<span className="relative shrink-0">
|
|
<AvatarImg
|
|
src={me.avatar_url}
|
|
fallback={name}
|
|
className="w-5.5 h-5.5 rounded-full text-3xs"
|
|
/>
|
|
</span>
|
|
</Pressable>
|
|
|
|
<PopoverSurface
|
|
popover={acctPop}
|
|
ariaLabel={name}
|
|
className={`${SURFACE.popover} w-60 p-3 animate-[popIn_0.16s_ease]`}
|
|
>
|
|
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
|
<AvatarImg
|
|
src={me.avatar_url}
|
|
fallback={name}
|
|
className="w-10 h-10 rounded-full text-sm shrink-0"
|
|
/>
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-semibold truncate">{name}</div>
|
|
<div className="text-xs text-muted truncate">{me.email}</div>
|
|
{/* 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 && (
|
|
<div
|
|
className="text-2xs text-muted truncate mt-0.5"
|
|
title={t("header.account.googleAddress", { email: me.google_email })}
|
|
>
|
|
<span className="uppercase tracking-wide">Google</span> · {me.google_email}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{me.role === "admin" && (
|
|
<div className="flex items-center gap-1.5 mt-2 text-2xs font-medium text-accent">
|
|
<Shield className="w-3.5 h-3.5" />
|
|
{t("header.account.admin")}
|
|
</div>
|
|
)}
|
|
{otherAccounts.length > 0 && (
|
|
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
|
|
{otherAccounts.map((a) => (
|
|
<Pressable
|
|
key={a.id}
|
|
onClick={() => 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"
|
|
>
|
|
<AvatarImg
|
|
src={a.avatar_url}
|
|
fallback={a.display_name ?? a.email}
|
|
className="w-6 h-6 rounded-full text-3xs shrink-0"
|
|
/>
|
|
<span className="min-w-0">
|
|
<span className="block text-[0.8125rem] text-fg truncate">
|
|
{displayName(a)}
|
|
</span>
|
|
<span className="block text-2xs text-muted truncate">{a.email}</span>
|
|
</span>
|
|
</Pressable>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="mt-2 pt-2 border-t border-border flex flex-col">
|
|
<Pressable
|
|
onClick={() => {
|
|
// 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"
|
|
>
|
|
<UserPlus className="w-4 h-4" />
|
|
{t("header.account.addAccount")}
|
|
</Pressable>
|
|
<Pressable
|
|
onClick={logout}
|
|
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"
|
|
>
|
|
<LogOut className="w-4 h-4" />
|
|
{t("header.account.signOut")}
|
|
</Pressable>
|
|
</div>
|
|
</PopoverSurface>
|
|
</div>
|
|
</div>
|
|
</NavRail>
|
|
</div>
|
|
);
|
|
}
|