Skip to content

Commit a2a01cc

Browse files
Infinite Scroll for CLN Transactions
1 parent c80d8d0 commit a2a01cc

File tree

3 files changed

+125
-51
lines changed

3 files changed

+125
-51
lines changed

apps/frontend/src/components/cln/CLNTransactionsList/CLNTransactionsList.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { screen } from '@testing-library/react';
1+
import { screen, within } from '@testing-library/react';
22
import { mockAppStore, mockBKPRStoreData, mockCLNStoreData, mockListChannels, mockRootStoreData, mockSelectedChannel } from '../../../utilities/test-utilities/mockData';
33
import { renderWithProviders } from '../../../utilities/test-utilities/mockStore';
44
import CLNTransactionsList from './CLNTransactionsList';
@@ -45,8 +45,9 @@ describe('CLNTransactionsList component ', () => {
4545
it('if it has transactions, show the offers list', async () => {
4646
await renderWithProviders(<CLNTransactionsList />, { preloadedState: mockAppStore, initialRoute: ['/cln'] });
4747
const transactionsList = screen.getByTestId('cln-transactions-list');
48+
const transactionHeaders = within(transactionsList).getAllByTestId('cln-transaction-header');
4849
expect(transactionsList).toBeInTheDocument();
49-
expect(transactionsList.children.length).toBe(1);
50+
expect(transactionHeaders.length).toBe(1);
5051
});
5152

5253
it('if there are no channels, show the text encouraging opening a channel', async () => {

apps/frontend/src/components/cln/CLNTransactionsList/CLNTransactionsList.tsx

Lines changed: 100 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
11
import './CLNTransactionsList.scss';
2-
import { useState } from 'react';
2+
import { useCallback, useEffect, useRef, useState } from 'react';
33
import { motion, AnimatePresence } from 'framer-motion';
44
import { Row, Col, Spinner, Alert } from 'react-bootstrap';
5-
5+
import PerfectScrollbar from 'react-perfect-scrollbar';
66
import { formatCurrency } from '../../../utilities/data-formatters';
77
import { IncomingArrowSVG } from '../../../svgs/IncomingArrow';
88
import { OutgoingArrowSVG } from '../../../svgs/OutgoingArrow';
99
import DateBox from '../../shared/DateBox/DateBox';
1010
import FiatBox from '../../shared/FiatBox/FiatBox';
1111
import Transaction from '../CLNTransaction/CLNTransaction';
12-
import { TRANSITION_DURATION, Units } from '../../../utilities/constants';
12+
import { TRANSITION_DURATION, Units, TODAY, SCROLL_BATCH_SIZE, SCROLL_THRESHOLD } from '../../../utilities/constants';
1313
import { NoCLNTransactionLightSVG } from '../../../svgs/NoCLNTransactionLight';
1414
import { NoCLNTransactionDarkSVG } from '../../../svgs/NoCLNTransactionDark';
1515
import { useSelector } from 'react-redux';
1616
import { selectActiveChannelsExist, selectFiatConfig, selectFiatUnit, selectIsAuthenticated, selectIsDarkMode, selectUIConfigUnit } from '../../../store/rootSelectors';
1717
import { selectListLightningTransactions } from '../../../store/clnSelectors';
1818

19-
const TODAY = Math.floor(Date.now() / 1000);
20-
2119
const PaymentHeader = ({ payment }) => {
2220
const fiatUnit = useSelector(selectFiatUnit);
2321
const uiConfigUnit = useSelector(selectUIConfigUnit);
@@ -213,6 +211,69 @@ export const CLNTransactionsList = () => {
213211
const listLightningTransactions = useSelector(selectListLightningTransactions);
214212
const initExpansions = (listLightningTransactions.clnTransactions?.reduce((acc: boolean[]) => [...acc, false], []) || []);
215213
const [expanded, setExpanded] = useState<boolean[]>(initExpansions);
214+
215+
const [displayedTransactions, setDisplayedTransactions] = useState<any[]>([]);
216+
const [currentIndex, setCurrentIndex] = useState(0);
217+
const [isLoading, setIsLoading] = useState(false);
218+
const [allTransactionsLoaded, setAllTransactionsLoaded] = useState(false);
219+
const containerRef = useRef<HTMLDivElement>(null);
220+
221+
const setContainerRef = useCallback((ref: HTMLElement | null) => {
222+
if (ref) {
223+
(containerRef as React.MutableRefObject<HTMLElement | null>).current = ref;
224+
}
225+
}, []);
226+
227+
useEffect(() => {
228+
if (listLightningTransactions?.clnTransactions?.length > 0) {
229+
const initialBatch = listLightningTransactions.clnTransactions.slice(0, SCROLL_BATCH_SIZE);
230+
setDisplayedTransactions(initialBatch);
231+
setCurrentIndex(SCROLL_BATCH_SIZE);
232+
if (SCROLL_BATCH_SIZE >= listLightningTransactions.clnTransactions.length) {
233+
setAllTransactionsLoaded(true);
234+
}
235+
}
236+
}, [listLightningTransactions]);
237+
238+
const loadMoreTransactions = useCallback(() => {
239+
if (isLoading || allTransactionsLoaded) return;
240+
setIsLoading(true);
241+
setTimeout(() => {
242+
const nextIndex = currentIndex + SCROLL_BATCH_SIZE;
243+
const newTransactions = listLightningTransactions.clnTransactions.slice(
244+
currentIndex,
245+
nextIndex
246+
);
247+
setDisplayedTransactions(prev => [...prev, ...newTransactions]);
248+
setCurrentIndex(nextIndex);
249+
250+
if (nextIndex >= listLightningTransactions.clnTransactions.length) {
251+
setAllTransactionsLoaded(true);
252+
}
253+
254+
setIsLoading(false);
255+
}, 300);
256+
}, [currentIndex, isLoading, allTransactionsLoaded, listLightningTransactions]);
257+
258+
const handleScroll = useCallback((container) => {
259+
if (!container || isLoading || allTransactionsLoaded) return;
260+
261+
const { scrollTop, scrollHeight, clientHeight } = container;
262+
const bottomOffset = scrollHeight - scrollTop - clientHeight;
263+
264+
if (bottomOffset < SCROLL_THRESHOLD) {
265+
loadMoreTransactions();
266+
}
267+
}, [isLoading, allTransactionsLoaded, loadMoreTransactions]);
268+
269+
useEffect(() => {
270+
const container = containerRef.current;
271+
if (container) {
272+
container?.addEventListener('scroll', handleScroll);
273+
return () => container?.removeEventListener('scroll', handleScroll);
274+
}
275+
}, [handleScroll]);
276+
216277
return (
217278
isAuthenticated && listLightningTransactions.isLoading ?
218279
<span className='h-100 d-flex justify-content-center align-items-center'>
@@ -221,29 +282,44 @@ export const CLNTransactionsList = () => {
221282
:
222283
listLightningTransactions.error ?
223284
<Alert className='py-0 px-1 fs-7' variant='danger' data-testid='cln-transactions-list-error'>{listLightningTransactions.error}</Alert> :
224-
listLightningTransactions?.clnTransactions && listLightningTransactions?.clnTransactions.length && listLightningTransactions?.clnTransactions.length > 0 ?
225-
<div className='cln-transactions-list' data-testid='cln-transactions-list'>
226-
{
227-
listLightningTransactions?.clnTransactions?.map((transaction, i) => (
228-
<CLNTransactionsAccordion key={i} i={i} expanded={expanded} setExpanded={setExpanded} initExpansions={initExpansions} transaction={transaction} />
229-
))
285+
listLightningTransactions?.clnTransactions && listLightningTransactions?.clnTransactions.length && listLightningTransactions?.clnTransactions.length > 0 ?
286+
<PerfectScrollbar
287+
containerRef={setContainerRef}
288+
onScrollY={handleScroll}
289+
className='cln-transactions-list'
290+
data-testid='cln-transactions-list'
291+
options={{
292+
suppressScrollX: true,
293+
wheelPropagation: false
294+
}}
295+
>
296+
{displayedTransactions.map((transaction, i) => (
297+
<CLNTransactionsAccordion key={i} i={i} expanded={expanded} setExpanded={setExpanded} initExpansions={initExpansions} transaction={transaction} />
298+
))}
299+
{isLoading && (
300+
<Col xs={12} className='d-flex align-items-center justify-content-center mb-5'>
301+
<Spinner animation='grow' variant='primary' />
302+
</Col>
303+
)}
304+
{allTransactionsLoaded && listLightningTransactions?.clnTransactions.length > 100 &&
305+
<h6 className='d-flex align-self-center py-4 text-muted'>No more transactions to load!</h6>
306+
}
307+
</PerfectScrollbar>
308+
:
309+
<Row className='text-light fs-6 h-75 mt-5 align-items-center justify-content-center'>
310+
<Row className='d-flex align-items-center justify-content-center mt-2'>
311+
{isDarkMode ?
312+
<NoCLNTransactionDarkSVG className='no-clntx-dark pb-1' /> :
313+
<NoCLNTransactionLightSVG className='no-clntx-light pb-1' />
230314
}
231-
</div>
232-
:
233-
<Row className='text-light fs-6 h-75 mt-5 align-items-center justify-content-center'>
234-
<Row className='d-flex align-items-center justify-content-center mt-2'>
235-
{isDarkMode ?
236-
<NoCLNTransactionDarkSVG className='no-clntx-dark pb-1' /> :
237-
<NoCLNTransactionLightSVG className='no-clntx-light pb-1' />
315+
<Row className='text-center'>
316+
{activeChannelsExist ?
317+
'No transaction found. Click send/receive to start!' :
318+
'No transaction found. Open channel to start!'
238319
}
239-
<Row className='text-center'>
240-
{activeChannelsExist ?
241-
'No transaction found. Click send/receive to start!' :
242-
'No transaction found. Open channel to start!'
243-
}
244-
</Row>
245320
</Row>
246321
</Row>
322+
</Row>
247323
);
248324
};
249325

apps/frontend/src/components/cln/CLNWallet/CLNWallet.tsx

Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import './CLNWallet.scss';
22
import { useState, lazy, Suspense } from 'react';
33
import { motion, AnimatePresence } from 'framer-motion';
4-
import PerfectScrollbar from 'react-perfect-scrollbar';
54
import { Card, Col, ButtonGroup, Spinner, Alert, Tab, Nav } from 'react-bootstrap';
65
const CLNTransactionsList = lazy(() => import('../CLNTransactionsList/CLNTransactionsList'));
76
const CLNOffersList = lazy(() => import('../CLNOffersList/CLNOffersList'));
@@ -74,30 +73,28 @@ const CLNWallet = (props) => {
7473
</Nav.Link>
7574
</Nav.Item>
7675
</Nav>
77-
<PerfectScrollbar>
78-
<AnimatePresence mode="wait">
79-
<motion.div
80-
key={selectedTab}
81-
initial={{ y: 10, opacity: 0 }}
82-
animate={{ y: 0, opacity: 1 }}
83-
transition={{ duration: TRANSITION_DURATION }}
84-
className="h-100 d-flex flex-column align-items-stretch"
85-
>
86-
<Tab.Content className="h-100 d-flex flex-column">
87-
<Tab.Pane className="h-100 list-scroll-container" eventKey="transactions">
88-
<Suspense fallback={<Loading />}>
89-
<CLNTransactionsList />
90-
</Suspense>
91-
</Tab.Pane>
92-
<Tab.Pane eventKey="offers">
93-
<Suspense fallback={<Loading />}>
94-
<CLNOffersList />
95-
</Suspense>
96-
</Tab.Pane>
97-
</Tab.Content>
98-
</motion.div>
99-
</AnimatePresence>
100-
</PerfectScrollbar>
76+
<AnimatePresence mode="wait">
77+
<motion.div
78+
key={selectedTab}
79+
initial={{ y: 10, opacity: 0 }}
80+
animate={{ y: 0, opacity: 1 }}
81+
transition={{ duration: TRANSITION_DURATION }}
82+
className="h-100 d-flex flex-column align-items-stretch"
83+
>
84+
<Tab.Content className="h-100 d-flex flex-column">
85+
<Tab.Pane className="h-100 list-scroll-container" eventKey="transactions">
86+
<Suspense fallback={<Loading />}>
87+
<CLNTransactionsList />
88+
</Suspense>
89+
</Tab.Pane>
90+
<Tab.Pane eventKey="offers">
91+
<Suspense fallback={<Loading />}>
92+
<CLNOffersList />
93+
</Suspense>
94+
</Tab.Pane>
95+
</Tab.Content>
96+
</motion.div>
97+
</AnimatePresence>
10198
</Tab.Container>
10299
</Card.Body>
103100
</Card.Body>

0 commit comments

Comments
 (0)