diff --git a/frontend/components/swap-transfer-tracker/index.tsx b/frontend/components/swap-transfer-tracker/index.tsx index a01b0ec..5e3ab0a 100644 --- a/frontend/components/swap-transfer-tracker/index.tsx +++ b/frontend/components/swap-transfer-tracker/index.tsx @@ -1,6 +1,7 @@ 'use client' -import { ArrowLeftRight, Send } from 'lucide-react' +import { ArrowLeftRight, Info, Pause, Play, Send } from 'lucide-react' +import { useState } from 'react' import { LiveBadge } from '@/components/common/live-badge' import { SectionHeader } from '@/components/ui/section-header' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' @@ -26,13 +27,32 @@ export default function SwapTransferTracker() { isConnected: isTransferConnected, cumulativeTransferred, } = useTransferEvents() + const [isFollowingData, setIsFollowingData] = useState(true) return (
+ > + +
@@ -52,14 +72,12 @@ export default function SwapTransferTracker() {
- + @@ -67,10 +85,18 @@ export default function SwapTransferTracker() { value="swaps" className="mt-0 overflow-x-auto scrollbar-none" > - +
+
+ + Hover to pause +
) } diff --git a/frontend/components/swap-transfer-tracker/swap-row.tsx b/frontend/components/swap-transfer-tracker/swap-row.tsx index de42432..58bf958 100644 --- a/frontend/components/swap-transfer-tracker/swap-row.tsx +++ b/frontend/components/swap-transfer-tracker/swap-row.tsx @@ -1,6 +1,5 @@ 'use client' -import { motion } from 'framer-motion' import { ExternalLink } from '@/components/ui/external-link' import { EXPLORER_URL } from '@/constants/common' import { getSwapProviderConfig } from '@/constants/swap-provider-config' @@ -19,11 +18,7 @@ export function SwapRow({ swap, gridClass }: SwapRowProps) { const config = getSwapProviderConfig(swap.provider) return ( - {formatTimeDisplay(swap.timestamp)} - + ) } diff --git a/frontend/components/swap-transfer-tracker/swaps.tsx b/frontend/components/swap-transfer-tracker/swaps.tsx index 27f0937..38c5064 100644 --- a/frontend/components/swap-transfer-tracker/swaps.tsx +++ b/frontend/components/swap-transfer-tracker/swaps.tsx @@ -1,6 +1,9 @@ 'use client' -import { AnimatePresence } from 'framer-motion' +import type { ReactElement } from 'react' +import { useState } from 'react' +import { List, type RowComponentProps } from 'react-window' +import { useVirtualizedList } from '@/hooks/use-virtualized-list' import { cn } from '@/lib/utils' import type { SwapData } from '@/types/swap' import { SwapRow } from './swap-row' @@ -8,11 +11,44 @@ import { SwapRow } from './swap-row' interface SwapsProps { data: SwapData[] isLoading: boolean + isFollowingData: boolean } const TABLE_GRID = 'grid grid-cols-6 gap-6 px-4' +const ROW_HEIGHT = 45 + +interface SwapRowData { + dataRef: React.RefObject + gridClass: string +} + +// Cell component defined outside to maintain stable reference +function SwapCell({ + index, + style, + dataRef, + gridClass, +}: RowComponentProps): ReactElement { + const swap = dataRef.current?.[index] + + return ( +
+ {swap && } +
+ ) +} + +export function Swaps({ data, isLoading, isFollowingData }: SwapsProps) { + const [isHovering, setIsHovering] = useState(false) + const isFollowing = isFollowingData && !isHovering + + const { containerRef, listRef, containerHeight, displayedData, rowProps } = + useVirtualizedList({ + data, + isFollowing, + gridClass: TABLE_GRID, + }) -export function Swaps({ data, isLoading }: SwapsProps) { return (
Time
-
- {data.length === 0 ? ( +
setIsHovering(true)} + onPointerLeave={() => setIsHovering(false)} + > + {displayedData.length === 0 ? (

{isLoading ? 'Waiting for events...' : 'No swaps yet'}

) : ( - - {data.map((swap) => ( - - ))} - + )}
diff --git a/frontend/components/swap-transfer-tracker/transfer-row.tsx b/frontend/components/swap-transfer-tracker/transfer-row.tsx index a726280..1683ace 100644 --- a/frontend/components/swap-transfer-tracker/transfer-row.tsx +++ b/frontend/components/swap-transfer-tracker/transfer-row.tsx @@ -1,6 +1,5 @@ 'use client' -import { motion } from 'framer-motion' import { ExternalLink } from '@/components/ui/external-link' import { EXPLORER_URL } from '@/constants/common' import { formatTimeDisplay } from '@/lib/timestamp' @@ -18,11 +17,7 @@ export function TransferRow({ transfer, gridClass }: TransferRowProps) { const tokenSymbol = transfer.type === 'native' ? 'MON' : 'WMON' return ( - {formatTimeDisplay(transfer.timestamp)} - +
) } diff --git a/frontend/components/swap-transfer-tracker/transfers.tsx b/frontend/components/swap-transfer-tracker/transfers.tsx index 493b460..1b3fe6f 100644 --- a/frontend/components/swap-transfer-tracker/transfers.tsx +++ b/frontend/components/swap-transfer-tracker/transfers.tsx @@ -1,6 +1,9 @@ 'use client' -import { AnimatePresence } from 'framer-motion' +import type { ReactElement } from 'react' +import { useState } from 'react' +import { List, type RowComponentProps } from 'react-window' +import { useVirtualizedList } from '@/hooks/use-virtualized-list' import { cn } from '@/lib/utils' import type { TransferData } from '@/types/transfer' import { CumulativeTransferCounter } from './cumulative-transfer-counter' @@ -10,15 +13,49 @@ interface TransfersProps { transfers: TransferData[] isLoading: boolean cumulativeTransferred: bigint + isFollowingData: boolean } const TABLE_GRID = 'grid grid-cols-6 gap-6 px-4' +const ROW_HEIGHT = 45 + +interface TransferRowData { + dataRef: React.RefObject + gridClass: string +} + +// Cell component defined outside to maintain stable reference +function TransferCell({ + index, + style, + dataRef, + gridClass, +}: RowComponentProps): ReactElement { + const transfer = dataRef.current?.[index] + + return ( +
+ {transfer && } +
+ ) +} export function Transfers({ transfers, isLoading, cumulativeTransferred, + isFollowingData, }: TransfersProps) { + const [isHovering, setIsHovering] = useState(false) + const isFollowing = isFollowingData && !isHovering + + const { containerRef, listRef, containerHeight, displayedData, rowProps } = + useVirtualizedList({ + data: transfers, + isFollowing, + gridClass: TABLE_GRID, + }) + return (
Time
-
- {transfers.length === 0 ? ( +
setIsHovering(true)} + onPointerLeave={() => setIsHovering(false)} + > + {displayedData.length === 0 ? (

{isLoading ? 'Waiting for events...' : 'No transfers yet'}

) : ( - - {transfers.map((transfer) => ( - - ))} - + )}
diff --git a/frontend/components/ui/stat-card.tsx b/frontend/components/ui/stat-card.tsx index 859f172..1d897e8 100644 --- a/frontend/components/ui/stat-card.tsx +++ b/frontend/components/ui/stat-card.tsx @@ -24,9 +24,9 @@ export function StatCard({ iconBgClassName = 'bg-tracker-active/10', }: StatCardProps) { return ( -
-
-
+
+
+

{label}

diff --git a/frontend/hooks/use-transfer-events.ts b/frontend/hooks/use-transfer-events.ts index c54110a..bced138 100644 --- a/frontend/hooks/use-transfer-events.ts +++ b/frontend/hooks/use-transfer-events.ts @@ -12,7 +12,7 @@ import { parseTopicsString } from '@/lib/abi-decode' import type { SerializableEventData } from '@/types/events' import type { TransferData } from '@/types/transfer' -const MAX_TRANSFERS = 50 +const MAX_TRANSFERS = Infinity /** * Parse native transfer event (TxnCallFrame with non-zero value) diff --git a/frontend/hooks/use-virtualized-list.ts b/frontend/hooks/use-virtualized-list.ts new file mode 100644 index 0000000..f82ca7d --- /dev/null +++ b/frontend/hooks/use-virtualized-list.ts @@ -0,0 +1,95 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import type { ListImperativeAPI } from 'react-window' + +interface UseVirtualizedListOptions { + data: T[] + isFollowing: boolean + gridClass: string +} + +interface UseVirtualizedListReturn { + containerRef: React.RefObject + listRef: React.RefObject + containerHeight: number + displayedData: T[] + dataRef: React.RefObject + rowProps: { dataRef: React.RefObject; gridClass: string } +} + +export function useVirtualizedList({ + data, + isFollowing, + gridClass, +}: UseVirtualizedListOptions): UseVirtualizedListReturn { + const containerRef = useRef(null) + const listRef = useRef(null) + const [containerHeight, setContainerHeight] = useState(384) + + // Freeze data when paused - this prevents re-renders while paused + const [displayedData, setDisplayedData] = useState(data) + + // Update displayed data only when following + useEffect(() => { + if (isFollowing) { + setDisplayedData(data) + } + }, [data, isFollowing]) + + // Store displayed data in ref for stable rowProps + const dataRef = useRef(displayedData) + dataRef.current = displayedData + + // Auto-scroll to top when following and new data arrives + useEffect(() => { + if (isFollowing && displayedData.length > 0 && listRef.current) { + listRef.current.scrollToRow({ + index: 0, + align: 'start', + behavior: 'smooth', + }) + } + }, [displayedData.length, isFollowing]) + + // Stable rowProps - dataRef never changes reference, only its .current + const rowPropsRef = useRef({ dataRef, gridClass }) + const rowProps = rowPropsRef.current + + // Update container height on resize + useEffect(() => { + const node = containerRef.current + if (!node) return + + const updateHeight = () => { + setContainerHeight(node.clientHeight) + } + updateHeight() + + const resizeObserver = new ResizeObserver(updateHeight) + resizeObserver.observe(node) + return () => resizeObserver.disconnect() + }, []) + + // Prevent scroll when following + useEffect(() => { + const node = containerRef.current + if (!node || !isFollowing) return + + const preventScroll = (e: WheelEvent) => { + e.preventDefault() + } + + node.addEventListener('wheel', preventScroll, { passive: false }) + return () => node.removeEventListener('wheel', preventScroll) + }, [isFollowing]) + + return { + containerRef, + listRef, + containerHeight, + displayedData, + dataRef, + rowProps, + } +}