diff --git a/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx b/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx
index dd91c02c6..9e534b479 100644
--- a/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx
+++ b/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx
@@ -24,6 +24,7 @@ import {
import {
type AsyncState,
type AttachmentMeta,
+ CommunityGroupedList,
DetailRow,
ErrorMessage,
LoadingSpinner,
@@ -75,8 +76,9 @@ export function FeedbackTab({
}
return (
-
- {items.map((item: AdminFeedbackSummaryDto) => {
+ {
const id = item.id;
const text = item.bodySummary.slice(0, 120);
const receivedAt = item.receivedAt;
@@ -104,8 +106,8 @@ export function FeedbackTab({
);
- })}
-
+ }}
+ />
);
}
diff --git a/desktop/src/features/admin-console/AdminConsolePanel.tsx b/desktop/src/features/admin-console/AdminConsolePanel.tsx
index 3b2276123..194cd5cb0 100644
--- a/desktop/src/features/admin-console/AdminConsolePanel.tsx
+++ b/desktop/src/features/admin-console/AdminConsolePanel.tsx
@@ -48,6 +48,7 @@ import {
DetailRow,
ErrorMessage,
LoadingSpinner,
+ CommunityGroupedList,
formatTimestamp,
useAsyncLoad,
} from "./AdminConsolePanelHelpers";
@@ -468,8 +469,9 @@ function ReportsTab({
}
return (
-
- {reports.map((report: AdminReportDto) => {
+ {
const id = report.id;
const summary = report.reportType || "Report";
const status = report.status;
@@ -499,8 +501,8 @@ function ReportsTab({
);
- })}
-
+ }}
+ />
);
}
diff --git a/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx b/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx
index 74ff8c689..a4d0a6f33 100644
--- a/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx
+++ b/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx
@@ -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 = {
+ /** 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[] {
+ const groups: CommunityGroup[] = [];
+ const byId = new Map>();
+ 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 ;
+ }
+ return (
+
+ {groups.map((group) => (
+
+
+ {group.communityHost}
+
+ {group.items.map(renderItem)}
+
+ ))}
+
+ );
+}
diff --git a/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx b/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx
index 4f7d87117..4c9dce7a8 100644
--- a/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx
+++ b/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx
@@ -23,6 +23,7 @@ import {
AlertCircle,
Check,
CheckCircle2,
+ ChevronRight,
Copy,
Info,
LoaderCircle,
@@ -228,8 +229,8 @@ export function AdminConsoleSettingsCard() {
data-testid="settings-admin-console"
>
{pubkeyHex ? (
@@ -412,58 +413,66 @@ function AdminConsoleSettingsSession({ pubkeyHex }: { pubkeyHex: string }) {
return (
<>
-
-
{
- 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();
- }}
- />
-
void handleSave()}
- size="sm"
- type="button"
- variant={inputChanged ? "default" : "outline"}
- >
- {isSaving ? (
-
- ) : (
- "Save"
- )}
-
- {savedOrigin && (
-
+
+
+ Advanced: admin origin
+
+
+
+ {
+ 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();
+ }}
+ />
+ void handleSave()}
+ size="sm"
+ type="button"
+ variant={inputChanged ? "default" : "outline"}
+ >
+ {isSaving ? (
+
+ ) : (
+ "Save"
+ )}
+
+ {savedOrigin && (
+ runProbe(savedOrigin)}
+ size="sm"
+ type="button"
+ variant="ghost"
+ >
+ Re-probe
+
)}
- data-testid="admin-probe-refresh"
- disabled={probeUiState.kind === "probing"}
- onClick={() => runProbe(savedOrigin)}
- size="sm"
- type="button"
- variant="ghost"
- >
- Re-probe
-
- )}
-
+
+
+
diff --git a/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs
index 95ca6e748..1c2e6d445 100644
--- a/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs
+++ b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs
@@ -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
→ 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 → 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();
+});
diff --git a/desktop/src/features/admin-console/grouping.test.mjs b/desktop/src/features/admin-console/grouping.test.mjs
new file mode 100644
index 000000000..b3f3cfc8b
--- /dev/null
+++ b/desktop/src/features/admin-console/grouping.test.mjs
@@ -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");
+});
diff --git a/desktop/src/features/admin-console/hooks.ts b/desktop/src/features/admin-console/hooks.ts
new file mode 100644
index 000000000..3ecdfd11a
--- /dev/null
+++ b/desktop/src/features/admin-console/hooks.ts
@@ -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 => {
+ 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;
+}
diff --git a/desktop/src/features/admin-console/nav.test.mjs b/desktop/src/features/admin-console/nav.test.mjs
new file mode 100644
index 000000000..4e79e5793
--- /dev/null
+++ b/desktop/src/features/admin-console/nav.test.mjs
@@ -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,
+ );
+});
diff --git a/desktop/src/features/admin-console/nav.ts b/desktop/src/features/admin-console/nav.ts
new file mode 100644
index 000000000..fba66c2ed
--- /dev/null
+++ b/desktop/src/features/admin-console/nav.ts
@@ -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;
+ }
+}
diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx
index b869d8357..4a50de548 100644
--- a/desktop/src/features/settings/ui/SettingsPanels.tsx
+++ b/desktop/src/features/settings/ui/SettingsPanels.tsx
@@ -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(
);
case "moderation":
- return ;
- case "admin-console":
return ;
case "custom-emoji":
return ;
diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx
index e5e6ba1ae..1b4a66417 100644
--- a/desktop/src/features/settings/ui/SettingsView.tsx
+++ b/desktop/src/features/settings/ui/SettingsView.tsx
@@ -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(null);
diff --git a/desktop/src/features/settings/ui/settingsNavGroups.test.mjs b/desktop/src/features/settings/ui/settingsNavGroups.test.mjs
index 1760a9f5f..1a25adffc 100644
--- a/desktop/src/features/settings/ui/settingsNavGroups.test.mjs
+++ b/desktop/src/features/settings/ui/settingsNavGroups.test.mjs
@@ -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`,
+ );
+ }
+});