From 6c29e1fad0cc00469c790b60f80846be754e41e7 Mon Sep 17 00:00:00 2001 From: Rassl Date: Fri, 8 May 2026 03:29:57 +0400 Subject: [PATCH] feat: boost anon --- src/components/boost/boost-button.tsx | 38 ++++++++----- src/components/layout/node-preview-panel.tsx | 14 ++--- src/components/layout/node-row.tsx | 9 +++- src/lib/__tests__/node-preview-panel.test.tsx | 53 ++++++++----------- 4 files changed, 60 insertions(+), 54 deletions(-) diff --git a/src/components/boost/boost-button.tsx b/src/components/boost/boost-button.tsx index 557487c..ee362c2 100644 --- a/src/components/boost/boost-button.tsx +++ b/src/components/boost/boost-button.tsx @@ -13,13 +13,23 @@ const DEFAULT_BOOST_AMOUNT = 10 interface BoostButtonProps { refId: string - pubkey: string + ownerReference: string + /** Optional — only used for the admin direct-keysend path. After phase-4d this + * field disappears from the API; admin boosts then fall through to /boost. */ + pubkey?: string routeHint?: string boostCount?: number className?: string } -export function BoostButton({ refId, pubkey, routeHint, boostCount = 0, className }: BoostButtonProps) { +export function BoostButton({ + refId, + ownerReference, + pubkey, + routeHint, + boostCount = 0, + className, +}: BoostButtonProps) { const [count, setCount] = useState(boostCount) const [boosting, setBoosting] = useState(false) const [flash, setFlash] = useState(false) @@ -34,26 +44,26 @@ export function BoostButton({ refId, pubkey, routeHint, boostCount = 0, classNam setBoosting(true) setError(null) - // Prefer separate pubkey + route_hint props (new split storage); - // fall back to parsing a compound pubkey for legacy callers. - const dest = routeHint ? { pubkey, route_hint: routeHint } : parsePubkeyWithHint(pubkey) - console.log("[boost] parsed dest:", dest) - try { if (!isMocksEnabled()) { - if (isAdmin && isSphinx()) { - // Admin path: pay directly from Sphinx wallet, then record + const adminDirect = isAdmin && isSphinx() && !!pubkey + if (adminDirect) { + // Admin path: pay directly from Sphinx wallet, then record. Requires a + // real pubkey — when pubkey is unavailable (post-4d), falls through to + // the /boost path below where boltwall keysends from its own node. + const dest = routeHint ? { pubkey, route_hint: routeHint } : parsePubkeyWithHint(pubkey!) await adminKeysend(dest.pubkey, DEFAULT_BOOST_AMOUNT, dest.route_hint) await api.post("/boost/record", { refid: refId, amount: DEFAULT_BOOST_AMOUNT, ...dest }) } else { - // Regular user path: L402-gated boost + // Regular path: L402-gated boost. Server resolves contributor identity + // (keysend vs anon-credit) from the owner_reference_id. + const body = { refid: refId, amount: DEFAULT_BOOST_AMOUNT, owner_reference_id: ownerReference } try { - await api.post("/boost", { refid: refId, amount: DEFAULT_BOOST_AMOUNT, ...dest }) + await api.post("/boost", body) } catch (err) { - // 402 = insufficient LSAT balance — buy/top-up and retry if (err instanceof Response && err.status === 402) { await payL402(setBudget) - await api.post("/boost", { refid: refId, amount: DEFAULT_BOOST_AMOUNT, ...dest }) + await api.post("/boost", body) } else { throw err } @@ -71,7 +81,7 @@ export function BoostButton({ refId, pubkey, routeHint, boostCount = 0, classNam } finally { setBoosting(false) } - }, [refId, pubkey, routeHint, boosting, isAdmin, setBudget, refreshBalance]) + }, [refId, ownerReference, pubkey, routeHint, boosting, isAdmin, setBudget, refreshBalance]) return (
diff --git a/src/components/layout/node-preview-panel.tsx b/src/components/layout/node-preview-panel.tsx index f4dd233..994dc54 100644 --- a/src/components/layout/node-preview-panel.tsx +++ b/src/components/layout/node-preview-panel.tsx @@ -456,8 +456,6 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp const [fullNode, setFullNode] = useState(null) const [price, setPrice] = useState(null) const refreshBalance = useUserStore((s) => s.refreshBalance) - const userPubKey = useUserStore((s) => s.pubKey) - const userRouteHint = useUserStore((s) => s.routeHint) const isAdmin = useUserStore((s) => s.isAdmin) const openModal = useModalStore((s) => s.open) @@ -467,13 +465,16 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp const { icon: PlaceholderIcon, accent: schemaAccent } = getSchemaIconInfo(schema?.icon) const props = node.properties const nodeIsBlocked = isBlockedStatus(props?.status) + const ownerReference = typeof props?.owner_reference_id === "string" ? props.owner_reference_id : undefined + // Legacy pubkey/route_hint for the admin direct-keysend path; phase-4d removes them. const pubkey = typeof props?.pubkey === "string" ? props.pubkey : undefined const routeHint = typeof props?.route_hint === "string" ? props.route_hint : undefined const boostAmt = typeof props?.boost === "number" ? props.boost : 0 - const userFullPubkey = userPubKey && userRouteHint ? `${userPubKey}_${userRouteHint}` : userPubKey - const isContributor = !!pubkey && pubkey === userFullPubkey - const hideBoost = isAdmin || isContributor + // Self-boost detection moved server-side: caller's L402 isn't known to the + // frontend, so /boost rejects with SELF_BOOST when caller equals contributor. + // Admin still hides locally to match the existing UX. + const hideBoost = isAdmin let title = pickString(props, schema?.title_key) ?? pickString(props, schema?.index) if (!title) { @@ -621,10 +622,11 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp > {displayNodeType(nodeType)} - {pubkey && !hideBoost && ( + {ownerReference && !hideBoost && (
- {!hideBoost && pubkey && ( + {!hideBoost && ownerReference && (
e.stopPropagation()} className="shrink-0">
)} - {!hideBoost && !pubkey && boostAmt > 0 && ( + {!hideBoost && !ownerReference && boostAmt > 0 && (
{boostAmt} diff --git a/src/lib/__tests__/node-preview-panel.test.tsx b/src/lib/__tests__/node-preview-panel.test.tsx index 64ff111..31fca5a 100644 --- a/src/lib/__tests__/node-preview-panel.test.tsx +++ b/src/lib/__tests__/node-preview-panel.test.tsx @@ -256,38 +256,25 @@ describe("NodePreviewPanel – boost visibility", () => { beforeEach(() => { vi.clearAllMocks() userStoreOverrides = {} - // Default: api probe returns 200 so BoostButton area is reached - mockApiGet.mockResolvedValue({ nodes: [{ ref_id: "abc", node_type: "Topic", properties: { name: "Test Node", pubkey: "03abc" } }], edges: [] }) }) - const nodeWithPubkey = (pubkey: string): GraphNode => ({ + const nodeWithOwner = (ownerReferenceId: string): GraphNode => ({ ref_id: "abc", node_type: "Topic", - properties: { name: "Test Node", pubkey }, + properties: { name: "Test Node", owner_reference_id: ownerReferenceId }, }) - it("hides BoostButton when bare pubkey matches user pubKey (contributor)", async () => { - userStoreOverrides = { pubKey: "03abc", routeHint: "", isAdmin: false } - mockApiGet.mockResolvedValue(makeGraphData(nodeWithPubkey("03abc"))) + // Note: phase-4b moves self-boost detection server-side. The frontend no + // longer hides BoostButton when the viewer matches the contributor — /boost + // returns SELF_BOOST instead. Tests below cover the remaining client gates. - const { container } = render( - - ) - - await waitFor(() => { - expect(screen.queryByRole("button", { name: /unlock/i })).toBeNull() - }) - // BoostButton mock renders null, but its parent div should not be in DOM - expect(container.querySelector(".ml-auto")).toBeNull() - }) - - it("hides BoostButton when compound pubkey matches user pubKey_routeHint (contributor)", async () => { - userStoreOverrides = { pubKey: "03abc", routeHint: "02xyz_123456", isAdmin: false } - const compoundPubkey = "03abc_02xyz_123456" - mockApiGet.mockResolvedValue(makeGraphData(nodeWithPubkey(compoundPubkey))) + it("hides BoostButton when isAdmin is true", async () => { + userStoreOverrides = { pubKey: "03other", routeHint: "", isAdmin: true } + const node = nodeWithOwner("lsat:11111111-1111-1111-1111-111111111111") + mockApiGet.mockResolvedValue(makeGraphData(node)) const { container } = render( - + ) await waitFor(() => { @@ -296,32 +283,34 @@ describe("NodePreviewPanel – boost visibility", () => { expect(container.querySelector(".ml-auto")).toBeNull() }) - it("hides BoostButton when isAdmin is true regardless of pubkey", async () => { - userStoreOverrides = { pubKey: "03other", routeHint: "", isAdmin: true } - mockApiGet.mockResolvedValue(makeGraphData(nodeWithPubkey("03abc"))) + it("renders BoostButton wrapper when node has owner_reference_id and viewer is not admin", async () => { + userStoreOverrides = { pubKey: "03other", routeHint: "", isAdmin: false } + const node = nodeWithOwner("lsat:11111111-1111-1111-1111-111111111111") + mockApiGet.mockResolvedValue(makeGraphData(node)) const { container } = render( - + ) await waitFor(() => { expect(screen.queryByRole("button", { name: /unlock/i })).toBeNull() }) - expect(container.querySelector(".ml-auto")).toBeNull() + expect(container.querySelector(".ml-auto")).not.toBeNull() }) - it("renders BoostButton wrapper when non-owner non-admin views a pubkey node", async () => { + it("does not render BoostButton wrapper when node has no owner_reference_id", async () => { userStoreOverrides = { pubKey: "03other", routeHint: "", isAdmin: false } - mockApiGet.mockResolvedValue(makeGraphData(nodeWithPubkey("03abc"))) + const node: GraphNode = { ref_id: "abc", node_type: "Topic", properties: { name: "Test Node" } } + mockApiGet.mockResolvedValue(makeGraphData(node)) const { container } = render( - + ) await waitFor(() => { expect(screen.queryByRole("button", { name: /unlock/i })).toBeNull() }) - expect(container.querySelector(".ml-auto")).not.toBeNull() + expect(container.querySelector(".ml-auto")).toBeNull() }) })