diff --git a/README.md b/README.md index 571d9893..f59998c0 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ - APP_CONFIG_FILE: Path for cln-application's configuration file (default: `./config.json`) - APP_LOG_FILE: Path for cln-application's log file (default: `./application-cln.log`) - APP_MODE: Mode for logging and other settings (valid values: production/development/testing, default: `production`) - - APP_CONNECT: Choose how to connect to CLN (valid values: COMMANDO/REST/GRPC, default: `COMMANDO`) + - APP_CONNECT: Choose how to connect to CLN (valid values: COMMANDO/REST, default: `COMMANDO`) # Core lightning Values - LIGHTNING_HOST: IP address of Core lightning node (default: `localhost`) @@ -90,9 +90,9 @@ - LIGHTNING_REST_CA_CERT_FILE: CA certificate file path including file name for REST TLS authentication (used by `REST` APP_CONNECT and `https` LIGHTNING_REST_PROTOCOL; default: `./ca.pem`) # CLN gRPC Values - - LIGHTNING_GRPC_HOST: IP address/hostname of Core Lightning GRPC interface (used if APP_CONNECT is `GRPC`, default: `localhost`) + - LIGHTNING_GRPC_HOST: IP address/hostname of Core Lightning GRPC interface (default: `localhost`) - LIGHTNING_GRPC_TOR_HOST: Tor hidden service URL for Core Lightning GRPC interface (default: ``) - - LIGHTNING_GRPC_PORT: Core lightning's GRPC port (used if APP_CONNECT is `GRPC`; default: `9736`) + - LIGHTNING_GRPC_PORT: Core lightning's GRPC port (default: `9736`) - LIGHTNING_GRPC_PROTO_PATH: URL to directory containing CLN gRPC protocol definitions (default: `https://github.com/ElementsProject/lightning/tree/master/cln-grpc/proto`) - LIGHTNING_GRPC_CLIENT_KEY_FILE: Client key file path including file name for GRPC TLS authentication (used by `GRPC` APP_CONNECT; default: `./client-key.pem`) - LIGHTNING_GRPC_CLIENT_CERT_FILE: Client certificate file path including file name for GRPC TLS authentication (used by `GRPC` APP_CONNECT; default: `./client.pem`) diff --git a/apps/backend/source/server.ts b/apps/backend/source/server.ts index c5b9bc8e..45aa483f 100755 --- a/apps/backend/source/server.ts +++ b/apps/backend/source/server.ts @@ -130,9 +130,9 @@ async function startServer() { server.listen({ port: APP_PORT, host: APP_HOST }); } catch (err: any) { if (err.code) { - logger.error('Server Startup Error: ', err); + logger.error('Server Startup Error:', err); } else { - logger.error('Server Startup Error: ', throwApiError(err)); + logger.error('Server Startup Error:', throwApiError(err)); } process.exit(1); } diff --git a/apps/backend/source/service/lightning.service.ts b/apps/backend/source/service/lightning.service.ts index 54f1c68d..ff76e00e 100755 --- a/apps/backend/source/service/lightning.service.ts +++ b/apps/backend/source/service/lightning.service.ts @@ -3,14 +3,12 @@ import https from 'https'; import axios, { AxiosHeaders } from 'axios'; import Lnmessage from 'lnmessage'; import { GRPCError, LightningError, ValidationError } from '../models/errors.js'; -import { GRPCService } from './grpc.service.js'; import { HttpStatusCode, APP_CONSTANTS, AppConnect, LN_MESSAGE_CONFIG, REST_CONFIG, - GRPC_CONFIG, } from '../shared/consts.js'; import { logger } from '../shared/logger.js'; import { setEnvVariables, validateEnvVariables } from '../shared/utils.js'; @@ -46,8 +44,13 @@ export class LightningService { } break; case AppConnect.GRPC: - logger.info('GRPC connecting with config: ' + JSON.stringify(GRPC_CONFIG)); - this.clnService = new GRPCService(GRPC_CONFIG); + this.clnService = null; + throw new ValidationError( + HttpStatusCode.INVALID_DATA, + 'gRPC connection to the Lightning node is not supported. Please use the COMMANDO or REST options for APP_CONNECT.', + ); + // logger.info('GRPC connecting with config: ' + JSON.stringify(GRPC_CONFIG)); + // this.clnService = new GRPCService(GRPC_CONFIG); break; default: logger.info('lnMessage connecting with config: ' + JSON.stringify(LN_MESSAGE_CONFIG)); @@ -71,8 +74,13 @@ export class LightningService { return axios .post(method, methodParams, this.axiosConfig) .then((commandRes: any) => { - logger.info('REST response for ' + method + ': ' + JSON.stringify(commandRes.data)); - return Promise.resolve(commandRes.data); + logger.info( + 'REST response for ' + + method + + ': ' + + JSON.stringify(commandRes.data || commandRes.rows), + ); + return Promise.resolve(commandRes.data || commandRes.rows); }) .catch((err: any) => { logger.error('REST lightning error from ' + method + ' command'); diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 9ced85cf..2058ee22 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -15,5 +15,5 @@ "lib": ["ES2022"] }, "files": ["./source/server.ts"], - "include": ["./source/**/*.d.ts"] + "include": ["./source/**/*.ts"] } diff --git a/apps/frontend/src/components/App/App.scss b/apps/frontend/src/components/App/App.scss index 31ae52dc..f68cbb62 100644 --- a/apps/frontend/src/components/App/App.scss +++ b/apps/frontend/src/components/App/App.scss @@ -1,5 +1,3 @@ -@import '../../styles/bootstrap-custom'; -@import '../../styles/constants'; @import '../../styles/shared'; .list-scroll-container { diff --git a/apps/frontend/src/components/App/App.tsx b/apps/frontend/src/components/App/App.tsx index 978edd3d..3d1fb506 100644 --- a/apps/frontend/src/components/App/App.tsx +++ b/apps/frontend/src/components/App/App.tsx @@ -1,4 +1,11 @@ import './App.scss'; +import '../shared/FiatBox/FiatBox.scss'; +import '../shared/CurrencyBox/CurrencyBox.scss'; +import '../shared/ToastMessage/ToastMessage.scss'; +import '../shared/InvalidInputMessage/InvalidInputMessage.scss'; +import '../shared/StatusAlert/StatusAlert.scss'; +import '../cln/Overview/Overview.scss'; + import { Container } from 'react-bootstrap'; import useBreakpoint from '../../hooks/use-breakpoint'; diff --git a/apps/frontend/src/components/bookkeeper/AccountEvents/AccountEventsGraph/AccountEventsGraph.scss b/apps/frontend/src/components/bookkeeper/AccountEvents/AccountEventsGraph/AccountEventsGraph.scss index d15a4f16..62eb7b5e 100644 --- a/apps/frontend/src/components/bookkeeper/AccountEvents/AccountEventsGraph/AccountEventsGraph.scss +++ b/apps/frontend/src/components/bookkeeper/AccountEvents/AccountEventsGraph/AccountEventsGraph.scss @@ -1,4 +1,4 @@ -@import '../../../../styles/constants.scss'; +@use '../../../../styles/constants' as *; .bkpr-tooltip { border: 1px solid $light-dark; diff --git a/apps/frontend/src/components/bookkeeper/AccountEvents/AccountEventsRoot.scss b/apps/frontend/src/components/bookkeeper/AccountEvents/AccountEventsRoot.scss index e0b1133a..1181992c 100644 --- a/apps/frontend/src/components/bookkeeper/AccountEvents/AccountEventsRoot.scss +++ b/apps/frontend/src/components/bookkeeper/AccountEvents/AccountEventsRoot.scss @@ -1,4 +1,4 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .account-events-container { padding: 0 1rem; diff --git a/apps/frontend/src/components/bookkeeper/AccountEvents/AccountEventsTable/AccountEventsTable.scss b/apps/frontend/src/components/bookkeeper/AccountEvents/AccountEventsTable/AccountEventsTable.scss index 1ec2768a..331d1d12 100644 --- a/apps/frontend/src/components/bookkeeper/AccountEvents/AccountEventsTable/AccountEventsTable.scss +++ b/apps/frontend/src/components/bookkeeper/AccountEvents/AccountEventsTable/AccountEventsTable.scss @@ -1,4 +1,4 @@ -@import '../../../../styles/constants.scss'; +@use '../../../../styles/constants' as *; .account-events-table { padding: 0 !important; diff --git a/apps/frontend/src/components/bookkeeper/BkprHome/AccountEventsInfo/AccountEventsInfo.scss b/apps/frontend/src/components/bookkeeper/BkprHome/AccountEventsInfo/AccountEventsInfo.scss index aa8af854..1d5387c0 100644 --- a/apps/frontend/src/components/bookkeeper/BkprHome/AccountEventsInfo/AccountEventsInfo.scss +++ b/apps/frontend/src/components/bookkeeper/BkprHome/AccountEventsInfo/AccountEventsInfo.scss @@ -1 +1 @@ -@import '../../../../styles/constants.scss'; +@use '../../../../styles/constants' as *; diff --git a/apps/frontend/src/components/bookkeeper/BkprHome/BkprHome.scss b/apps/frontend/src/components/bookkeeper/BkprHome/BkprHome.scss index 3a19c8e1..d433805f 100644 --- a/apps/frontend/src/components/bookkeeper/BkprHome/BkprHome.scss +++ b/apps/frontend/src/components/bookkeeper/BkprHome/BkprHome.scss @@ -1,4 +1,4 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; [data-screensize='SM'], [data-screensize='XS'] { diff --git a/apps/frontend/src/components/bookkeeper/BkprHome/SatsFlowInfo/SatsFlowInfo.scss b/apps/frontend/src/components/bookkeeper/BkprHome/SatsFlowInfo/SatsFlowInfo.scss index 93437dcf..fbda5ab0 100644 --- a/apps/frontend/src/components/bookkeeper/BkprHome/SatsFlowInfo/SatsFlowInfo.scss +++ b/apps/frontend/src/components/bookkeeper/BkprHome/SatsFlowInfo/SatsFlowInfo.scss @@ -1,4 +1,4 @@ -@import '../../../../styles/constants.scss'; +@use '../../../../styles/constants' as *; [data-screensize='SM'], [data-screensize='XS'] { diff --git a/apps/frontend/src/components/bookkeeper/BkprHome/VolumeInfo/VolumeInfo.scss b/apps/frontend/src/components/bookkeeper/BkprHome/VolumeInfo/VolumeInfo.scss index 30a16072..e9e606d1 100644 --- a/apps/frontend/src/components/bookkeeper/BkprHome/VolumeInfo/VolumeInfo.scss +++ b/apps/frontend/src/components/bookkeeper/BkprHome/VolumeInfo/VolumeInfo.scss @@ -1,4 +1,4 @@ -@import '../../../../styles/constants.scss'; +@use '../../../../styles/constants' as *; [data-screensize='SM'], [data-screensize='XS'] { diff --git a/apps/frontend/src/components/bookkeeper/SatsFlow/SatsFlowGraph/SatsFlowGraph.scss b/apps/frontend/src/components/bookkeeper/SatsFlow/SatsFlowGraph/SatsFlowGraph.scss index 726c806f..02354eff 100644 --- a/apps/frontend/src/components/bookkeeper/SatsFlow/SatsFlowGraph/SatsFlowGraph.scss +++ b/apps/frontend/src/components/bookkeeper/SatsFlow/SatsFlowGraph/SatsFlowGraph.scss @@ -1,4 +1,4 @@ -@import '../../../../styles/constants.scss'; +@use '../../../../styles/constants' as *; .bkpr-tooltip { border: 1px solid $light-dark; @@ -8,7 +8,7 @@ box-shadow: 0px 4px 8px 0px rgba($gray-400, 0.16); } -.sats-flow-lagend-bullet { +.col-sats-flow-lagend .sats-flow-lagend-bullet { width: 12px; height: 12px; margin-right: 6px; @@ -37,4 +37,7 @@ color: $light-dark; background-color: $tooltip-bg-dark; } + .col-sats-flow-lagend .span-sats-flow-lagend { + color: $light-dark; + } } diff --git a/apps/frontend/src/components/bookkeeper/SatsFlow/SatsFlowGraph/SatsFlowGraph.tsx b/apps/frontend/src/components/bookkeeper/SatsFlow/SatsFlowGraph/SatsFlowGraph.tsx index 68d078cc..dd560361 100644 --- a/apps/frontend/src/components/bookkeeper/SatsFlow/SatsFlowGraph/SatsFlowGraph.tsx +++ b/apps/frontend/src/components/bookkeeper/SatsFlow/SatsFlowGraph/SatsFlowGraph.tsx @@ -55,9 +55,9 @@ const SatsFlowGraphLegend = (props: any) => { {payload .filter((entry: any) => entry.value !== 'net_inflow_msat') .map((entry: any, index: number) => ( - +
- {titleCase(entry.value.replace(/_/g, ' '))} + {titleCase(entry.value.replace(/_/g, ' '))} )) } diff --git a/apps/frontend/src/components/bookkeeper/SatsFlow/SatsFlowRoot.scss b/apps/frontend/src/components/bookkeeper/SatsFlow/SatsFlowRoot.scss index f809e2f6..d0d50ed4 100644 --- a/apps/frontend/src/components/bookkeeper/SatsFlow/SatsFlowRoot.scss +++ b/apps/frontend/src/components/bookkeeper/SatsFlow/SatsFlowRoot.scss @@ -1,4 +1,4 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .satsflow-container { padding: 0 1rem; diff --git a/apps/frontend/src/components/bookkeeper/Volume/VolumeGraph/VolumeGraph.scss b/apps/frontend/src/components/bookkeeper/Volume/VolumeGraph/VolumeGraph.scss index 1032e95d..2939b80f 100644 --- a/apps/frontend/src/components/bookkeeper/Volume/VolumeGraph/VolumeGraph.scss +++ b/apps/frontend/src/components/bookkeeper/Volume/VolumeGraph/VolumeGraph.scss @@ -1,4 +1,4 @@ -@import '../../../../styles/constants.scss'; +@use '../../../../styles/constants' as *; .volume-graph { width: 100%; @@ -49,4 +49,7 @@ color: $light-dark; background-color: $tooltip-bg-dark; } + .volume-graph-legend { + color: $light-dark; + } } diff --git a/apps/frontend/src/components/bookkeeper/Volume/VolumeRoot.scss b/apps/frontend/src/components/bookkeeper/Volume/VolumeRoot.scss index 8791b75a..007914f2 100644 --- a/apps/frontend/src/components/bookkeeper/Volume/VolumeRoot.scss +++ b/apps/frontend/src/components/bookkeeper/Volume/VolumeRoot.scss @@ -1,4 +1,4 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .volume-container { padding: 0 1rem; diff --git a/apps/frontend/src/components/cln/BTCTransaction/BTCTransaction.scss b/apps/frontend/src/components/cln/BTCTransaction/BTCTransaction.scss index be476118..b912da18 100644 --- a/apps/frontend/src/components/cln/BTCTransaction/BTCTransaction.scss +++ b/apps/frontend/src/components/cln/BTCTransaction/BTCTransaction.scss @@ -1,4 +1,5 @@ -@import '../../../styles/constants.scss'; +@use 'sass:color'; +@use '../../../styles/constants' as *; .btc-transaction-placeholder { transform-origin: top left; @@ -13,7 +14,7 @@ cursor: pointer; &:hover { svg path { - stroke: darken($primary, 10%); + stroke: color.adjust($primary, $lightness: -10%); } } } @@ -24,7 +25,7 @@ cursor: pointer; &:hover { svg path { - fill: darken($primary, 10%); + fill: color.adjust($primary, $lightness: -10%); } } } diff --git a/apps/frontend/src/components/cln/BTCTransaction/BTCTransaction.tsx b/apps/frontend/src/components/cln/BTCTransaction/BTCTransaction.tsx index e031d15e..ac2cf4e3 100755 --- a/apps/frontend/src/components/cln/BTCTransaction/BTCTransaction.tsx +++ b/apps/frontend/src/components/cln/BTCTransaction/BTCTransaction.tsx @@ -19,7 +19,7 @@ const TransactionDetail = ({ transaction, copyHandler, openLinkHandler }) => { Blockheight - + {transaction.blockheight} @@ -31,7 +31,7 @@ const TransactionDetail = ({ transaction, copyHandler, openLinkHandler }) => { Description - + {transaction.description} @@ -43,7 +43,7 @@ const TransactionDetail = ({ transaction, copyHandler, openLinkHandler }) => { Transaction ID - + {transaction.txid} @@ -61,7 +61,7 @@ const TransactionDetail = ({ transaction, copyHandler, openLinkHandler }) => { Payment ID - + {transaction.payment_id} @@ -79,7 +79,7 @@ const TransactionDetail = ({ transaction, copyHandler, openLinkHandler }) => { Outpoint - + {transaction.outpoint} diff --git a/apps/frontend/src/components/cln/BTCTransactionsList/BTCTransactionsList.scss b/apps/frontend/src/components/cln/BTCTransactionsList/BTCTransactionsList.scss index 06da89fd..e5ab4600 100644 --- a/apps/frontend/src/components/cln/BTCTransactionsList/BTCTransactionsList.scss +++ b/apps/frontend/src/components/cln/BTCTransactionsList/BTCTransactionsList.scss @@ -1,4 +1,5 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; +@import 'bootstrap/scss/mixins'; .btc-transactions-list { cursor: pointer; diff --git a/apps/frontend/src/components/cln/BTCTransactionsList/BTCTransactionsList.tsx b/apps/frontend/src/components/cln/BTCTransactionsList/BTCTransactionsList.tsx index 653e780d..df952c97 100755 --- a/apps/frontend/src/components/cln/BTCTransactionsList/BTCTransactionsList.tsx +++ b/apps/frontend/src/components/cln/BTCTransactionsList/BTCTransactionsList.tsx @@ -1,5 +1,5 @@ import './BTCTransactionsList.scss'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Spinner, Alert, Row, Col } from 'react-bootstrap'; import PerfectScrollbar from 'react-perfect-scrollbar'; @@ -10,12 +10,15 @@ import { OutgoingArrowSVG } from '../../../svgs/OutgoingArrow'; import DateBox from '../../shared/DateBox/DateBox'; import FiatBox from '../../shared/FiatBox/FiatBox'; import Transaction from '../BTCTransaction/BTCTransaction'; -import { SCROLL_BATCH_SIZE, SCROLL_THRESHOLD, TRANSITION_DURATION, Units } from '../../../utilities/constants'; +import { SCROLL_PAGE_SIZE, SCROLL_THRESHOLD, TRANSITION_DURATION, Units } from '../../../utilities/constants'; import { NoBTCTransactionDarkSVG } from '../../../svgs/NoBTCTransactionDark'; import { NoBTCTransactionLightSVG } from '../../../svgs/NoBTCTransactionLight'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { selectFiatConfig, selectFiatUnit, selectIsAuthenticated, selectIsDarkMode, selectUIConfigUnit } from '../../../store/rootSelectors'; import { selectListBitcoinTransactions } from '../../../store/clnSelectors'; +import { CLNService } from '../../../services/http.service'; +import { setListBitcoinTransactions, setListBitcoinTransactionsLoading } from '../../../store/clnSlice'; +import { ListBitcoinTransactions } from '../../../types/cln.type'; const WithdrawHeader = ({ withdraw }) => { const fiatUnit = useSelector(selectFiatUnit); @@ -29,7 +32,7 @@ const WithdrawHeader = ({ withdraw }) => { - {titleCase(withdraw.tag)} + {titleCase(withdraw.tag)} { - {titleCase(deposit.tag)} + {titleCase(deposit.tag)} { const isDarkMode = useSelector(selectIsDarkMode); @@ -140,8 +142,7 @@ const BTCTransactionsAccordion = ({ animate={{ backgroundColor: (isDarkMode ? (expanded[i] ? '#0C0C0F' : '#2A2A2C') : (expanded[i] ? '#EBEFF9' : '#FFFFFF')) }} transition={{ duration: TRANSITION_DURATION }} onClick={() => { - initExpansions[i] = !expanded[i]; - return setExpanded(initExpansions); + setExpanded(expanded.map((_, idx) => idx === i ? !expanded[i] : false)); }} > {transaction?.tag?.toLowerCase() === 'withdrawal' ? ( @@ -173,57 +174,43 @@ const BTCTransactionsAccordion = ({ }; export const BTCTransactionsList = () => { + const dispatch = useDispatch(); const isDarkMode = useSelector(selectIsDarkMode); const isAuthenticated = useSelector(selectIsAuthenticated); - const listBitcoinTransactions = useSelector(selectListBitcoinTransactions); - const initExpansions = (listBitcoinTransactions.btcTransactions?.reduce((acc: boolean[]) => [...acc, false], []) || []); - const [expanded, setExpanded] = useState(initExpansions); + const { btcTransactions, isLoading, page, hasMore, error } = useSelector(selectListBitcoinTransactions); + const [expanded, setExpanded] = useState(new Array(btcTransactions.length).fill(false)); - const [displayedTransactions, setDisplayedTransactions] = useState([]); - const [currentIndex, setCurrentIndex] = useState(0); - const [isLoading, setIsLoading] = useState(false); - const [allTransactionsLoaded, setAllTransactionsLoaded] = useState(false); - const containerRef = useRef(null); - - const setContainerRef = useCallback((ref: HTMLElement | null) => { - if (ref) { - (containerRef as React.MutableRefObject).current = ref; - } - }, []); - - useEffect(() => { - if (listBitcoinTransactions?.btcTransactions?.length > 0) { - const initialBatch = listBitcoinTransactions?.btcTransactions.slice(0, SCROLL_BATCH_SIZE); - setDisplayedTransactions(initialBatch); - setCurrentIndex(SCROLL_BATCH_SIZE); - if (SCROLL_BATCH_SIZE >= listBitcoinTransactions?.btcTransactions.length) { - setAllTransactionsLoaded(true); + const loadMoreTransactions = useCallback(async () => { + if (isLoading || !hasMore) return; + + dispatch(setListBitcoinTransactionsLoading(true)); + + try { + const offset = page * SCROLL_PAGE_SIZE; + const listBtcTransactionsRes: any = await CLNService.listBTCTransactions(offset); + if (listBtcTransactionsRes.error) { + dispatch(setListBitcoinTransactions({ + error: listBtcTransactionsRes.error + } as ListBitcoinTransactions)); + return; } + setExpanded(prev => [...prev, ...new Array(listBtcTransactionsRes.btcTransactions.length).fill(false)]); + dispatch(setListBitcoinTransactions({ + ...listBtcTransactionsRes, + page: page + 1, + hasMore: listBtcTransactionsRes.btcTransactions.length >= SCROLL_PAGE_SIZE, + } as ListBitcoinTransactions)); + } catch (error: any) { + dispatch(setListBitcoinTransactions({ + error: error.message || 'Failed to load transactions' + } as ListBitcoinTransactions)); + } finally { + dispatch(setListBitcoinTransactionsLoading(false)); } - }, [listBitcoinTransactions]); - - const loadMoreTransactions = useCallback(() => { - if (isLoading || allTransactionsLoaded) return; - setIsLoading(true); - setTimeout(() => { - const nextIndex = currentIndex + SCROLL_BATCH_SIZE; - const newTransactions = listBitcoinTransactions?.btcTransactions.slice( - currentIndex, - nextIndex - ); - setDisplayedTransactions(prev => [...prev, ...newTransactions]); - setCurrentIndex(nextIndex); - - if (nextIndex >= listBitcoinTransactions?.btcTransactions.length) { - setAllTransactionsLoaded(true); - } - - setIsLoading(false); - }, 300); - }, [currentIndex, isLoading, allTransactionsLoaded, listBitcoinTransactions]); + }, [isLoading, hasMore, page, dispatch]); - const handleScroll = useCallback((container) => { - if (!container || isLoading || allTransactionsLoaded) return; + const handleScroll = useCallback((container: HTMLElement) => { + if (!container || isLoading || !hasMore) return; const { scrollTop, scrollHeight, clientHeight } = container; const bottomOffset = scrollHeight - scrollTop - clientHeight; @@ -231,57 +218,81 @@ export const BTCTransactionsList = () => { if (bottomOffset < SCROLL_THRESHOLD) { loadMoreTransactions(); } - }, [isLoading, allTransactionsLoaded, loadMoreTransactions]); + }, [isLoading, hasMore, loadMoreTransactions]); - useEffect(() => { - const container = containerRef.current; - if (container) { - container?.addEventListener('scroll', handleScroll); - return () => container?.removeEventListener('scroll', handleScroll); - } - }, [handleScroll]); - - return ( - isAuthenticated && listBitcoinTransactions.isLoading ? + if (isAuthenticated && isLoading && btcTransactions.length === 0) { + return ( - - : - listBitcoinTransactions.error ? - {listBitcoinTransactions.error} : - listBitcoinTransactions?.btcTransactions && listBitcoinTransactions?.btcTransactions.length && listBitcoinTransactions?.btcTransactions.length > 0 ? - - {displayedTransactions.map((transaction, i) => ( - - ))} - {isLoading && ( - - - - )} - {allTransactionsLoaded && listBitcoinTransactions?.btcTransactions.length > 100 && -
No more transactions to load!
+ + ); + } + + if (error && btcTransactions.length === 0) { + return {error}; + } + + if (!btcTransactions || btcTransactions.length === 0) { + return ( + + + {isDarkMode ? + : + } -
- : - - - { isDarkMode ? - : - - } - No transaction found. Click deposit to receive amount! + + No transaction found. Click deposit to receive amount! + + ); + } + + return ( + + {btcTransactions.map((transaction, i) => ( + + ))} + + {isLoading && ( + + + Loading more transactions... + + )} + + {!hasMore && btcTransactions.length > 0 && ( +
+ No more transactions to load! +
+ )} + + {error && btcTransactions.length > 0 && ( + + {error} + + + )} +
); }; diff --git a/apps/frontend/src/components/cln/BTCWallet/BTCWallet.scss b/apps/frontend/src/components/cln/BTCWallet/BTCWallet.scss index 0f924cee..556522a1 100644 --- a/apps/frontend/src/components/cln/BTCWallet/BTCWallet.scss +++ b/apps/frontend/src/components/cln/BTCWallet/BTCWallet.scss @@ -1,4 +1,4 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .btc-transactions-tabs { border-bottom: 0.5px solid $border-color; diff --git a/apps/frontend/src/components/cln/BTCWallet/BTCWallet.tsx b/apps/frontend/src/components/cln/BTCWallet/BTCWallet.tsx index f94e6e29..90d2be51 100755 --- a/apps/frontend/src/components/cln/BTCWallet/BTCWallet.tsx +++ b/apps/frontend/src/components/cln/BTCWallet/BTCWallet.tsx @@ -6,14 +6,46 @@ import { BitcoinWalletSVG } from '../../../svgs/BitcoinWallet'; import { WithdrawSVG } from '../../../svgs/Withdraw'; import { DepositSVG } from '../../../svgs/Deposit'; import CurrencyBox from '../../shared/CurrencyBox/CurrencyBox'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { selectIsAuthenticated, selectWalletBalances } from '../../../store/rootSelectors'; import { Loading } from '../../ui/Loading/Loading'; +import { RefreshSVG } from '../../../svgs/Refresh'; +import { resetListBitcoinTransactions, setListBitcoinTransactions, setListBitcoinTransactionsLoading } from '../../../store/clnSlice'; +import { CLNService } from '../../../services/http.service'; +import { ListBitcoinTransactions } from '../../../types/cln.type'; +import { SCROLL_PAGE_SIZE } from '../../../utilities/constants'; const BTCWallet = (props) => { + const dispatch = useDispatch(); const isAuthenticated = useSelector(selectIsAuthenticated); const walletBalances = useSelector(selectWalletBalances); + const refreshHandler = async () => { + dispatch(setListBitcoinTransactionsLoading(true)); + dispatch(resetListBitcoinTransactions()); + try { + const offset = 0; + const listBtcTransactionsRes: any = await CLNService.listBTCTransactions(offset); + if (listBtcTransactionsRes.error) { + dispatch(setListBitcoinTransactions({ + error: listBtcTransactionsRes.error + } as ListBitcoinTransactions)); + return; + } + dispatch(setListBitcoinTransactions({ + ...listBtcTransactionsRes, + page: 1, + hasMore: listBtcTransactionsRes.btcTransactions.length >= SCROLL_PAGE_SIZE, + } as ListBitcoinTransactions)); + } catch (error: any) { + dispatch(setListBitcoinTransactions({ + error: error.message || 'Failed to load transactions' + } as ListBitcoinTransactions)); + } finally { + dispatch(setListBitcoinTransactionsLoading(false)); + } + } + return ( @@ -52,7 +84,11 @@ const BTCWallet = (props) => { -
Transactions
+
Transactions + + + +
}> diff --git a/apps/frontend/src/components/cln/CLNOffer/CLNOffer.scss b/apps/frontend/src/components/cln/CLNOffer/CLNOffer.scss index 0950615d..263d916c 100644 --- a/apps/frontend/src/components/cln/CLNOffer/CLNOffer.scss +++ b/apps/frontend/src/components/cln/CLNOffer/CLNOffer.scss @@ -1,4 +1,5 @@ -@import '../../../styles/constants.scss'; +@use 'sass:color'; +@use '../../../styles/constants' as *; .cln-offer-placeholder { transform-origin: top left; @@ -13,7 +14,7 @@ cursor: pointer; &:hover { svg path { - stroke: darken($primary, 10%); + stroke: color.adjust($primary, $lightness: -10%); } } } @@ -24,7 +25,7 @@ cursor: pointer; &:hover { svg path { - fill: darken($primary, 10%); + fill: color.adjust($primary, $lightness: -10%); } } } diff --git a/apps/frontend/src/components/cln/CLNOffer/CLNOffer.tsx b/apps/frontend/src/components/cln/CLNOffer/CLNOffer.tsx index 80db3002..738bdacc 100755 --- a/apps/frontend/src/components/cln/CLNOffer/CLNOffer.tsx +++ b/apps/frontend/src/components/cln/CLNOffer/CLNOffer.tsx @@ -17,7 +17,7 @@ const OfferDetail = ({ offer, copyHandler }) => { Bolt 12 - + {offer.bolt12} @@ -27,6 +27,21 @@ const OfferDetail = ({ offer, copyHandler }) => { ) : ( <> )} + {offer.description ? ( + + + Description + + + {offer.description} + + + + + + ) : ( + <> + )} ); }; @@ -40,6 +55,9 @@ const CLNOffer = (props) => { case 'Bolt12': textToCopy = props.offer.bolt12; break; + case 'Description': + textToCopy = props.offer.description; + break; default: textToCopy = props.offer.bolt12; break; diff --git a/apps/frontend/src/components/cln/CLNOffersList/CLNOffersList.scss b/apps/frontend/src/components/cln/CLNOffersList/CLNOffersList.scss index 7ef3460e..592f2dcd 100644 --- a/apps/frontend/src/components/cln/CLNOffersList/CLNOffersList.scss +++ b/apps/frontend/src/components/cln/CLNOffersList/CLNOffersList.scss @@ -1,4 +1,5 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; +@import 'bootstrap/scss/mixins'; .cln-offers-list { cursor: pointer; diff --git a/apps/frontend/src/components/cln/CLNOffersList/CLNOffersList.test.tsx b/apps/frontend/src/components/cln/CLNOffersList/CLNOffersList.test.tsx index d7f9f9ea..18d48d2d 100644 --- a/apps/frontend/src/components/cln/CLNOffersList/CLNOffersList.test.tsx +++ b/apps/frontend/src/components/cln/CLNOffersList/CLNOffersList.test.tsx @@ -1,5 +1,5 @@ import { screen, within } from '@testing-library/react'; -import { mockAppStore, mockBKPRStoreData, mockCLNStoreData, mockListOffers, mockRootStoreData } from '../../../utilities/test-utilities/mockData'; +import { mockAppStore, mockBKPRStoreData, mockCLNStoreData, mockRootStoreData } from '../../../utilities/test-utilities/mockData'; import { renderWithProviders } from '../../../utilities/test-utilities/mockStore'; import CLNOffersList from './CLNOffersList'; @@ -10,8 +10,10 @@ describe('CLNOffersList component ', () => { cln: { ...mockCLNStoreData, listOffers: { - ...mockListOffers, - isLoading: true + isLoading: true, + page: 1, + hasMore: true, + offers: [] } }, bkpr: mockBKPRStoreData @@ -32,6 +34,8 @@ describe('CLNOffersList component ', () => { ...mockCLNStoreData, listOffers: { isLoading: false, + page: 1, + hasMore: true, offers: [], error: 'error message' } @@ -57,6 +61,8 @@ describe('CLNOffersList component ', () => { ...mockCLNStoreData, listOffers: { isLoading: false, + page: 1, + hasMore: true, offers: [], } }, diff --git a/apps/frontend/src/components/cln/CLNOffersList/CLNOffersList.tsx b/apps/frontend/src/components/cln/CLNOffersList/CLNOffersList.tsx index 92ee427c..2d306f4c 100755 --- a/apps/frontend/src/components/cln/CLNOffersList/CLNOffersList.tsx +++ b/apps/frontend/src/components/cln/CLNOffersList/CLNOffersList.tsx @@ -1,17 +1,20 @@ import './CLNOffersList.scss'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Row, Col, Spinner, Alert } from 'react-bootstrap'; import PerfectScrollbar from 'react-perfect-scrollbar'; import { IncomingArrowSVG } from '../../../svgs/IncomingArrow'; import Offer from '../CLNOffer/CLNOffer'; -import { SCROLL_BATCH_SIZE, SCROLL_THRESHOLD, TRANSITION_DURATION } from '../../../utilities/constants'; +import { SCROLL_PAGE_SIZE, SCROLL_THRESHOLD, TRANSITION_DURATION } from '../../../utilities/constants'; import { NoCLNTransactionLightSVG } from '../../../svgs/NoCLNTransactionLight'; import { NoCLNTransactionDarkSVG } from '../../../svgs/NoCLNTransactionDark'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { selectListOffers } from '../../../store/clnSelectors'; import { selectIsAuthenticated, selectIsDarkMode } from '../../../store/rootSelectors'; +import { ListOffers } from '../../../types/cln.type'; +import { setListOffers, setListOffersLoading } from '../../../store/clnSlice'; +import { CLNService } from '../../../services/http.service'; const OfferHeader = ({ offer }) => { return ( @@ -51,7 +54,6 @@ const CLNOffersAccordion = ({ i, expanded, setExpanded, - initExpansions, offer, }) => { const isDarkMode = useSelector(selectIsDarkMode); @@ -64,8 +66,7 @@ const CLNOffersAccordion = ({ animate={{ backgroundColor: (isDarkMode ? (expanded[i] ? '#0C0C0F' : '#2A2A2C') : (expanded[i] ? '#EBEFF9' : '#FFFFFF')) }} transition={{ duration: TRANSITION_DURATION }} onClick={() => { - initExpansions[i] = !expanded[i]; - return setExpanded(initExpansions); + setExpanded(expanded.map((_, idx) => idx === i ? !expanded[i] : false)); }} > @@ -94,115 +95,135 @@ const CLNOffersAccordion = ({ }; export const CLNOffersList = () => { + const dispatch = useDispatch(); const isDarkMode = useSelector(selectIsDarkMode); const isAuthenticated = useSelector(selectIsAuthenticated); - const listOffers = useSelector(selectListOffers); - const initExpansions = (listOffers.offers?.reduce((acc: boolean[]) => [...acc, false], []) || []); - const [expanded, setExpanded] = useState(initExpansions); + const { offers, isLoading, page, hasMore, error } = useSelector(selectListOffers); + const [expanded, setExpanded] = useState(new Array(offers.length).fill(false)); - const [displayedOffers, setDisplayedOffers] = useState([]); - const [currentIndex, setCurrentIndex] = useState(0); - const [isLoading, setIsLoading] = useState(false); - const [allOffersLoaded, setAllOffersLoaded] = useState(false); - const containerRef = useRef(null); - - const setContainerRef = useCallback((ref: HTMLElement | null) => { - if (ref) { - (containerRef as React.MutableRefObject).current = ref; - } - }, []); - - useEffect(() => { - if (listOffers && listOffers.offers && listOffers.offers.length > 0) { - const initialBatch = listOffers.offers.slice(0, SCROLL_BATCH_SIZE); - setDisplayedOffers(initialBatch); - setCurrentIndex(SCROLL_BATCH_SIZE); - if (SCROLL_BATCH_SIZE >= listOffers?.offers?.length) { - setAllOffersLoaded(true); + // Load more offers from API + const loadMoreOffers = useCallback(async () => { + if (isLoading || !hasMore) return; + + dispatch(setListOffersLoading(true)); + + try { + const offset = page * SCROLL_PAGE_SIZE; + const listOffersRes: any = await CLNService.listOffers(offset); + if (listOffersRes.error) { + dispatch(setListOffers({ + error: listOffersRes.error + } as ListOffers)); + return; } + setExpanded(prev => [...prev, ...new Array(listOffersRes.offers.length).fill(false)]); + dispatch(setListOffers({ + ...listOffersRes, + page: page + 1, + hasMore: listOffersRes.offers.length >= SCROLL_PAGE_SIZE, + } as ListOffers)); + } catch (error: any) { + dispatch(setListOffers({ + error: error.message || 'Failed to load offers' + } as ListOffers)); + } finally { + dispatch(setListOffersLoading(false)); } - }, [listOffers]); - - const loadMoreTransactions = useCallback(() => { - if (isLoading || allOffersLoaded) return; - setIsLoading(true); - setTimeout(() => { - const nextIndex = currentIndex + SCROLL_BATCH_SIZE; - const newOffers = listOffers?.offers?.slice( - currentIndex, - nextIndex - ) || []; - setDisplayedOffers(prev => [...prev, ...newOffers]); - setCurrentIndex(nextIndex); - - if (listOffers && listOffers.offers && nextIndex >= listOffers?.offers?.length) { - setAllOffersLoaded(true); - } - - setIsLoading(false); - }, 300); - }, [currentIndex, isLoading, allOffersLoaded, listOffers]); + }, [isLoading, hasMore, page, dispatch]); - const handleScroll = useCallback((container) => { - if (!container || isLoading || allOffersLoaded) return; + // Scroll handler + const handleScroll = useCallback((container: HTMLElement) => { + if (!container || isLoading || !hasMore) return; const { scrollTop, scrollHeight, clientHeight } = container; const bottomOffset = scrollHeight - scrollTop - clientHeight; if (bottomOffset < SCROLL_THRESHOLD) { - loadMoreTransactions(); + loadMoreOffers(); } - }, [isLoading, allOffersLoaded, loadMoreTransactions]); + }, [isLoading, hasMore, loadMoreOffers]); - useEffect(() => { - const container = containerRef.current; - if (container) { - container?.addEventListener('scroll', handleScroll); - return () => container?.removeEventListener('scroll', handleScroll); - } - }, [handleScroll]); - - return ( - isAuthenticated && listOffers.isLoading ? + // Render initial loading state + if (isAuthenticated && isLoading && offers.length === 0) { + return ( - - - : - listOffers.error ? - {listOffers.error} : - listOffers?.offers && listOffers?.offers.length && listOffers?.offers.length > 0 ? - - {displayedOffers.map((offer, i) => ( - - ))} - {isLoading && ( - - - - )} - {allOffersLoaded && listOffers?.offers.length > 100 && -
No more offers to load!
+ + + ); + } + + // Render error state + if (error && offers.length === 0) { + return ( + + {error} + + ); + } + + // Render empty state + if (!offers || offers.length === 0) { + return ( + + + {isDarkMode ? + : + } -
- : - - - { isDarkMode ? - : - - } - No offer found. Click receive to generate new offer! + + No offer found. Click receive to generate new offer! + + ); + } + + // Render offers list + return ( + + {offers.map((offer, i) => ( + + ))} + + {isLoading && ( + + + Loading more offers... + + )} + + {!hasMore && offers.length > 0 && ( +
+ No more offers to load! +
+ )} + + {error && offers.length > 0 && ( + + {error} + + + )} +
); }; diff --git a/apps/frontend/src/components/cln/CLNSend/CLNSend.tsx b/apps/frontend/src/components/cln/CLNSend/CLNSend.tsx index 9ae40683..d23ed8c2 100755 --- a/apps/frontend/src/components/cln/CLNSend/CLNSend.tsx +++ b/apps/frontend/src/components/cln/CLNSend/CLNSend.tsx @@ -287,7 +287,7 @@ const CLNSend = (props) => { {decodeResponse.description} diff --git a/apps/frontend/src/components/cln/CLNTransaction/CLNTransaction.scss b/apps/frontend/src/components/cln/CLNTransaction/CLNTransaction.scss index d004f60c..a3cf95a0 100644 --- a/apps/frontend/src/components/cln/CLNTransaction/CLNTransaction.scss +++ b/apps/frontend/src/components/cln/CLNTransaction/CLNTransaction.scss @@ -1,4 +1,5 @@ -@import '../../../styles/constants.scss'; +@use 'sass:color'; +@use '../../../styles/constants' as *; .cln-transaction-placeholder { transform-origin: top left; @@ -13,7 +14,7 @@ cursor: pointer; &:hover { svg path { - stroke: darken($primary, 10%); + stroke: color.adjust($primary, $lightness: -10%); } } } diff --git a/apps/frontend/src/components/cln/CLNTransaction/CLNTransaction.tsx b/apps/frontend/src/components/cln/CLNTransaction/CLNTransaction.tsx index 750611bc..a424f4d1 100755 --- a/apps/frontend/src/components/cln/CLNTransaction/CLNTransaction.tsx +++ b/apps/frontend/src/components/cln/CLNTransaction/CLNTransaction.tsx @@ -18,7 +18,7 @@ const Payment = ({ payment, copyHandler }) => { Transaction Fee (mSats) - + {payment.amount_sent_msat ? formatCurrency( payment.amount_sent_msat - payment.amount_msat, @@ -39,7 +39,7 @@ const Payment = ({ payment, copyHandler }) => { Invoice - + {payment.bolt11 || payment.bolt12} @@ -54,7 +54,7 @@ const Payment = ({ payment, copyHandler }) => { Preimage - + {payment.payment_preimage} @@ -76,7 +76,7 @@ const Invoice = ({ invoice, copyHandler }) => { Valid till - + @@ -88,7 +88,7 @@ const Invoice = ({ invoice, copyHandler }) => { Invoice - + {invoice.bolt11 || invoice.bolt12} @@ -103,7 +103,7 @@ const Invoice = ({ invoice, copyHandler }) => { Preimage - + {invoice.payment_preimage} diff --git a/apps/frontend/src/components/cln/CLNTransactionsList/CLNTransactionsList.scss b/apps/frontend/src/components/cln/CLNTransactionsList/CLNTransactionsList.scss index 62c9f1f1..ecbe0201 100644 --- a/apps/frontend/src/components/cln/CLNTransactionsList/CLNTransactionsList.scss +++ b/apps/frontend/src/components/cln/CLNTransactionsList/CLNTransactionsList.scss @@ -1,4 +1,5 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; +@import 'bootstrap/scss/mixins'; .cln-transactions-list { cursor: pointer; diff --git a/apps/frontend/src/components/cln/CLNTransactionsList/CLNTransactionsList.test.tsx b/apps/frontend/src/components/cln/CLNTransactionsList/CLNTransactionsList.test.tsx index 3329eedc..5f1264fc 100644 --- a/apps/frontend/src/components/cln/CLNTransactionsList/CLNTransactionsList.test.tsx +++ b/apps/frontend/src/components/cln/CLNTransactionsList/CLNTransactionsList.test.tsx @@ -11,6 +11,8 @@ describe('CLNTransactionsList component ', () => { ...mockCLNStoreData, listLightningTransactions: { isLoading: true, + page: 1, + hasMore: true, clnTransactions: [] } }, @@ -32,6 +34,8 @@ describe('CLNTransactionsList component ', () => { ...mockCLNStoreData, listLightningTransactions: { isLoading: false, + page: 1, + hasMore: true, clnTransactions: [], error: 'error message' } @@ -63,13 +67,15 @@ describe('CLNTransactionsList component ', () => { ...mockCLNStoreData, listLightningTransactions: { isLoading: false, + page: 1, + hasMore: true, clnTransactions: [] } }, bkpr: mockBKPRStoreData }; await renderWithProviders(, { preloadedState: customMockStore, initialRoute: ['/cln'] }); - expect(screen.getByText('No transaction found. Open channel to start!')).toBeInTheDocument(); + expect(screen.getByText('No channel found. Open channel to start!')).toBeInTheDocument(); }); it('if there are are active channels, show the text saying to use a channel', async () => { @@ -85,6 +91,8 @@ describe('CLNTransactionsList component ', () => { ...mockCLNStoreData, listLightningTransactions: { isLoading: false, + page: 1, + hasMore: true, clnTransactions: [] } }, diff --git a/apps/frontend/src/components/cln/CLNTransactionsList/CLNTransactionsList.tsx b/apps/frontend/src/components/cln/CLNTransactionsList/CLNTransactionsList.tsx index 29143918..c8edd0e5 100755 --- a/apps/frontend/src/components/cln/CLNTransactionsList/CLNTransactionsList.tsx +++ b/apps/frontend/src/components/cln/CLNTransactionsList/CLNTransactionsList.tsx @@ -1,5 +1,5 @@ import './CLNTransactionsList.scss'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Row, Col, Spinner, Alert } from 'react-bootstrap'; import PerfectScrollbar from 'react-perfect-scrollbar'; @@ -9,12 +9,15 @@ import { OutgoingArrowSVG } from '../../../svgs/OutgoingArrow'; import DateBox from '../../shared/DateBox/DateBox'; import FiatBox from '../../shared/FiatBox/FiatBox'; import Transaction from '../CLNTransaction/CLNTransaction'; -import { TRANSITION_DURATION, Units, TODAY, SCROLL_BATCH_SIZE, SCROLL_THRESHOLD } from '../../../utilities/constants'; +import { TRANSITION_DURATION, Units, TODAY, SCROLL_PAGE_SIZE, SCROLL_THRESHOLD } from '../../../utilities/constants'; import { NoCLNTransactionLightSVG } from '../../../svgs/NoCLNTransactionLight'; import { NoCLNTransactionDarkSVG } from '../../../svgs/NoCLNTransactionDark'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { selectActiveChannelsExist, selectFiatConfig, selectFiatUnit, selectIsAuthenticated, selectIsDarkMode, selectUIConfigUnit } from '../../../store/rootSelectors'; import { selectListLightningTransactions } from '../../../store/clnSelectors'; +import { setListLightningTransactions, setListLightningTransactionsLoading } from '../../../store/clnSlice'; +import { CLNService } from '../../../services/http.service'; +import { ListLightningTransactions } from '../../../types/cln.type'; const PaymentHeader = ({ payment }) => { const fiatUnit = useSelector(selectFiatUnit); @@ -159,7 +162,6 @@ const CLNTransactionsAccordion = ({ i, expanded, setExpanded, - initExpansions, transaction, }) => { const isDarkMode = useSelector(selectIsDarkMode); @@ -172,8 +174,7 @@ const CLNTransactionsAccordion = ({ animate={{ backgroundColor: (isDarkMode ? (expanded[i] ? '#0C0C0F' : '#2A2A2C') : (expanded[i] ? '#EBEFF9' : '#FFFFFF')) }} transition={{ duration: TRANSITION_DURATION }} onClick={() => { - initExpansions[i] = !expanded[i]; - return setExpanded(initExpansions); + setExpanded(expanded.map((_, idx) => idx === i ? !expanded[i] : false)); }} > {transaction.type?.toLowerCase() === 'payment' ? ( @@ -205,58 +206,46 @@ const CLNTransactionsAccordion = ({ }; export const CLNTransactionsList = () => { + const dispatch = useDispatch(); const isDarkMode = useSelector(selectIsDarkMode); const isAuthenticated = useSelector(selectIsAuthenticated); const activeChannelsExist = useSelector(selectActiveChannelsExist); - const listLightningTransactions = useSelector(selectListLightningTransactions); - const initExpansions = (listLightningTransactions.clnTransactions?.reduce((acc: boolean[]) => [...acc, false], []) || []); - const [expanded, setExpanded] = useState(initExpansions); + const { clnTransactions, isLoading, page, hasMore, error } = useSelector(selectListLightningTransactions); + const [expanded, setExpanded] = useState(new Array(clnTransactions.length).fill(false)); - const [displayedTransactions, setDisplayedTransactions] = useState([]); - const [currentIndex, setCurrentIndex] = useState(0); - const [isLoading, setIsLoading] = useState(false); - const [allTransactionsLoaded, setAllTransactionsLoaded] = useState(false); - const containerRef = useRef(null); - - const setContainerRef = useCallback((ref: HTMLElement | null) => { - if (ref) { - (containerRef as React.MutableRefObject).current = ref; - } - }, []); - - useEffect(() => { - if (listLightningTransactions?.clnTransactions?.length > 0) { - const initialBatch = listLightningTransactions.clnTransactions.slice(0, SCROLL_BATCH_SIZE); - setDisplayedTransactions(initialBatch); - setCurrentIndex(SCROLL_BATCH_SIZE); - if (SCROLL_BATCH_SIZE >= listLightningTransactions.clnTransactions.length) { - setAllTransactionsLoaded(true); + // Load more transactions from API + const loadMoreTransactions = useCallback(async () => { + if (isLoading || !hasMore) return; + + dispatch(setListLightningTransactionsLoading(true)); + + try { + const offset = page * SCROLL_PAGE_SIZE; + const listClnTransactionsRes: any = await CLNService.listLightningTransactions(offset); + if (listClnTransactionsRes.error) { + dispatch(setListLightningTransactions({ + error: listClnTransactionsRes.error + } as ListLightningTransactions)); + return; } + setExpanded(prev => [...prev, ...new Array(listClnTransactionsRes.clnTransactions?.length).fill(false)]); + dispatch(setListLightningTransactions({ + ...listClnTransactionsRes, + page: page + 1, + hasMore: listClnTransactionsRes.clnTransactions?.length >= SCROLL_PAGE_SIZE, // Could be greater also due to unique_timestamps aggregation + } as ListLightningTransactions)); + } catch (error: any) { + dispatch(setListLightningTransactions({ + error: error.message || 'Failed to load transactions' + } as ListLightningTransactions)); + } finally { + dispatch(setListLightningTransactionsLoading(false)); } - }, [listLightningTransactions]); - - const loadMoreTransactions = useCallback(() => { - if (isLoading || allTransactionsLoaded) return; - setIsLoading(true); - setTimeout(() => { - const nextIndex = currentIndex + SCROLL_BATCH_SIZE; - const newTransactions = listLightningTransactions.clnTransactions.slice( - currentIndex, - nextIndex - ); - setDisplayedTransactions(prev => [...prev, ...newTransactions]); - setCurrentIndex(nextIndex); - - if (nextIndex >= listLightningTransactions.clnTransactions.length) { - setAllTransactionsLoaded(true); - } - - setIsLoading(false); - }, 300); - }, [currentIndex, isLoading, allTransactionsLoaded, listLightningTransactions]); + }, [isLoading, hasMore, page, dispatch]); - const handleScroll = useCallback((container) => { - if (!container || isLoading || allTransactionsLoaded) return; + // Scroll handler + const handleScroll = useCallback((container: HTMLElement) => { + if (!container || isLoading || !hasMore) return; const { scrollTop, scrollHeight, clientHeight } = container; const bottomOffset = scrollHeight - scrollTop - clientHeight; @@ -264,62 +253,92 @@ export const CLNTransactionsList = () => { if (bottomOffset < SCROLL_THRESHOLD) { loadMoreTransactions(); } - }, [isLoading, allTransactionsLoaded, loadMoreTransactions]); + }, [isLoading, hasMore, loadMoreTransactions]); - useEffect(() => { - const container = containerRef.current; - if (container) { - container?.addEventListener('scroll', handleScroll); - return () => container?.removeEventListener('scroll', handleScroll); - } - }, [handleScroll]); - - return ( - isAuthenticated && listLightningTransactions.isLoading ? + // Render initial loading state + if (isAuthenticated && isLoading && clnTransactions.length === 0) { + return ( - : - listLightningTransactions.error ? - {listLightningTransactions.error} : - listLightningTransactions?.clnTransactions && listLightningTransactions?.clnTransactions.length && listLightningTransactions?.clnTransactions.length > 0 ? - - {displayedTransactions.map((transaction, i) => ( - - ))} - {isLoading && ( - - - - )} - {allTransactionsLoaded && listLightningTransactions?.clnTransactions.length > 100 && -
No more transactions to load!
+ ); + } + + // Render error state + if (error && clnTransactions.length === 0) { + return ( + + {error} + + ); + } + + // Render empty state + if (!clnTransactions || clnTransactions.length === 0) { + return ( + + + {isDarkMode ? + : + } -
- : - - - {isDarkMode ? - : - + + {activeChannelsExist ? + 'No transaction found. Click send/receive to start!' : + 'No channel found. Open channel to start!' } - - {activeChannelsExist ? - 'No transaction found. Click send/receive to start!' : - 'No transaction found. Open channel to start!' - } - + + ); + } + + // Render transactions list + return ( + + {clnTransactions.map((transaction, i) => ( + + ))} + + {isLoading && ( + + + Loading more transactions... + + )} + + {!hasMore && clnTransactions.length > 0 && ( +
+ No more transactions to load! +
+ )} + + {error && clnTransactions.length > 0 && ( + + {error} + + + )} +
); }; diff --git a/apps/frontend/src/components/cln/CLNWallet/CLNWallet.scss b/apps/frontend/src/components/cln/CLNWallet/CLNWallet.scss index 3ae7e93f..d1a2ea77 100644 --- a/apps/frontend/src/components/cln/CLNWallet/CLNWallet.scss +++ b/apps/frontend/src/components/cln/CLNWallet/CLNWallet.scss @@ -1,4 +1,4 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .nav.cln-transactions-tabs { border: none; diff --git a/apps/frontend/src/components/cln/CLNWallet/CLNWallet.tsx b/apps/frontend/src/components/cln/CLNWallet/CLNWallet.tsx index a0ca4838..e2e6908f 100755 --- a/apps/frontend/src/components/cln/CLNWallet/CLNWallet.tsx +++ b/apps/frontend/src/components/cln/CLNWallet/CLNWallet.tsx @@ -8,16 +8,74 @@ import { LightningWalletSVG } from '../../../svgs/LightningWallet'; import { WithdrawSVG } from '../../../svgs/Withdraw'; import { DepositSVG } from '../../../svgs/Deposit'; import CurrencyBox from '../../shared/CurrencyBox/CurrencyBox'; -import { TRANSITION_DURATION } from '../../../utilities/constants'; -import { useSelector } from 'react-redux'; +import { SCROLL_PAGE_SIZE, TRANSITION_DURATION } from '../../../utilities/constants'; +import { useDispatch, useSelector } from 'react-redux'; import { selectIsAuthenticated, selectWalletBalances } from '../../../store/rootSelectors'; import { Loading } from '../../ui/Loading/Loading'; +import { RefreshSVG } from '../../../svgs/Refresh'; +import { resetListLightningTransactions, resetListOffers, setListLightningTransactions, setListLightningTransactionsLoading, setListOffers, setListOffersLoading } from '../../../store/clnSlice'; +import { CLNService } from '../../../services/http.service'; +import { ListLightningTransactions, ListOffers } from '../../../types/cln.type'; const CLNWallet = (props) => { + const dispatch = useDispatch(); const isAuthenticated = useSelector(selectIsAuthenticated); const walletBalances = useSelector(selectWalletBalances); const [selectedTab, setSelectedTab] = useState('transactions'); + const refreshHandler = async (calledBy) => { + if(calledBy === 'OFFERS') { + dispatch(setListOffersLoading(true)); + dispatch(resetListOffers()); + + try { + const offset = 0; + const listOffersRes: any = await CLNService.listOffers(offset); + if (listOffersRes.error) { + dispatch(setListOffers({ + error: listOffersRes.error + } as ListOffers)); + return; + } + dispatch(setListOffers({ + ...listOffersRes, + page: 1, + hasMore: listOffersRes.offers.length >= SCROLL_PAGE_SIZE, + } as ListOffers)); + } catch (error: any) { + dispatch(setListOffers({ + error: error.message || 'Failed to load offers' + } as ListOffers)); + } finally { + dispatch(setListOffersLoading(false)); + } + } else { + dispatch(setListLightningTransactionsLoading(true)); + dispatch(resetListLightningTransactions()); + try { + const offset = 0; + const listClnTransactionsRes: any = await CLNService.listLightningTransactions(offset); + if (listClnTransactionsRes.error) { + dispatch(setListLightningTransactions({ + error: listClnTransactionsRes.error + } as ListLightningTransactions)); + return; + } + dispatch(setListLightningTransactions({ + ...listClnTransactionsRes, + page: 1, + hasMore: listClnTransactionsRes.clnTransactions?.length >= SCROLL_PAGE_SIZE, // Could be greater also due to unique_timestamps aggregation + } as ListLightningTransactions)); + } catch (error: any) { + dispatch(setListLightningTransactions({ + error: error.message || 'Failed to load transactions' + } as ListLightningTransactions)); + } finally { + dispatch(setListLightningTransactionsLoading(false)); + } + } + } + return ( @@ -64,12 +122,20 @@ const CLNWallet = (props) => { diff --git a/apps/frontend/src/components/cln/ChannelDetails/ChannelDetails.scss b/apps/frontend/src/components/cln/ChannelDetails/ChannelDetails.scss index b20ce8cf..ec4248da 100644 --- a/apps/frontend/src/components/cln/ChannelDetails/ChannelDetails.scss +++ b/apps/frontend/src/components/cln/ChannelDetails/ChannelDetails.scss @@ -1,4 +1,5 @@ -@import '../../../styles/constants.scss'; +@use 'sass:color'; +@use '../../../styles/constants' as *; .channel-scroll-container { max-height: 53vh; @@ -12,7 +13,7 @@ cursor: pointer; &:hover { svg path { - stroke: darken($primary, 10%); + stroke: color.adjust($primary, $lightness: -10%); } } } @@ -23,7 +24,7 @@ cursor: pointer; &:hover { svg path { - fill: darken($primary, 10%); + fill: color.adjust($primary, $lightness: -10%); } } } diff --git a/apps/frontend/src/components/cln/Channels/Channels.scss b/apps/frontend/src/components/cln/Channels/Channels.scss index f0c8a64a..aa5069fe 100644 --- a/apps/frontend/src/components/cln/Channels/Channels.scss +++ b/apps/frontend/src/components/cln/Channels/Channels.scss @@ -1,7 +1,16 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; +@import 'bootstrap/scss/mixins'; .channels-scroll-container { overflow: hidden; + & .scrollbar-container.ps.ps--active-y .list-channels { + & .list-item-channel { + margin-right: 0.5rem; + & .list-item-div { + padding-right: 0.5rem; + } + } + } & .list-channels { transition: background-color $theme-transition ease; & .list-item-channel { diff --git a/apps/frontend/src/components/cln/Channels/Channels.tsx b/apps/frontend/src/components/cln/Channels/Channels.tsx index e69df8b3..80ea2fd1 100755 --- a/apps/frontend/src/components/cln/Channels/Channels.tsx +++ b/apps/frontend/src/components/cln/Channels/Channels.tsx @@ -19,7 +19,7 @@ const Channels = (props) => { return ( - Payment Channels + Payment Channels { isAuthenticated && listChannels.isLoading ? @@ -39,7 +39,7 @@ const Channels = (props) => { variants={props.newlyOpenedChannelId === channel.channel_id ? STAGERRED_SPRING_VARIANTS_3 : {}} initial='hidden' animate='visible' exit='hidden' custom={0} onClick={() => (props.onChannelClick(channel))} > -
+
<>
{ const isAuthenticated = useSelector(selectIsAuthenticated); const walletBalances = useSelector(selectWalletBalances); - const listPeers = useSelector(selectListPeers); + const numPeers = useSelector(selectNumPeers); const listChannels = useSelector(selectListChannels); const currentScreenSize = useBreakpoint(); const countChannels: any = useMotionValue(0); @@ -25,7 +24,7 @@ const Overview = () => { const roundedPeers: any = useTransform(countPeers, Math.round); useEffect(() => { - if (listChannels.activeChannels?.length > 0 && countChannels.prev === 0) { + if (listChannels.activeChannels?.length > 0 && (countChannels.prev === 0 || countChannels.prev === undefined)) { countChannels.current = 0; countChannels.prev = 0; const animationChannels = animate(countChannels, listChannels.activeChannels?.length, { duration: COUNTUP_DURATION }); @@ -38,19 +37,22 @@ const Overview = () => { }, [listChannels.activeChannels, countChannels]); useEffect(() => { - if (listPeers.peers && listPeers.peers.length && listPeers.peers.length > 0 - && countPeers.prev === 0) { + if (numPeers && numPeers > 0 && (countPeers.prev === 0 || countPeers.prev === undefined)) { countPeers.current = 0; countPeers.prev = 0; - const animationPeers = animate(countPeers, listPeers.peers.length, { duration: COUNTUP_DURATION }); + const animationPeers = animate(countPeers, numPeers, { duration: COUNTUP_DURATION }); + return animationPeers.stop; + } else { + countPeers.current = numPeers; + const animationPeers = animate(countPeers, numPeers, { duration: COUNTUP_DURATION }); return animationPeers.stop; } - }, [listPeers.peers, countPeers]); + }, [numPeers, countPeers]); return ( - + diff --git a/apps/frontend/src/components/modals/ConnectWallet/ConnectWallet.scss b/apps/frontend/src/components/modals/ConnectWallet/ConnectWallet.scss index 13983f60..fc458004 100644 --- a/apps/frontend/src/components/modals/ConnectWallet/ConnectWallet.scss +++ b/apps/frontend/src/components/modals/ConnectWallet/ConnectWallet.scss @@ -1,4 +1,4 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .qr-container { cursor: zoom-in; diff --git a/apps/frontend/src/components/modals/Logout/Logout.scss b/apps/frontend/src/components/modals/Logout/Logout.scss index 31289fe5..89f25392 100644 --- a/apps/frontend/src/components/modals/Logout/Logout.scss +++ b/apps/frontend/src/components/modals/Logout/Logout.scss @@ -1,4 +1,4 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .modal-content { & .modal-body { diff --git a/apps/frontend/src/components/modals/NodeInfo/NodeInfo.scss b/apps/frontend/src/components/modals/NodeInfo/NodeInfo.scss index 18e152b9..69e88baa 100644 --- a/apps/frontend/src/components/modals/NodeInfo/NodeInfo.scss +++ b/apps/frontend/src/components/modals/NodeInfo/NodeInfo.scss @@ -1,4 +1,4 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .fa-circle-xmark { & path { diff --git a/apps/frontend/src/components/modals/QRCodeLarge/QRCodeLarge.scss b/apps/frontend/src/components/modals/QRCodeLarge/QRCodeLarge.scss index 40e78017..9d886914 100644 --- a/apps/frontend/src/components/modals/QRCodeLarge/QRCodeLarge.scss +++ b/apps/frontend/src/components/modals/QRCodeLarge/QRCodeLarge.scss @@ -1,4 +1,4 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .qr-container-large { position: relative; diff --git a/apps/frontend/src/components/modals/SQLTerminal/SQLTerminal.scss b/apps/frontend/src/components/modals/SQLTerminal/SQLTerminal.scss index 0d20db67..e213e983 100644 --- a/apps/frontend/src/components/modals/SQLTerminal/SQLTerminal.scss +++ b/apps/frontend/src/components/modals/SQLTerminal/SQLTerminal.scss @@ -1,4 +1,5 @@ -@import '../../../styles/constants.scss'; +@use 'sass:color'; +@use '../../../styles/constants' as *; .terminal-container { display: flex; @@ -40,10 +41,10 @@ &:focus-visible { outline: none; svg path { - stroke: darken($primary, 10%); + stroke: color.adjust($primary, $lightness: -10%); &.svg-add { stroke: none; - fill: darken($primary, 10%); + fill: color.adjust($primary, $lightness: -10%); } } } diff --git a/apps/frontend/src/components/modals/SQLTerminal/SQLTerminal.tsx b/apps/frontend/src/components/modals/SQLTerminal/SQLTerminal.tsx index 7cc0c993..59abcfdf 100644 --- a/apps/frontend/src/components/modals/SQLTerminal/SQLTerminal.tsx +++ b/apps/frontend/src/components/modals/SQLTerminal/SQLTerminal.tsx @@ -55,7 +55,7 @@ const SQLTerminal = () => { const handleExecute = useCallback(async () => { const formattedQuery = query.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim(); try { - const result = await RootService.executeSql(formattedQuery); + const result: any = await RootService.executeSql(formattedQuery); setOutput(JSON.stringify(result.rows, null, 2) + '\n\n'); setOutput(formattedQuery + '\n' + JSON.stringify(result.rows, null, 2) + '\n\n'); } catch (error: any) { diff --git a/apps/frontend/src/components/shared/CurrencyBox/CurrencyBox.tsx b/apps/frontend/src/components/shared/CurrencyBox/CurrencyBox.tsx index 1c2060ec..29548703 100755 --- a/apps/frontend/src/components/shared/CurrencyBox/CurrencyBox.tsx +++ b/apps/frontend/src/components/shared/CurrencyBox/CurrencyBox.tsx @@ -1,4 +1,3 @@ -import './CurrencyBox.scss'; import { useEffect, useState } from 'react'; import { motion, useMotionValue, useTransform, animate } from 'framer-motion'; import { OverlayTrigger, Tooltip } from 'react-bootstrap'; diff --git a/apps/frontend/src/components/shared/DataFilterOptions/DataFilterOptions.scss b/apps/frontend/src/components/shared/DataFilterOptions/DataFilterOptions.scss index e111ae1c..3648148e 100644 --- a/apps/frontend/src/components/shared/DataFilterOptions/DataFilterOptions.scss +++ b/apps/frontend/src/components/shared/DataFilterOptions/DataFilterOptions.scss @@ -1,4 +1,4 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .time-granularity-group { & .dropdown-toggle { diff --git a/apps/frontend/src/components/shared/DateBox/DateBox.tsx b/apps/frontend/src/components/shared/DateBox/DateBox.tsx index 64260c1f..a8bc8c50 100755 --- a/apps/frontend/src/components/shared/DateBox/DateBox.tsx +++ b/apps/frontend/src/components/shared/DateBox/DateBox.tsx @@ -3,17 +3,23 @@ import { OverlayTrigger, Tooltip } from 'react-bootstrap'; import { convertIntoDateFormat } from '../../../utilities/data-formatters'; const DateBox = props => { + const content = ( +
+ {convertIntoDateFormat(props.dataValue)} +
+ ); + + if (!props.showTooltip) { + return content; + } + return ( {props.dataType} : <> - } + overlay={{props.dataType}} > -
- {convertIntoDateFormat(props.dataValue)} -
+ {content}
); }; diff --git a/apps/frontend/src/components/shared/DatepickerInput/DatepickerInput.scss b/apps/frontend/src/components/shared/DatepickerInput/DatepickerInput.scss index d17fbcd6..94189051 100644 --- a/apps/frontend/src/components/shared/DatepickerInput/DatepickerInput.scss +++ b/apps/frontend/src/components/shared/DatepickerInput/DatepickerInput.scss @@ -1,4 +1,4 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .react-datepicker-wrapper { width: 6.5rem; diff --git a/apps/frontend/src/components/shared/FeerateRange/FeerateRange.scss b/apps/frontend/src/components/shared/FeerateRange/FeerateRange.scss index cc4e77c1..97e9bd63 100644 --- a/apps/frontend/src/components/shared/FeerateRange/FeerateRange.scss +++ b/apps/frontend/src/components/shared/FeerateRange/FeerateRange.scss @@ -1,4 +1,4 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .slider-container { width: 100%; diff --git a/apps/frontend/src/components/shared/FiatBox/FiatBox.scss b/apps/frontend/src/components/shared/FiatBox/FiatBox.scss index bc6294df..e832ce39 100644 --- a/apps/frontend/src/components/shared/FiatBox/FiatBox.scss +++ b/apps/frontend/src/components/shared/FiatBox/FiatBox.scss @@ -1,4 +1,4 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .fiat-box-span { & .svg-currency { diff --git a/apps/frontend/src/components/shared/FiatBox/FiatBox.tsx b/apps/frontend/src/components/shared/FiatBox/FiatBox.tsx index eff7dfe4..ad311b4a 100755 --- a/apps/frontend/src/components/shared/FiatBox/FiatBox.tsx +++ b/apps/frontend/src/components/shared/FiatBox/FiatBox.tsx @@ -1,4 +1,3 @@ -import './FiatBox.scss'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { formatFiatValue } from '../../../utilities/data-formatters'; diff --git a/apps/frontend/src/components/shared/FiatSelection/FiatSelection.scss b/apps/frontend/src/components/shared/FiatSelection/FiatSelection.scss index 5398690e..112e3e68 100644 --- a/apps/frontend/src/components/shared/FiatSelection/FiatSelection.scss +++ b/apps/frontend/src/components/shared/FiatSelection/FiatSelection.scss @@ -1,10 +1,11 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .fiat-dropdown.dropdown { & .svg-curr-symbol { margin-top: 3px; } & .dropdown-menu { + min-width: 5.5rem; & .dropdown-item { & .svg-currency { fill: $dark; @@ -17,8 +18,8 @@ } } & .fiat-dropdown-scroller { - max-height: 200px; - height: 200px; + max-height: 12rem; + height: 12rem; } } & button.dropdown-toggle { diff --git a/apps/frontend/src/components/shared/FiatSelection/FiatSelection.tsx b/apps/frontend/src/components/shared/FiatSelection/FiatSelection.tsx index 40c47b66..2b06bbee 100755 --- a/apps/frontend/src/components/shared/FiatSelection/FiatSelection.tsx +++ b/apps/frontend/src/components/shared/FiatSelection/FiatSelection.tsx @@ -45,14 +45,14 @@ const FiatSelection = (props) => {
{FIAT_CURRENCIES.map((fiat, i) => - + { fiat.symbol ? : } - + {fiat.currency} diff --git a/apps/frontend/src/components/shared/InvalidInputMessage/InvalidInputMessage.tsx b/apps/frontend/src/components/shared/InvalidInputMessage/InvalidInputMessage.tsx index 98b2e03a..148b1942 100755 --- a/apps/frontend/src/components/shared/InvalidInputMessage/InvalidInputMessage.tsx +++ b/apps/frontend/src/components/shared/InvalidInputMessage/InvalidInputMessage.tsx @@ -1,4 +1,3 @@ -import './InvalidInputMessage.scss'; import { motion } from 'framer-motion'; import { STAGERRED_SPRING_VARIANTS_2 } from '../../../utilities/constants'; import { InformationSVG } from '../../../svgs/Information'; diff --git a/apps/frontend/src/components/shared/StatusAlert/StatusAlert.scss b/apps/frontend/src/components/shared/StatusAlert/StatusAlert.scss index 32d30d5c..6b39618f 100644 --- a/apps/frontend/src/components/shared/StatusAlert/StatusAlert.scss +++ b/apps/frontend/src/components/shared/StatusAlert/StatusAlert.scss @@ -1,4 +1,5 @@ -@import '../../../styles/constants.scss'; +@use 'sass:color'; +@use '../../../styles/constants' as *; .alert { padding: 0.75rem; @@ -14,7 +15,7 @@ } &:hover { & path { - stroke: darken($danger, 25%); + stroke: color.adjust($danger, $lightness: -25%); } } } @@ -26,7 +27,7 @@ } &:hover { & path { - stroke: darken($success, 25%); + stroke: color.adjust($success, $lightness: -25%); } } } diff --git a/apps/frontend/src/components/shared/StatusAlert/StatusAlert.tsx b/apps/frontend/src/components/shared/StatusAlert/StatusAlert.tsx index a13229be..d1012cb5 100755 --- a/apps/frontend/src/components/shared/StatusAlert/StatusAlert.tsx +++ b/apps/frontend/src/components/shared/StatusAlert/StatusAlert.tsx @@ -1,4 +1,3 @@ -import './StatusAlert.scss'; import { motion } from 'framer-motion'; import { Spinner, Col } from 'react-bootstrap'; diff --git a/apps/frontend/src/components/shared/ToastMessage/ToastMessage.scss b/apps/frontend/src/components/shared/ToastMessage/ToastMessage.scss index 105f9711..569198c8 100644 --- a/apps/frontend/src/components/shared/ToastMessage/ToastMessage.scss +++ b/apps/frontend/src/components/shared/ToastMessage/ToastMessage.scss @@ -1,4 +1,5 @@ -@import '../../../styles/constants.scss'; +@use 'sass:color'; +@use '../../../styles/constants' as *; .toast-container { & .toast { @@ -26,7 +27,7 @@ } &:hover { & svg path { - stroke: darken($light, 15%); + stroke: color.adjust($light, $lightness: -15%); } } } @@ -74,7 +75,7 @@ span.btn-toast-close { &:hover { & svg path { - stroke: lighten($light-dark, 15%); + stroke: color.adjust($light-dark, $lightness: 15%); } } } diff --git a/apps/frontend/src/components/shared/ToastMessage/ToastMessage.tsx b/apps/frontend/src/components/shared/ToastMessage/ToastMessage.tsx index b7193b5c..e7f9b54e 100755 --- a/apps/frontend/src/components/shared/ToastMessage/ToastMessage.tsx +++ b/apps/frontend/src/components/shared/ToastMessage/ToastMessage.tsx @@ -1,4 +1,3 @@ -import './ToastMessage.scss'; import { useEffect, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { diff --git a/apps/frontend/src/components/shared/ToggleSwitch/ToggleSwitch.scss b/apps/frontend/src/components/shared/ToggleSwitch/ToggleSwitch.scss index 549ca93a..f6411e08 100644 --- a/apps/frontend/src/components/shared/ToggleSwitch/ToggleSwitch.scss +++ b/apps/frontend/src/components/shared/ToggleSwitch/ToggleSwitch.scss @@ -1,4 +1,6 @@ -@import '../../../styles/constants.scss'; +@use 'sass:color'; +@use '../../../styles/constants' as *; +@import 'bootstrap/scss/mixins'; .toggle { color: $dark; @@ -37,8 +39,8 @@ border-bottom-right-radius: 0.5rem; } &:hover { - background-color: lighten($primary, 5%); - border-color: lighten($primary, 5%); + background-color: color.adjust($primary, $lightness: 5%); + border-color: color.adjust($primary, $lightness: 5%); } } &[data-isswitchon='true'] { @@ -49,7 +51,7 @@ @include color-mode(dark) { .toggle { color: $white; - background: lighten($card-bg-dark, 10%); + background: color.adjust($card-bg-dark, $lightness: 10%); & .toggle-switch { color: $card-bg-dark; diff --git a/apps/frontend/src/components/ui/Header/Header.scss b/apps/frontend/src/components/ui/Header/Header.scss index 94e2032f..b14c34ba 100644 --- a/apps/frontend/src/components/ui/Header/Header.scss +++ b/apps/frontend/src/components/ui/Header/Header.scss @@ -1,5 +1,5 @@ @use 'sass:math'; -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .header { & .header-info-logo { diff --git a/apps/frontend/src/components/ui/Menu/Menu.scss b/apps/frontend/src/components/ui/Menu/Menu.scss index d3c258c5..2e6aeddb 100644 --- a/apps/frontend/src/components/ui/Menu/Menu.scss +++ b/apps/frontend/src/components/ui/Menu/Menu.scss @@ -1,6 +1,7 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .menu-dropdown.dropdown { + text-decoration: none; & .dropdown-toggle.btn-menu { background-color: $primary; width: 8rem; diff --git a/apps/frontend/src/components/ui/Settings/Settings.scss b/apps/frontend/src/components/ui/Settings/Settings.scss index 6ce93e58..b76d2654 100644 --- a/apps/frontend/src/components/ui/Settings/Settings.scss +++ b/apps/frontend/src/components/ui/Settings/Settings.scss @@ -1,4 +1,4 @@ -@import '../../../styles/constants.scss'; +@use '../../../styles/constants' as *; .settings-menu.dropdown { & .dropdown-toggle.btn-settings-menu { @@ -25,7 +25,6 @@ @include color-mode(light) { .settings-menu.dropdown { - margin-left: 0.5rem; & .dropdown-toggle.btn-settings-menu { color: $white; & svg path { diff --git a/apps/frontend/src/index.tsx b/apps/frontend/src/index.tsx index 97109845..26e97631 100755 --- a/apps/frontend/src/index.tsx +++ b/apps/frontend/src/index.tsx @@ -1,6 +1,7 @@ import ReactDOM from 'react-dom/client'; import { Provider } from 'react-redux'; import { RouterProvider } from 'react-router-dom'; +import { Loading } from './components/ui/Loading/Loading'; import { createRootRouter } from './routes/router.config'; import { HttpService, RootService } from './services/http.service'; import logger from './services/logger.service'; @@ -38,7 +39,10 @@ async function bootstrapApp() { const root = ReactDOM.createRoot(document.getElementById('root')!); root.render( - + } + /> ); } diff --git a/apps/frontend/src/routes/dataLoader.tsx b/apps/frontend/src/routes/dataLoader.tsx index 045e9b78..2e766934 100644 --- a/apps/frontend/src/routes/dataLoader.tsx +++ b/apps/frontend/src/routes/dataLoader.tsx @@ -6,13 +6,11 @@ import { AppState } from "../store/store.type"; export async function rootLoader({}: LoaderFunctionArgs) { const state = appStore.getState() as AppState; if (state.root.authStatus.isAuthenticated) { - const [connectwalletData, rootData] = await Promise.all([ - RootService.getConnectWallet(), - RootService.fetchRootData() - ]); - return { ...rootData, connectWallet: connectwalletData }; + const rootData = await RootService.fetchRootData(); + const refreshData = await RootService.refreshData(); + return [rootData, refreshData]; } - return null + return null; } export async function clnLoader({}: LoaderFunctionArgs) { diff --git a/apps/frontend/src/routes/router.config.tsx b/apps/frontend/src/routes/router.config.tsx index f9e74478..ca11c938 100644 --- a/apps/frontend/src/routes/router.config.tsx +++ b/apps/frontend/src/routes/router.config.tsx @@ -4,8 +4,8 @@ import { Loading } from '../components/ui/Loading/Loading'; import AccountEventsRoot from '../components/bookkeeper/AccountEvents/AccountEventsRoot'; import SatsFlowRoot from '../components/bookkeeper/SatsFlow/SatsFlowRoot'; import VolumeRoot from '../components/bookkeeper/Volume/VolumeRoot'; -import { bkprLoader, clnLoader, rootLoader } from './dataLoader'; -import { RootRouterReduxSync, CLNRouterReduxSync, BKPRRouterReduxSync } from './routerReduxSync'; +import { rootLoader } from './dataLoader'; +import { RootRouterReduxSync } from './routerReduxSync'; const App = lazy(() => import('../components/App/App')); const CLNHome = lazy(() => import('../components/cln/CLNHome/CLNHome')); @@ -20,6 +20,7 @@ export const rootRouteConfig = [ ), + HydrateFallback: () => , loader: rootLoader, children: [ { @@ -27,20 +28,16 @@ export const rootRouteConfig = [ element: ( }> - ), - loader: clnLoader, }, { path: 'bookkeeper', element: ( }> - ), - loader: bkprLoader, children: [ { path: 'accountevents', @@ -78,6 +75,7 @@ export function createRootRouter() { v7_relativeSplatPath: true, v7_normalizeFormMethod: true, v7_startTransition: true, + v7_partialHydration: true, } as any }); } diff --git a/apps/frontend/src/routes/routerReduxSync.tsx b/apps/frontend/src/routes/routerReduxSync.tsx index 59354a7d..a4b4fac0 100644 --- a/apps/frontend/src/routes/routerReduxSync.tsx +++ b/apps/frontend/src/routes/routerReduxSync.tsx @@ -1,50 +1,19 @@ -import { useLoaderData } from 'react-router-dom'; import { useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { clearBKPRStore, setAccountEvents, setSatsFlow, setVolume } from '../store/bkprSlice'; -import { clearCLNStore, setFeeRate, setListBitcoinTransactions, setListInvoices, setListOffers, setListPayments } from '../store/clnSlice'; -import { setListChannels, setListFunds, setListPeers, setNodeInfo, setConnectWallet } from '../store/rootSlice'; +import { clearBKPRStore } from '../store/bkprSlice'; +import { clearCLNStore } from '../store/clnSlice'; import { APP_WAIT_TIME } from '../utilities/constants'; import { useDispatch, useSelector } from 'react-redux'; -import { RootService } from '../services/http.service'; -import { RootLoaderData } from '../types/root.type'; -import { CLNLoaderData } from '../types/cln.type'; -import { BKPRLoaderData } from '../types/bookkeeper.type'; +import { BookkeeperService, CLNService, RootService } from '../services/http.service'; import { selectAuthStatus } from '../store/rootSelectors'; import logger from '../services/logger.service'; export function RootRouterReduxSync() { const navigate = useNavigate(); - const rootData = useLoaderData() as RootLoaderData; const dispatch = useDispatch(); const { pathname } = useLocation(); const authStatus = useSelector(selectAuthStatus); - - useEffect(() => { - if (!rootData) return; - - if (authStatus.isAuthenticated && authStatus.isValidPassword) { - if (rootData.nodeInfo) { - dispatch(setNodeInfo(rootData.nodeInfo)); - } - if (rootData.listChannels && rootData.listNodes) { - dispatch(setListChannels({ - listChannels: rootData.listChannels, - listNodes: rootData.listNodes - })); - } - if (rootData.listPeers) { - dispatch(setListPeers(rootData.listPeers)); - } - if (rootData.listFunds) { - dispatch(setListFunds(rootData.listFunds)); - } - if (rootData.connectWallet) { - dispatch(setConnectWallet(rootData.connectWallet)); - } - } - }, [authStatus.isAuthenticated, authStatus.isValidPassword, rootData, dispatch, pathname]); - + // Handle polling useEffect(() => { if (!authStatus?.isAuthenticated || !authStatus?.isValidPassword) return; @@ -52,11 +21,7 @@ export function RootRouterReduxSync() { const interval = setInterval(async () => { if (document.visibilityState === 'visible' && authStatus?.isAuthenticated) { try { - const rootData = await RootService.fetchRootData(); - dispatch(setNodeInfo(rootData?.nodeInfo)); - dispatch(setListChannels({listChannels: rootData?.listChannels, listNodes: rootData?.listNodes})); - dispatch(setListPeers(rootData?.listPeers)); - dispatch(setListFunds(rootData?.listFunds)); + await RootService.refreshData(); } catch (error) { logger.error('Error fetching root data:', error); } @@ -68,7 +33,24 @@ export function RootRouterReduxSync() { // Handle navigation for authenticated users useEffect(() => { + const fetchRouteData = async () => { + if (pathname.includes('/cln')) { + try { + await CLNService.fetchCLNData(); + } catch (error) { + logger.error('Error fetching CLN data:', error); + } + } + else if (pathname.includes('/bookkeeper')) { + try { + await BookkeeperService.fetchBKPRData(); + } catch (error) { + logger.error('Error fetching BKPR data:', error); + } + } + }; const targetPath = pathname.includes('/bookkeeper') ? pathname : '/cln'; + fetchRouteData(); if (pathname !== targetPath) { navigate(targetPath, { replace: true }); } @@ -88,65 +70,3 @@ export function RootRouterReduxSync() { return null; } - -export function CLNRouterReduxSync() { - const clnData = useLoaderData() as CLNLoaderData; - const dispatch = useDispatch(); - const authStatus = useSelector(selectAuthStatus); - - useEffect(() => { - if (!clnData) return; - if (authStatus.isAuthenticated && authStatus.isValidPassword) { - if (clnData.listInvoices) { - dispatch(setListInvoices(clnData.listInvoices)); - } - if (clnData.listSendPays) { - dispatch(setListPayments(clnData.listSendPays)); - } - if (clnData.listOffers) { - dispatch(setListOffers(clnData.listOffers)); - } - if (clnData.listAccountEvents) { - dispatch(setListBitcoinTransactions(clnData.listAccountEvents)); - } - if (clnData.feeRates) { - dispatch(setFeeRate(clnData.feeRates)); - } - } - }, [authStatus.isAuthenticated, authStatus.isValidPassword, clnData, dispatch]); - - return null; -} - -export function BKPRRouterReduxSync() { - const bkprData = useLoaderData() as BKPRLoaderData; - const dispatch = useDispatch(); - const authStatus = useSelector(selectAuthStatus); - - useEffect(() => { - if (!bkprData) return; - if (authStatus.isAuthenticated && authStatus.isValidPassword) { - if (bkprData.satsFlow) { - dispatch(setSatsFlow({ - satsFlow: bkprData.satsFlow, - timeGranularity: bkprData.timeGranularity, - startTimestamp: bkprData.startTimestamp, - endTimestamp: bkprData.endTimestamp - })); - } - if (bkprData.accountEvents) { - dispatch(setAccountEvents({ - accountEvents: bkprData.accountEvents, - timeGranularity: bkprData.timeGranularity, - startTimestamp: bkprData.startTimestamp, - endTimestamp: bkprData.endTimestamp - })); - } - if (bkprData.volume) { - dispatch(setVolume({ volume: bkprData.volume })); - } - } - }, [authStatus.isAuthenticated, authStatus.isValidPassword, bkprData, dispatch]); - - return null; -} diff --git a/apps/frontend/src/services/data-transform.service.ts b/apps/frontend/src/services/data-transform.service.ts index 0c160aab..77a151e2 100644 --- a/apps/frontend/src/services/data-transform.service.ts +++ b/apps/frontend/src/services/data-transform.service.ts @@ -10,7 +10,10 @@ import { BkprSummaryInfo, SummaryRoute, } from '../types/bookkeeper.type'; +import { BTCTransaction, LightningTransaction, Offer } from '../types/cln.type'; +import { PeerChannel } from '../types/root.type'; import { getPeriodKey, getTimestampFromPeriodKey, getTimestampWithGranularity, secondsForTimeGranularity, TimeGranularity } from '../utilities/constants'; +import { sortDescByKey } from '../utilities/data-formatters'; export function calculateAllPeriodKeys( calculateFor: string, @@ -358,8 +361,8 @@ const mapToAccountEventsAccounts = (row: (string | null | number)[]): AccountEve balance_msat: 0 as number, }); -export const convertArrayToAccountEventsObj = (raw: any[]): AccountEventsAccount[] => { - return raw.map(mapToAccountEventsAccounts).sort((a, b) => a.timestamp - b.timestamp); +export const convertArrayToAccountEventsObj = (row: any[]): AccountEventsAccount[] => { + return row.map(mapToAccountEventsAccounts).sort((a, b) => a.timestamp - b.timestamp); }; const mapToSatsFlowEvents = (row: (string | number)[]): SatsFlowEvent => ({ @@ -375,8 +378,8 @@ const mapToSatsFlowEvents = (row: (string | number)[]): SatsFlowEvent => ({ payment_id: row[9] as string, }); -export const convertArrayToSatsFlowObj = (raw: any[]): SatsFlowEvent[] => { - return raw.map(mapToSatsFlowEvents).sort((a, b) => a.timestamp - b.timestamp); +export const convertArrayToSatsFlowObj = (row: any[]): SatsFlowEvent[] => { + return row.map(mapToSatsFlowEvents).sort((a, b) => a.timestamp - b.timestamp); }; const mapToVolume = (row: (string | number)[]): VolumeRow => ({ @@ -391,6 +394,188 @@ const mapToVolume = (row: (string | number)[]): VolumeRow => ({ fee_msat: row[8] as number, }); -export const convertArrayToVolumeObj = (raw: any[]): VolumeRow[] => { - return raw.map(mapToVolume).sort((a, b) => a.fee_msat - b.fee_msat); +export const convertArrayToVolumeObj = (row: any[]): VolumeRow[] => { + return row.map(mapToVolume).sort((a, b) => a.fee_msat - b.fee_msat); +}; + +export const convertArrayToPeerChannelsObj = (rows: any[]): PeerChannel[] => { + const channels = rows.map((row: any[]) => ({ + node_alias: row[0], + peer_id: row[1], + channel_id: row[2], + short_channel_id: row[3], + state: row[4], + peer_connected: row[5], + to_us_msat: row[6], + total_msat: row[7], + their_to_self_delay: row[8], + opener: row[9], + private: row[10], + dust_limit_msat: row[11], + spendable_msat: row[12], + receivable_msat: row[13], + funding_txid: row[14], + current_state: '', + total_sat: Math.floor((row[7] || 0) / 1000), + to_us_sat: Math.floor((row[6] || 0) / 1000), + to_them_sat: Math.floor(((row[7] || 0) - (row[6] || 0)) / 1000) + })); + + return channels; +}; + +const paymentReducer = (accumulator, currentLightningTx) => { + const currPayHash = currentLightningTx.payment_hash; + currentLightningTx = { ...currentLightningTx }; + if(currentLightningTx.type === 'PAYMENT') { + if (!currentLightningTx.partid) { currentLightningTx.partid = 0; } + if (!accumulator[currPayHash]) { + accumulator[currPayHash] = [currentLightningTx]; + } else { + accumulator[currPayHash].push(currentLightningTx); + } + } else { + accumulator[currPayHash] = [currentLightningTx]; + } + return accumulator; +}; + +const summaryReducer = (accumulator, mpp) => { + if (mpp.status?.toLowerCase() === 'complete') { + accumulator.amount_msat = accumulator.amount_msat + mpp.amount_msat; + accumulator.amount_sent_msat = accumulator.amount_sent_msat + mpp.amount_sent_msat; + accumulator.status = mpp.status; + } + if (mpp.bolt11 && !accumulator.bolt11) { accumulator.bolt11 = mpp.bolt11; } + if (mpp.bolt12 && !accumulator.bolt12) { accumulator.bolt12 = mpp.bolt12; } + if (mpp.label && !accumulator.label) { accumulator.label = mpp.label; } + if (mpp.description && !accumulator.description) { accumulator.description = mpp.description; } + if (mpp.payment_preimage && !accumulator.payment_preimage) { accumulator.payment_preimage = mpp.payment_preimage; } + return accumulator; +}; + +const groupBy = (lightningTxs) => { + const lightningTxsInGroups = lightningTxs?.reduce(paymentReducer, {}); + const lightningTxsGrpArray = Object.keys(lightningTxsInGroups)?.map((key) => ( + lightningTxsInGroups[key][0].type === 'PAYMENT' + && lightningTxsInGroups[key].length + && lightningTxsInGroups[key].length > 1) + ? sortDescByKey(lightningTxsInGroups[key], 'partid') : lightningTxsInGroups[key]); + return lightningTxsGrpArray?.reduce((acc, curr) => { + let temp: any = {}; + if (curr.length && curr.length === 1) { + // For PAYMENT & INVOICE both + temp = JSON.parse(JSON.stringify(curr[0])); + if (curr[0].type === 'PAYMENT') { + temp.is_group = false; + temp.is_expanded = false; + temp.total_parts = 1; + delete temp.partid; + } + } else { + // Only applies on MPP PAYMENTS + const paySummary = curr?.reduce(summaryReducer, { amount_msat: 0, amount_sent_msat: 0, status: (curr[0] && curr[0].status) ? curr[0].status : 'failed' }); + temp = { + type: 'PAYMENT', is_group: true, is_expanded: false, total_parts: (curr.length ? curr.length : 0), status: paySummary.status, payment_hash: curr[0].payment_hash, + destination: curr[0].destination, amount_msat: paySummary.amount_msat, amount_sent_msat: paySummary.amount_sent_msat, created_at: curr[0].created_at, + mpps: curr + }; + if (paySummary.bolt11) { temp.bolt11 = paySummary.bolt11; } + if (paySummary.bolt12) { temp.bolt12 = paySummary.bolt12; } + if (paySummary.bolt11 && !temp.bolt11) { temp.bolt11 = paySummary.bolt11; } + if (paySummary.bolt12 && !temp.bolt12) { temp.bolt12 = paySummary.bolt12; } + if (paySummary.label && !temp.label) { temp.label = paySummary.label; } + if (paySummary.description && !temp.description) { temp.description = paySummary.description; } + if (paySummary.payment_preimage && !temp.payment_preimage) { temp.payment_preimage = paySummary.payment_preimage; } + } + return acc.concat(temp); + }, []); +}; + +export const convertArrayToLightningTransactionsObj = (rows: any[]): LightningTransaction[] => { + if (!rows || rows.length === 0) { return []; } + const lightningTransactions = rows.map((row: any[]) => { + const type = row[0]; + if (type === 'INVOICE') { + return { + type: 'INVOICE', + payment_hash: row[1], + status: row[2], + label: row[3], + description: row[4], + bolt11: row[5], + bolt12: row[6], + payment_preimage: row[7], + amount_msat: row[8], + amount_received_msat: row[9], + expires_at: row[10], + paid_at: row[11], + } as LightningTransaction; + } else { + return { + type: 'PAYMENT', + payment_hash: row[1], + status: row[2], + label: row[3], + description: row[4], + bolt11: row[5], + bolt12: row[6], + payment_preimage: row[7], + amount_msat: row[8], + amount_sent_msat: row[9], + created_at: row[10], + completed_at: row[11], + destination: row[12], + groupid: row[13], + partid: row[14], + } as LightningTransaction; + } + }); + const lightningTransactionsAfterGroupedPayments = groupBy(lightningTransactions); + return lightningTransactionsAfterGroupedPayments; +}; + +export const convertArrayToOffersObj = (rows: any[]): Offer[] => { + if (!rows || rows.length === 0) { return []; } + const offers = rows.map((row: any[]) => ({ + offer_id: row[0], + active: row[1], + single_use: row[2], + bolt12: row[3], + used: row[4], + label: row[5], + description: row[6], + })); + + return offers; +}; + +export const convertArrayToBTCTransactionsObj = (rows: any[]): BTCTransaction[] => { + if (!rows || rows.length === 0) { return []; } + const transactions: BTCTransaction[] = rows.map((row: any[]) => ({ + account: 'wallet', + blockheight: row[0], + credit_msat: row[1] || 0, + currency: 'bcrt', + debit_msat: row[2] || 0, + outpoint: row[3], + tag: row[4], + timestamp: row[5], + txid: row[6], + type: 'chain' + })); + + // Merge change outputs with their withdrawal transactions + return transactions.reduce((acc: BTCTransaction[], tx) => { + const lastTx = acc[acc.length - 1]; + // Check if this deposit is change from the previous withdrawal + const isChangeOutput = lastTx && lastTx.tag?.toLowerCase() === 'withdrawal' && tx.tag?.toLowerCase() === 'deposit' && lastTx.timestamp === tx.timestamp && tx.outpoint?.includes(lastTx.txid || ''); + if (isChangeOutput) { + // Subtract change from withdrawal to get net amount + lastTx.debit_msat = (lastTx.debit_msat as number) - (tx.credit_msat as number); + } else { + acc.push(tx); + } + return acc; + }, []); }; diff --git a/apps/frontend/src/services/http.service.ts b/apps/frontend/src/services/http.service.ts index c764ecf5..79d8878e 100644 --- a/apps/frontend/src/services/http.service.ts +++ b/apps/frontend/src/services/http.service.ts @@ -1,17 +1,22 @@ import axios from 'axios'; import moment from 'moment'; -import { ApplicationConfiguration, AuthResponse } from '../types/root.type'; -import { API_BASE_URL, API_VERSION, APP_WAIT_TIME, PaymentType, TimeGranularity, getTimestampWithGranularity, SATS_MSAT } from '../utilities/constants'; +import { ApplicationConfiguration, AuthResponse, Peer } from '../types/root.type'; +import { API_BASE_URL, API_VERSION, APP_WAIT_TIME, PaymentType, TimeGranularity, getTimestampWithGranularity, SATS_MSAT, SCROLL_PAGE_SIZE } from '../utilities/constants'; import { AccountEventsSQL, SatsFlowSQL, VolumeSQL } from '../utilities/bookkeeper-sql'; import logger from './logger.service'; -import { convertArrayToAccountEventsObj, convertArrayToSatsFlowObj, convertArrayToVolumeObj } from './data-transform.service'; +import { convertArrayToAccountEventsObj, convertArrayToBTCTransactionsObj, convertArrayToLightningTransactionsObj, convertArrayToOffersObj, convertArrayToSatsFlowObj, convertArrayToVolumeObj } from './data-transform.service'; import { defaultRootState } from '../store/rootSelectors'; import { AppState } from '../store/store.type'; import { appStore } from '../store/appStore'; +import { AccountEventsAccount, SatsFlowEvent, VolumeRow } from '../types/bookkeeper.type'; +import { listBTCTransactionsSQL, listLightningTransactionsSQL, ListOffersSQL } from '../utilities/cln-sql'; +import { setConnectWallet, setListChannels, setListFunds, setNodeInfo } from '../store/rootSlice'; +import { setFeeRate, setListBitcoinTransactions, setListLightningTransactions, setListOffers } from '../store/clnSlice'; +import { setAccountEvents, setSatsFlow, setVolume } from '../store/bkprSlice'; const axiosInstance = axios.create({ baseURL: API_BASE_URL + API_VERSION, - timeout: APP_WAIT_TIME * 5, + timeout: APP_WAIT_TIME * 10, withCredentials: true, }); @@ -24,18 +29,42 @@ function handleAxiosError(error) { } async function executeRequests>>( - requests: T + requests: T, + onEachComplete?: (key: keyof T, data: any) => void ): Promise { const entries = Object.entries(requests) as [keyof T, T[keyof T]][]; - const settledResults = await Promise.allSettled(entries.map(([item, promise]) => { - logger.info(item); - return promise; - })); - + const settledResults = await Promise.allSettled( + entries.map(([key, promise]) => { + logger.info(key); + + return promise + .then((value) => { + if (onEachComplete) { + onEachComplete(key, { + ...value, + error: null, + isLoading: false, + }); + } + return value; + }) + .catch((error) => { + if (onEachComplete) { + onEachComplete(key, { + error: handleAxiosError(error), + isLoading: false, + }); + } + throw error; + }); + }) + ); + + // Still return the combined results for the caller return entries.reduce((acc, [name], index) => { const result = settledResults[index]; acc[name] = { - ...result.status === 'fulfilled' ? result.value : null, + ...(result.status === 'fulfilled' ? result.value : null), error: result.status === 'rejected' ? handleAxiosError(result.reason) : null, isLoading: false, }; @@ -97,7 +126,7 @@ export class HttpService { } } - static async clnCall(method: string, params: Record = {}) { + static async clnCall(method: string, params: Record = {}): Promise { try { return await this.post('/cln/call', { method, params }); } catch (error) { @@ -170,18 +199,26 @@ export class RootService { } static async listChannels() { - return HttpService.clnCall('listpeerchannels'); - } - - static async listNodes() { - return HttpService.clnCall('listnodes'); - } - - static async listPeers() { - return HttpService.clnCall('listpeers'); + const [peerChannels, nodes]: [any, any] = await Promise.all([ + HttpService.clnCall('listpeerchannels'), + HttpService.clnCall('listnodes') + ]); + const nodesMap = new Map( + nodes.nodes?.map(node => [node.nodeid, node]) || [] + ); + const merged = peerChannels.channels?.map(channel => ({ + ...channel, + node_alias: nodesMap.get(channel.peer_id)?.alias ?? '' + })) || []; + return { channels: merged }; + // // No pagination, need full data for active, pending and inactive channels calculations + // // Un-comment after sql plugin improvements + // const listChannelsArr: any = await HttpService.clnCall('sql', { query: ListPeerChannelsSQL }); + // return { channels: convertArrayToPeerChannelsObj(listChannelsArr.rows ? listChannelsArr.rows : []) }; } static async listFunds() { + // No pagination, need full data for balance calculations return HttpService.clnCall('listfunds'); } @@ -208,40 +245,56 @@ export class RootService { } static async fetchRootData() { - const nodeResult = await executeRequests({ + const results = await executeRequests({ nodeInfo: this.getNodeInfo(), + connectWallet: this.getConnectWallet(), + }, + (key, data) => { + switch(key) { + case 'nodeInfo': + appStore.dispatch(setNodeInfo(data)); + break; + case 'connectWallet': + appStore.dispatch(setConnectWallet(data)); + break; + } }); + return results; + } + + static async refreshData() { const results = await executeRequests({ - listChannels: this.listChannels(), - listNodes: this.listNodes(), - listPeers: this.listPeers(), listFunds: this.listFunds(), + listChannels: this.listChannels(), + }, + (key, data) => { + switch(key) { + case 'listFunds': + appStore.dispatch(setListFunds(data)); + break; + case 'listChannels': + appStore.dispatch(setListChannels(data)); + break; + } }); - return { - nodeInfo: nodeResult.nodeInfo, - listChannels: results.listChannels, - listNodes: results.listNodes, - listPeers: results.listPeers, - listFunds: results.listFunds, - }; + return results; } } export class CLNService { - static async listInvoices() { - return HttpService.clnCall('listinvoices'); + static async listLightningTransactions(offset: number) { + const listCLNTransactionsArr: any = await HttpService.clnCall('sql', { query: listLightningTransactionsSQL(SCROLL_PAGE_SIZE, offset) }); + return { clnTransactions: convertArrayToLightningTransactionsObj(listCLNTransactionsArr.rows ? listCLNTransactionsArr.rows : []) }; } - static async listSendPays() { - return HttpService.clnCall('listsendpays'); + static async listOffers(offset: number) { + const listOffersArr: any = await HttpService.clnCall('sql', { query: ListOffersSQL(SCROLL_PAGE_SIZE, offset) }); + return { offers: convertArrayToOffersObj(listOffersArr.rows ? listOffersArr.rows : []) }; } - static async listOffers() { - return HttpService.clnCall('listoffers'); - } - - static async listAccountEvents() { - return HttpService.clnCall('bkpr-listaccountevents'); + static async listBTCTransactions(offset: number) { + const listBTCTransactionsArr: any = await HttpService.clnCall('sql', { query: listBTCTransactionsSQL(SCROLL_PAGE_SIZE, offset) }); + return { btcTransactions: convertArrayToBTCTransactionsObj(listBTCTransactionsArr.rows ? listBTCTransactionsArr.rows : []) }; } static async getFeeRates() { @@ -312,30 +365,69 @@ export class CLNService { static async fetchCLNData() { const state = appStore.getState() as AppState; if (state.root.authStatus.isAuthenticated) { - const results = await executeRequests({ - listInvoices: this.listInvoices(), - listSendPays: this.listSendPays(), - listOffers: this.listOffers(), - listAccountEvents: this.listAccountEvents(), - feeRates: this.getFeeRates() - }); - + const results = await executeRequests( + { + feeRates: this.getFeeRates(), + listBtcTransactions: this.listBTCTransactions(0), + listOffers: this.listOffers(0), + listLightningTransactions: this.listLightningTransactions(0), + }, + (key, data) => { + switch(key) { + case 'feeRates': + appStore.dispatch(setFeeRate(data)); + break; + case 'listBtcTransactions': + appStore.dispatch(setListBitcoinTransactions({ + ...data, + page: 1, + hasMore: data.btcTransactions?.length >= SCROLL_PAGE_SIZE, // Could be greater also due to payment_hash aggregation + })); + break; + case 'listOffers': + appStore.dispatch(setListOffers({ + ...data, + page: 1, + hasMore: data.offers?.length === SCROLL_PAGE_SIZE, + })); + break; + case 'listLightningTransactions': + appStore.dispatch(setListLightningTransactions({ + ...data, + page: 1, + hasMore: data.clnTransactions?.length >= SCROLL_PAGE_SIZE, // Could be greater also due to unique_timestamps aggregation + })); + break; + } + } + ); + return { - listInvoices: results.listInvoices, - listSendPays: results.listSendPays, - listOffers: results.listOffers, - listAccountEvents: results.listAccountEvents, feeRates: results.feeRates, + listBitcoinTransactions: { + ...results.listBtcTransactions, + page: 1, + hasMore: results.listBtcTransactions.btcTransactions?.length >= SCROLL_PAGE_SIZE, + }, + listOffers: { + ...results.listOffers, + page: 1, + hasMore: results.listOffers.offers?.length === SCROLL_PAGE_SIZE, + }, + listLightningTransactions: { + ...results.listLightningTransactions, + page: 1, + hasMore: results.listLightningTransactions.clnTransactions?.length >= SCROLL_PAGE_SIZE, + } }; } } - } export class BookkeeperService { static async getAccountEvents() { try { - const accountEvents = await HttpService.clnCall('sql', [AccountEventsSQL]); + const accountEvents = await HttpService.clnCall('sql', { query: AccountEventsSQL }) as { events: AccountEventsAccount[], rows?: [], error?: any }; if (accountEvents.rows) { accountEvents.events = convertArrayToAccountEventsObj(accountEvents.rows); delete accountEvents.rows; @@ -349,7 +441,7 @@ export class BookkeeperService { static async getSatsFlow(startTimestamp: number, endTimestamp: number) { try { - const satsFlow = await HttpService.clnCall('sql', [SatsFlowSQL(startTimestamp, endTimestamp)]); + const satsFlow = await HttpService.clnCall('sql', { query: SatsFlowSQL(startTimestamp, endTimestamp) }) as { satsFlowEvents: SatsFlowEvent[], rows?: [], error?: any }; if (satsFlow.rows) { satsFlow.satsFlowEvents = convertArrayToSatsFlowObj(satsFlow.rows); delete satsFlow.rows; @@ -363,7 +455,7 @@ export class BookkeeperService { static async getVolume() { try { - const volume = await HttpService.clnCall('sql', [VolumeSQL]); + const volume = await HttpService.clnCall('sql', { query: VolumeSQL }) as { forwards: VolumeRow[], rows?: [], error?: any }; if (volume.rows) { volume.forwards = convertArrayToVolumeObj(volume.rows); delete volume.rows; @@ -383,12 +475,36 @@ export class BookkeeperService { const oneMonthBack = moment(currentDate).subtract(1, 'month').add(1, 'day').toDate(); const startTimestamp = getTimestampWithGranularity(timeGranularity, oneMonthBack, 'start'); const endTimestamp = getTimestampWithGranularity(timeGranularity, currentDate, 'end'); - - const results = await executeRequests({ - accountEvents: this.getAccountEvents(), - satsFlow: this.getSatsFlow(startTimestamp, endTimestamp), - volume: this.getVolume() - }); + const results = await executeRequests( + { + accountEvents: this.getAccountEvents(), + satsFlow: this.getSatsFlow(startTimestamp, endTimestamp), + volume: this.getVolume() + }, + (key, data) => { + switch(key) { + case 'accountEvents': + appStore.dispatch(setAccountEvents({ + accountEvents: data, + timeGranularity, + startTimestamp, + endTimestamp + })); + break; + case 'satsFlow': + appStore.dispatch(setSatsFlow({ + satsFlow: data, + timeGranularity, + startTimestamp, + endTimestamp + })); + break; + case 'volume': + appStore.dispatch(setVolume({ volume: data })); + break; + } + } + ); return { timeGranularity, @@ -400,5 +516,5 @@ export class BookkeeperService { }; } } - + } diff --git a/apps/frontend/src/setupTests.ts b/apps/frontend/src/setupTests.ts index 68a68b82..8b48554b 100644 --- a/apps/frontend/src/setupTests.ts +++ b/apps/frontend/src/setupTests.ts @@ -1,5 +1,5 @@ import '@testing-library/jest-dom'; -import { spyOnBKPRGetAccountEvents, spyOnBKPRGetSatsFlow, spyOnBKPRGetVolume, spyOnGetInfo, spyOnListChannels, spyOnListFunds, spyOnListNodes, spyOnListPeers } from './utilities/test-utilities/mockService'; +import { spyOnBKPRGetAccountEvents, spyOnBKPRGetSatsFlow, spyOnBKPRGetVolume, spyOnGetInfo, spyOnListChannels, spyOnListFunds } from './utilities/test-utilities/mockService'; import { TextEncoder, TextDecoder } from 'util'; global.TextEncoder = TextEncoder; @@ -19,9 +19,7 @@ beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); spyOnGetInfo(); - spyOnListNodes(); spyOnListChannels(); - spyOnListPeers(); spyOnListFunds(); spyOnBKPRGetAccountEvents(); spyOnBKPRGetSatsFlow(); diff --git a/apps/frontend/src/store/bkprSlice.tsx b/apps/frontend/src/store/bkprSlice.tsx index a333c71d..cb645b87 100644 --- a/apps/frontend/src/store/bkprSlice.tsx +++ b/apps/frontend/src/store/bkprSlice.tsx @@ -1,6 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { TimeGranularity } from '../utilities/constants'; -import { AccountEvents, SatsFlow, VolumeData } from '../types/bookkeeper.type'; +import { AccountEvents, SatsFlowEvent, VolumeData } from '../types/bookkeeper.type'; import { transformAccountEventsByPeriods, transformSatsFlowByPeriods, transformVolumeData } from '../services/data-transform.service'; import { defaultBKPRState } from './bkprSelectors'; @@ -35,7 +35,7 @@ const bkprSlice = createSlice({ setSatsFlow: ( state, action: PayloadAction<{ - satsFlow: SatsFlow; + satsFlow: { satsFlowEvents: SatsFlowEvent[], error?: any }; timeGranularity: TimeGranularity; startTimestamp: number; endTimestamp: number; diff --git a/apps/frontend/src/store/clnSelectors.tsx b/apps/frontend/src/store/clnSelectors.tsx index 2c9aa4aa..26eb9b48 100644 --- a/apps/frontend/src/store/clnSelectors.tsx +++ b/apps/frontend/src/store/clnSelectors.tsx @@ -2,26 +2,14 @@ import { createSelector } from '@reduxjs/toolkit'; import { CLNState } from '../types/cln.type'; export const defaultCLNState: CLNState = { - listInvoices: { isLoading: true, invoices: [] }, - listPayments: { isLoading: true, payments: [] }, - listOffers: { isLoading: true, offers: [] }, - listLightningTransactions: { isLoading: true, clnTransactions: [] }, - listBitcoinTransactions: { isLoading: true, btcTransactions: [] }, + listOffers: { isLoading: true, page: 0, hasMore: true, offers: [] }, + listLightningTransactions: { isLoading: true, page: 0, hasMore: true, clnTransactions: [] }, + listBitcoinTransactions: { isLoading: true, page: 0, hasMore: true, btcTransactions: [] }, feeRate: { isLoading: true }, }; const selectCLNState = (state: { cln: CLNState }) => state.cln || defaultCLNState; -export const selectListInvoices = createSelector( - selectCLNState, - (cln) => cln.listInvoices -); - -export const selectListPayments = createSelector( - selectCLNState, - (cln) => cln.listPayments -); - export const selectListOffers = createSelector( selectCLNState, (cln) => cln.listOffers @@ -43,13 +31,13 @@ export const selectFeeRate = createSelector( ); export const selectInvoiceByHash = (paymentHash: string) => createSelector( - selectListInvoices, - (data) => data.invoices?.find(inv => inv.payment_hash === paymentHash) + selectListLightningTransactions, + (data) => data.clnTransactions?.find(inv => inv.payment_hash === paymentHash) ); export const selectPaymentByHash = (paymentHash: string) => createSelector( - selectListPayments, - (data) => data.payments?.find(pay => pay.payment_hash === paymentHash) + selectListLightningTransactions, + (data) => data.clnTransactions?.find(pay => pay.payment_hash === paymentHash) ); export const selectCurrentFeeRate = createSelector( diff --git a/apps/frontend/src/store/clnSlice.tsx b/apps/frontend/src/store/clnSlice.tsx index a2c2aa31..c8ef9a8b 100644 --- a/apps/frontend/src/store/clnSlice.tsx +++ b/apps/frontend/src/store/clnSlice.tsx @@ -1,171 +1,100 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { ListInvoices, ListPayments, ListOffers, BkprTransaction, NodeFeeRate, Invoice, Payment, AccountEvents } from '../types/cln.type'; -import { sortDescByKey } from '../utilities/data-formatters'; +import { ListOffers, NodeFeeRate, ListLightningTransactions, ListBitcoinTransactions } from '../types/cln.type'; import { defaultCLNState } from './clnSelectors'; -const paymentReducer = (accumulator, currentPayment) => { - const currPayHash = currentPayment.payment_hash; - currentPayment = { ...currentPayment }; - if (!currentPayment.partid) { currentPayment.partid = 0; } - if (!accumulator[currPayHash]) { - accumulator[currPayHash] = [currentPayment]; - } else { - accumulator[currPayHash].push(currentPayment); - } - return accumulator; -}; - -const summaryReducer = (accumulator, mpp) => { - if (mpp.status?.toLowerCase() === 'complete') { - accumulator.amount_msat = accumulator.amount_msat + mpp.amount_msat; - accumulator.amount_sent_msat = accumulator.amount_sent_msat + mpp.amount_sent_msat; - accumulator.status = mpp.status; - } - if (mpp.bolt11 && !accumulator.bolt11) { accumulator.bolt11 = mpp.bolt11; } - if (mpp.bolt12 && !accumulator.bolt12) { accumulator.bolt12 = mpp.bolt12; } - if (mpp.label && !accumulator.label) { accumulator.label = mpp.label; } - if (mpp.description && !accumulator.description) { accumulator.description = mpp.description; } - if (mpp.payment_preimage && !accumulator.payment_preimage) { accumulator.payment_preimage = mpp.payment_preimage; } - return accumulator; -}; - -const groupBy = (payments) => { - const paymentsInGroups = payments?.reduce(paymentReducer, {}); - const paymentsGrpArray = Object.keys(paymentsInGroups)?.map((key) => ((paymentsInGroups[key].length && paymentsInGroups[key].length > 1) ? sortDescByKey(paymentsInGroups[key], 'partid') : paymentsInGroups[key])); - return paymentsGrpArray?.reduce((acc, curr) => { - let temp: any = {}; - if (curr.length && curr.length === 1) { - temp = JSON.parse(JSON.stringify(curr[0])); - temp.is_group = false; - temp.is_expanded = false; - temp.total_parts = 1; - delete temp.partid; - } else { - const paySummary = curr?.reduce(summaryReducer, { amount_msat: 0, amount_sent_msat: 0, status: (curr[0] && curr[0].status) ? curr[0].status : 'failed' }); - temp = { - is_group: true, is_expanded: false, total_parts: (curr.length ? curr.length : 0), status: paySummary.status, payment_hash: curr[0].payment_hash, - destination: curr[0].destination, amount_msat: paySummary.amount_msat, amount_sent_msat: paySummary.amount_sent_msat, created_at: curr[0].created_at, - mpps: curr - }; - if (paySummary.bolt11) { temp.bolt11 = paySummary.bolt11; } - if (paySummary.bolt12) { temp.bolt12 = paySummary.bolt12; } - if (paySummary.bolt11 && !temp.bolt11) { temp.bolt11 = paySummary.bolt11; } - if (paySummary.bolt12 && !temp.bolt12) { temp.bolt12 = paySummary.bolt12; } - if (paySummary.label && !temp.label) { temp.label = paySummary.label; } - if (paySummary.description && !temp.description) { temp.description = paySummary.description; } - if (paySummary.payment_preimage && !temp.payment_preimage) { temp.payment_preimage = paySummary.payment_preimage; } - } - return acc.concat(temp); - }, []); -}; - -const mergeLightningTransactions = (invoices: Invoice[], payments: Payment[]) => { - let mergedTransactions: any[] = []; - let totalTransactionsLength = (invoices?.length || 0) + (payments?.length || 0); - for (let i = 0, v = 0, p = 0; i < totalTransactionsLength; i++) { - if (v === (invoices?.length || 0)) { - payments.slice(p)?.map(payment => { - mergedTransactions.push({ type: 'PAYMENT', payment_hash: payment.payment_hash, status: payment.status, amount_msat: (payment.amount_msat || payment.msatoshi), label: payment.label, bolt11: payment.bolt11, description: payment.description, bolt12: payment.bolt12, payment_preimage: payment.payment_preimage, created_at: payment.created_at, amount_sent_msat: (payment.amount_sent_msat || payment.msatoshi_sent), destination: payment.destination, expires_at: null, amount_received_msat: null, paid_at: null }); - return payment; - }) - i = totalTransactionsLength; - } else if (p === (payments?.length || 0)) { - invoices.slice(v)?.map(invoice => { - if (invoice.status !== 'expired') { - mergedTransactions.push({ type: 'INVOICE', payment_hash: invoice.payment_hash, status: invoice.status, amount_msat: (invoice.amount_msat || invoice.msatoshi), label: invoice.label, bolt11: invoice.bolt11, description: invoice.description, bolt12: invoice.bolt12, payment_preimage: invoice.payment_preimage, created_at: null, amount_sent_msat: null, destination: null, expires_at: invoice.expires_at, amount_received_msat: (invoice.amount_received_msat || invoice.msatoshi_received), paid_at: invoice.paid_at }); - } - return invoice; - }); - i = totalTransactionsLength; - } else if ((payments[p].created_at || 0) >= (invoices[v].paid_at || invoices[v].expires_at || 0)) { - mergedTransactions.push({ type: 'PAYMENT', payment_hash: payments[p].payment_hash, status: payments[p].status, amount_msat: (payments[p].amount_msat || payments[p].msatoshi), label: payments[p].label, bolt11: payments[p].bolt11, description: payments[p].description, bolt12: payments[p].bolt12, payment_preimage: payments[p].payment_preimage, created_at: payments[p].created_at, amount_sent_msat: (payments[p].amount_sent_msat || payments[p].msatoshi_sent), destination: payments[p].destination, expires_at: null, amount_received_msat: null, paid_at: null }); - p++; - } else if ((payments[p].created_at || 0) < (invoices[v].paid_at || invoices[v].expires_at || 0)) { - if (invoices[v].status !== 'expired') { - mergedTransactions.push({ type: 'INVOICE', payment_hash: invoices[v].payment_hash, status: invoices[v].status, amount_msat: (invoices[v].amount_msat || invoices[v].msatoshi), label: invoices[v].label, bolt11: invoices[v].bolt11, description: invoices[v].description, bolt12: invoices[v].bolt12, payment_preimage: invoices[v].payment_preimage, created_at: null, amount_sent_msat: null, destination: null, expires_at: invoices[v].expires_at, amount_received_msat: (invoices[v].amount_received_msat || invoices[v].msatoshi_received), paid_at: invoices[v].paid_at }); - } - v++; - } - } - return mergedTransactions; -}; - -const filterOnChainTransactions = (events: BkprTransaction[]) => { - if (!events) { - return []; - } else { - return events.reduce((acc: any[], event) => { - event = { ...event }; - if (event.account?.toLowerCase() === 'wallet' && (event.tag?.toLowerCase() === 'deposit' || event.tag?.toLowerCase() === 'withdrawal')) { - event.credit_msat = event.credit_msat || 0; - event.debit_msat = event.debit_msat || 0; - const lastTx = acc.length && acc.length > 0 ? acc[acc.length - 1] : { tag: '' }; - if (lastTx.tag?.toLowerCase() === 'withdrawal' && event.tag?.toLowerCase() === 'deposit' && lastTx.timestamp === event.timestamp && event.outpoint?.includes(lastTx.txid)) { - // Calculate the net amount from the last withdrawal and this deposit - lastTx.debit_msat = (lastTx.debit_msat - +event.credit_msat); - } else { - acc.push(event); - } - } - return acc; - }, []); - } -}; - const clnSlice = createSlice({ name: 'cln', initialState: defaultCLNState, reducers: { - setListInvoices(state, action: PayloadAction) { + setListLightningTransactions(state, action: PayloadAction) { if (action.payload.error) { - state.listInvoices = { ...state.listInvoices, error: action.payload.error }; + state.listLightningTransactions = { ...state.listLightningTransactions, error: action.payload.error }; return; } - const sortedInvoices = [...(action.payload.invoices ?? [])].sort((i1, i2) => { - const c1 = i1.paid_at || i1.expires_at || 0; - const c2 = i2.paid_at || i2.expires_at || 0; - return c2 - c1; - }); - - state.listInvoices = { ...action.payload, invoices: sortedInvoices }; - if (!state.listPayments.isLoading && !state.listInvoices.isLoading) { - const merged = mergeLightningTransactions(sortedInvoices, state.listPayments?.payments ?? []); - state.listLightningTransactions = { isLoading: false, clnTransactions: merged }; + if (action.payload.page === 1) { + // Replace array for page 1 + state.listLightningTransactions = { + ...state.listLightningTransactions, + ...action.payload, + }; + } else { + // Append to existing array for page > 1 + state.listLightningTransactions = { + ...state.listLightningTransactions, + ...action.payload, + clnTransactions: [ + ...state.listLightningTransactions.clnTransactions, + ...action.payload.clnTransactions, + ], + }; } }, - setListPayments(state, action: PayloadAction) { - if (action.payload.error) { - state.listPayments = { ...state.listPayments, error: action.payload.error }; - return; - } - const sortedPayments = action.payload.payments?.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)) ?? []; - const grouped = groupBy(sortedPayments); - state.listPayments = { ...action.payload, payments: grouped }; - if (!state.listPayments.isLoading && !state.listInvoices.isLoading) { - const merged = mergeLightningTransactions(state.listInvoices.invoices ?? [], grouped); - state.listLightningTransactions = { isLoading: false, clnTransactions: merged }; - } + setListLightningTransactionsLoading(state, action: PayloadAction) { + state.listLightningTransactions.isLoading = action.payload; + }, + resetListLightningTransactions(state) { + state.listLightningTransactions = defaultCLNState.listLightningTransactions; }, setListOffers(state, action: PayloadAction) { if (action.payload.error) { state.listOffers = { ...state.listOffers, error: action.payload.error }; return; } - state.listOffers = action.payload; + + if (action.payload.page === 1) { + // Replace array for page 1 + state.listOffers = { + ...state.listOffers, + ...action.payload, + }; + } else { + // Append to existing array for page > 1 + state.listOffers = { + ...state.listOffers, + ...action.payload, + offers: [ + ...state.listOffers.offers, + ...action.payload.offers, + ], + }; + } + }, + setListOffersLoading(state, action: PayloadAction) { + state.listOffers.isLoading = action.payload; }, - setListBitcoinTransactions(state, action: PayloadAction) { + resetListOffers(state) { + state.listOffers = defaultCLNState.listOffers; + }, + setListBitcoinTransactions(state, action: PayloadAction) { if (action.payload.error) { state.listBitcoinTransactions = { ...state.listBitcoinTransactions, error: action.payload.error }; return; } - if (action.payload.events && action.payload.events.length >= 0) { - const sorted = action.payload.events?.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)) ?? []; + + if (action.payload.page === 1) { + // Replace array for page 1 state.listBitcoinTransactions = { - isLoading: false, - btcTransactions: filterOnChainTransactions(sorted), + ...state.listBitcoinTransactions, + ...action.payload, + }; + } else { + // Append to existing array for page > 1 + state.listBitcoinTransactions = { + ...state.listBitcoinTransactions, + ...action.payload, + btcTransactions: [ + ...state.listBitcoinTransactions.btcTransactions, + ...action.payload.btcTransactions, + ], }; } }, + setListBitcoinTransactionsLoading(state, action: PayloadAction) { + state.listBitcoinTransactions.isLoading = action.payload; + }, + resetListBitcoinTransactions(state) { + state.listBitcoinTransactions = defaultCLNState.listBitcoinTransactions; + }, setFeeRate(state, action: PayloadAction) { if (action.payload.error) { state.feeRate = { ...state.feeRate, error: action.payload.error }; @@ -180,10 +109,15 @@ const clnSlice = createSlice({ }); export const { - setListInvoices, - setListPayments, + setListLightningTransactionsLoading, + setListLightningTransactions, + resetListLightningTransactions, + setListOffersLoading, setListOffers, + resetListOffers, + setListBitcoinTransactionsLoading, setListBitcoinTransactions, + resetListBitcoinTransactions, setFeeRate, clearCLNStore } = clnSlice.actions; diff --git a/apps/frontend/src/store/rootSelectors.tsx b/apps/frontend/src/store/rootSelectors.tsx index 6d9dd902..7a299414 100644 --- a/apps/frontend/src/store/rootSelectors.tsx +++ b/apps/frontend/src/store/rootSelectors.tsx @@ -24,9 +24,8 @@ export const defaultRootState: RootState = { btcSpendableBalance: 0, btcReservedBalance: 0, }, - nodeInfo: { isLoading: true, alias: '', version: '', error: null }, + nodeInfo: { isLoading: true, alias: '', version: '', num_peers: 0, error: null }, listFunds: { isLoading: true, channels: [], outputs: [] }, - listPeers: { isLoading: true, peers: [] }, listChannels: { isLoading: true, activeChannels: [], pendingChannels: [], inactiveChannels: [], mergedChannels: [] }, }; @@ -87,9 +86,9 @@ export const selectListFunds = createSelector( (root) => root.listFunds ); -export const selectListPeers = createSelector( +export const selectNumPeers = createSelector( selectRootState, - (root) => root.listPeers + (root) => root.nodeInfo.num_peers ); export const selectListChannels = createSelector( diff --git a/apps/frontend/src/store/rootSlice.tsx b/apps/frontend/src/store/rootSlice.tsx index 750a6b72..1d971e16 100644 --- a/apps/frontend/src/store/rootSlice.tsx +++ b/apps/frontend/src/store/rootSlice.tsx @@ -1,10 +1,10 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { Node, Fund, FundChannel, FundOutput, RootState } from '../types/root.type'; +import { Fund, FundChannel, FundOutput, RootState } from '../types/root.type'; import { SATS_MSAT } from '../utilities/constants'; import { sortDescByKey } from '../utilities/data-formatters'; import { defaultRootState } from './rootSelectors'; -const aggregatePeerChannels = (listPeerChannels: any, listNodes: Node[]) => { +const aggregatePeerChannels = (listPeerChannels: any) => { const aggregatedChannels: any = { activeChannels: [], pendingChannels: [], inactiveChannels: [], mergedChannels: [] }; if (!listPeerChannels || !listPeerChannels.channels) { return aggregatedChannels; @@ -12,7 +12,6 @@ const aggregatePeerChannels = (listPeerChannels: any, listNodes: Node[]) => { listPeerChannels.channels.forEach((peerChannel: any) => { peerChannel = { ...peerChannel, - node_alias: listNodes.find((node) => node?.nodeid === peerChannel.peer_id)?.alias?.replace(/-\d+-.*$/, '') || 'Unknown', to_us_sat: Math.floor((peerChannel.to_us_msat || 0) / SATS_MSAT), total_sat: Math.floor((peerChannel.total_msat || 0) / SATS_MSAT), to_them_sat: Math.floor(((peerChannel.total_msat || 0) - (peerChannel.to_us_msat || 0)) / SATS_MSAT), @@ -110,19 +109,16 @@ const rootSlice = createSlice({ state.walletBalances = calculateBalances(action.payload); state.listFunds = action.payload; }, - setListPeers(state, action: PayloadAction) { - state.listPeers = action.payload; - }, setListChannels( state, - action: PayloadAction<{ listChannels: any; listNodes: any }> + action: PayloadAction ) { - const { listChannels, listNodes } = action.payload; - if (listChannels.error || listNodes.error) { - state.listChannels = { ...state.listChannels, error: (listChannels.error || listNodes.error) }; + const listChannels = action.payload; + if (listChannels.error) { + state.listChannels = { ...state.listChannels, error: listChannels.error }; return; } - const aggr = aggregatePeerChannels(listChannels, listNodes.nodes); + const aggr = aggregatePeerChannels(listChannels); state.listChannels = { ...aggr, isLoading: false }; }, clearRootStore(state) { @@ -141,7 +137,6 @@ export const { setFiatConfig, setNodeInfo, setListFunds, - setListPeers, setListChannels, clearRootStore, } = rootSlice.actions; diff --git a/apps/frontend/src/styles/bootstrap-custom.scss b/apps/frontend/src/styles/bootstrap-custom.scss deleted file mode 100644 index 773eeaf9..00000000 --- a/apps/frontend/src/styles/bootstrap-custom.scss +++ /dev/null @@ -1,13 +0,0 @@ -@import 'constants'; - -$theme-colors: ( - primary: $primary, - secondary: $secondary, - success: $success, - warning: $warning, - danger: $danger, - dark: $dark, - light: $light, -); - -@import '~bootstrap'; diff --git a/apps/frontend/src/styles/constants.scss b/apps/frontend/src/styles/constants.scss index 04adca83..dcad530a 100644 --- a/apps/frontend/src/styles/constants.scss +++ b/apps/frontend/src/styles/constants.scss @@ -1,9 +1,9 @@ -@import './fonts.scss'; -@import '~bootstrap/scss/functions'; -@import '~bootstrap/scss/variables'; -@import '~bootstrap/scss/mixins'; +@use 'sass:color'; +@use './fonts.scss'; @import 'react-perfect-scrollbar/dist/css/styles.css'; +@import 'bootstrap/scss/functions'; +// define custom variables BEFORE importing Bootstrap variables $transition-time: 300ms; $theme-transition: 500ms; $color-mode-type: data; @@ -15,35 +15,30 @@ $font-family-base: -apple-system, BlinkMacSystemFont, 'Inter', 'DM Sans', 'Helve 'Segoe UI', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; -$font-size-base: 14px; +$font-size-base: 0.875rem; $font-weight-base: 500; -$btn-font-size: 14px; +$btn-font-size: 0.875rem; +// Define theme colors BEFORE importing Bootstrap variables +$white: #ffffff; +$gray-200: #e9ecef; +$gray-300: #dee2e6; $primary: #e1ba2d; -$primary-darker: #cca103; -$secondary: $gray-100; +$secondary: #ffffff; $success: #33db95; $warning: #fe8e02; $danger: #dc3545; -$blue: #1b2559; -$darker-blue: #141c44; -$light: #9f9f9f; -$dark: #3a4247; -$light-dark: #b7bbc2; -$card-bg-dark: #2a2a2c; -$text-dark: #131314; -$form-ctrl-bg-dark: #303032; -$dark-blue: #101828; -$border-color-dark: #495057; -$border-color: #dee2e6; -$tooltip-bg-dark: #1b1b1d; +$btn-bg: $primary; -$body-bg-light: #ebeff9; -$body-bg: $white; -$body-color: $dark; -$body-tertiary-bg: $white; -$body-bg-dark: #0c0c0f; -$body-color-dark: $white; +$theme-colors: ( + primary: $primary, + secondary: $secondary, + success: $success, + warning: $warning, + danger: $danger, + dark: #3a4247, + light: #9f9f9f, +); $border-radius: 1.25rem; $btn-link-color: $primary; @@ -51,11 +46,10 @@ $btn-padding-x: 0.625rem; $form-check-input-checked-bg-color: $primary; $form-check-input-checked-border-color: $primary; -$form-check-input-focus-border: lighten($primary, 10%); -$form-check-input-focus-box-shadow: 0 0 0 0.25rem rgba(lighten($primary, 10%), 0.25); +$form-check-input-focus-border: color.adjust($primary, $lightness: 10%); +$form-check-input-focus-box-shadow: 0 0 0 0.25rem rgba(color.adjust($primary, $lightness: 10%), 0.25); $card-border-width: 0.5px; -$card-border-color: rgba($light, 0.1); $card-border-radius: $border-radius; $card-cap-bg: transparent; @@ -63,17 +57,44 @@ $dropdown-min-width: 5rem; $dropdown-box-shadow: none; $dropdown-border-radius: 0.5rem; -$input-color: $dark; -$input-disabled-bg: $gray-200; $modal-backdrop-opacity: 0.2; - + $form-range-track-height: 0.25rem; $form-range-track-bg: $success; + +$toast-max-width: 25rem; + +// Custom variables that depend on Bootstrap variables +$primary-darker: #cca103; +$blue: #1b2559; +$darker-blue: #141c44; +$light: #9f9f9f; +$dark: #3a4247; +$light-dark: #b7bbc2; +$card-bg-dark: #2a2a2c; +$text-dark: #131314; +$form-ctrl-bg-dark: #303032; +$dark-blue: #101828; +$border-color-dark: #495057; +$border-color: #dee2e6; +$tooltip-bg-dark: #1b1b1d; + +$body-bg-light: #ebeff9; +$body-bg: $white; +$body-color: $dark; +$body-tertiary-bg: $white; +$body-bg-dark: #0c0c0f; +$body-color-dark: $white; + $form-range-thumb-bg: $white; $form-range-thumb-border: 0.5px solid $gray-300; $form-range-thumb-focus-box-shadow: 0 0 0 1px $body-bg, $primary; -$form-range-thumb-active-bg: tint-color($primary, 70%); +$form-range-thumb-active-bg: color.scale($primary, $lightness: 70%); -$toast-max-width: 25rem; +$input-color: $dark; +$input-disabled-bg: $gray-200; + +// Import Bootstrap +@import 'bootstrap/scss/bootstrap'; diff --git a/apps/frontend/src/styles/mode-dark.scss b/apps/frontend/src/styles/mode-dark.scss index 14c33a9f..21c24031 100644 --- a/apps/frontend/src/styles/mode-dark.scss +++ b/apps/frontend/src/styles/mode-dark.scss @@ -1,4 +1,5 @@ -@import 'constants'; +@use 'sass:color'; +@import 'bootstrap/scss/mixins'; @include color-mode(dark) { .text-light { @@ -30,11 +31,32 @@ } .card { + color: $text-dark; background-color: $card-bg-dark; - box-shadow: none; + border: none; + transition: + background-color $theme-transition ease, + box-shadow $theme-transition ease; &.bg-primary { color: $text-dark; } + & .card-header { + background-color: $card-bg-dark; + transition: all $theme-transition ease; + border-bottom: none; + } + & .card-body { + transition: all $theme-transition ease; + } + & .card-footer { + background-color: $card-bg-dark; + transition: all $theme-transition ease; + border-top: none; + } + } + + .inner-box-shadow { + box-shadow: 1px 1px 2px 2px rgba($light-dark, 0.20); } .list-group-item, @@ -91,6 +113,10 @@ stroke: $primary; } + .card.overview-balance-card { + box-shadow: 1px 1px 2px 2px rgba($light-dark, 0.20); + } + span.span-close-svg { cursor: pointer; border-radius: $border-radius; @@ -100,7 +126,7 @@ &:hover, &:focus-visible { outline: none; & svg path { - stroke: lighten($light-dark, 15%); + stroke: color.adjust($light-dark, $lightness: 15%); } } } @@ -205,7 +231,7 @@ } .switch { - border: 1px solid lighten($white, 20%); + border: 1px solid color.adjust($white, $lightness: 20%); & .handle { background-color: $dark; @@ -216,7 +242,7 @@ } &[data-isswitchon='true'] { - border: 1px solid darken($success, 10%); + border: 1px solid color.adjust($success, $lightness: -20%);; } } @@ -277,6 +303,10 @@ } } + .modal-content { + background-color: $card-bg-dark; + } + .ps.ps--active-y > .ps__rail-y { background-color: $dark; } diff --git a/apps/frontend/src/styles/mode-light.scss b/apps/frontend/src/styles/mode-light.scss index 6570a045..74250edf 100644 --- a/apps/frontend/src/styles/mode-light.scss +++ b/apps/frontend/src/styles/mode-light.scss @@ -1,4 +1,5 @@ -@import 'constants'; +@use 'sass:color'; +@import 'bootstrap/scss/mixins'; @include color-mode(light) { .text-contrast { @@ -9,6 +10,10 @@ color: $white; } + .text-light { + color: $light !important; + } + .text-light-white { color: $light !important; } @@ -64,10 +69,36 @@ stroke: $white; } + .bg-light { + background-color: $light !important; + } + .card { + border: none; + transition: + background-color $theme-transition ease, + box-shadow $theme-transition ease; &.bg-primary { color: $white; } + & .card-header { + background-color: $card-bg; + transition: all $theme-transition ease; + border-bottom: none; + } + & .card-body { + transition: all $theme-transition ease; + } + & .card-footer { + background-color: $card-bg; + transition: all $theme-transition ease; + border-top: none; + } + } + + .card.overview-balance-card { + box-shadow: 0px 4px 8px 0px $gray-400; + color: $white; } .list-group-item, @@ -84,7 +115,7 @@ &:hover, &:focus-visible { outline: none; & svg path { - stroke: darken($light, 15%); + stroke: color.adjust($light, $lightness: -15%);; } } } diff --git a/apps/frontend/src/styles/shared.scss b/apps/frontend/src/styles/shared.scss index 86f498c9..e53f62be 100755 --- a/apps/frontend/src/styles/shared.scss +++ b/apps/frontend/src/styles/shared.scss @@ -1,5 +1,8 @@ +@use 'sass:color'; +@import './constants'; @import './mode-dark.scss'; @import './mode-light.scss'; +@import 'bootstrap/scss/mixins'; @import 'react-perfect-scrollbar/dist/css/styles.css'; @import 'react-datepicker/dist/react-datepicker.css'; @@ -135,6 +138,7 @@ a { } .btn.btn-primary { + background-color: $primary; transition: color $theme-transition ease; color: var(--bs-body-color); font-weight: 600; @@ -148,8 +152,8 @@ a { &:hover, &:focus-visible { outline: none; - background-color: darken($primary, 8%); - border-color: darken($primary, 8%); + background-color: color.adjust($primary, $lightness: -8%); + border-color: color.adjust($primary, $lightness: -8%); } } @@ -165,7 +169,7 @@ a { &:focus-visible { outline: none; transform: scale(1.01); - color: darken($primary, 10%); + color: color.adjust($primary, $lightness: -10%); } } @@ -210,8 +214,8 @@ button.btn-rounded { &:hover, &:focus-visible { outline: none; - background-color: darken($primary, 8%) !important; - border-color: darken($primary, 8%) !important; + background-color: color.adjust($primary, $lightness: -8%) !important; + border-color: color.adjust($primary, $lightness: -8%) !important; } } &.btn-primary, @@ -219,8 +223,8 @@ button.btn-rounded { &:hover, &:focus-visible { outline: none; - background-color: darken($primary, 8%) !important; - border-color: darken($primary, 8%) !important; + background-color: color.adjust($primary, $lightness: -8%) !important; + border-color: color.adjust($primary, $lightness: -8%) !important; } } &:disabled { @@ -337,7 +341,7 @@ button.btn-rounded { svg { & circle, - & path:not(.path-settings), + & path, & line, & defs > linearGradient > stop { transition: @@ -359,18 +363,36 @@ svg { display: inline-flex; &.fa-xl { height: 1.25rem; + width: 1.25rem; } &.fa-lg { height: 1rem; + width: 1rem; } &.fa-md { height: 0.75rem; + width: 0.75rem; } &.fa-sm { height: 0.5rem; + width: 0.5rem; } &.fa-xs { height: 0.25rem; + width: 0.25rem; + } + } +} + +.span-refresh { + cursor: pointer; + & .svg-refresh { + transition: transform $transition-time ease; + &:hover { + transform: rotate(0.5turn); + & > path { + fill: $primary; + } } } } @@ -389,7 +411,7 @@ svg { border-radius: 1.25rem; padding: 0.25rem; background-color: $white; - border: 1px solid lighten($light, 20%); + border: 1px solid color.adjust($light, $lightness: 20%); & .handle { background-color: $light; @@ -405,7 +427,7 @@ svg { &[data-isswitchon='true'] { justify-content: flex-end; background-color: $success; - border: 1px solid lighten($success, 20%); + border: 1px solid color.adjust($success, $lightness: 20%); & .handle { background-color: $white; } @@ -415,22 +437,6 @@ svg { } } -.card { - transition: - background-color $theme-transition ease, - box-shadow $theme-transition ease; - border: none; - & .card-header { - border-bottom: none; - } - & .card-body { - transition: color $theme-transition ease; - } - & .card-footer { - border-top: none; - } -} - .cards-container { height: 67vh; margin-bottom: 1.5rem; @@ -465,6 +471,10 @@ svg { overflow: hidden; } +.ps > .ps__rail-y { + transition: background-color $theme-transition ease; +} + .ps.ps--active-y > .ps__rail-y { opacity: 1; z-index: 99; @@ -571,10 +581,10 @@ label.form-label { } &:hover { svg path { - stroke: darken($primary, 10%); + stroke: color.adjust($primary, $lightness: -10%); &.svg-add { stroke: none; - fill: darken($primary, 10%); + fill: color.adjust($primary, $lightness: -10%); } } } @@ -586,7 +596,7 @@ label.form-label { } &:hover { & path { - fill: darken($primary, 10%); + fill: color.adjust($primary, $lightness: -10%); } } } @@ -666,11 +676,18 @@ label.form-label { } } -.wallet-card { +.card.wallet-card { border-top-left-radius: $border-radius; border-top-right-radius: $border-radius; border-bottom-left-radius: 0; border-bottom-right-radius: 0; + border: none; +} + +.card.overview-balance-card { + padding-left: 0.5rem !important; + background-color: $primary; + flex-grow: 1 !important; } .information-svg { diff --git a/apps/frontend/src/svgs/Currency.tsx b/apps/frontend/src/svgs/Currency.tsx index 9e8a2f8d..da3af3e9 100755 --- a/apps/frontend/src/svgs/Currency.tsx +++ b/apps/frontend/src/svgs/Currency.tsx @@ -1,6 +1,25 @@ import React from 'react'; export const CurrencySVG = props => { + const krSVG = ( + + + + + + + + + ); + const currencies = { PLN: { -28z'/> , - DKK: - - - , + DKK: krSVG, IDR: @@ -65,14 +81,8 @@ export const CurrencySVG = props => { , - NOK: - - - , - SEK: - - - , + NOK: krSVG, + SEK: krSVG, THB: { }; - return currencies[props.fiat]; + return currencies[props.fiat] || null; }; diff --git a/apps/frontend/src/svgs/Refresh.tsx b/apps/frontend/src/svgs/Refresh.tsx new file mode 100755 index 00000000..0bb9d538 --- /dev/null +++ b/apps/frontend/src/svgs/Refresh.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { OverlayTrigger, Tooltip } from 'react-bootstrap'; + +// +export const RefreshSVG = () => { + return ( + Refresh} + > + + + + + ); +}; diff --git a/apps/frontend/src/types/cln.type.ts b/apps/frontend/src/types/cln.type.ts index 182a0a54..d0360b24 100644 --- a/apps/frontend/src/types/cln.type.ts +++ b/apps/frontend/src/types/cln.type.ts @@ -1,23 +1,20 @@ export interface CLNLoaderData { - listInvoices: ListInvoices; - listSendPays: ListPayments; + listLightningTransactions: ListLightningTransactions; listOffers: ListOffers; - listAccountEvents: AccountEvents; + listBitcoinTransactions: ListBitcoinTransactions; feeRates: NodeFeeRate; } export type CLNState = { - listInvoices: ListInvoices; - listPayments: ListPayments; + listLightningTransactions: ListLightningTransactions; listOffers: ListOffers; - listLightningTransactions: any; - listBitcoinTransactions: any; + listBitcoinTransactions: ListBitcoinTransactions; feeRate: NodeFeeRate; }; export type AccountEvents = { isLoading: boolean; - events: BkprTransaction[]; + events: BTCTransaction[]; error?: any; } export type Invoice = { @@ -42,12 +39,6 @@ export type Invoice = { // For backward compatibility: End }; -export type ListInvoices = { - isLoading: boolean; - invoices?: Invoice[]; - error?: any; -}; - export type Payment = { is_group: boolean; is_expanded: boolean; @@ -66,42 +57,42 @@ export type Payment = { bolt12?: string; payment_preimage?: string; erroronion?: string; + mpps?: any[]; // For backward compatibility: Start msatoshi?: number; msatoshi_sent?: number; // For backward compatibility: End }; -export type ListPayments = { - isLoading: boolean; - payments?: Payment[]; - error?: any; -}; - export type LightningTransaction = { type: string; //INVOICE/PAYMENT // Both payment_hash?: string; status?: string; label?: string; - bolt11?: string; description?: string; + bolt11?: string; bolt12?: string; payment_preimage?: string; - // Payment - created_at?: number; - destination?: string; - // Invoice - created_index?: number; - expires_at?: number; - paid_at?: number; amount_msat?: number; + // Invoice specific amount_received_msat?: number; + expires_at?: number; + paid_at?: number; + // Payment specific + amount_sent_msat?: number; + created_at?: number; + completed_at?: number; + destination?: string; + groupid?: number; + partid?: number; }; export type ListLightningTransactions = { isLoading: boolean; - clnTransactions?: LightningTransaction[]; + page: number; + hasMore: boolean; + clnTransactions: LightningTransaction[]; error?: any; }; @@ -156,7 +147,7 @@ export type btcWithdraw = { description?: string; }; -export type BkprTransaction = { +export type BTCTransaction = { account: string; type?: string; // 'onchain_fee', 'chain', 'channel' credit_msat?: string | number; @@ -177,7 +168,9 @@ export type BkprTransaction = { export type ListBitcoinTransactions = { isLoading: boolean; - btcTransactions?: BkprTransaction[]; + page: number; + hasMore: boolean; + btcTransactions: BTCTransaction[]; error?: any; }; @@ -226,12 +219,14 @@ export type Offer = { single_use?: boolean; used?: boolean; label?: string; - valid?: boolean; + description?: string; }; export type ListOffers = { isLoading: boolean; - offers?: Offer[]; + page: number; + hasMore: boolean; + offers: Offer[]; error?: any; }; diff --git a/apps/frontend/src/types/root.type.ts b/apps/frontend/src/types/root.type.ts index 78838018..ea70c0b4 100755 --- a/apps/frontend/src/types/root.type.ts +++ b/apps/frontend/src/types/root.type.ts @@ -6,8 +6,6 @@ export interface RootLoaderData { fiatConfig: FiatConfig; nodeInfo: NodeInfo; listChannels: ListPeerChannels; - listNodes: ListNodes; - listPeers: ListPeers; listFunds: Fund; connectWallet: WalletConnect; } @@ -23,7 +21,6 @@ export type RootState = { walletBalances: WalletBalances; nodeInfo: NodeInfo; listFunds: Fund; - listPeers: ListPeers; listChannels: ListPeerChannels; }; @@ -35,7 +32,7 @@ export type NodeInfo = { id?: string; alias?: string; color?: string; - num_peers?: number; + num_peers: number; num_pending_channels?: number; num_active_channels?: number; num_inactive_channels?: number; @@ -174,86 +171,33 @@ export type Peer = { option_will_fund?: LiquidityAd; }; -export type ListPeers = { - isLoading: boolean; - peers?: Peer[]; - error?: any; -}; - export type ChannelType = { bits?: number[]; names?: string[]; }; export type PeerChannel = { + node_alias: string; peer_id: string; - peer_connected: boolean; + channel_id: string; + short_channel_id?: string; state: string; + peer_connected: boolean; + to_us_msat: number; + total_msat: number; + their_to_self_delay?: number; + opener?: string; + private?: boolean; + dust_limit_msat?: number; + spendable_msat?: number; + receivable_msat?: number; + funding_txid?: string; // Added for UI: Start current_state: string; - node_alias: string; total_sat: number; to_us_sat: number; to_them_sat: number; // Added for UI: End - reestablished?: boolean; - scratch_txid?: string; - last_tx_fee_msat?: number; - direction?: number; - close_to_addr?: string; - channel_type?: ChannelType; - updates?: { local?: any; remote?: any }; - ignore_fee_limits?: boolean; - lost_state?: boolean; - feerate?: ChannelFeeRate; - owner?: string; - short_channel_id?: string; - channel_id?: string; - funding_txid?: string; - funding_outnum?: number; - initial_feerate?: string; - last_feerate?: string; - next_feerate?: string; - next_fee_step?: number; - inflight?: Inflight[]; - close_to?: string; - private?: boolean; - opener?: string; - closer?: string; - features?: string[]; - funding?: Funding; - total_msat?: number; - to_us_msat?: number; - to_them_msat?: number; - min_to_us_msat?: number; - max_to_us_msat?: number; - fee_base_msat?: number; - fee_proportional_millionths?: number; - dust_limit_msat?: number; - max_total_htlc_in_msat?: number; - their_reserve_msat?: number; - our_reserve_msat?: number; - spendable_msat?: number; - receivable_msat?: number; - minimum_htlc_in_msat?: number; - minimum_htlc_out_msat?: number; - maximum_htlc_out_msat?: number; - their_to_self_delay?: number; - our_to_self_delay?: number; - max_accepted_htlcs?: number; - alias?: Alias; - state_changes?: StateChange[]; - status?: string[]; - in_payments_offered?: number; - in_offered_msat?: number; - in_payments_fulfilled?: number; - in_fulfilled_msat?: number; - out_payments_offered?: number; - out_offered_msat?: number; - out_payments_fulfilled?: number; - out_fulfilled_msat?: number; - last_stable_connection?: number; - htlcs?: HTLC[]; }; export type ListPeerChannels = { @@ -359,22 +303,3 @@ export type Fund = { outputs?: FundOutput[]; error?: any; }; - -/** - * Node from ListNodes. - */ -export type Node = { - nodeid: string; - last_timestamp?: number; - alias?: string; - color?: string; - features?: string[]; - addresses?: Address[]; - option_will_fund?: LiquidityAd; -}; - -export type ListNodes = { - isLoading: boolean; - nodes: Node[]; - error?: any; -}; diff --git a/apps/frontend/src/utilities/bookkeeper-sql.ts b/apps/frontend/src/utilities/bookkeeper-sql.ts index 7ae32411..0747f993 100644 --- a/apps/frontend/src/utilities/bookkeeper-sql.ts +++ b/apps/frontend/src/utilities/bookkeeper-sql.ts @@ -29,16 +29,4 @@ export const SatsFlowSQL = (startTimestamp: number, endTimestamp: number): strin endTimestamp + ';'; -export const VolumeSQL = - 'SELECT in_channel, ' + - '(SELECT peer_id FROM peerchannels WHERE peerchannels.short_channel_id=in_channel) AS in_channel_peerid, ' + - '(SELECT nodes.alias FROM nodes WHERE nodes.nodeid=(SELECT peer_id FROM peerchannels WHERE peerchannels.short_channel_id=in_channel)) AS in_channel_peer_alias, ' + - 'SUM(in_msat), ' + - 'out_channel, ' + - '(SELECT peer_id FROM peerchannels WHERE peerchannels.short_channel_id=out_channel) AS out_channel_peerid, ' + - '(SELECT nodes.alias FROM nodes WHERE nodes.nodeid=(SELECT peer_id FROM peerchannels WHERE peerchannels.short_channel_id=out_channel)) AS out_channel_peer_alias, ' + - 'SUM(out_msat), ' + - 'SUM(fee_msat) ' + - 'FROM forwards ' + - "WHERE forwards.status='settled' " + - 'GROUP BY in_channel, out_channel;'; +export const VolumeSQL = "SELECT f.in_channel, pc_in.peer_id AS in_channel_peerid, n_in.alias AS in_channel_peer_alias, SUM(f.in_msat) AS total_in_msat, f.out_channel, pc_out.peer_id AS out_channel_peerid, n_out.alias AS out_channel_peer_alias, SUM(f.out_msat) AS total_out_msat, SUM(f.fee_msat) AS total_fee_msat FROM forwards f LEFT JOIN peerchannels pc_in ON pc_in.short_channel_id = f.in_channel LEFT JOIN nodes n_in ON n_in.nodeid = pc_in.peer_id LEFT JOIN peerchannels pc_out ON pc_out.short_channel_id = f.out_channel LEFT JOIN nodes n_out ON n_out.nodeid = pc_out.peer_id WHERE f.status = 'settled' GROUP BY f.in_channel, pc_in.peer_id, n_in.alias, f.out_channel, pc_out.peer_id, n_out.alias;"; \ No newline at end of file diff --git a/apps/frontend/src/utilities/cln-sql.ts b/apps/frontend/src/utilities/cln-sql.ts new file mode 100644 index 00000000..de0b26e2 --- /dev/null +++ b/apps/frontend/src/utilities/cln-sql.ts @@ -0,0 +1,14 @@ +export const ListPeerChannelsSQL = "SELECT n.alias as node_alias, pc.peer_id, pc.channel_id, pc.short_channel_id, pc.state, pc.peer_connected, pc.to_us_msat, pc.total_msat, pc.their_to_self_delay, pc.opener, pc.private, pc.dust_limit_msat, pc.spendable_msat, pc.receivable_msat, pc.funding_txid FROM peerchannels pc LEFT JOIN nodes n ON pc.peer_id = n.nodeid;"; + +export const ListOffersSQL = (limit, offset) => "SELECT offer_id, active, single_use, bolt12, used, label, COALESCE(description, NULL) as description FROM offers ORDER BY offer_id LIMIT " + limit + " OFFSET " + offset; + +// We use a single-query approach instead of splitting these queries because it: +// - Keeps the code simpler and easier to reason about +// - Reduces the risk of bugs +// - Is easier to maintain over time +// - Performs better for the common case (~20K-40K total records; typically 1–5 pages fetched) +// - Break even at ~8-9 pages with a page size of 100 +export const listLightningTransactionsSQL = (limit, offset) => "WITH unique_payment_hashes AS (SELECT payment_hash, created_at as sort_time FROM (SELECT DISTINCT payment_hash, MAX(created_at) as created_at FROM sendpays GROUP BY payment_hash)), unique_invoice_hashes AS (SELECT payment_hash, COALESCE(paid_at, expires_at) as sort_time FROM invoices WHERE status != 'expired'), all_unique_hashes AS (SELECT payment_hash, sort_time FROM unique_payment_hashes UNION ALL SELECT payment_hash, sort_time FROM unique_invoice_hashes), paginated_hashes AS (SELECT DISTINCT payment_hash FROM all_unique_hashes ORDER BY sort_time DESC LIMIT " + limit + " OFFSET " + offset + ") SELECT 'INVOICE' as type, i.payment_hash, i.status, i.label, i.description, i.bolt11, i.bolt12, i.payment_preimage, i.amount_msat, i.amount_received_msat, i.expires_at, i.paid_at, NULL as completed_at, NULL as groupid, NULL as partid, COALESCE(i.paid_at, i.expires_at) as sort_time FROM invoices i INNER JOIN paginated_hashes ph ON i.payment_hash = ph.payment_hash WHERE i.status != 'expired' UNION ALL SELECT 'PAYMENT' as type, s.payment_hash, s.status, s.label, s.description, s.bolt11, s.bolt12, s.payment_preimage, s.amount_msat, s.amount_sent_msat, s.created_at, s.completed_at, s.destination, s.groupid, s.partid, s.created_at as sort_time FROM sendpays s INNER JOIN paginated_hashes ph ON s.payment_hash = ph.payment_hash ORDER BY sort_time DESC;"; + +export const listBTCTransactionsSQL = (limit, offset) => "WITH unique_timestamps AS (SELECT DISTINCT timestamp FROM bkpr_accountevents WHERE account = 'wallet' AND (tag = 'deposit' OR tag = 'withdrawal') ORDER BY timestamp DESC LIMIT " + limit + " OFFSET " + offset + ") SELECT e.blockheight, e.credit_msat, e.debit_msat, e.outpoint, e.tag, e.timestamp, e.txid FROM bkpr_accountevents e INNER JOIN unique_timestamps ut ON e.timestamp = ut.timestamp WHERE e.account = 'wallet' AND (e.tag = 'deposit' OR e.tag = 'withdrawal') ORDER BY e.timestamp DESC;"; + diff --git a/apps/frontend/src/utilities/constants.ts b/apps/frontend/src/utilities/constants.ts index cec85d0d..7b2a0d6b 100755 --- a/apps/frontend/src/utilities/constants.ts +++ b/apps/frontend/src/utilities/constants.ts @@ -18,11 +18,11 @@ export const API_BASE_URL = PROTOCOL + '//' + HOST + ':' + PORT; export const API_VERSION = '/v1'; export const LOG_LEVEL = process.env.NODE_ENV !== 'production' ? 'info' : 'warn'; -export const APP_WAIT_TIME = 15 * 1000; // 15 seconds +export const APP_WAIT_TIME = 30 * 1000; // 30 seconds export const CLEAR_STATUS_ALERT_DELAY = 10000; // 10 seconds export const TODAY = Math.floor(Date.now() / 1000); -export const SCROLL_BATCH_SIZE = 20; // For infinite scroll, number of items to load per batch -export const SCROLL_THRESHOLD = 120; // For infinite scroll, distance from bottom to trigger next batch +export const SCROLL_PAGE_SIZE = 100; // For infinite scroll, number of items to load per page +export const SCROLL_THRESHOLD = 200; // For infinite scroll, distance from bottom in pixels (px) to trigger next page load. export const BTC_MSAT = 100000000000; export const BTC_SATS = 100000000; diff --git a/apps/frontend/src/utilities/test-utilities/mockData.tsx b/apps/frontend/src/utilities/test-utilities/mockData.tsx index 1f7486d7..af6b56c5 100644 --- a/apps/frontend/src/utilities/test-utilities/mockData.tsx +++ b/apps/frontend/src/utilities/test-utilities/mockData.tsx @@ -1,5 +1,5 @@ import { ApplicationModes, TimeGranularity, Units } from '../constants'; -import { Offer, LightningTransaction, Invoice, BkprTransaction, Rune } from '../../types/cln.type'; +import { Offer, LightningTransaction, Invoice, BTCTransaction, Rune } from '../../types/cln.type'; import { AccountEvents, BkprSummaryInfo, SatsFlow, VolumeData } from '../../types/bookkeeper.type'; import { PeerChannel } from '../../types/root.type'; import { faDollarSign } from '@fortawesome/free-solid-svg-icons'; @@ -80,7 +80,7 @@ export const mockOffer: Offer = { bolt12: 'lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcgq3rcdrqqpgg5uethwvs8xatzwd3hy6tsw35k7mskyyp68zdn5tm65mulfnxpnu4a0ght4q6ev6v7s6m3tj4259rlcdlnz3q', used: true, - valid: true, + description: 'Mock Offer' }; export const mockClnTransaction: LightningTransaction = { @@ -93,10 +93,9 @@ export const mockClnTransaction: LightningTransaction = { status: 'unpaid', description: 'Breakfast', expires_at: Date.now() + 3600000, // 1hr from now, - created_index: 4, }; -export const mockBTCTransaction: BkprTransaction = { +export const mockBTCTransaction: BTCTransaction = { account: 'wallet', type: 'chain', tag: 'deposit', @@ -109,97 +108,25 @@ export const mockBTCTransaction: BkprTransaction = { }; export const mockSelectedChannel: PeerChannel = { - peer_id: '024244c0c7d23d1b411578a1a2376fb4cebf5526449e1a83241fd4a12801034c5b', - peer_connected: false, - current_state: 'ACTIVE', node_alias: 'CLNReg2', - channel_type: { - bits: [12], - names: ['static_remotekey/even'], - }, - ignore_fee_limits: true, - updates: { - local: { - htlc_minimum_msat: 0, - htlc_maximum_msat: 1485000000, - cltv_expiry_delta: 6, - fee_base_msat: 1, - fee_proportional_millionths: 10, - }, - }, - state: 'CHANNELD_NORMAL', - scratch_txid: '4fe207694ae089014b9008f2463dbf438553e26a2ad94ba2e455357c84692400', - last_tx_fee_msat: 183000, - lost_state: false, - feerate: { - perkw: 253, - perkb: 1012, - }, - short_channel_id: '185x1x1', - direction: 1, + peer_id: '024244c0c7d23d1b411578a1a2376fb4cebf5526449e1a83241fd4a12801034c5b', channel_id: 'e84435b002a67feedbe958aeb01710cb0d832fd20281e63a2e9943c5c4c4d7e1', - funding_txid: 'caef9fceb32991981ca39501d8cd807c7f4d4732de46c1381aac5b3027bd75ee', - funding_outnum: 1, - close_to_addr: 'bcrt1pns2fct20yudt54v8ta5jxqq909lwelu82gpwp2ym59ffelzcp7rqg0w646', - close_to: '51209c149c2d4f271aba55875f69230005797eecff875202e0a89ba1529cfc580f86', - private: false, - opener: 'remote', - alias: { - local: '13947255x7038877x61250', - }, - features: ['option_static_remotekey'], - funding: { - local_funds_msat: 0, - remote_funds_msat: 1500000000, - pushed_msat: 0, - }, - to_us_sat: 0, - to_them_sat: 1500000, - total_sat: 1500000, + short_channel_id: '185x1x1', + state: 'CHANNELD_NORMAL', + peer_connected: false, to_us_msat: 0, - min_to_us_msat: 0, - max_to_us_msat: 0, total_msat: 1500000000, - fee_base_msat: 1, - fee_proportional_millionths: 10, + their_to_self_delay: 6, + opener: 'remote', + private: false, dust_limit_msat: 546000, - max_total_htlc_in_msat: 18446744073709551615, - their_reserve_msat: 15000000, - our_reserve_msat: 15000000, spendable_msat: 0, receivable_msat: 1484460000, - minimum_htlc_in_msat: 0, - minimum_htlc_out_msat: 0, - maximum_htlc_out_msat: 1485000000, - their_to_self_delay: 6, - our_to_self_delay: 6, - max_accepted_htlcs: 483, - state_changes: [ - { - timestamp: '2023-09-14T17:53:38.684Z', - old_state: 'DUALOPEND_OPEN_COMMITTED', - new_state: 'DUALOPEND_AWAITING_LOCKIN', - cause: 'remote', - message: 'Sigs exchanged, waiting for lock-in', - }, - { - timestamp: '2023-09-14T17:56:31.918Z', - old_state: 'DUALOPEND_AWAITING_LOCKIN', - new_state: 'CHANNELD_NORMAL', - cause: 'remote', - message: 'Lockin complete', - }, - ], - status: ['CHANNELD_NORMAL:Will attempt reconnect in 300 seconds'], - in_payments_offered: 0, - in_offered_msat: 0, - in_payments_fulfilled: 0, - in_fulfilled_msat: 0, - out_payments_offered: 0, - out_offered_msat: 0, - out_payments_fulfilled: 0, - out_fulfilled_msat: 0, - htlcs: [], + funding_txid: 'caef9fceb32991981ca39501d8cd807c7f4d4732de46c1381aac5b3027bd75ee', + current_state: 'ACTIVE', + total_sat: 1500000, + to_us_sat: 0, + to_them_sat: 1500000, }; export const mockAuthStatus = { @@ -393,26 +320,6 @@ export const mockListFunds = { isLoading: false }; -export const mockListPeers = { - peers: [ - { - connected: true, - features: "08a0880a8a59a1", - id: "03fbdd0a9ddba420be1ec9146802c9b95f6233b1a36c5e2c223884fde157be7ff5", - netaddr: ["127.0.0.1:7171"], - num_channels: 2 - }, - { - connected: true, - features: "08a0880a8a59a1", - id: "020371140f2ec44c4d6cea50c018310e9409c97c410c56fbc120de3e85b0358d02", - netaddr: ["127.0.0.1:58232"], - num_channels: 1 - } - ], - isLoading: false -}; - // Channel type definition export const mockChannelType = { bits: [12, 22], @@ -729,6 +636,11 @@ export const mockActiveChannels = [mockActiveChannel1, mockActiveChannel2, mockA export const mockPendingChannels = []; export const mockInactiveChannels = []; +export const mockListChannelsAPIRes = { + channels: [...mockActiveChannels, ...mockPendingChannels, ...mockInactiveChannels], + isLoading: false +}; + export const mockListChannels = { activeChannels: mockActiveChannels, pendingChannels: mockPendingChannels, @@ -748,7 +660,6 @@ export const mockRootStoreData = { listFunds: mockListFunds, appConfig: mockAppConfig, fiatConfig: mockFiatConfig, - listPeers: mockListPeers, listChannels: mockListChannels, }; @@ -797,16 +708,21 @@ export const mockOffer1 = { active: true, single_use: false, bolt12: "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcgq3rcdrqqpgg5uethwvs8xatzwd3hy6tsw35k7mskyyp68zdn5tm65mulfnxpnu4a0ght4q6ev6v7s6m3tj4259rlcdlnz3q", - used: false + used: false, + description: 'Fish Sale!' }; export const mockListOffers = { - offers: [mockOffer1], - isLoading: false + isLoading: false, + page: 1, + hasMore: false, + offers: [mockOffer1] }; export const mockListLightningTransactions = { isLoading: false, + page: 1, + hasMore: false, clnTransactions: [ { type: "INVOICE", @@ -823,6 +739,8 @@ export const mockListLightningTransactions = { export const mockListBitcoinTransactions = { isLoading: false, + page: 1, + hasMore: false, btcTransactions: [ { account: "wallet", @@ -869,8 +787,6 @@ export const mockFeeRate = { }; export const mockCLNStoreData = { - listInvoices: mockListInvoices, - listPayments: mockListPayments, listOffers: mockListOffers, listLightningTransactions: mockListLightningTransactions, listBitcoinTransactions: mockListBitcoinTransactions, diff --git a/apps/frontend/src/utilities/test-utilities/mockService.ts b/apps/frontend/src/utilities/test-utilities/mockService.ts index c8da2975..29cd7946 100644 --- a/apps/frontend/src/utilities/test-utilities/mockService.ts +++ b/apps/frontend/src/utilities/test-utilities/mockService.ts @@ -1,6 +1,6 @@ import SHA256 from 'crypto-js/sha256'; import { BookkeeperService, CLNService, RootService } from '../../services/http.service'; -import { mockAccountEventsData, mockAuthStatus, mockDecodedInvoice, mockFetchInvoice, mockInvoiceRune, mockListChannels, mockListFunds, mockListPeers, mockNewAddr, mockNodeInfo, mockSatsFlowData, mockSendPayment, mockSQLResponse, mockVolumeData } from '../../utilities/test-utilities/mockData'; +import { mockAccountEventsData, mockAuthStatus, mockDecodedInvoice, mockFetchInvoice, mockInvoiceRune, mockListChannelsAPIRes, mockListFunds, mockNewAddr, mockNodeInfo, mockSatsFlowData, mockSendPayment, mockSQLResponse, mockVolumeData } from '../../utilities/test-utilities/mockData'; export const spyOnUserLogin = () => ( jest.spyOn(RootService, 'userLogin').mockImplementation(async (password) => { @@ -18,16 +18,8 @@ export const spyOnGetInfo = () => ( jest.spyOn(RootService, 'getNodeInfo').mockImplementation(async () => mockNodeInfo) ); -export const spyOnListNodes = () => ( - jest.spyOn(RootService, 'listNodes').mockImplementation(async () => {}) -); - export const spyOnListChannels = () => ( - jest.spyOn(RootService, 'listChannels').mockImplementation(async () => mockListChannels) -); - -export const spyOnListPeers = () => ( - jest.spyOn(RootService, 'listPeers').mockImplementation(async () => mockListPeers) + jest.spyOn(RootService, 'listChannels').mockImplementation(async () => mockListChannelsAPIRes) ); export const spyOnListFunds = () => (