feat(desktop): make the relay console the single Moderation surface

Wire "moderation" into settingsNavGroups under the Communities group. The
entry was defined in settingsSections but never added to a nav group, so the
sidebar never rendered it and nothing deep-linked to it — it has been
unreachable since #1617.

Repoint the "moderation" section to the relay admin console (AdminConsole
SettingsCard), retitle it "Moderation", and drop the separate "admin-console"
section id so a single surface owns relay-level trust & safety.

Gate the nav entry behind origin + probe resolution: hidden with no origin,
always visible for a saved manual origin (the Advanced control that fixes a
bad URL lives inside the surface), and — for an advertised-only origin —
visible on authorization or a transport flake but hidden on a definitive
non-admin verdict. Group reports and feedback by community for
cross-community triage, and collapse the manual origin field under an
Advanced disclosure that keeps its controls mounted.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Hayt
2026-08-12 20:04:33 -04:00
co-authored by Will Pfleger
parent 142646976d
commit c538df8c38
12 changed files with 568 additions and 95 deletions
@@ -24,6 +24,7 @@ import {
import {
type AsyncState,
type AttachmentMeta,
CommunityGroupedList,
DetailRow,
ErrorMessage,
LoadingSpinner,
@@ -75,8 +76,9 @@ export function FeedbackTab({
}
return (
<ul className="space-y-1">
{items.map((item: AdminFeedbackSummaryDto) => {
<CommunityGroupedList
items={items}
renderItem={(item: AdminFeedbackSummaryDto) => {
const id = item.id;
const text = item.bodySummary.slice(0, 120);
const receivedAt = item.receivedAt;
@@ -104,8 +106,8 @@ export function FeedbackTab({
</button>
</li>
);
})}
</ul>
}}
/>
);
}
@@ -48,6 +48,7 @@ import {
DetailRow,
ErrorMessage,
LoadingSpinner,
CommunityGroupedList,
formatTimestamp,
useAsyncLoad,
} from "./AdminConsolePanelHelpers";
@@ -468,8 +469,9 @@ function ReportsTab({
}
return (
<ul className="space-y-1">
{reports.map((report: AdminReportDto) => {
<CommunityGroupedList
items={reports}
renderItem={(report: AdminReportDto) => {
const id = report.id;
const summary = report.reportType || "Report";
const status = report.status;
@@ -499,8 +501,8 @@ function ReportsTab({
</button>
</li>
);
})}
</ul>
}}
/>
);
}
@@ -6,6 +6,7 @@
*/
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { AlertCircle, LoaderCircle } from "lucide-react";
import { formatRelativeTime } from "../forum/lib/time";
@@ -173,3 +174,75 @@ export function parseImetaAttachments(tags: unknown): AttachmentMeta[] {
}
return result;
}
// ── Community grouping ─────────────────────────────────────────────────────
/** A run of rows that share one community, tagged with its display host. */
export type CommunityGroup<T> = {
/** Stable community identifier — used as the React key. */
communityId: string;
/** Human-facing host label rendered as the group heading. */
communityHost: string;
items: T[];
};
/**
* Group deployment-wide rows by community, preserving each community's
* first-seen order and the server's row order within it.
*
* The admin API returns reports and feedback across every community on the
* deployment; operators triage per community, so rows are bucketed by
* `communityId` (stable) and labelled by `communityHost` (display). A blank
* host falls back to the id so a group is never headed by an empty string.
*/
export function groupByCommunity<
T extends { communityId: string; communityHost: string },
>(items: T[]): CommunityGroup<T>[] {
const groups: CommunityGroup<T>[] = [];
const byId = new Map<string, CommunityGroup<T>>();
for (const item of items) {
let group = byId.get(item.communityId);
if (!group) {
group = {
communityId: item.communityId,
communityHost: item.communityHost || item.communityId,
items: [],
};
byId.set(item.communityId, group);
groups.push(group);
}
group.items.push(item);
}
return groups;
}
/**
* Render community-grouped rows under per-community headings.
*
* A single community collapses to a flat list (no redundant heading); two or
* more render a labelled section each. `renderItem` produces the row for one
* entry — the caller owns row markup so navigation/testids are unchanged.
*/
export function CommunityGroupedList<
T extends { communityId: string; communityHost: string },
>({ items, renderItem }: { items: T[]; renderItem: (item: T) => ReactNode }) {
const groups = groupByCommunity(items);
if (groups.length <= 1) {
return <ul className="space-y-1">{items.map(renderItem)}</ul>;
}
return (
<div className="space-y-4">
{groups.map((group) => (
<section key={group.communityId} data-testid="community-group">
<h4
className="mb-1.5 text-xs font-semibold text-muted-foreground"
data-testid="community-group-host"
>
{group.communityHost}
</h4>
<ul className="space-y-1">{group.items.map(renderItem)}</ul>
</section>
))}
</div>
);
}
@@ -23,6 +23,7 @@ import {
AlertCircle,
Check,
CheckCircle2,
ChevronRight,
Copy,
Info,
LoaderCircle,
@@ -228,8 +229,8 @@ export function AdminConsoleSettingsCard() {
data-testid="settings-admin-console"
>
<SettingsSectionHeader
title="Admin console"
description="Connect to your relay's deployment admin API. Auto-detected from your relay when available — otherwise paste the value of BUZZ_ADMIN_HOST from your relay config."
title="Moderation"
description="Triage moderation reports and product feedback across every community on your relay. Auto-detected from your relay when available — otherwise open Advanced to paste the value of BUZZ_ADMIN_HOST from your relay config."
/>
{pubkeyHex ? (
<AdminConsoleSettingsSession key={pubkeyHex} pubkeyHex={pubkeyHex} />
@@ -412,58 +413,66 @@ function AdminConsoleSettingsSession({ pubkeyHex }: { pubkeyHex: string }) {
return (
<>
<div className="mb-6 space-y-3">
<div className="flex gap-2">
<Input
autoComplete="off"
className="flex-1 font-mono text-sm"
data-testid="admin-origin-input"
disabled={isSaving}
onChange={(e) => {
setOriginInput(e.target.value);
// General reset: abort and clear probe state on every input
// change, not only when state is `probing`. This prevents a
// stale probe result from a previous value being committed.
abortAndResetProbe();
}}
placeholder="https://admin.yourrelay.example.com"
spellCheck={false}
type="url"
value={originInput}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSave();
}}
/>
<Button
data-testid="admin-origin-save"
disabled={isSaving || !inputChanged}
onClick={() => void handleSave()}
size="sm"
type="button"
variant={inputChanged ? "default" : "outline"}
>
{isSaving ? (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
) : (
"Save"
)}
</Button>
{savedOrigin && (
<Button
className={cn(
"text-xs",
probeUiState.kind === "probing" && "opacity-50",
<details className="group/advanced rounded-md border border-border/60">
<summary className="flex cursor-pointer list-none items-center gap-1.5 px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring [&::-webkit-details-marker]:hidden">
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform group-open/advanced:rotate-90" />
Advanced: admin origin
</summary>
<div className="space-y-3 px-3 pb-3">
<div className="flex gap-2">
<Input
autoComplete="off"
className="flex-1 font-mono text-sm"
data-testid="admin-origin-input"
disabled={isSaving}
onChange={(e) => {
setOriginInput(e.target.value);
// General reset: abort and clear probe state on every input
// change, not only when state is `probing`. This prevents a
// stale probe result from a previous value being committed.
abortAndResetProbe();
}}
placeholder="https://admin.yourrelay.example.com"
spellCheck={false}
type="url"
value={originInput}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSave();
}}
/>
<Button
data-testid="admin-origin-save"
disabled={isSaving || !inputChanged}
onClick={() => void handleSave()}
size="sm"
type="button"
variant={inputChanged ? "default" : "outline"}
>
{isSaving ? (
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
) : (
"Save"
)}
</Button>
{savedOrigin && (
<Button
className={cn(
"text-xs",
probeUiState.kind === "probing" && "opacity-50",
)}
data-testid="admin-probe-refresh"
disabled={probeUiState.kind === "probing"}
onClick={() => runProbe(savedOrigin)}
size="sm"
type="button"
variant="ghost"
>
Re-probe
</Button>
)}
data-testid="admin-probe-refresh"
disabled={probeUiState.kind === "probing"}
onClick={() => runProbe(savedOrigin)}
size="sm"
type="button"
variant="ghost"
>
Re-probe
</Button>
)}
</div>
</div>
</div>
</details>
<div className="min-h-[1.5rem]">
<ProbeStatusBadge uiState={probeUiState} />
@@ -1696,3 +1696,129 @@ test("discovery-skipped: a saved origin takes precedence and discovery is not at
await unmount();
});
// ── community grouping ────────────────────────────────────────────────────
test("reports-grouped-by-community: multi-community reports render per-community headings", async () => {
// The admin API returns deployment-wide reports; the console buckets them
// by community for triage. Two communities → two group headings; rows stay
// navigable (the first non-tab, non-processing report opens its detail).
//
// Mutation evidence: revert ReportsTab to a flat <ul> → community-group
// headings vanish and this test goes red.
const origin = "https://admin.example.com";
const pubkey = "a7".repeat(32);
const reports = [
{
id: "00000000-0000-0000-0000-0000000000a1",
communityId: "comm-1",
communityHost: "alpha.example.com",
reportEventId: "aa",
reporterPubkey: "bb",
targetKind: "event",
target: "cc",
reportType: "spam",
status: "open",
createdAt: "2024-06-01T12:00:00Z",
},
{
id: "00000000-0000-0000-0000-0000000000a2",
communityId: "comm-2",
communityHost: "beta.example.com",
reportEventId: "dd",
reporterPubkey: "ee",
targetKind: "event",
target: "ff",
reportType: "abuse",
status: "open",
createdAt: "2024-06-02T12:00:00Z",
},
];
setIpcHandler("admin_list_reports", () => Promise.resolve(reports));
setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
await doRender();
await settle(30);
const groups = container.querySelectorAll("[data-testid='community-group']");
assert.equal(
groups.length,
2,
`two communities must render two groups; got ${groups.length}`,
);
const hosts = Array.from(
container.querySelectorAll("[data-testid='community-group-host']"),
).map((el) => el.textContent);
assert.deepEqual(
hosts,
["alpha.example.com", "beta.example.com"],
`group headings must show each community host in first-seen order; got: ${JSON.stringify(hosts)}`,
);
await unmount();
});
test("feedback-grouped-by-community: multi-community feedback renders per-community headings", async () => {
// Same grouping contract for the Feedback tab.
//
// Mutation evidence: revert FeedbackTab to a flat <ul> → group headings
// vanish and this test goes red.
const origin = "https://admin.example.com";
const pubkey = "b8".repeat(32);
const feedback = [
{
id: "00000000-0000-0000-0000-0000000000b1",
communityId: "comm-1",
communityHost: "alpha.example.com",
submitterPubkey: "sub1",
category: "bug",
bodySummary: "Alpha feedback body",
receivedAt: "2024-06-01T09:00:00Z",
},
{
id: "00000000-0000-0000-0000-0000000000b2",
communityId: "comm-2",
communityHost: "beta.example.com",
submitterPubkey: "sub2",
category: "idea",
bodySummary: "Beta feedback body",
receivedAt: "2024-06-02T09:00:00Z",
},
];
setIpcHandler("admin_list_reports", () => Promise.resolve([]));
setIpcHandler("admin_list_feedback", () => Promise.resolve(feedback));
const { container, doRender, unmount } = mountPanel({ origin, pubkey });
await doRender();
await settle(30);
// Switch to the Feedback tab.
const feedbackTab = container.querySelector(
"[data-testid='admin-tab-feedback']",
);
assert.ok(feedbackTab, "Feedback tab must be present");
await act(async () => {
fireEvent.click(feedbackTab);
await new Promise((r) => setTimeout(r, 30));
});
await settle(30);
const hosts = Array.from(
container.querySelectorAll("[data-testid='community-group-host']"),
).map((el) => el.textContent);
assert.deepEqual(
hosts,
["alpha.example.com", "beta.example.com"],
`feedback group headings must show each community host; got: ${JSON.stringify(hosts)}`,
);
await unmount();
});
@@ -0,0 +1,56 @@
/**
* Unit tests for community grouping of deployment-wide admin rows.
*
* The admin API returns reports and feedback across every community on the
* deployment; the console buckets them by community for triage. Grouping must
* preserve first-seen community order and server row order within a community,
* and never head a group with an empty host.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { groupByCommunity } from "./AdminConsolePanelHelpers.tsx";
test("group-empty-returns-empty: no rows → no groups", () => {
assert.deepEqual(groupByCommunity([]), []);
});
test("group-single-community-one-bucket: rows sharing a community collapse to one group", () => {
const rows = [
{ communityId: "c1", communityHost: "a.example.com", id: "r1" },
{ communityId: "c1", communityHost: "a.example.com", id: "r2" },
];
const groups = groupByCommunity(rows);
assert.equal(groups.length, 1);
assert.equal(groups[0].communityId, "c1");
assert.equal(groups[0].communityHost, "a.example.com");
assert.deepEqual(
groups[0].items.map((r) => r.id),
["r1", "r2"],
);
});
test("group-preserves-first-seen-order: communities keep the order they first appear", () => {
const rows = [
{ communityId: "c2", communityHost: "b.example.com", id: "r1" },
{ communityId: "c1", communityHost: "a.example.com", id: "r2" },
{ communityId: "c2", communityHost: "b.example.com", id: "r3" },
];
const groups = groupByCommunity(rows);
assert.deepEqual(
groups.map((g) => g.communityId),
["c2", "c1"],
);
// Interleaved rows for c2 stay together in server order.
assert.deepEqual(
groups[0].items.map((r) => r.id),
["r1", "r3"],
);
});
test("group-blank-host-falls-back-to-id: an empty host never heads a group", () => {
const rows = [{ communityId: "c1", communityHost: "", id: "r1" }];
const groups = groupByCommunity(rows);
assert.equal(groups[0].communityHost, "c1");
});
@@ -0,0 +1,56 @@
import { useQuery } from "@tanstack/react-query";
import { useIdentityQuery } from "@/shared/api/hooks";
import { discoverAdminOrigin, getAdminOrigin, probeAdminOrigin } from "./api";
import type { ModerationNavResolution } from "./nav";
export const moderationNavResolutionQueryKey = (pubkeyHex: string) =>
["moderationNavResolution", pubkeyHex] as const;
/**
* Resolve the origin + probe state that decide whether the Moderation nav
* entry is visible. Mirrors the settings card's mount resolution: a saved
* manual origin wins outright (and short-circuits the probe, since the gate
* shows the entry regardless); otherwise NIP-11 discovery is attempted and,
* when it advertises an origin, probed so the gate can distinguish an
* authorized relay from a definitive non-admin verdict.
*
* Keyed by pubkey so an identity switch re-resolves. Errors from the probe
* resolve to a `"error"` outcome rather than rejecting a transport flake
* must keep the entry visible, not make the whole query fail.
*/
export function useModerationNavResolution():
| ModerationNavResolution
| undefined {
const { data: identity } = useIdentityQuery();
const pubkeyHex = identity?.pubkey ?? "";
const query = useQuery({
enabled: pubkeyHex.length > 0,
queryKey: moderationNavResolutionQueryKey(pubkeyHex),
staleTime: 60_000,
queryFn: async (): Promise<ModerationNavResolution> => {
const saved = await getAdminOrigin(pubkeyHex);
if (saved) {
return { originSource: "saved", probe: null };
}
let discovered: string | null = null;
try {
discovered = await discoverAdminOrigin();
} catch {
discovered = null;
}
if (!discovered) {
return { originSource: "none", probe: null };
}
try {
const result = await probeAdminOrigin(discovered);
return { originSource: "advertised", probe: result.state };
} catch {
return { originSource: "advertised", probe: "error" };
}
},
});
return query.data;
}
@@ -0,0 +1,88 @@
/**
* Unit tests for the Settings Moderation nav visibility gate.
*
* The gate decides whether ordinary members ever see the Moderation entry.
* Its two load-bearing rules are:
* 1. A saved manual origin always shows the entry the Advanced control that
* edits/clears a bad saved URL lives inside the surface, so hiding it would
* permanently lock a user out of fixing their own state.
* 2. An advertised-only origin follows the probe: visible on a plausible
* authorization or a transport flake, hidden on a definitive non-admin
* verdict. Flake must never silently hide the entry.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { shouldShowModerationNav } from "./nav.ts";
test("no-origin-hides-entry: neither advertised nor saved → hidden", () => {
assert.equal(
shouldShowModerationNav({ originSource: "none", probe: null }),
false,
);
});
test("saved-origin-authorized-shows-entry: saved manual origin visible", () => {
assert.equal(
shouldShowModerationNav({
originSource: "saved",
probe: "nip98Authorized",
}),
true,
);
});
test("saved-origin-bad-probe-still-shows-entry: notAdminApi under a saved origin stays visible so the user can fix it", () => {
for (const probe of ["notAdminApi", "nip98Denied", "tokenMode", "error"]) {
assert.equal(
shouldShowModerationNav({ originSource: "saved", probe }),
true,
`saved origin must stay visible for probe=${probe}`,
);
}
});
test("advertised-authorized-shows-entry: advertised origin that authorizes is visible", () => {
assert.equal(
shouldShowModerationNav({
originSource: "advertised",
probe: "nip98Authorized",
}),
true,
);
});
test("advertised-disabled-shows-entry: advertised origin in auth-disabled mode is visible", () => {
assert.equal(
shouldShowModerationNav({ originSource: "advertised", probe: "disabled" }),
true,
);
});
test("advertised-flake-shows-entry: transport flake never silently hides an advertised entry", () => {
for (const probe of ["networkOrIntercepted", "error"]) {
assert.equal(
shouldShowModerationNav({ originSource: "advertised", probe }),
true,
`advertised origin must stay visible for flake probe=${probe}`,
);
}
});
test("advertised-denied-hides-entry: a definitive non-admin verdict hides an advertised-only entry", () => {
for (const probe of ["nip98Denied", "tokenMode", "notAdminApi"]) {
assert.equal(
shouldShowModerationNav({ originSource: "advertised", probe }),
false,
`advertised origin must hide for definitive verdict probe=${probe}`,
);
}
});
test("advertised-null-probe-hides-entry: fail-closed on an unresolved probe", () => {
assert.equal(
shouldShowModerationNav({ originSource: "advertised", probe: null }),
false,
);
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Pure visibility logic for the Settings Moderation nav entry.
*
* Kept free of React and IO so the gate decision is unit-testable in
* isolation; `hooks.ts` resolves the origin + probe state that feed it.
*/
import type { AdminProbeState } from "./api";
/** Where the admin origin came from for the active identity. */
export type ModerationOriginSource = "saved" | "advertised" | "none";
/**
* Probe outcome for the resolved origin. `"error"` distinguishes a probe that
* threw (transport flake) from a definitive relay verdict; `null` means no
* probe was run (no origin, or a saved origin that wins without probing).
*/
export type ModerationProbeOutcome = AdminProbeState | "error" | null;
export type ModerationNavResolution = {
originSource: ModerationOriginSource;
probe: ModerationProbeOutcome;
};
/**
* Decide whether the Moderation nav entry is visible.
*
* - No origin (neither saved-manual nor advertised) hidden. Ordinary members
* never see a dead entry.
* - A saved manual origin always shows the entry, regardless of probe state:
* the Advanced affordance that edits/clears the origin lives inside the
* surface, so hiding it would lock a user out of fixing a bad saved URL.
* - An advertised-only origin shows the entry when the probe plausibly
* authorizes (`nip98Authorized`/`disabled`) or is a transport flake
* (`networkOrIntercepted`/`error` never a silent disappear on flake), and
* hides it on a definitive non-admin verdict (`nip98Denied`/`tokenMode`/
* `notAdminApi`). Fail-closed: any unrecognised outcome hides the entry.
*/
export function shouldShowModerationNav(res: ModerationNavResolution): boolean {
if (res.originSource === "none") return false;
if (res.originSource === "saved") return true;
switch (res.probe) {
case "nip98Authorized":
case "disabled":
case "networkOrIntercepted":
case "error":
return true;
default:
return false;
}
}
@@ -14,7 +14,6 @@ import {
MonitorCog,
Moon,
ShieldAlert,
ShieldCheck,
Smartphone,
Smile,
Sun,
@@ -66,7 +65,6 @@ import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard";
import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard";
import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard";
import { MobilePairingCard } from "./MobilePairingCard";
import { ModerationQueueCard } from "./ModerationQueueCard";
import { NotificationSettingsCard } from "./NotificationSettingsCard";
import { AgentsSettingsPanel } from "./AgentsSettingsPanel";
import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard";
@@ -90,7 +88,6 @@ export type SettingsSection =
| "hosted-communities"
| "community-members"
| "moderation"
| "admin-console"
| "custom-emoji"
| "local-archive"
| "mobile"
@@ -111,7 +108,6 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [
"hosted-communities",
"community-members",
"moderation",
"admin-console",
"custom-emoji",
"local-archive",
"mobile",
@@ -211,11 +207,6 @@ export const settingsSections: SettingsSectionDescriptor[] = [
label: "Moderation",
icon: ShieldAlert,
},
{
value: "admin-console",
label: "Moderation console",
icon: ShieldCheck,
},
{
value: "custom-emoji",
label: "Custom emoji",
@@ -871,8 +862,6 @@ export function renderSettingsSection(
<CommunityMembersSettingsCard currentPubkey={props.currentPubkey} />
);
case "moderation":
return <ModerationQueueCard />;
case "admin-console":
return <AdminConsoleSettingsCard />;
case "custom-emoji":
return <CustomEmojiSettingsCard />;
@@ -3,6 +3,8 @@ import { getVersion } from "@tauri-apps/api/app";
import { AlertCircle, ArrowLeft, LoaderCircle, RefreshCw } from "lucide-react";
import { useMyRelayMembershipLookupQuery } from "@/features/community-members/hooks";
import { useModerationNavResolution } from "@/features/admin-console/hooks";
import { shouldShowModerationNav } from "@/features/admin-console/nav";
import {
canManageCommunityMembers,
shouldWarnMissingMembershipSnapshot,
@@ -67,18 +69,11 @@ export const settingsNavGroups: Array<{
},
{
label: "Communities",
sections: ["hosted-communities", "community-members"],
sections: ["hosted-communities", "community-members", "moderation"],
},
{
label: "App",
sections: [
"agents",
"compute",
"experimental",
"mobile",
"updates",
"admin-console",
],
sections: ["agents", "compute", "experimental", "mobile", "updates"],
},
];
@@ -136,6 +131,7 @@ export function SettingsView({
}: SettingsViewProps) {
const { isMobile, open: sidebarOpen, setOpen: setSidebarOpen } = useSidebar();
const myMembershipQuery = useMyRelayMembershipLookupQuery();
const moderationNav = useModerationNavResolution();
const featureState = useFeatureSnapshot();
const visibleSections = React.useMemo(() => {
return settingsSections.filter((s) => {
@@ -153,9 +149,14 @@ export function SettingsView({
if (s.value === "community-members") {
return canManageCommunityMembers(myMembershipQuery.data);
}
// Moderation surfaces the relay admin console. Hidden until the origin
// resolves (no flash of a dead entry); then gated by origin + probe.
if (s.value === "moderation") {
return moderationNav != null && shouldShowModerationNav(moderationNav);
}
return true;
});
}, [myMembershipQuery.data, featureState]);
}, [myMembershipQuery.data, moderationNav, featureState]);
const [isLoaded, setIsLoaded] = React.useState(false);
const [appVersion, setAppVersion] = React.useState<string | null>(null);
@@ -3,22 +3,42 @@ import test from "node:test";
import { settingsNavGroups } from "./SettingsView.tsx";
test("admin-console is present in the App nav group", () => {
const appGroup = settingsNavGroups.find((g) => g.label === "App");
assert.ok(appGroup, "App group must exist in settingsNavGroups");
test("moderation is wired into the Communities nav group", () => {
const communitiesGroup = settingsNavGroups.find(
(g) => g.label === "Communities",
);
assert.ok(
appGroup.sections.includes("admin-console"),
`expected "admin-console" in App group sections, got: ${JSON.stringify(appGroup.sections)}`,
communitiesGroup,
"Communities group must exist in settingsNavGroups",
);
assert.ok(
communitiesGroup.sections.includes("moderation"),
`expected "moderation" in Communities group sections, got: ${JSON.stringify(communitiesGroup.sections)}`,
);
});
test("admin-console is the last entry in the App nav group", () => {
const appGroup = settingsNavGroups.find((g) => g.label === "App");
assert.ok(appGroup, "App group must exist in settingsNavGroups");
const last = appGroup.sections.at(-1);
assert.equal(
last,
"admin-console",
`expected "admin-console" to be last in App group, got: ${last}`,
test("moderation follows community-members in the Communities nav group", () => {
const communitiesGroup = settingsNavGroups.find(
(g) => g.label === "Communities",
);
assert.ok(
communitiesGroup,
"Communities group must exist in settingsNavGroups",
);
const membersIndex = communitiesGroup.sections.indexOf("community-members");
const moderationIndex = communitiesGroup.sections.indexOf("moderation");
assert.ok(membersIndex !== -1, "community-members must be present");
assert.ok(
moderationIndex > membersIndex,
`expected "moderation" after "community-members", got: ${JSON.stringify(communitiesGroup.sections)}`,
);
});
test("the removed admin-console id is not wired into any nav group", () => {
for (const group of settingsNavGroups) {
assert.ok(
!group.sections.includes("admin-console"),
`"admin-console" must not appear in the "${group.label}" group`,
);
}
});