Skip to content

Commit d51d970

Browse files
authored
Merge pull request #1072 from DeFiVC/feat/frontend-improvements
feat: SSE reconnection state, list virtualization, wallet availability, stream card memoization
2 parents 177e420 + 95b916c commit d51d970

8 files changed

Lines changed: 240 additions & 168 deletions

File tree

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@stellar/freighter-api": "^6.0.1",
1616
"@stellar/stellar-sdk": "^15.1.0",
1717
"@tanstack/react-query": "^5.101.0",
18+
"@tanstack/react-virtual": "^3.14.9",
1819
"lucide-react": "^0.575.0",
1920
"next": "16.2.9",
2021
"next-themes": "^0.4.6",

frontend/src/components/dashboard/ActivityHistory.tsx

Lines changed: 131 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
"use client";
22

3-
import React from "react";
3+
import React, { useRef } from "react";
44
import Link from "next/link";
5+
import { useVirtualizer } from "@tanstack/react-virtual";
56
import { BackendStreamEvent } from "@/lib/api-types";
67
import { formatAmount } from "@/utils/amount";
78
import TransactionTracker from "@/components/TransactionTracker";
89
import { Download, ExternalLink, Clock } from "lucide-react";
910
import { Button } from "../ui/Button";
1011

12+
const VIRTUALIZATION_THRESHOLD = 50;
13+
1114
interface ActivityHistoryProps {
1215
events: BackendStreamEvent[];
1316
isLoading?: boolean;
@@ -17,6 +20,17 @@ export const ActivityHistory: React.FC<ActivityHistoryProps> = ({
1720
events,
1821
isLoading,
1922
}) => {
23+
const parentRef = useRef<HTMLDivElement>(null);
24+
const shouldVirtualize = events.length > VIRTUALIZATION_THRESHOLD;
25+
26+
const virtualizer = useVirtualizer({
27+
count: events.length,
28+
getScrollElement: () => parentRef.current,
29+
estimateSize: () => 120,
30+
overscan: 5,
31+
enabled: shouldVirtualize,
32+
});
33+
2034
const exportToCSV = () => {
2135
const headers = [
2236
"Stream ID",
@@ -139,55 +153,128 @@ export const ActivityHistory: React.FC<ActivityHistoryProps> = ({
139153
</Button>
140154
</div>
141155

142-
<div className="relative space-y-4 before:absolute before:inset-0 before:ml-5 before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-slate-700 before:to-transparent">
143-
{events.map((event, index) => (
156+
{shouldVirtualize ? (
157+
<div
158+
ref={parentRef}
159+
className="relative h-[600px] overflow-auto before:absolute before:inset-0 before:ml-5 before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-slate-700 before:to-transparent"
160+
>
144161
<div
145-
key={`${event.id}-${index}`}
146-
className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group"
162+
style={{ height: virtualizer.getTotalSize() }}
163+
className="relative w-full"
147164
>
148-
{/* Dot */}
149-
<div className="flex items-center justify-center w-10 h-10 rounded-full border border-slate-700 bg-slate-900 text-accent shadow shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2">
150-
<Clock className="h-5 w-5" />
151-
</div>
152-
{/* Content Card */}
153-
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-2.5rem)] p-4 bg-white/5 border border-glass-border rounded-xl hover:bg-white/10 transition-colors shadow-xl">
154-
<div className="flex flex-col sm:flex-row justify-between items-start mb-2 gap-2">
155-
<div>
156-
<p className="text-white font-medium text-sm sm:text-base">
157-
{renderEventMessage(event)}
158-
</p>
159-
<time className="text-xs text-slate-400 flex items-center gap-1 mt-1">
160-
{new Date(event.timestamp * 1000).toLocaleString()}
161-
</time>
165+
{virtualizer.getVirtualItems().map((virtualRow) => {
166+
const event = events[virtualRow.index];
167+
if (!event) return null;
168+
return (
169+
<div
170+
key={`${event.id}-${virtualRow.index}`}
171+
ref={virtualizer.measureElement}
172+
data-index={virtualRow.index}
173+
style={{
174+
position: "absolute",
175+
top: 0,
176+
left: 0,
177+
width: "100%",
178+
transform: `translateY(${virtualRow.start}px)`,
179+
}}
180+
className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group mb-4"
181+
>
182+
{/* Dot */}
183+
<div className="flex items-center justify-center w-10 h-10 rounded-full border border-slate-700 bg-slate-900 text-accent shadow shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2">
184+
<Clock className="h-5 w-5" />
185+
</div>
186+
{/* Content Card */}
187+
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-2.5rem)] p-4 bg-white/5 border border-glass-border rounded-xl hover:bg-white/10 transition-colors shadow-xl">
188+
<div className="flex flex-col sm:flex-row justify-between items-start mb-2 gap-2">
189+
<div>
190+
<p className="text-white font-medium text-sm sm:text-base">
191+
{renderEventMessage(event)}
192+
</p>
193+
<time className="text-xs text-slate-400 flex items-center gap-1 mt-1">
194+
{new Date(event.timestamp * 1000).toLocaleString()}
195+
</time>
196+
</div>
197+
<span className="text-[10px] uppercase tracking-wider px-2 py-1 rounded bg-accent/10 text-accent font-bold border border-accent/20">
198+
{event.eventType}
199+
</span>
200+
</div>
201+
202+
{event.transactionHash && (
203+
<div className="mt-4 pt-4 border-t border-white/5 flex items-center justify-between">
204+
<TransactionTracker
205+
status="confirmed"
206+
action="withdraw"
207+
txHash={event.transactionHash}
208+
streamId={event.streamId.toString()}
209+
/>
210+
<a
211+
href={`https://stellar.expert/explorer/testnet/tx/${event.transactionHash}`}
212+
target="_blank"
213+
rel="noopener noreferrer"
214+
className="text-slate-500 hover:text-white transition-colors"
215+
title="View on Explorer"
216+
>
217+
<ExternalLink className="h-4 w-4" />
218+
</a>
219+
</div>
220+
)}
221+
</div>
162222
</div>
163-
<span className="text-[10px] uppercase tracking-wider px-2 py-1 rounded bg-accent/10 text-accent font-bold border border-accent/20">
164-
{event.eventType}
165-
</span>
223+
);
224+
})}
225+
</div>
226+
</div>
227+
) : (
228+
<div className="relative space-y-4 before:absolute before:inset-0 before:ml-5 before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-slate-700 before:to-transparent">
229+
{events.map((event, index) => (
230+
<div
231+
key={`${event.id}-${index}`}
232+
className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group"
233+
>
234+
{/* Dot */}
235+
<div className="flex items-center justify-center w-10 h-10 rounded-full border border-slate-700 bg-slate-900 text-accent shadow shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2">
236+
<Clock className="h-5 w-5" />
166237
</div>
167-
168-
{event.transactionHash && (
169-
<div className="mt-4 pt-4 border-t border-white/5 flex items-center justify-between">
170-
<TransactionTracker
171-
status="confirmed"
172-
action="withdraw"
173-
txHash={event.transactionHash}
174-
streamId={event.streamId.toString()}
175-
/>
176-
<a
177-
href={`https://stellar.expert/explorer/testnet/tx/${event.transactionHash}`}
178-
target="_blank"
179-
rel="noopener noreferrer"
180-
className="text-slate-500 hover:text-white transition-colors"
181-
title="View on Explorer"
182-
>
183-
<ExternalLink className="h-4 w-4" />
184-
</a>
238+
{/* Content Card */}
239+
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-2.5rem)] p-4 bg-white/5 border border-glass-border rounded-xl hover:bg-white/10 transition-colors shadow-xl">
240+
<div className="flex flex-col sm:flex-row justify-between items-start mb-2 gap-2">
241+
<div>
242+
<p className="text-white font-medium text-sm sm:text-base">
243+
{renderEventMessage(event)}
244+
</p>
245+
<time className="text-xs text-slate-400 flex items-center gap-1 mt-1">
246+
{new Date(event.timestamp * 1000).toLocaleString()}
247+
</time>
248+
</div>
249+
<span className="text-[10px] uppercase tracking-wider px-2 py-1 rounded bg-accent/10 text-accent font-bold border border-accent/20">
250+
{event.eventType}
251+
</span>
185252
</div>
186-
)}
253+
254+
{event.transactionHash && (
255+
<div className="mt-4 pt-4 border-t border-white/5 flex items-center justify-between">
256+
<TransactionTracker
257+
status="confirmed"
258+
action="withdraw"
259+
txHash={event.transactionHash}
260+
streamId={event.streamId.toString()}
261+
/>
262+
<a
263+
href={`https://stellar.expert/explorer/testnet/tx/${event.transactionHash}`}
264+
target="_blank"
265+
rel="noopener noreferrer"
266+
className="text-slate-500 hover:text-white transition-colors"
267+
title="View on Explorer"
268+
>
269+
<ExternalLink className="h-4 w-4" />
270+
</a>
271+
</div>
272+
)}
273+
</div>
187274
</div>
188-
</div>
189-
))}
190-
</div>
275+
))}
276+
</div>
277+
)}
191278

