diff --git a/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx b/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx
index 2ff1292dbd..a0e88f7022 100644
--- a/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx
+++ b/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx
@@ -93,7 +93,7 @@ export function FeedbackTab({
>
{text}
- {status && status !== "new" && (
+ {status !== "new" && (
{status}
@@ -321,7 +321,7 @@ function FeedbackStatusControl({
onStatusChanged,
}: {
feedbackId: string;
- currentStatus: AdminFeedbackStatus | null | undefined;
+ currentStatus: AdminFeedbackStatus | null;
origin: string;
onStatusChanged: (newStatus: AdminFeedbackStatus) => void;
}) {
@@ -391,9 +391,9 @@ export function FeedbackDetail({
onBack: () => void;
}) {
// Local status state: initialized from server, updated on PATCH.
- const [localStatus, setLocalStatus] = useState<
- AdminFeedbackStatus | null | undefined
- >(undefined);
+ const [localStatus, setLocalStatus] = useState
(
+ null,
+ );
const detailState: AsyncState = useAsyncLoad(
() => getAdminFeedback(origin, feedbackId),
@@ -402,16 +402,12 @@ export function FeedbackDetail({
);
// Sync localStatus from server data on load (but not on every re-render).
+ // `status` is a required wire field — read it directly, never default a
+ // missing value to "new" (that would misreport a reviewed/archived entry).
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — sync once when data arrives
useEffect(() => {
if (detailState.status === "ok") {
- setLocalStatus(
- (
- detailState.data as AdminFeedbackDto & {
- status?: AdminFeedbackStatus;
- }
- ).status ?? "new",
- );
+ setLocalStatus(detailState.data.status);
}
}, [detailState.status === "ok"]);
diff --git a/desktop/src/features/admin-console/AdminConsolePanel.tsx b/desktop/src/features/admin-console/AdminConsolePanel.tsx
index 6695852dab..d28b759ad0 100644
--- a/desktop/src/features/admin-console/AdminConsolePanel.tsx
+++ b/desktop/src/features/admin-console/AdminConsolePanel.tsx
@@ -591,15 +591,14 @@ function ReportsTab({
const isProcessing = status === "processing";
return (
+ {/* Processing rows stay navigable: the enforcement state (progress,
+ retry, cancel) lives inside the detail view, so disabling the row
+ would hide exactly the controls an operator needs while an action
+ is pending. Detail suppresses only the resolve form for a
+ non-open report. */}
{
- if (!isProcessing) setSelectedId(id);
- }}
+ className="w-full rounded-md border border-border/60 px-3 py-2.5 text-left text-sm hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ onClick={() => setSelectedId(id)}
type="button"
>
{summary}
diff --git a/desktop/src/features/admin-console/adminConsolePanel.test.mjs b/desktop/src/features/admin-console/adminConsolePanel.test.mjs
index d70c2608a8..b96c0e2041 100644
--- a/desktop/src/features/admin-console/adminConsolePanel.test.mjs
+++ b/desktop/src/features/admin-console/adminConsolePanel.test.mjs
@@ -1161,73 +1161,6 @@ test("probe-no-role: disabled-mode panel renders without role badge", async () =
await unmount();
});
-// ── processing report not actionable ─────────────────────────────────────
-
-test("processing-report-not-actionable: processing report is disabled in list", async () => {
- // Verifies that a report with status=processing is rendered as disabled/non-clickable.
- //
- // Mutation evidence: remove the disabled/isProcessing branch from ReportsTab →
- // the button is enabled → test goes red.
-
- const origin = "https://admin.example.com";
- const pubkey = "f5".repeat(32);
-
- const processingReport = {
- id: "00000000-0000-0000-0000-000000000010",
- communityId: "00000000-0000-0000-0000-000000000002",
- communityHost: "relay.example.com",
- reportEventId: "aabb",
- reporterPubkey: "ccdd",
- targetKind: "event",
- target: "eeff",
- reportType: "spam",
- status: "processing",
- createdAt: "2024-01-01T00:00:00Z",
- };
-
- setIpcHandler("admin_list_reports", () =>
- Promise.resolve([processingReport]),
- );
- setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
-
- const { container, doRender, unmount } = mountPanel({ origin, pubkey });
- await doRender();
- await settle(30);
-
- // The report button must exist but be disabled.
- const buttons = container.querySelectorAll("button");
- let reportButton = null;
- for (const btn of buttons) {
- const testid = btn.getAttribute("data-testid") ?? "";
- if (testid.startsWith("admin-tab")) continue;
- // The report row button in the list.
- if (
- btn.textContent?.includes("spam") ||
- btn.textContent?.includes("processing")
- ) {
- reportButton = btn;
- break;
- }
- }
-
- // A processing report row must render as a disabled button (non-actionable).
- if (reportButton != null) {
- assert.ok(
- reportButton.disabled,
- `processing report button must be disabled; got enabled`,
- );
- }
-
- // The container should show "processing" text.
- const text = container.textContent ?? "";
- assert.ok(
- text.includes("processing"),
- `processing status must be visible; got: ${text.slice(0, 300)}`,
- );
-
- await unmount();
-});
-
// ── action matrix: allowedActionsForTargetKind ────────────────────────────
// Note: allowedActionsForTargetKind is a pure function tested inline via the
diff --git a/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs
index 749e9ee2fe..7632f1cb3c 100644
--- a/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs
+++ b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs
@@ -1145,6 +1145,101 @@ test("report-detail-renders-structured-fields: ReportDetail shows field layout,
await unmount();
});
+test("processing-report-navigable-suppresses-resolve-form: a processing report opens into detail, shows enforcement state, and hides the resolve form", async () => {
+ // Thufir finding 4: processing rows must stay navigable. The enforcement
+ // state (progress/retry/cancel) lives inside the detail view, so disabling
+ // the row hides exactly the UI an operator needs while an action is pending.
+ // "Not actionable" means suppress the resolve form, not block navigation.
+ //
+ // Mutation evidence: re-add `disabled={isProcessing}` to the ReportsTab row →
+ // the click never opens detail, report-detail-fields never renders → red.
+ // Drop the `isOpen` gate on ResolveReportForm → the resolve form renders for
+ // a processing report → the resolve-form-absent assertion goes red.
+
+ const origin = "https://admin.example.com";
+ const pubkey = "f5".repeat(32);
+
+ const processingItem = {
+ id: "00000000-0000-0000-0000-000000000010",
+ communityId: "00000000-0000-0000-0000-000000000002",
+ communityHost: "relay.example.com",
+ reportEventId: "aabb",
+ reporterPubkey: "ccdd",
+ targetKind: "event",
+ target: "eeff",
+ reportType: "spam",
+ status: "processing",
+ createdAt: "2024-01-01T00:00:00Z",
+ };
+ const processingDetail = {
+ ...processingItem,
+ channelId: null,
+ note: null,
+ resolvedBy: null,
+ resolvedAt: null,
+ actionId: null,
+ activeAction: {
+ id: "00000000-0000-0000-0000-0000000000e1",
+ reportId: processingItem.id,
+ action: "ban",
+ status: "enforcing",
+ requestId: "00000000-0000-0000-0000-0000000000e2",
+ expirationSecs: null,
+ reason: null,
+ createdAt: "2024-01-01T00:00:00Z",
+ updatedAt: "2024-01-01T00:00:01Z",
+ },
+ message: null,
+ };
+
+ setIpcHandler("admin_list_reports", () => Promise.resolve([processingItem]));
+ setIpcHandler("admin_get_report", () => Promise.resolve(processingDetail));
+ setIpcHandler("admin_list_feedback", () => Promise.resolve([]));
+
+ const { container, doRender, unmount } = mountPanel({ origin, pubkey });
+ await doRender();
+ await settle(30);
+
+ // The processing row must be a navigable (non-disabled) button.
+ const rowButtons = Array.from(container.querySelectorAll("button")).filter(
+ (btn) => !(btn.getAttribute("data-testid") ?? "").startsWith("admin-tab"),
+ );
+ const processingRow = rowButtons.find((btn) =>
+ btn.textContent?.includes("spam"),
+ );
+ assert.ok(processingRow, "processing report row must be present");
+ assert.ok(
+ !processingRow.disabled,
+ "processing report row must stay navigable (not disabled)",
+ );
+
+ // Navigate into the detail.
+ await act(async () => {
+ fireEvent.click(processingRow);
+ await new Promise((r) => setTimeout(r, 30));
+ });
+ await settle(30);
+
+ // Detail renders (navigation succeeded).
+ assert.ok(
+ container.querySelector("[data-testid='report-detail-fields']"),
+ "report-detail-fields must render after navigating into a processing report",
+ );
+ // Enforcement state block is shown for a processing report with an action.
+ assert.ok(
+ container.querySelector("[data-testid='enforcement-state-block']"),
+ "enforcement-state-block must render for a processing report",
+ );
+ // The resolve form must be suppressed for a non-open (processing) report.
+ assert.equal(
+ container.querySelector("[data-testid='resolve-report-form']"),
+ null,
+ "resolve form must NOT render for a processing report",
+ );
+
+ await unmount();
+});
+
test("feedback-detail-renders-structured-fields: FeedbackDetail shows field layout, not raw JSON", async () => {
// Verifies item 3: the feedback detail view renders data-testid='feedback-detail-fields'.
// Lives here (jsdom) because tab switching and item navigation require fireEvent.click.
@@ -1163,6 +1258,7 @@ test("feedback-detail-renders-structured-fields: FeedbackDetail shows field layo
submitterPubkey: "submitter001pubkey",
category: "bug",
bodySummary: "App crashes on startup",
+ status: "new",
receivedAt: "2024-05-01T09:00:05Z",
};
@@ -1175,6 +1271,7 @@ test("feedback-detail-renders-structured-fields: FeedbackDetail shows field layo
submitterPubkey: "submitter001pubkey",
category: "bug",
body: "App crashes on startup — full detail body text",
+ status: "new",
tags: [],
eventCreatedAt: "2024-05-01T09:00:00Z",
receivedAt: "2024-05-01T09:00:05Z",
@@ -1795,6 +1892,7 @@ test("feedback-grouped-by-community: multi-community feedback renders per-commun
submitterPubkey: "sub1",
category: "bug",
bodySummary: "Alpha feedback body",
+ status: "new",
receivedAt: "2024-06-01T09:00:00Z",
},
{
@@ -1804,6 +1902,7 @@ test("feedback-grouped-by-community: multi-community feedback renders per-commun
submitterPubkey: "sub2",
category: "idea",
bodySummary: "Beta feedback body",
+ status: "new",
receivedAt: "2024-06-02T09:00:00Z",
},
];
@@ -1838,6 +1937,112 @@ test("feedback-grouped-by-community: multi-community feedback renders per-commun
await unmount();
});
+test("feedback-status-honest: a reviewed detail reports reviewed, never defaulting to new", async () => {
+ // Thufir finding 5 (desktop half): `status` is a required wire field. A
+ // reviewed/archived entry must render its real status after reload, not be
+ // silently presented as "new". The status control must also initialize its
+ // selected state from the server value.
+ //
+ // Mutation evidence: reinstate `detailState.data.status ?? "new"` in
+ // FeedbackDetail → a reviewed entry would still show, but re-adding the
+ // absent-defaulting cast and feeding an entry with no status would present
+ // it as new; here we assert the reviewed value round-trips and its button
+ // is the active (default-variant) one.
+
+ const origin = "https://admin.example.com";
+ const pubkey = "d5".repeat(32);
+
+ const reviewedSummary = {
+ id: "00000000-0000-0000-0000-0000000000d5",
+ communityId: "comm-1",
+ communityHost: "alpha.example.com",
+ submitterPubkey: "sub-reviewed",
+ category: "bug",
+ bodySummary: "Already-triaged feedback",
+ status: "reviewed",
+ receivedAt: "2024-06-01T09:00:00Z",
+ };
+ const reviewedDetail = {
+ id: reviewedSummary.id,
+ communityId: reviewedSummary.communityId,
+ communityHost: reviewedSummary.communityHost,
+ eventId: "revevent",
+ submitterPubkey: reviewedSummary.submitterPubkey,
+ category: "bug",
+ body: "Already-triaged feedback full body",
+ status: "reviewed",
+ tags: [],
+ eventCreatedAt: "2024-06-01T09:00:00Z",
+ receivedAt: "2024-06-01T09:00:00Z",
+ };
+
+ setIpcHandler("admin_list_reports", () => Promise.resolve([]));
+ setIpcHandler("admin_list_feedback", () =>
+ Promise.resolve([reviewedSummary]),
+ );
+ setIpcHandler("admin_get_feedback", () => Promise.resolve(reviewedDetail));
+
+ 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);
+
+ // List row shows the "reviewed" badge (status !== "new").
+ const listText = container.textContent ?? "";
+ assert.ok(
+ listText.includes("reviewed"),
+ `list row must show the reviewed badge; got: ${listText.slice(0, 400)}`,
+ );
+
+ // Navigate into the feedback detail.
+ const listRow = Array.from(container.querySelectorAll("button")).find(
+ (btn) =>
+ !(btn.getAttribute("data-testid") ?? "").startsWith("admin-tab") &&
+ btn.textContent?.includes("Already-triaged feedback"),
+ );
+ assert.ok(listRow, "feedback list row must be present");
+ await act(async () => {
+ fireEvent.click(listRow);
+ await new Promise((r) => setTimeout(r, 30));
+ });
+ await settle(30);
+
+ // The status control initializes from the server value: the "reviewed"
+ // button is the active (default-variant) selection, not "new".
+ const control = container.querySelector(
+ "[data-testid='feedback-status-control']",
+ );
+ assert.ok(control, "feedback status control must render");
+ const reviewedBtn = container.querySelector(
+ "[data-testid='feedback-status-btn-reviewed']",
+ );
+ const newBtn = container.querySelector(
+ "[data-testid='feedback-status-btn-new']",
+ );
+ assert.ok(reviewedBtn && newBtn, "status buttons must render");
+ // The active status is styled with a ring highlight (see FeedbackStatusControl).
+ assert.ok(
+ (reviewedBtn.className ?? "").includes("ring-2"),
+ `the reviewed button must be marked active; got className: ${reviewedBtn.className}`,
+ );
+ assert.ok(
+ !(newBtn.className ?? "").includes("ring-2"),
+ `the new button must NOT be active for a reviewed entry; got className: ${newBtn.className}`,
+ );
+
+ await unmount();
+});
+
// ── reopen ────────────────────────────────────────────────────────────────
/**
diff --git a/desktop/src/features/admin-console/api.ts b/desktop/src/features/admin-console/api.ts
index 550fc419e4..f60da3e93d 100644
--- a/desktop/src/features/admin-console/api.ts
+++ b/desktop/src/features/admin-console/api.ts
@@ -167,6 +167,8 @@ export type AdminFeedbackDto = {
submitterPubkey: string;
category?: string | null;
body: string;
+ /** Triage status: `"new"` | `"reviewed"` | `"archived"`. Always present. */
+ status: AdminFeedbackStatus;
/** Full source tags — consumed as imeta attachment metadata. */
tags: unknown;
eventCreatedAt: string;
@@ -188,8 +190,8 @@ export type AdminFeedbackSummaryDto = {
submitterPubkey: string;
category?: string | null;
bodySummary: string;
- /** Feedback triage status: `"new"` | `"reviewed"` | `"archived"`. */
- status?: AdminFeedbackStatus | null;
+ /** Triage status: `"new"` | `"reviewed"` | `"archived"`. Always present. */
+ status: AdminFeedbackStatus;
receivedAt: string;
};
@@ -349,13 +351,21 @@ export async function reopenAdminReport(
export type AdminFeedbackStatus = "new" | "reviewed" | "archived";
+/**
+ * The PATCH /api/admin/v1/feedback/{id} response — the relay echoes only the
+ * updated `status`, not a full feedback record.
+ */
+export type AdminFeedbackStatusResult = {
+ status: AdminFeedbackStatus;
+};
+
/** Update feedback status — PATCH /api/admin/v1/feedback/{id}. */
export async function patchAdminFeedback(
origin: string,
id: string,
status: AdminFeedbackStatus,
-): Promise {
- return invokeTauri("admin_patch_feedback", {
+): Promise {
+ return invokeTauri("admin_patch_feedback", {
origin,
id,
body: { status },
diff --git a/desktop/src/features/admin-console/hooks.test.mjs b/desktop/src/features/admin-console/hooks.test.mjs
new file mode 100644
index 0000000000..4d6e821526
--- /dev/null
+++ b/desktop/src/features/admin-console/hooks.test.mjs
@@ -0,0 +1,49 @@
+/**
+ * Unit tests for the Moderation nav resolver's cache key.
+ *
+ * The resolver's verdict depends on NIP-11 discovery, which is relay-specific.
+ * The cache key MUST therefore include the connected relay origin so a
+ * workspace switch busts the cached verdict instead of serving the previous
+ * relay's answer for up to `staleTime`.
+ */
+
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { moderationNavResolutionQueryKey } from "./hooks.ts";
+
+test("query-key-scoped-to-relay: switching relay origin yields a distinct cache key", () => {
+ const pubkey = "a".repeat(64);
+ const keyA = moderationNavResolutionQueryKey(
+ pubkey,
+ "https://relay-a.example",
+ );
+ const keyB = moderationNavResolutionQueryKey(
+ pubkey,
+ "https://relay-b.example",
+ );
+ assert.notDeepEqual(
+ keyA,
+ keyB,
+ "same pubkey on a different relay must not reuse the cached verdict",
+ );
+});
+
+test("query-key-stable-per-relay: same pubkey and relay yields an equal key", () => {
+ const pubkey = "b".repeat(64);
+ const origin = "https://relay.example";
+ assert.deepEqual(
+ moderationNavResolutionQueryKey(pubkey, origin),
+ moderationNavResolutionQueryKey(pubkey, origin),
+ "a stable identity+relay pair must hit the same cache entry",
+ );
+});
+
+test("query-key-distinguishes-unresolved-relay: a null origin is its own key dimension", () => {
+ const pubkey = "c".repeat(64);
+ assert.notDeepEqual(
+ moderationNavResolutionQueryKey(pubkey, null),
+ moderationNavResolutionQueryKey(pubkey, "https://relay.example"),
+ "an unresolved relay must not share a cache entry with a resolved one",
+ );
+});
diff --git a/desktop/src/features/admin-console/hooks.ts b/desktop/src/features/admin-console/hooks.ts
index 3ecdfd11ae..9e5b479ab4 100644
--- a/desktop/src/features/admin-console/hooks.ts
+++ b/desktop/src/features/admin-console/hooks.ts
@@ -1,11 +1,14 @@
import { useQuery } from "@tanstack/react-query";
import { useIdentityQuery } from "@/shared/api/hooks";
+import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
import { discoverAdminOrigin, getAdminOrigin, probeAdminOrigin } from "./api";
import type { ModerationNavResolution } from "./nav";
-export const moderationNavResolutionQueryKey = (pubkeyHex: string) =>
- ["moderationNavResolution", pubkeyHex] as const;
+export const moderationNavResolutionQueryKey = (
+ pubkeyHex: string,
+ relayOrigin: string | null,
+) => ["moderationNavResolution", pubkeyHex, relayOrigin] as const;
/**
* Resolve the origin + probe state that decide whether the Moderation nav
@@ -15,19 +18,24 @@ export const moderationNavResolutionQueryKey = (pubkeyHex: string) =>
* 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.
+ * Keyed by pubkey **and the connected relay origin**: NIP-11 discovery is
+ * relay-dependent, so a pubkey-only key would serve the previous relay's
+ * verdict for up to `staleTime` after a workspace switch. Gating `enabled`
+ * on the relay origin also defers resolution until the relay identity is
+ * known, so no verdict is computed against an unresolved relay. 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 relayOrigin = useRelayOrigin();
const query = useQuery({
- enabled: pubkeyHex.length > 0,
- queryKey: moderationNavResolutionQueryKey(pubkeyHex),
+ enabled: pubkeyHex.length > 0 && relayOrigin != null,
+ queryKey: moderationNavResolutionQueryKey(pubkeyHex, relayOrigin),
staleTime: 60_000,
queryFn: async (): Promise => {
const saved = await getAdminOrigin(pubkeyHex);
diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx
index 1b4a664179..b3ce0585b9 100644
--- a/desktop/src/features/settings/ui/SettingsView.tsx
+++ b/desktop/src/features/settings/ui/SettingsView.tsx
@@ -171,10 +171,18 @@ export function SettingsView({
}, []);
React.useEffect(() => {
+ // Defer section normalization while a direct link (`?section=moderation`)
+ // targets the Moderation entry and its visibility is still unresolved.
+ // That entry's visibility depends on an async origin+probe resolver
+ // (`moderationNav` is `undefined` until it resolves); redirecting first
+ // would bounce a valid direct link before the probe can authorize.
+ if (section === "moderation" && moderationNav === undefined) {
+ return;
+ }
if (!visibleSections.some((entry) => entry.value === section)) {
onSectionChange(visibleSections[0]?.value ?? "appearance");
}
- }, [onSectionChange, section, visibleSections]);
+ }, [moderationNav, onSectionChange, section, visibleSections]);
React.useEffect(() => {
if (!isMobile && !sidebarOpen) {