mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user