192279
{events.length === 0 && !isLoading && (
193280
<div className="text-center py-12 text-slate-400 bg-white/5 rounded-xl border border-dashed border-slate-700">

frontend/src/components/dashboard/SSEStatusIndicator.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ export function SSEStatusIndicator({
5858
{/* Disconnect Banner */}
5959
{showDisconnectBanner && isDisconnected && (
6060
<div
61-
className="fixed top-0 left-0 right-0 z-50 bg-red-500/90 text-white p-4 flex items-center justify-between gap-4 animate-in slide-in-from-top"
61+
className={`fixed top-0 left-0 right-0 z-50 ${
62+
isReconnecting
63+
? "bg-yellow-500/90"
64+
: "bg-red-500/90"
65+
} text-white p-4 flex items-center justify-between gap-4 animate-in slide-in-from-top`}
6266
role="alert"
6367
>
6468
<div className="flex items-center gap-3 max-w-2xl">

frontend/src/components/dashboard/dashboard-view.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import toast from "react-hot-toast";
1818
import {
1919
getDashboardAnalytics,
2020
fetchDashboardData,
21-
useDashboard,
2221
dashboardQueryKey,
2322
type DashboardSnapshot,
2423
type Stream,
@@ -48,6 +47,7 @@ import {
4847
type StreamFormData,
4948
} from "../stream-creation/StreamCreationWizard";
5049
import { TopUpModal } from "../stream-creation/TopUpModal";
50+
import { useQueryClient } from "@tanstack/react-query";
5151
import { CancelConfirmModal } from "../stream-creation/CancelConfirmModal";
5252
import { StreamDetailsModal } from "./StreamDetailsModal";
5353
import { Button } from "../ui/Button";

frontend/src/components/streams/IncomingStreamCard.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import React from "react";
34
import { Button } from "@/components/ui/Button";
45
import { useStreamingAmount } from "@/hooks/useStreamingAmount";
56
import type {
@@ -32,7 +33,7 @@ function badgeClassName(status: IncomingStreamStatus): string {
3233
}
3334
}
3435

35-
export function IncomingStreamCard({
36+
export const IncomingStreamCard = React.memo(function IncomingStreamCard({
3637
stream,
3738
withdrawing,
3839
onWithdraw,
@@ -124,4 +125,17 @@ export function IncomingStreamCard({
124125
</div>
125126
</article>
126127
);
127-
}
128+
}, (prevProps, nextProps) => {
129+
return (
130+
prevProps.stream.deposited === nextProps.stream.deposited &&
131+
prevProps.stream.withdrawn === nextProps.stream.withdrawn &&
132+
prevProps.stream.ratePerSecond === nextProps.stream.ratePerSecond &&
133+
prevProps.stream.lastUpdateTime === nextProps.stream.lastUpdateTime &&
134+
prevProps.stream.isActive === nextProps.stream.isActive &&
135+
prevProps.stream.isPaused === nextProps.stream.isPaused &&
136+
prevProps.stream.pausedAt === nextProps.stream.pausedAt &&
137+
prevProps.stream.totalPausedDuration === nextProps.stream.totalPausedDuration &&
138+
prevProps.stream.status === nextProps.stream.status &&
139+
prevProps.withdrawing === nextProps.withdrawing
140+
);
141+
});

frontend/src/components/wallet/WalletButton.tsx

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* - "Disconnect" button
1414
*/
1515

16-
import { useState, useRef, useEffect } from "react";
16+
import { useState, useRef, useEffect, useCallback } from "react";
1717
import { useWallet } from "@/context/wallet-context";
1818
import {
1919
shortenPublicKey,
@@ -27,8 +27,29 @@ export function WalletButton() {
2727
const [modalOpen, setModalOpen] = useState(false);
2828
const [dropdownOpen, setDropdownOpen] = useState(false);
2929
const [copied, setCopied] = useState(false);
30+
const [walletUnavailable, setWalletUnavailable] = useState(false);
3031
const dropdownRef = useRef<HTMLDivElement>(null);
3132

33+
const checkWalletAvailability = useCallback(() => {
34+
if (typeof window === "undefined") return true;
35+
const freighterAvailable =
36+
"freighter" in window || "stellar" in window;
37+
return freighterAvailable;
38+
}, []);
39+
40+
useEffect(() => {
41+
if (status !== "connected") return;
42+
43+
const interval = setInterval(() => {
44+
if (!checkWalletAvailability()) {
45+
setWalletUnavailable(true);
46+
disconnect();
47+
}
48+
}, 5000);
49+
50+
return () => clearInterval(interval);
51+
}, [status, checkWalletAvailability, disconnect]);
52+
3253
// Close dropdown when clicking outside
3354
useEffect(() => {
3455
function handleClickOutside(e: MouseEvent) {
@@ -59,7 +80,11 @@ export function WalletButton() {
5980
const handleDisconnect = () => {
6081
setDropdownOpen(false);
6182
setModalOpen(false);
62-
disconnect();
83+
try {
84+
disconnect();
85+
} catch {
86+
setWalletUnavailable(true);
87+
}
6388
};
6489

6590
// Close modal when connection is successful
@@ -78,6 +103,26 @@ export function WalletButton() {
78103
);
79104
}
80105

106+
if (walletUnavailable) {
107+
return (
108+
<div className="flex items-center gap-2">
109+
<span className="text-xs text-amber-600 dark:text-amber-400">
110+
Wallet extension unavailable
111+
</span>
112+
<button
113+
type="button"
114+
className="wallet-connect-btn"
115+
onClick={() => {
116+
setWalletUnavailable(false);
117+
setModalOpen(true);
118+
}}
119+
>
120+
Reconnect
121+
</button>
122+
</div>
123+
);
124+
}
125+
81126
if (status === "connected" && session) {
82127

83128
const networkOk = isExpectedNetwork(session.network);

frontend/src/lib/dashboard.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -88,21 +88,19 @@ async function fetchStreams(
8888
for (const endpoint of endpoints) {
8989
try {
9090
const response = await fetch(`${endpoint}?${params.toString()}`, { signal });
91-
if (response.ok) {
92-
const payload = (await response.json()) as
93-
| BackendStream[]
94-
| { data?: BackendStream[] };
95-
return Array.isArray(payload) ? payload : payload.data ?? [];
96-
}
97-
98-
if (response.status === 404) {
99-
lastError = new Error(`Endpoint not found: ${endpoint}`);
100-
continue;
101-
}
91+
if (response.ok) {
92+
const payload = (await response.json()) as
93+
| BackendStream[]
94+
| { data?: BackendStream[] };
95+
return Array.isArray(payload) ? payload : payload.data ?? [];
96+
}
10297

103-
lastError = new Error(`Failed to fetch streams (${response.status}) from ${endpoint}`);
104-
}
98+
if (response.status === 404) {
99+
lastError = new Error(`Endpoint not found: ${endpoint}`);
100+
continue;
101+
}
105102

103+
lastError = new Error(`Failed to fetch streams (${response.status}) from ${endpoint}`);
106104
} catch (err) {
107105
if (err instanceof Error && err.name === "AbortError") {
108106
throw err;

0 commit comments

Comments
 (0)