Skip to content
Open
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
95 changes: 81 additions & 14 deletions src/app/issues/[id]/IssueActions.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
"use client";

import { useState } from "react";
import { useState, useCallback, useRef } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { useAuth } from "@/context/AuthContext";
import { useWallet } from "@/context/WalletContext";
import { apiPost, ApiRequestError } from "@/lib/api";
import type { Bounty } from "@/types";
import { apiPost, apiRequest, ApiRequestError } from "@/lib/api";
import { useSmartPolling } from "@/hooks/useSmartPolling";
import type { Bounty, BountyStatus } from "@/types";

/**
* Claim-race detection (Issue #46):
* When a claim attempt fails because another user claimed first, the backend
* returns a 409 Conflict or a message containing "already claimed". We detect
* this specifically and show a distinct UI rather than a generic error.
*
* If the backend doesn't distinguish this case yet, this code treats any 409
* or message matching /already.?claimed|claim.*race/i as a race loss.
* Backend contract note: ideally return { code: "CLAIM_RACE_LOST", status: "claimed", claimedBy: "..." }
*/
function isClaimRaceLoss(err: unknown): boolean {
if (err instanceof ApiRequestError) {
if (err.status === 409) return true;
if (/already.?claimed|claim.*race|already.*taken/i.test(err.message)) return true;
}
return false;
}

