mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
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:
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 },
|
||||
|
||||
49
desktop/src/features/admin-console/hooks.test.mjs
Normal file
49
desktop/src/features/admin-console/hooks.test.mjs
Normal 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",
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user