99
1010interface FeedListProps {
1111 timestamp ?: number | null ;
12- allZaps ?: Transaction [ ] ;
13- allUsers ?: User [ ] ;
14- isLoading ?: boolean ;
1512}
1613interface ZapTransaction {
1714 from : User | null ;
@@ -23,125 +20,128 @@ interface ZapTransaction {
2320const ITEMS_PER_PAGE = 10 ; // Items per page
2421const MAX_RECORDS = 100 ; // Maximum records to display
2522
26- const FeedList : React . FC < FeedListProps > = ( {
27- timestamp,
28- allZaps = [ ] ,
29- allUsers = [ ] ,
30- isLoading = false
31- } ) => {
23+ // Wallet type identifiers - these match the exact naming convention used by the backend
24+ // Backend creates wallets with names 'Allowance' and 'Private' (see functions/sendZap/index.ts)
25+ // NOTE: If wallet naming conventions change on the backend, these must be updated
26+ const WALLET_NAME_ALLOWANCE = 'Allowance' ;
27+ const WALLET_NAME_PRIVATE = 'Private' ;
28+
29+ // Helper functions to identify wallet types by name
30+ // Using exact match (case-insensitive) to avoid false positives like "not_an_allowance_wallet"
31+ const isAllowanceWallet = ( walletName : string ) : boolean =>
32+ walletName . toLowerCase ( ) === WALLET_NAME_ALLOWANCE . toLowerCase ( ) ;
33+
34+ const isPrivateWallet = ( walletName : string ) : boolean =>
35+ walletName . toLowerCase ( ) === WALLET_NAME_PRIVATE . toLowerCase ( ) ;
36+
37+ const FeedList : React . FC < FeedListProps > = ( { timestamp } ) => {
3238 const [ zaps , setZaps ] = useState < ZapTransaction [ ] > ( [ ] ) ;
33- const [ users , setUsers ] = useState < User [ ] > ( [ ] ) ;
3439 const [ loading , setLoading ] = useState ( true ) ;
3540 const [ error , setError ] = useState < string | null > ( null ) ;
3641 const [ currentPage , setCurrentPage ] = useState ( 1 ) ;
3742 const initialRender = useRef ( true ) ;
3843
39- // NEW: State for sorting (excluding the Memo field)
40- const [ sortField , setSortField ] = useState < 'time' | 'from' | 'to' | 'amount' > (
41- 'time' ,
42- ) ;
44+ // State for sorting (excluding the Memo field)
45+ const [ sortField , setSortField ] = useState < 'time' | 'from' | 'to' | 'amount' > ( 'time' ) ;
4346 const [ sortOrder , setSortOrder ] = useState < 'asc' | 'desc' > ( 'desc' ) ;
4447
4548 // Get admin key from environment
46- const adminKey = process . env . REACT_APP_LNBITS_ADMINKEY as string ;
49+ const adminKey = process . env . REACT_APP_LNBITS_ADMINKEY ;
4750
4851 useEffect ( ( ) => {
4952 const fetchZapsStepByStep = async ( ) => {
5053 setLoading ( true ) ;
5154 setError ( null ) ;
5255
5356 try {
57+ // Validate adminKey is configured
58+ if ( ! adminKey ) {
59+ setError ( 'Configuration error: Admin key not set.' ) ;
60+ setLoading ( false ) ;
61+ return ;
62+ }
63+
5464 const paymentsSinceTimestamp =
5565 timestamp === null || timestamp === undefined || timestamp === 0
5666 ? 0
5767 : timestamp ;
5868
59- // Step 1: Get all users from /users/api/v1/user
69+ // Step 1: Get all users
6070 const fetchedUsers = await getUsers ( adminKey , { } ) ;
6171 if ( ! fetchedUsers || fetchedUsers . length === 0 ) {
6272 setError ( 'Unable to load users. Please check your connection and try again.' ) ;
6373 setLoading ( false ) ;
6474 return ;
6575 }
66- setUsers ( fetchedUsers ) ;
6776
68- // Step 2: For each user, get wallets using /users/api/v1/ user/{userId}/wallet
77+ // Step 2: Get wallets for each user
6978 const allWalletsData : { userId : string ; wallets : Wallet [ ] } [ ] = [ ] ;
70- const allWalletsArray : Wallet [ ] = [ ] ;
7179
7280 for ( const user of fetchedUsers ) {
7381 const userWallets = await getUserWallets ( adminKey , user . id ) ;
74- const wallets = userWallets || [ ] ;
75-
7682 allWalletsData . push ( {
7783 userId : user . id ,
78- wallets : wallets
84+ wallets : userWallets || [ ]
7985 } ) ;
80- allWalletsArray . push ( ...wallets ) ;
8186 }
82- // Step 3: For each wallet, get payments from Private and Allowance wallets only
83- let allPayments : Transaction [ ] = [ ] ;
87+ // Step 3: Get payments from both Allowance and Private wallets
88+ // We need both to match sender (Allowance) with receiver (Private)
89+ const allowanceWalletIds = new Set < string > ( ) ;
90+ const privateWalletIds = new Set < string > ( ) ;
91+ const allRelevantWallets : Wallet [ ] = [ ] ;
92+ let failedWalletCount = 0 ;
8493
8594 for ( const userData of allWalletsData ) {
86- // Filter to only Private and Allowance wallets
87- const filteredWallets = userData . wallets . filter ( wallet => {
88- const walletName = wallet . name . toLowerCase ( ) ;
89- return walletName . includes ( 'private' ) || walletName . includes ( 'allowance' ) ;
95+ // Filter to Allowance and Private wallets
96+ const relevantWallets = userData . wallets . filter ( wallet =>
97+ isAllowanceWallet ( wallet . name ) || isPrivateWallet ( wallet . name )
98+ ) ;
99+
100+ // Track wallet IDs by type
101+ relevantWallets . forEach ( wallet => {
102+ if ( isAllowanceWallet ( wallet . name ) ) {
103+ allowanceWalletIds . add ( wallet . id ) ;
104+ }
105+ if ( isPrivateWallet ( wallet . name ) ) {
106+ privateWalletIds . add ( wallet . id ) ;
107+ }
90108 } ) ;
91109
92- // Get payments from filtered wallets only
93- for ( const wallet of filteredWallets ) {
94- try {
95- const payments = await getWalletTransactionsSince (
96- wallet . inkey ,
97- paymentsSinceTimestamp ,
98- null
99- ) ;
100- allPayments = allPayments . concat ( payments ) ;
101- } catch ( err ) {
102- console . error ( `Error fetching payments for wallet ${ wallet . id } :` , err ) ;
103- }
104- }
110+ allRelevantWallets . push ( ...relevantWallets ) ;
105111 }
106112
107- // Filter out weekly allowance cleared transactions only
108- const allowanceTransactions = allPayments . filter (
109- f => ! f . memo . includes ( 'Weekly Allowance cleared' ) ,
113+ // Fetch all wallet transactions in parallel for better performance
114+ const paymentPromises = allRelevantWallets . map ( wallet =>
115+ getWalletTransactionsSince ( wallet . inkey , paymentsSinceTimestamp , null )
116+ . catch ( err => {
117+ console . error ( `Error fetching payments for wallet ${ wallet . id } :` , err ) ;
118+ failedWalletCount ++ ;
119+ return [ ] as Transaction [ ] ; // Return empty array on error
120+ } )
110121 ) ;
111122
112- // Deduplicate internal transfers - only show the incoming side (positive amount)
113- // For internal transfers, we have 2 records with the same checking_id (one negative, one positive)
114- // We only want to show one transaction per transfer
115- const seenCheckingIds = new Set < string > ( ) ;
116- const deduplicatedTransactions = allowanceTransactions . filter ( payment => {
117- const cleanId = payment . checking_id ?. replace ( 'internal_' , '' ) || '' ;
118-
119- // If this is an internal transfer (has matching checking_id)
120- if ( cleanId && payment . checking_id ?. startsWith ( 'internal_' ) ) {
121- // Only show the incoming side (positive amount)
122- if ( payment . amount < 0 ) {
123- return false ; // Skip outgoing side
124- }
123+ const paymentResults = await Promise . all ( paymentPromises ) ;
124+ const allPayments = paymentResults . flat ( ) ;
125125
126- // Check if we've already seen this checking_id
127- if ( seenCheckingIds . has ( cleanId ) ) {
128- return false ; // Skip duplicate
129- }
130- seenCheckingIds . add ( cleanId ) ;
131- }
132-
133- return true ;
134- } ) ;
126+ // Log warning if some wallets failed to load
127+ if ( failedWalletCount > 0 ) {
128+ console . warn ( `${ failedWalletCount } wallet(s) failed to load transactions` ) ;
129+ }
135130
136131 // Create wallet ID to user mapping
137132 const walletToUserMap = new Map < string , User > ( ) ;
138133 allWalletsData . forEach ( userData => {
139- userData . wallets . forEach ( wallet => {
140- walletToUserMap . set ( wallet . id , fetchedUsers . find ( u => u . id === userData . userId ) ! ) ;
141- } ) ;
134+ const user = fetchedUsers . find ( u => u . id === userData . userId ) ;
135+ if ( user ) {
136+ userData . wallets . forEach ( wallet => {
137+ walletToUserMap . set ( wallet . id , user ) ;
138+ } ) ;
139+ } else {
140+ console . warn ( `User not found for userId: ${ userData . userId } - wallet transactions may show as Unknown` ) ;
141+ }
142142 } ) ;
143143
144- // Create a map of all payments by checking_id for internal transfer matching
144+ // Map payments by checking_id (built before filtering to find receiving side)
145145 const paymentsByCheckingId = new Map < string , Transaction [ ] > ( ) ;
146146 allPayments . forEach ( payment => {
147147 const cleanId = payment . checking_id ?. replace ( 'internal_' , '' ) || '' ;
@@ -152,46 +152,79 @@ const FeedList: React.FC<FeedListProps> = ({
152152 }
153153 } ) ;
154154
155- const allowanceZaps = deduplicatedTransactions . map ( ( transaction , index ) => {
156- const walletOwner = walletToUserMap . get ( transaction . wallet_id ) || null ;
155+ // Helper to find the receiving payment for a given outgoing payment
156+ const findReceiverWalletId = ( payment : Transaction ) : string | null => {
157+ const cleanId = payment . checking_id ?. replace ( 'internal_' , '' ) || '' ;
158+ if ( ! cleanId ) return null ;
159+
160+ const matchingPayments = paymentsByCheckingId . get ( cleanId ) || [ ] ;
161+ const receivingPayment = matchingPayments . find ( p =>
162+ p . wallet_id !== payment . wallet_id && p . amount > 0
163+ ) ;
164+ return receivingPayment ?. wallet_id || null ;
165+ } ;
166+
167+ // Filter: Only outgoing payments FROM Allowance wallets TO Private wallets
168+ const allowanceTransactions = allPayments . filter ( payment => {
169+ // Must be from an Allowance wallet
170+ if ( ! allowanceWalletIds . has ( payment . wallet_id ) ) return false ;
171+ // Must be outgoing (negative amount)
172+ if ( payment . amount >= 0 ) return false ;
173+ // Exclude weekly allowance cleared transactions
174+ if ( payment . memo ?. includes ( 'Weekly Allowance cleared' ) ) return false ;
175+
176+ // Verify the receiver is a Private wallet (not external Lightning payment)
177+ const receiverWalletId = findReceiverWalletId ( payment ) ;
178+ if ( ! receiverWalletId || ! privateWalletIds . has ( receiverWalletId ) ) {
179+ return false ;
180+ }
181+
182+ return true ;
183+ } ) ;
184+
185+ // Deduplicate internal transfers by checking_id
186+ const seenCheckingIds = new Set < string > ( ) ;
187+ const deduplicatedTransactions = allowanceTransactions . filter ( payment => {
188+ const cleanId = payment . checking_id ?. replace ( 'internal_' , '' ) || '' ;
189+
190+ if ( cleanId ) {
191+ if ( seenCheckingIds . has ( cleanId ) ) {
192+ return false ; // Skip duplicate
193+ }
194+ seenCheckingIds . add ( cleanId ) ;
195+ }
157196
158- // Determine if this is incoming (positive amount) or outgoing (negative amount)
159- const isIncoming = transaction . amount > 0 ;
197+ return true ;
198+ } ) ;
199+
200+ const allowanceZaps = deduplicatedTransactions . map ( ( transaction , index ) => {
201+ // FROM = owner of the Allowance wallet (sender)
202+ const fromUser = walletToUserMap . get ( transaction . wallet_id ) || null ;
160203
161- let fromUser : User | null = null ;
204+ // TO = recipient (owner of the Private wallet that received the payment)
162205 let toUser : User | null = null ;
163206
164- // Try to find matching internal payment (the other side of the transfer )
207+ // Try to find matching internal payment (the receiving side)
165208 const cleanCheckingId = transaction . checking_id ?. replace ( 'internal_' , '' ) || '' ;
166209 const matchingPayments = paymentsByCheckingId . get ( cleanCheckingId ) || [ ] ;
167210 const matchingPayment = matchingPayments . find ( p => p . wallet_id !== transaction . wallet_id ) ;
168211
169- if ( isIncoming ) {
170- // For incoming payments: TO = wallet owner
171- toUser = walletOwner ;
172-
173- // FROM = the owner of the matching outgoing payment (if found)
174- if ( matchingPayment ) {
175- fromUser = walletToUserMap . get ( matchingPayment . wallet_id ) || null ;
176- } else {
177- // Fallback to extra field
178- const fromUserId = transaction . extra ?. from ?. user ;
179- fromUser = fromUserId ? fetchedUsers . find ( f => f . id === fromUserId ) || null : null ;
180- }
181- } else {
182- // For outgoing payments: FROM = wallet owner
183- fromUser = walletOwner ;
184-
185- // TO = the owner of the matching incoming payment (if found)
186- if ( matchingPayment ) {
187- toUser = walletToUserMap . get ( matchingPayment . wallet_id ) || null ;
188- } else {
189- // Fallback to extra field
190- const toUserId = transaction . extra ?. to ?. user ;
191- toUser = toUserId ? fetchedUsers . find ( f => f . id === toUserId ) || null : null ;
212+ if ( matchingPayment ) {
213+ toUser = walletToUserMap . get ( matchingPayment . wallet_id ) || null ;
214+ if ( ! toUser ) {
215+ console . warn ( `Receiver wallet ${ matchingPayment . wallet_id } found but user mapping missing` ) ;
192216 }
193217 }
194218
219+ // Fallback: Try extra.to.user field
220+ if ( ! toUser && transaction . extra ?. to ?. user ) {
221+ const toUserId = transaction . extra . to . user ;
222+ toUser = fetchedUsers . find ( f => f . id === toUserId ) || null ;
223+ }
224+
225+ if ( ! toUser ) {
226+ console . warn ( `Could not determine receiver for transaction ${ transaction . checking_id } ` ) ;
227+ }
195228
196229 return {
197230 from : fromUser ,
@@ -223,18 +256,16 @@ const FeedList: React.FC<FeedListProps> = ({
223256 fetchZapsStepByStep ( ) ;
224257 }
225258 } , [ timestamp , adminKey ] ) ;
226- // NEW: Function to handle header clicks for sorting
259+
227260 const handleSort = ( field : 'time' | 'from' | 'to' | 'amount' ) => {
228261 if ( sortField === field ) {
229- // Toggle sort order if the same field is clicked
230262 setSortOrder ( sortOrder === 'asc' ? 'desc' : 'asc' ) ;
231263 } else {
232- // Change sort field and set default order to ascending
233264 setSortField ( field ) ;
234265 setSortOrder ( 'asc' ) ;
235266 }
236267 } ;
237- // NEW: Sort the zaps array based on the selected sort field and order
268+
238269 const sortedZaps = [ ...zaps ] . sort ( ( a , b ) => {
239270 let valA , valB ;
240271
@@ -266,7 +297,7 @@ const FeedList: React.FC<FeedListProps> = ({
266297 } ) ;
267298
268299 // Calculate pagination variables
269- const totalPages = Math . ceil ( sortedZaps . length / ITEMS_PER_PAGE ) ;
300+ const totalPages = Math . max ( 1 , Math . ceil ( sortedZaps . length / ITEMS_PER_PAGE ) ) ;
270301 const indexOfLastItem = currentPage * ITEMS_PER_PAGE ;
271302 const indexOfFirstItem = indexOfLastItem - ITEMS_PER_PAGE ;
272303 const currentItems = sortedZaps . slice ( indexOfFirstItem , indexOfLastItem ) ;
0 commit comments