diff --git a/README.md b/README.md index 197e72e1..3c8b5fa7 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ Have questions? Want to share ideas or projects? Join the conversation! - **❓ [Ask Questions](https://github.com/flowfi/flowfi/discussions/categories/q-a)** - Get help in GitHub Discussions Q&A - **💡 [Share Ideas](https://github.com/flowfi/flowfi/discussions/categories/ideas)** - Propose features and discuss improvements - **🎪 [Show and Tell](https://github.com/flowfi/flowfi/discussions/categories/show-and-tell)** - Share projects and use cases built with FlowFi -- **📖 [Discussions Guide](DISCUSSIONS.md)** - Learn when to use Discussions vs Issues +- **📖 [Discussions Guide](DISCUSSIONS.md)** - Learn when to use Discussions vs Issues. ## Contributors @@ -293,4 +293,4 @@ This project follows the [all-contributors](https://github.com/all-contributors/ ## License -MIT +MIT. diff --git a/frontend/src/app/streams/[id]/page.tsx b/frontend/src/app/streams/[id]/page.tsx index 8e1e8ab5..a728168c 100644 --- a/frontend/src/app/streams/[id]/page.tsx +++ b/frontend/src/app/streams/[id]/page.tsx @@ -1,19 +1,23 @@ "use client"; +import { useCallback, useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { Loader2 } from "lucide-react"; import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import LiveCounter from "@/components/Livecounter"; import ProgressBar from "@/components/Progressbar"; +import { CancelStreamModal } from "@/components/streams/CancelStreamModal"; import TransactionTracker, { type TransactionStatus, } from "@/components/TransactionTracker"; import { Button } from "@/components/ui/Button"; import toast from "react-hot-toast"; import { useWallet } from "@/context/wallet-context"; +import { useCancelStream } from "@/hooks/useCancelStream"; import { useStreamEvents } from "@/hooks/useStreamEvents"; +import { useWithdrawStream } from "@/hooks/useWithdrawStream"; import { - withdrawFromStream, - cancelStream, topUpStream, pauseStream, resumeStream, @@ -43,7 +47,13 @@ export default function StreamDetailsPage() { const [stream, setStream] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [withdrawing, setWithdrawing] = useState(false); + const [topUpAmount, setTopUpAmount] = useState(""); + const [showTopUp, setShowTopUp] = useState(false); + const [showCancelModal, setShowCancelModal] = useState(false); + const [withdrawTxHash, setWithdrawTxHash] = useState(); + const [withdrawStatus, setWithdrawStatus] = useState("idle"); + const [withdrawError, setWithdrawError] = useState(); + const { cancel: cancelStream, isPending: cancelling } = useCancelStream(); const [cancelling, setCancelling] = useState(false); const [pausing, setPausing] = useState(false); const [resuming, setResuming] = useState(false); @@ -57,6 +67,7 @@ export default function StreamDetailsPage() { const [pauseResumeError, setPauseResumeError] = useState( undefined, ); + const { withdraw: withdrawStream, isPending: withdrawing } = useWithdrawStream(); // SSE integration for real-time stream updates const { events: streamEvents } = useStreamEvents({ @@ -64,31 +75,30 @@ export default function StreamDetailsPage() { autoReconnect: true, }); - useEffect(() => { - if (!isHydrated || !session) { - return; - } - - async function fetchStream() { - try { - const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; - const response = await fetch(`${baseUrl}/v1/streams/${streamId}`); - if (!response.ok) { - throw new Error("Stream not found"); - } - const data = await response.json(); - setStream(data); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to fetch stream"); - } finally { - setLoading(false); + const fetchStream = useCallback(async () => { + try { + const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; + const response = await fetch(`${baseUrl}/v1/streams/${streamId}`); + if (!response.ok) { + throw new Error("Stream not found"); } + const data = await response.json(); + setStream(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch stream"); + } finally { + setLoading(false); } + }, [streamId]); - if (streamId) { - fetchStream(); + useEffect(() => { + if (!isHydrated || !session || !streamId) { + return; } - }, [streamId, session, isHydrated]); + + void fetchStream(); + }, [fetchStream, isHydrated, session, streamId]); // Handle SSE events to update stream state in real-time useEffect(() => { @@ -96,23 +106,9 @@ export default function StreamDetailsPage() { const latestEvent = streamEvents[0]; console.log('Stream event received:', latestEvent); - // Re-fetch stream data to get the latest state from server - async function refetchStream() { - try { - const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"; - const response = await fetch(`${baseUrl}/v1/streams/${streamId}`); - if (response.ok) { - const data = await response.json(); - setStream(data); - } - } catch (err) { - console.error('Failed to refresh stream:', err); - } - } - - refetchStream(); + void fetchStream(); } - }, [streamEvents, streamId]); + }, [fetchStream, streamEvents]); const handleWithdraw = async () => { if (!session) { @@ -120,39 +116,51 @@ export default function StreamDetailsPage() { return; } - setWithdrawing(true); + setWithdrawStatus("idle"); + setWithdrawError(undefined); + try { - await withdrawFromStream(session, { streamId: BigInt(streamId) }); - toast.success("Withdrawal successful!"); - // Refresh stream data - window.location.reload(); + const { txHash } = await withdrawStream(streamId); + setWithdrawTxHash(txHash); + setWithdrawStatus("submitted"); + setStream((current) => + current + ? { + ...current, + withdrawnAmount: current.depositedAmount, + lastUpdateTime: Math.floor(Date.now() / 1000), + } + : current, + ); + toast.success("Withdraw transaction submitted."); + void fetchStream(); } catch (err) { - toast.error(toSorobanErrorMessage(err)); - } finally { - setWithdrawing(false); + const message = err instanceof Error ? err.message : "Failed to withdraw stream."; + setWithdrawError(message); + setWithdrawStatus("failed"); + toast.error(message); } }; - const handleCancel = async () => { + const handleConfirmCancel = async () => { if (!session) { toast.error("Please connect your wallet first"); return; } - if (!confirm("Are you sure you want to cancel this stream?")) { - return; - } - - setCancelling(true); try { - await cancelStream(session, { streamId: BigInt(streamId) }); - toast.success("Stream cancelled successfully!"); - // Refresh stream data - window.location.reload(); + const cancelledStream = await cancelStream(streamId); + setStream((current) => ({ + ...(current ?? cancelledStream), + ...cancelledStream, + status: "CANCELLED", + isActive: false, + })); + setShowCancelModal(false); + toast.success("Stream cancelled successfully."); + void fetchStream(); } catch (err) { - toast.error(toSorobanErrorMessage(err)); - } finally { - setCancelling(false); + toast.error(err instanceof Error ? err.message : "Failed to cancel stream."); } }; @@ -276,7 +284,24 @@ export default function StreamDetailsPage() { const deposited = parseFloat(stream.depositedAmount) / 1e7; const withdrawn = parseFloat(stream.withdrawnAmount) / 1e7; const claimable = deposited - withdrawn; + const displayedClaimable = withdrawStatus === "submitted" ? 0 : claimable; const percentage = Math.round((withdrawn / deposited) * 100); + const normalizedStatus = ( + stream.status || + (stream.isPaused ? "PAUSED" : stream.isActive ? "ACTIVE" : "COMPLETED") + ).toUpperCase(); + const displayStatus = + stream.status || (stream.isPaused ? "PAUSED" : stream.isActive ? "ACTIVE" : "COMPLETED"); + const isConnectedSender = + Boolean(session?.publicKey) && + session?.publicKey.toLowerCase() === stream.sender.toLowerCase(); + const isConnectedRecipient = + Boolean(session?.publicKey) && + session?.publicKey.toLowerCase() === stream.recipient.toLowerCase(); + const canCancelStream = + isConnectedSender && (normalizedStatus === "ACTIVE" || normalizedStatus === "PAUSED"); + const hasClaimableAmount = displayedClaimable > 0; + const canWithdrawStream = isConnectedRecipient && hasClaimableAmount; return (
@@ -295,7 +320,7 @@ export default function StreamDetailsPage() {

- Status: {stream.status} + Status: {displayStatus}

Sender:{" "} @@ -377,6 +402,7 @@ export default function StreamDetailsPage() { isPaused={stream.isPaused} pausedAt={stream.pausedAt} /> +

{/* Actions */} @@ -385,13 +411,22 @@ export default function StreamDetailsPage() {

Actions

- + {canWithdrawStream && ( + + )} + {canCancelStream && ( + + )} {/* Pause button - show for active streams owned by sender */} {stream.isActive && !stream.isPaused && session?.publicKey === stream.sender && (
)} @@ -479,6 +532,13 @@ export default function StreamDetailsPage() { + setShowCancelModal(false)} + onConfirm={() => void handleConfirmCancel()} + />
); -} \ No newline at end of file +} diff --git a/frontend/src/components/streams/CancelStreamModal.tsx b/frontend/src/components/streams/CancelStreamModal.tsx new file mode 100644 index 00000000..65014bc6 --- /dev/null +++ b/frontend/src/components/streams/CancelStreamModal.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { AlertTriangle, X } from "lucide-react"; +import { Button } from "@/components/ui/Button"; + +interface CancelStreamModalProps { + isOpen: boolean; + isCancelling: boolean; + streamId: string; + onClose: () => void; + onConfirm: () => void; +} + +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])', +].join(","); + +export function CancelStreamModal({ + isOpen, + isCancelling, + streamId, + onClose, + onConfirm, +}: CancelStreamModalProps) { + const dialogRef = useRef(null); + const closeButtonRef = useRef(null); + const previouslyFocusedRef = useRef(null); + + useEffect(() => { + if (!isOpen) { + return; + } + + previouslyFocusedRef.current = document.activeElement as HTMLElement | null; + closeButtonRef.current?.focus(); + document.body.style.overflow = "hidden"; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + if (!isCancelling) { + onClose(); + } + return; + } + + if (event.key !== "Tab" || !dialogRef.current) { + return; + } + + const focusableElements = Array.from( + dialogRef.current.querySelectorAll(FOCUSABLE_SELECTOR), + ); + + if (focusableElements.length === 0) { + event.preventDefault(); + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } + + if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = ""; + previouslyFocusedRef.current?.focus(); + }; + }, [isCancelling, isOpen, onClose]); + + if (!isOpen) { + return null; + } + + return ( +
{ + if (event.target === event.currentTarget && !isCancelling) { + onClose(); + } + }} + > +
+
+ +
+

