fix(desktop): navigable processing rows, honest feedback status, relay-scoped nav gate

Close the desktop half of Thufir's #4768 pass-1 findings that need no relay
change. The relay-contract consumption (canonical action DTO, real cancel
route) waits on #3777.

Processing report rows were disabled in the list, but the enforcement
progress/retry/cancel UI lives only inside the detail view — so the row was
locked exactly when an operator needs to inspect a pending or failed action.
Keep processing rows navigable; the detail view already suppresses the resolve
form for any non-open report.

Feedback triage `status` was optional on the wire types and silently defaulted
to "new" when absent, misreporting a reviewed/archived entry as new after
reload. Make `status` required on both feedback DTOs and read it directly, and
type PATCH's actual `{status}` echo instead of claiming a full summary record.

The Moderation nav resolver keyed its 60s cache on pubkey alone, but NIP-11
discovery is relay-dependent — a workspace switch could serve the previous
relay's verdict. Key the resolver on the connected relay origin (and gate its
`enabled` on a resolved origin), and defer the `?section=moderation`
invalid-section redirect while the resolver is unresolved so a direct link is
not bounced before the probe can authorize.

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 23:03:30 -04:00
parent fbb8b6cbb8
commit 8fff6dae80
8 changed files with 307 additions and 99 deletions

View File

@@ -93,7 +93,7 @@ export function FeedbackTab({
>
<div className="flex items-center gap-2 mb-0.5">
<span className="block font-medium line-clamp-2">{text}</span>
{status && status !== "new" && (
{status !== "new" && (
<Badge className="shrink-0" variant="secondary">
{status}
</Badge>
@@ -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<AdminFeedbackStatus | null>(
null,
);
const detailState: AsyncState<AdminFeedbackDto> = 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"]);

View File

@@ -591,15 +591,14 @@ function ReportsTab({
const isProcessing = status === "processing";
return (
<li key={id}>
{/* 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. */}
<button
className={cn(
"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",
isProcessing && "opacity-60 cursor-default",
)}
disabled={isProcessing}
onClick={() => {
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"
>
<span className="block font-medium">{summary}</span>

View File

@@ -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

View File

@@ -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 ────────────────────────────────────────────────────────────────
/**

View File

@@ -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<AdminFeedbackSummaryDto> {
return invokeTauri<AdminFeedbackSummaryDto>("admin_patch_feedback", {
): Promise<AdminFeedbackStatusResult> {
return invokeTauri<AdminFeedbackStatusResult>("admin_patch_feedback", {
origin,
id,
body: { status },

View File

@@ -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",
);
});

View File

@@ -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<ModerationNavResolution> => {
const saved = await getAdminOrigin(pubkeyHex);

View File

@@ -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) {