Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 24 additions & 14 deletions src/components/boost/boost-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand All @@ -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 (
<div className="flex flex-col items-start gap-1">
Expand Down
14 changes: 8 additions & 6 deletions src/components/layout/node-preview-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -456,8 +456,6 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
const [fullNode, setFullNode] = useState<GraphNode | null>(null)
const [price, setPrice] = useState<number | null>(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)

Expand All @@ -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) {
Expand Down Expand Up @@ -621,10 +622,11 @@ export function NodePreviewPanel({ node, onBack, schemas }: NodePreviewPanelProp
>
{displayNodeType(nodeType)}
</Badge>
{pubkey && !hideBoost && (
{ownerReference && !hideBoost && (
<div className="ml-auto">
<BoostButton
refId={node.ref_id}
ownerReference={ownerReference}
pubkey={pubkey}
routeHint={routeHint}
boostCount={boostAmt}
Expand Down
9 changes: 7 additions & 2 deletions src/components/layout/node-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ export function NodeRow({
}
if (!name) name = node.ref_id

const ownerReference = typeof props?.owner_reference_id === "string" ? props.owner_reference_id : undefined
// pubkey/routeHint are still read here only for the admin direct-keysend path
// inside BoostButton. After phase-4d these become null and admin boosts fall
// through to /boost (boltwall keysends on the admin's behalf).
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
Expand Down Expand Up @@ -168,18 +172,19 @@ export function NodeRow({
)
)}
</div>
{!hideBoost && pubkey && (
{!hideBoost && ownerReference && (
<div onClick={(e) => e.stopPropagation()} className="shrink-0">
<BoostButton
refId={node.ref_id}
ownerReference={ownerReference}
pubkey={pubkey}
routeHint={routeHint}
boostCount={boostAmt}
className="shrink-0"
/>
</div>
)}
{!hideBoost && !pubkey && boostAmt > 0 && (
{!hideBoost && !ownerReference && boostAmt > 0 && (
<div className="shrink-0 flex items-center gap-1 text-[11px] font-mono text-amber-400">
<Zap className="h-3 w-3" />
<span>{boostAmt}</span>
Expand Down
53 changes: 21 additions & 32 deletions src/lib/__tests__/node-preview-panel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<NodePreviewPanel node={nodeWithPubkey("03abc")} onBack={vi.fn()} schemas={[]} />
)

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(
<NodePreviewPanel node={nodeWithPubkey(compoundPubkey)} onBack={vi.fn()} schemas={[]} />
<NodePreviewPanel node={node} onBack={vi.fn()} schemas={[]} />
)

await waitFor(() => {
Expand All @@ -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(
<NodePreviewPanel node={nodeWithPubkey("03abc")} onBack={vi.fn()} schemas={[]} />
<NodePreviewPanel node={node} onBack={vi.fn()} schemas={[]} />
)

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(
<NodePreviewPanel node={nodeWithPubkey("03abc")} onBack={vi.fn()} schemas={[]} />
<NodePreviewPanel node={node} onBack={vi.fn()} schemas={[]} />
)

await waitFor(() => {
expect(screen.queryByRole("button", { name: /unlock/i })).toBeNull()
})
expect(container.querySelector(".ml-auto")).not.toBeNull()
expect(container.querySelector(".ml-auto")).toBeNull()
})
})

Expand Down
Loading