+ Cancel Stream? +

+

+ This submits an irreversible on-chain cancellation for stream #{streamId}. + The stream will stop, and this action cannot be undone. +

+
+ +
+ +
+ Only confirm if you are certain. The backend will submit the cancellation + transaction and the stream status will update after the request succeeds. +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/hooks/useCancelStream.ts b/frontend/src/hooks/useCancelStream.ts new file mode 100644 index 00000000..0a06f495 --- /dev/null +++ b/frontend/src/hooks/useCancelStream.ts @@ -0,0 +1,32 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { cancelStream } from "@/lib/api/streams"; + +interface UseCancelStreamResult { + cancel: (id: string) => Promise; + error: Error | null; + isPending: boolean; +} + +export function useCancelStream(): UseCancelStreamResult { + const [error, setError] = useState(null); + const [isPending, setIsPending] = useState(false); + + const cancel = useCallback(async (id: string) => { + setError(null); + setIsPending(true); + + try { + return await cancelStream(id); + } catch (err) { + const nextError = err instanceof Error ? err : new Error("Failed to cancel stream."); + setError(nextError); + throw nextError; + } finally { + setIsPending(false); + } + }, []); + + return { cancel, error, isPending }; +} diff --git a/frontend/src/hooks/useWithdrawStream.ts b/frontend/src/hooks/useWithdrawStream.ts new file mode 100644 index 00000000..c0617451 --- /dev/null +++ b/frontend/src/hooks/useWithdrawStream.ts @@ -0,0 +1,32 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { withdrawStream, type WithdrawStreamResponse } from "@/lib/api/streams"; + +interface UseWithdrawStreamResult { + withdraw: (id: string) => Promise; + error: Error | null; + isPending: boolean; +} + +export function useWithdrawStream(): UseWithdrawStreamResult { + const [error, setError] = useState(null); + const [isPending, setIsPending] = useState(false); + + const withdraw = useCallback(async (id: string) => { + setError(null); + setIsPending(true); + + try { + return await withdrawStream(id); + } catch (err) { + const nextError = err instanceof Error ? err : new Error("Failed to withdraw stream."); + setError(nextError); + throw nextError; + } finally { + setIsPending(false); + } + }, []); + + return { withdraw, error, isPending }; +} diff --git a/frontend/src/lib/api/streams.ts b/frontend/src/lib/api/streams.ts index f473d2fe..8e1b4492 100644 --- a/frontend/src/lib/api/streams.ts +++ b/frontend/src/lib/api/streams.ts @@ -1,3 +1,110 @@ +const DEFAULT_API_BASE_URL = "http://localhost:3001"; + +function getApiBaseUrl(): string { + return (process.env.NEXT_PUBLIC_API_URL ?? DEFAULT_API_BASE_URL).replace(/\/+$/, ""); +} + +function getStreamActionEndpointCandidates(id: string, action: "cancel" | "withdraw"): string[] { + const baseUrl = getApiBaseUrl(); + const endpoints = new Set(); + + if (baseUrl.endsWith("/api/v1") || baseUrl.endsWith("/v1")) { + endpoints.add(`${baseUrl}/streams/${id}/${action}`); + } else if (baseUrl.endsWith("/api")) { + endpoints.add(`${baseUrl}/v1/streams/${id}/${action}`); + } else { + endpoints.add(`${baseUrl}/api/v1/streams/${id}/${action}`); + endpoints.add(`${baseUrl}/v1/streams/${id}/${action}`); + } + + return [...endpoints]; +} + +async function getErrorMessage(response: Response, action: "cancel" | "withdraw"): Promise { + try { + const body = (await response.json()) as { error?: unknown; message?: unknown }; + + if (typeof body.message === "string" && body.message.trim()) { + return body.message; + } + + if (typeof body.error === "string" && body.error.trim()) { + return body.error; + } + } catch { + // Ignore invalid JSON and fall back to the status-based message. + } + + return `Failed to ${action} stream (${response.status})`; +} + +export async function cancelStream(id: string): Promise { + const endpoints = getStreamActionEndpointCandidates(id, "cancel"); + let notFoundError: Error | null = null; + + for (const endpoint of endpoints) { + const response = await fetch(endpoint, { + method: "POST", + headers: { + Accept: "application/json", + }, + }); + + if (response.ok) { + if (response.status === 204) { + return { id, status: "CANCELLED", isActive: false } as TStream; + } + + return (await response.json()) as TStream; + } + + if (response.status === 404 && endpoints.length > 1) { + notFoundError = new Error(`Endpoint not found: ${endpoint}`); + continue; + } + + throw new Error(await getErrorMessage(response, "cancel")); + } + + throw notFoundError ?? new Error("Failed to cancel stream."); +} + +export interface WithdrawStreamResponse { + txHash: string; +} + +export async function withdrawStream(id: string): Promise { + const endpoints = getStreamActionEndpointCandidates(id, "withdraw"); + let notFoundError: Error | null = null; + + for (const endpoint of endpoints) { + const response = await fetch(endpoint, { + method: "POST", + headers: { + Accept: "application/json", + }, + }); + + if (response.ok) { + const body = (await response.json()) as { txHash?: unknown; transactionHash?: unknown }; + const txHash = typeof body.txHash === "string" ? body.txHash : body.transactionHash; + + if (typeof txHash === "string" && txHash.trim()) { + return { txHash }; + } + + throw new Error("Withdraw response did not include a transaction hash."); + } + + if (response.status === 404 && endpoints.length > 1) { + notFoundError = new Error(`Endpoint not found: ${endpoint}`); + continue; + } + + throw new Error(await getErrorMessage(response, "withdraw")); + } + + throw notFoundError ?? new Error("Failed to withdraw stream."); import type { BackendStream } from "@/lib/api-types"; import { TOKEN_ADDRESSES } from "@/lib/soroban"; import { shortenPublicKey } from "@/lib/wallet";