export function IssueActions({ bounty }: { bounty: Bounty }) {
const router = useRouter();
Expand All @@ -15,10 +34,40 @@ export function IssueActions({ bounty }: { bounty: Bounty }) {
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [raceLost, setRaceLost] = useState(false);
const [currentStatus, setCurrentStatus] = useState<BountyStatus>(bounty.status);
const statusRef = useRef(bounty.status);

// Smart polling: re-fetch bounty status every 5s when visible, backing off
// if unchanged. This keeps the claim button state accurate without manual refresh.
const fetchStatus = useCallback(async () => {
try {
const updated = await apiRequest<{ status: BountyStatus; claimedBy?: string | null }>(
`/bounties/${bounty.id}/status`,
);
if (updated.status !== statusRef.current) {
statusRef.current = updated.status;
setCurrentStatus(updated.status);
signalChange();
} else {
signalNoChange();
}
} catch {
// Silently ignore polling errors — don't disrupt the UI
}
}, [bounty.id]);

const { signalChange, signalNoChange } = useSmartPolling(fetchStatus, {
interval: 5000,
maxBackoff: 12,
// Only poll when viewing an actionable bounty state
enabled: ["open", "funded", "claimed"].includes(currentStatus),
});

async function withWallet(action: (walletAddress: string) => Promise<void>) {
setError(null);
setNotice(null);
setRaceLost(false);
setPending(true);
try {
const walletAddress = address ?? (await connect());
Expand All @@ -45,6 +94,7 @@ export function IssueActions({ bounty }: { bounty: Bounty }) {
async function handleClaim() {
setError(null);
setNotice(null);
setRaceLost(false);
if (!user) {
router.push("/connect");
return;
Expand All @@ -55,7 +105,13 @@ export function IssueActions({ bounty }: { bounty: Bounty }) {
setNotice("You've claimed this issue. Open a pull request to get started.");
router.refresh();
} catch (err) {
setError(err instanceof ApiRequestError ? err.message : "Something went wrong.");
if (isClaimRaceLoss(err)) {
setRaceLost(true);
// Force an immediate status refresh to show the new claimant
await fetchStatus();
} else {
setError(err instanceof ApiRequestError ? err.message : "Something went wrong.");
}
} finally {
setPending(false);
}
Expand All @@ -76,42 +132,53 @@ export function IssueActions({ bounty }: { bounty: Bounty }) {
}
}

// Use polled status for rendering decisions, fall back to prop
const displayStatus = currentStatus;

return (
<div className="mt-10">
<div className="flex flex-wrap gap-3">
{bounty.status === "open" && (
{displayStatus === "open" && (
<Button size="lg" onClick={handleFund} disabled={pending || connecting}>
{pending || connecting ? "Confirming in wallet..." : "Fund this bounty"}
</Button>
)}
{bounty.status === "funded" && (
{displayStatus === "funded" && (
<Button size="lg" onClick={handleClaim} disabled={pending}>
{pending ? "Claiming..." : "Claim this issue"}
</Button>
)}
{(bounty.status === "funded" || bounty.status === "claimed") && (
{(displayStatus === "funded" || displayStatus === "claimed") && (
<Button size="lg" variant="outline" onClick={handleRefund} disabled={pending}>
Refund sponsor
</Button>
)}
{["in_review", "merged", "paid", "refunded", "expired"].includes(
bounty.status,
) && (
{["in_review", "merged", "paid", "refunded", "expired"].includes(displayStatus) && (
<Button size="lg" variant="outline" disabled>
{bounty.status === "paid"
{displayStatus === "paid"
? "Payout complete"
: bounty.status === "in_review"
: displayStatus === "in_review"
? "Awaiting PR merge"
: "No action available"}
</Button>
)}
</div>

{/* Claim-race-specific messaging — distinct from generic errors */}
{raceLost && (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-300">
<strong>Someone else claimed this bounty first.</strong> The status has been updated.
You can look for other open bounties to claim.
</div>
)}

{notice && <p className="mt-3 text-sm text-emerald-600 dark:text-emerald-400">{notice}</p>}
{error && <p className="mt-3 text-sm text-rose-600">{error}</p>}
{error && !raceLost && <p className="mt-3 text-sm text-rose-600">{error}</p>}
<p className="mt-3 text-xs text-slate-400 dark:text-slate-500">
Funding and claiming write to the live mergefi-backend API. Merge
detection and payout release happen automatically via GitHub
webhooks once a linked pull request is merged.
webhooks once a linked pull request is merged. Status auto-refreshes
while this page is active.
</p>
</div>
);
Expand Down
98 changes: 98 additions & 0 deletions src/hooks/useSmartPolling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"use client";

import { useEffect, useRef, useCallback } from "react";

interface SmartPollingOptions {
/** Polling interval in ms when tab is visible and data is changing */
interval: number;
/** Maximum backoff multiplier when data hasn't changed (default: 8x interval) */
maxBackoff?: number;
/** Whether polling is enabled */
enabled?: boolean;
}

/**
* Tab-aware smart polling hook with exponential backoff.
* - Pauses when tab is backgrounded (visibility API)
* - Backs off exponentially when consecutive polls return unchanged data
* - Resets to base interval when data changes or tab regains focus
*
* This is the interim solution until backend websocket events are available
* (per roadmap: "Real-time bounty/escrow status via websockets or polling
* once the backend emits webhook-driven events" is future work).
*/
export function useSmartPolling(
fetcher: () => Promise<void>,
options: SmartPollingOptions,
) {
const { interval, maxBackoff = 8, enabled = true } = options;
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const backoffRef = useRef(1);
const isVisibleRef = useRef(true);

const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);

const scheduleNext = useCallback(() => {
if (!enabled || !isVisibleRef.current) return;
const delay = Math.min(interval * backoffRef.current, interval * maxBackoff);
timerRef.current = setTimeout(async () => {
try {
await fetcher();
} catch {
// On error, don't increase backoff — retry at current rate
}
scheduleNext();
}, delay);
}, [fetcher, interval, maxBackoff, enabled]);

/** Signal that data has changed — resets backoff to 1x */
const signalChange = useCallback(() => {
backoffRef.current = 1;
clearTimer();
scheduleNext();
}, [clearTimer, scheduleNext]);

/** Signal that data was unchanged — increases backoff */
const signalNoChange = useCallback(() => {
backoffRef.current = Math.min(backoffRef.current * 2, maxBackoff);
}, [maxBackoff]);

useEffect(() => {
if (!enabled) {
clearTimer();
return;
}

const handleVisibility = () => {
isVisibleRef.current = document.visibilityState === "visible";
if (isVisibleRef.current) {
// Tab regained focus — reset backoff and restart polling
backoffRef.current = 1;
clearTimer();
scheduleNext();
} else {
// Tab backgrounded — pause polling
clearTimer();
}
};

document.addEventListener("visibilitychange", handleVisibility);

// Start polling if visible
if (document.visibilityState === "visible") {
scheduleNext();
}

return () => {
document.removeEventListener("visibilitychange", handleVisibility);
clearTimer();
};
}, [enabled, scheduleNext, clearTimer]);

return { signalChange, signalNoChange };
}