diff --git a/frontend/package.json b/frontend/package.json index 577e543b..44c32af1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^15.1.0", "@tanstack/react-query": "^5.101.0", + "@tanstack/react-virtual": "^3.14.9", "lucide-react": "^0.575.0", "next": "16.2.9", "next-themes": "^0.4.6", diff --git a/frontend/src/components/dashboard/ActivityHistory.tsx b/frontend/src/components/dashboard/ActivityHistory.tsx index 31664345..8c1d36b3 100644 --- a/frontend/src/components/dashboard/ActivityHistory.tsx +++ b/frontend/src/components/dashboard/ActivityHistory.tsx @@ -1,13 +1,16 @@ "use client"; -import React from "react"; +import React, { useRef } from "react"; import Link from "next/link"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { BackendStreamEvent } from "@/lib/api-types"; import { formatAmount } from "@/lib/amount"; import TransactionTracker from "@/components/TransactionTracker"; import { Download, ExternalLink, Clock } from "lucide-react"; import { Button } from "../ui/Button"; +const VIRTUALIZATION_THRESHOLD = 50; + interface ActivityHistoryProps { events: BackendStreamEvent[]; isLoading?: boolean; @@ -17,6 +20,17 @@ export const ActivityHistory: React.FC = ({ events, isLoading, }) => { + const parentRef = useRef(null); + const shouldVirtualize = events.length > VIRTUALIZATION_THRESHOLD; + + const virtualizer = useVirtualizer({ + count: events.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 120, + overscan: 5, + enabled: shouldVirtualize, + }); + const exportToCSV = () => { const headers = [ "Stream ID", @@ -139,55 +153,128 @@ export const ActivityHistory: React.FC = ({ -
- {events.map((event, index) => ( + {shouldVirtualize ? ( +
- {/* Dot */} -
- -
- {/* Content Card */} -
-
-
-

- {renderEventMessage(event)} -

- + {virtualizer.getVirtualItems().map((virtualRow) => { + const event = events[virtualRow.index]; + if (!event) return null; + return ( +
+ {/* Dot */} +
+ +
+ {/* Content Card */} +
+
+
+

+ {renderEventMessage(event)} +

+ +
+ + {event.eventType} + +
+ + {event.transactionHash && ( +
+ + + + +
+ )} +
- - {event.eventType} - + ); + })} +
+
+ ) : ( +
+ {events.map((event, index) => ( +
+ {/* Dot */} +
+
- - {event.transactionHash && ( -
- - - - + {/* Content Card */} +
+
+
+

+ {renderEventMessage(event)} +

+ +
+ + {event.eventType} +
- )} + + {event.transactionHash && ( +
+ + + + +
+ )} +
-
- ))} -
+ ))} +
+ )} {events.length === 0 && !isLoading && (
diff --git a/frontend/src/components/dashboard/SSEStatusIndicator.tsx b/frontend/src/components/dashboard/SSEStatusIndicator.tsx index 41f95613..5e57cfe6 100644 --- a/frontend/src/components/dashboard/SSEStatusIndicator.tsx +++ b/frontend/src/components/dashboard/SSEStatusIndicator.tsx @@ -58,7 +58,11 @@ export function SSEStatusIndicator({ {/* Disconnect Banner */} {showDisconnectBanner && isDisconnected && (
diff --git a/frontend/src/components/dashboard/dashboard-view.tsx b/frontend/src/components/dashboard/dashboard-view.tsx index 34568f84..6a0c252c 100644 --- a/frontend/src/components/dashboard/dashboard-view.tsx +++ b/frontend/src/components/dashboard/dashboard-view.tsx @@ -18,7 +18,6 @@ import toast from "react-hot-toast"; import { getDashboardAnalytics, fetchDashboardData, - useDashboard, dashboardQueryKey, type DashboardSnapshot, type Stream, @@ -48,6 +47,7 @@ import { type StreamFormData, } from "../stream-creation/StreamCreationWizard"; import { TopUpModal } from "../stream-creation/TopUpModal"; +import { useQueryClient } from "@tanstack/react-query"; import { CancelConfirmModal } from "../stream-creation/CancelConfirmModal"; import { StreamDetailsModal } from "./StreamDetailsModal"; import { Button } from "../ui/Button"; diff --git a/frontend/src/components/streams/IncomingStreamCard.tsx b/frontend/src/components/streams/IncomingStreamCard.tsx index bc167934..a5ca3e7d 100644 --- a/frontend/src/components/streams/IncomingStreamCard.tsx +++ b/frontend/src/components/streams/IncomingStreamCard.tsx @@ -1,5 +1,6 @@ "use client"; +import React from "react"; import { Button } from "@/components/ui/Button"; import { useStreamingAmount } from "@/hooks/useStreamingAmount"; import type { @@ -32,7 +33,7 @@ function badgeClassName(status: IncomingStreamStatus): string { } } -export function IncomingStreamCard({ +export const IncomingStreamCard = React.memo(function IncomingStreamCard({ stream, withdrawing, onWithdraw, @@ -124,4 +125,17 @@ export function IncomingStreamCard({
); -} +}, (prevProps, nextProps) => { + return ( + prevProps.stream.deposited === nextProps.stream.deposited && + prevProps.stream.withdrawn === nextProps.stream.withdrawn && + prevProps.stream.ratePerSecond === nextProps.stream.ratePerSecond && + prevProps.stream.lastUpdateTime === nextProps.stream.lastUpdateTime && + prevProps.stream.isActive === nextProps.stream.isActive && + prevProps.stream.isPaused === nextProps.stream.isPaused && + prevProps.stream.pausedAt === nextProps.stream.pausedAt && + prevProps.stream.totalPausedDuration === nextProps.stream.totalPausedDuration && + prevProps.stream.status === nextProps.stream.status && + prevProps.withdrawing === nextProps.withdrawing + ); +}); diff --git a/frontend/src/components/wallet/WalletButton.tsx b/frontend/src/components/wallet/WalletButton.tsx index ca78cc96..0109cab6 100644 --- a/frontend/src/components/wallet/WalletButton.tsx +++ b/frontend/src/components/wallet/WalletButton.tsx @@ -13,7 +13,7 @@ * - "Disconnect" button */ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; import { useWallet } from "@/context/wallet-context"; import { shortenPublicKey, @@ -27,8 +27,29 @@ export function WalletButton() { const [modalOpen, setModalOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); const [copied, setCopied] = useState(false); + const [walletUnavailable, setWalletUnavailable] = useState(false); const dropdownRef = useRef(null); + const checkWalletAvailability = useCallback(() => { + if (typeof window === "undefined") return true; + const freighterAvailable = + "freighter" in window || "stellar" in window; + return freighterAvailable; + }, []); + + useEffect(() => { + if (status !== "connected") return; + + const interval = setInterval(() => { + if (!checkWalletAvailability()) { + setWalletUnavailable(true); + disconnect(); + } + }, 5000); + + return () => clearInterval(interval); + }, [status, checkWalletAvailability, disconnect]); + // Close dropdown when clicking outside useEffect(() => { function handleClickOutside(e: MouseEvent) { @@ -59,7 +80,11 @@ export function WalletButton() { const handleDisconnect = () => { setDropdownOpen(false); setModalOpen(false); - disconnect(); + try { + disconnect(); + } catch { + setWalletUnavailable(true); + } }; // Close modal when connection is successful @@ -78,6 +103,26 @@ export function WalletButton() { ); } + if (walletUnavailable) { + return ( +
+ + Wallet extension unavailable + + +
+ ); + } + if (status === "connected" && session) { const networkOk = isExpectedNetwork(session.network); diff --git a/frontend/src/lib/dashboard.ts b/frontend/src/lib/dashboard.ts index 39a8814f..303f1289 100644 --- a/frontend/src/lib/dashboard.ts +++ b/frontend/src/lib/dashboard.ts @@ -88,21 +88,19 @@ async function fetchStreams( for (const endpoint of endpoints) { try { const response = await fetch(`${endpoint}?${params.toString()}`, { signal }); - if (response.ok) { - const payload = (await response.json()) as - | BackendStream[] - | { data?: BackendStream[] }; - return Array.isArray(payload) ? payload : payload.data ?? []; - } - - if (response.status === 404) { - lastError = new Error(`Endpoint not found: ${endpoint}`); - continue; - } + if (response.ok) { + const payload = (await response.json()) as + | BackendStream[] + | { data?: BackendStream[] }; + return Array.isArray(payload) ? payload : payload.data ?? []; + } - lastError = new Error(`Failed to fetch streams (${response.status}) from ${endpoint}`); - } + if (response.status === 404) { + lastError = new Error(`Endpoint not found: ${endpoint}`); + continue; + } + lastError = new Error(`Failed to fetch streams (${response.status}) from ${endpoint}`); } catch (err) { if (err instanceof Error && err.name === "AbortError") { throw err; diff --git a/package-lock.json b/package-lock.json index 6f78cc24..e88d10c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -235,6 +235,7 @@ "@stellar/freighter-api": "^6.0.1", "@stellar/stellar-sdk": "^15.1.0", "@tanstack/react-query": "^5.101.0", + "@tanstack/react-virtual": "^3.14.9", "lucide-react": "^0.575.0", "next": "16.2.9", "next-themes": "^0.4.6", @@ -2398,9 +2399,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2417,9 +2415,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2436,9 +2431,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2455,9 +2447,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2896,9 +2885,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2917,9 +2903,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2938,9 +2921,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2959,9 +2939,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2980,9 +2957,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3001,9 +2975,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3207,9 +3178,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3224,9 +3192,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3241,9 +3206,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3258,9 +3220,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3275,9 +3234,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3292,9 +3248,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3309,9 +3262,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3326,9 +3276,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3343,9 +3290,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3360,9 +3304,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3377,9 +3318,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3394,9 +3332,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3411,9 +3346,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3755,9 +3687,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3779,9 +3708,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3803,9 +3729,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3976,9 +3899,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3996,9 +3916,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4015,9 +3932,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4035,9 +3949,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4217,6 +4128,33 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", + "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", + "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -9722,9 +9660,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9747,9 +9682,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9771,9 +9703,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9795,9 +9724,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9886,9 +9812,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [