@@ -36,16 +36,27 @@ class HomeViewModel: ObservableObject {
3636 private let queue = DispatchQueue ( label: " HomeViewModel " , qos: . userInitiated)
3737 private let coinJoinService = CoinJoinService . shared
3838 private var timeSkewDialogShown : Bool = false
39-
39+
4040 static let shared : HomeViewModel = {
4141 return HomeViewModel ( transactionSource: DSWalletSource ( ) )
4242 } ( )
43-
43+
4444 private let transactionSource : TransactionSource
4545 private var txByHash : [ String : TransactionListDataItem ] = [ : ]
4646 private var crowdNodeTxSet = FullCrowdNodeSignUpTxSet ( )
4747 private var coinJoinTxSets : [ String : CoinJoinMixingTxSet ] = [ : ] // Grouped by date
4848 private var metadataProviders : [ MetadataProvider ] = [ ]
49+
50+ /// Tracks whether a full reload is currently in progress to prevent race conditions
51+ /// with incremental updates (Fix #3)
52+ private var isReloading : Bool = false
53+
54+ /// Tracks whether initial data load has completed (Fix #2)
55+ private var hasCompletedInitialLoad : Bool = false
56+
57+ /// Debounce timer for sync state changes to prevent excessive reloads (Fix #4)
58+ private var syncStateDebounceWorkItem : DispatchWorkItem ?
59+ private let syncStateDebounceInterval : TimeInterval = 0.5
4960
5061 @Published private( set) var txItems : [ TransactionGroup ] = [ ]
5162 @Published var shortcutItems : [ ShortcutAction ] = [ ]
@@ -141,6 +152,10 @@ class HomeViewModel: ObservableObject {
141152 self . crowdNodeTxSet = FullCrowdNodeSignUpTxSet ( )
142153 self . coinJoinTxSets. removeAll ( )
143154
155+ // Reset load tracking flags so the new network's data loads correctly
156+ self . hasCompletedInitialLoad = false
157+ self . isReloading = false
158+
144159 // Update UI-bound property on main thread
145160 DispatchQueue . main. async {
146161 self . txItems = [ ]
@@ -174,9 +189,13 @@ class HomeViewModel: ObservableObject {
174189 }
175190 . store ( in: & cancellableBag)
176191
192+ // Fix #5: Balance changes often indicate new transactions, so reload the full
193+ // transaction list, not just shortcuts. This ensures newly received or sent
194+ // transactions appear in the UI promptly.
177195 NotificationCenter . default. publisher ( for: NSNotification . Name. DSWalletBalanceDidChange)
178196 . sink { [ weak self] _ in
179- self ? . reloadShortcuts ( )
197+ DSLogger . log ( " HomeViewModel: Wallet balance changed, reloading transactions and shortcuts " )
198+ self ? . reloadTxsAndShortcuts ( )
180199 }
181200 . store ( in: & cancellableBag)
182201
@@ -188,11 +207,13 @@ class HomeViewModel: ObservableObject {
188207 }
189208 . store ( in: & cancellableBag)
190209
210+ // Fix #1: Always reload transactions when sync starts, not just during resync.
211+ // Previously, this only reloaded if isResyncingWallet was true, which meant
212+ // normal sync operations wouldn't refresh the transaction list.
191213 NotificationCenter . default. publisher ( for: Notification . Name. DSChainManagerSyncWillStart)
192214 . sink { [ weak self] _ in
193- if DWGlobalOptions . sharedInstance ( ) . isResyncingWallet {
194- self ? . reloadTxsAndShortcuts ( )
195- }
215+ DSLogger . log ( " HomeViewModel: Sync will start, reloading transactions " )
216+ self ? . reloadTxsAndShortcuts ( )
196217 }
197218 . store ( in: & cancellableBag)
198219
@@ -210,22 +231,23 @@ class HomeViewModel: ObservableObject {
210231 private func reloadTxDataSource( ) {
211232 self . queue. async { [ weak self] in
212233 guard let self = self else { return }
213-
234+
235+ // Fix #3: Set reload flag to prevent race conditions with incremental updates
236+ self . isReloading = true
237+ DSLogger . log ( " HomeViewModel: Starting full transaction reload " )
238+
214239 let transactions = transactionSource. allTransactions
215240 self . crowdNodeTxSet = FullCrowdNodeSignUpTxSet ( )
216241 self . coinJoinTxSets = [ : ]
217-
242+
218243 var items : [ TransactionListDataItem ] = transactions. compactMap { tx -> TransactionListDataItem ? in
219244 Tx . shared. updateRateIfNeeded ( for: tx)
220-
245+
221246 if !self . passesFilter ( tx: tx, displayMode: self . displayMode) {
222247 return nil
223248 }
224-
225- if !self . crowdNodeTxSet. isComplete && self . crowdNodeTxSet. tryInclude ( tx: tx) {
226- return nil
227- }
228-
249+
250+ // Fix #7: Remove duplicate CrowdNode check - only need to check once
229251 if !self . crowdNodeTxSet. isComplete && self . crowdNodeTxSet. tryInclude ( tx: tx) {
230252 // CrowdNode transactions will be included below
231253 return nil
@@ -234,15 +256,15 @@ class HomeViewModel: ObservableObject {
234256 let date = DWDateFormatter . sharedInstance. dateOnly ( from: tx. date)
235257 let coinJoinTxSet = self . coinJoinTxSets [ date] ?? CoinJoinMixingTxSet ( )
236258 self . coinJoinTxSets [ date] = coinJoinTxSet
237-
259+
238260 if coinJoinTxSet. tryInclude ( tx: tx) {
239261 // CoinJoin transactions will be included below
240262 return nil
241263 }
242-
264+
243265 return . tx( Transaction ( transaction: tx) , self . resolveMetadata ( for: tx. txHashData) )
244266 }
245-
267+
246268 self . txByHash. removeAll ( )
247269 items. forEach { item in
248270 self . txByHash [ item. id] = item
@@ -266,11 +288,17 @@ class HomeViewModel: ObservableObject {
266288 grouping: items. sorted ( by: { $0. date > $1. date } ) ,
267289 by: { DWDateFormatter . sharedInstance. dateOnly ( from: $0. date) }
268290 )
269-
291+
270292 let array = groupedItems. map { key, items in
271293 TransactionGroup ( id: key, date: items. first!. date, items: items)
272294 } . sorted { $0. date > $1. date }
273-
295+
296+ // Fix #2 & #3: Mark initial load complete and clear reload flag
297+ self . hasCompletedInitialLoad = true
298+ self . isReloading = false
299+
300+ DSLogger . log ( " HomeViewModel: Full reload complete, \( array. count) groups, \( self . txByHash. count) transactions cached " )
301+
274302 DispatchQueue . main. async {
275303 self . txItems = array
276304 }
@@ -280,42 +308,158 @@ class HomeViewModel: ObservableObject {
280308 private func onTransactionStatusChanged( tx: DSTransaction ) {
281309 self . queue. async { [ weak self] in
282310 guard let self = self else { return }
283-
311+
312+ // Fix #3: Skip incremental updates while a full reload is in progress
313+ // to prevent race conditions that could cause missing transactions
314+ if self . isReloading {
315+ DSLogger . log ( " HomeViewModel: Skipping incremental update during full reload for tx: \( tx. txHashHexString) " )
316+ return
317+ }
318+
319+ // Fix #2: If initial load hasn't completed yet, the cache is empty and
320+ // incremental updates won't work correctly. Trigger a full reload instead.
321+ if !self . hasCompletedInitialLoad {
322+ DSLogger . log ( " HomeViewModel: Initial load not complete, triggering full reload for tx: \( tx. txHashHexString) " )
323+ DispatchQueue . main. async {
324+ self . reloadTxsAndShortcuts ( )
325+ }
326+ return
327+ }
328+
284329 if !self . passesFilter ( tx: tx, displayMode: self . displayMode) {
285330 return
286331 }
287-
332+
288333 Tx . shared. updateRateIfNeeded ( for: tx)
289- var itemId = tx. txHashHexString
334+ let txHashHex = tx. txHashHexString
335+ var itemId = txHashHex
290336 var txItem : TransactionListDataItem = . tx( Transaction ( transaction: tx) , resolveMetadata ( for: tx. txHashData) )
291- let dateKey = DWDateFormatter . sharedInstance. dateOnly ( from: tx. date)
337+ let newDateKey = DWDateFormatter . sharedInstance. dateOnly ( from: tx. date)
338+
339+ // Track if this transaction was absorbed by a grouped set (CrowdNode/CoinJoin)
340+ var wasAbsorbedByGroup = false
292341
293342 if self . crowdNodeTxSet. tryInclude ( tx: tx) {
294343 itemId = FullCrowdNodeSignUpTxSet . id
295344 txItem = . crowdnode( self . crowdNodeTxSet)
345+ wasAbsorbedByGroup = true
346+ DSLogger . log ( " HomeViewModel: Transaction \( txHashHex) absorbed by CrowdNode group " )
296347 } else {
297- let coinJoinTxSet = self . coinJoinTxSets [ dateKey ] ?? CoinJoinMixingTxSet ( )
298- self . coinJoinTxSets [ dateKey ] = coinJoinTxSet
299-
348+ let coinJoinTxSet = self . coinJoinTxSets [ newDateKey ] ?? CoinJoinMixingTxSet ( )
349+ self . coinJoinTxSets [ newDateKey ] = coinJoinTxSet
350+
300351 if coinJoinTxSet. tryInclude ( tx: tx) {
301352 itemId = coinJoinTxSet. id
302353 txItem = . coinjoin( coinJoinTxSet)
354+ wasAbsorbedByGroup = true
355+ DSLogger . log ( " HomeViewModel: Transaction \( txHashHex) absorbed by CoinJoin group for date \( newDateKey) " )
356+ }
357+ }
358+
359+ // Fix: Check if this transaction exists under its original hash (not group ID)
360+ // This handles the case where a transaction was previously shown individually
361+ // but is now being absorbed by a CoinJoin/CrowdNode group
362+ if wasAbsorbedByGroup && self . txByHash [ txHashHex] != nil {
363+ DSLogger . log ( " HomeViewModel: Removing individual transaction \( txHashHex) as it's now in a group " )
364+
365+ // Perform iteration and removal entirely on main thread to avoid race conditions
366+ // where txItems could change between finding indices and performing removal
367+ DispatchQueue . main. async {
368+ // Find the group containing this transaction by searching current state
369+ for groupIndex in 0 ..< self . txItems. count {
370+ guard groupIndex < self . txItems. count else { break }
371+
372+ if let itemIndex = self . txItems [ groupIndex] . items. firstIndex ( where: { $0. id == txHashHex } ) {
373+ // Verify bounds are still valid before removal
374+ guard groupIndex < self . txItems. count,
375+ itemIndex < self . txItems [ groupIndex] . items. count else { break }
376+
377+ self . txItems [ groupIndex] . items. remove ( at: itemIndex)
378+
379+ // Remove empty groups (re-check bounds after item removal)
380+ if groupIndex < self . txItems. count && self . txItems [ groupIndex] . items. isEmpty {
381+ self . txItems. remove ( at: groupIndex)
382+ }
383+
384+ // Only remove from cache after confirming UI removal succeeded
385+ self . queue. async {
386+ self . txByHash. removeValue ( forKey: txHashHex)
387+ }
388+ break
389+ }
390+ }
303391 }
304392 }
305393
306394 if let existingItem = self . txByHash [ itemId] {
307395 // Updating existing item
308396 self . txByHash [ itemId] = txItem
397+
398+ // Fix: Find the OLD date group where this transaction currently lives
399+ // Transaction dates can change when going from unconfirmed to confirmed
400+ var oldGroupIndex : Int ? = nil
401+ var oldItemIndex : Int ? = nil
402+ var oldDateKey : String ? = nil
403+
404+ for (gIdx, group) in self . txItems. enumerated ( ) {
405+ if let iIdx = group. items. firstIndex ( where: { $0. id == itemId } ) {
406+ oldGroupIndex = gIdx
407+ oldItemIndex = iIdx
408+ oldDateKey = group. id
409+ break
410+ }
411+ }
412+
309413 var isChanged = true
310-
311414 if case let . tx( existingTx, oldMetadata) = existingItem, case let . tx( newTx, metadata) = txItem {
312415 isChanged = newTx. state != existingTx. state || oldMetadata != metadata
313416 }
314-
315- if isChanged {
316- if let groupIndex = self . txItems. firstIndex ( where: { $0. id == dateKey } ) ,
317- let itemIndex = self . txItems [ groupIndex] . items. firstIndex ( where: { $0. id == itemId } ) {
417+
418+ // Fix: Handle transaction moving between date groups (e.g., unconfirmed -> confirmed)
419+ if let oldDateKey = oldDateKey, oldDateKey != newDateKey {
420+ DSLogger . log ( " HomeViewModel: Transaction \( itemId) date changed from \( oldDateKey) to \( newDateKey) " )
421+
422+ // Remove from old group
423+ if let oldGIdx = oldGroupIndex, let oldIIdx = oldItemIndex {
318424 DispatchQueue . main. async {
425+ // Validate both group index and item index bounds before removal
426+ // txItems may have changed between when indices were captured and now
427+ guard oldGIdx >= 0 ,
428+ oldGIdx < self . txItems. count,
429+ oldIIdx >= 0 ,
430+ oldIIdx < self . txItems [ oldGIdx] . items. count else {
431+ DSLogger . log ( " HomeViewModel: Skipping removal - indices out of bounds (groupIdx: \( oldGIdx) , itemIdx: \( oldIIdx) ) " )
432+ return
433+ }
434+
435+ self . txItems [ oldGIdx] . items. remove ( at: oldIIdx)
436+
437+ // Remove empty groups (re-check bounds after item removal)
438+ if oldGIdx < self . txItems. count && self . txItems [ oldGIdx] . items. isEmpty {
439+ self . txItems. remove ( at: oldGIdx)
440+ }
441+
442+ // Add to new group (similar to new item logic)
443+ if let newGroupIndex = self . txItems. firstIndex ( where: { $0. id == newDateKey } ) {
444+ self . txItems [ newGroupIndex] . items. append ( txItem)
445+ self . txItems [ newGroupIndex] . items. sort { $0. date > $1. date }
446+ } else {
447+ let newGroup = TransactionGroup ( id: newDateKey, date: txItem. date, items: [ txItem] )
448+ let insertIndex = self . txItems. firstIndex ( where: { $0. date < txItem. date } )
449+ if let index = insertIndex {
450+ self . txItems. insert ( newGroup, at: index)
451+ } else {
452+ self . txItems. append ( newGroup)
453+ }
454+ }
455+ }
456+ }
457+ } else if isChanged {
458+ // Same date group, just update in place
459+ if let groupIndex = oldGroupIndex, let itemIndex = oldItemIndex {
460+ DispatchQueue . main. async {
461+ guard groupIndex < self . txItems. count,
462+ itemIndex < self . txItems [ groupIndex] . items. count else { return }
319463 let updatedGroup = self . txItems [ groupIndex]
320464 var updatedItems = updatedGroup. items
321465 updatedItems [ itemIndex] = txItem
@@ -328,8 +472,8 @@ class HomeViewModel: ObservableObject {
328472 // New item
329473 self . txByHash [ itemId] = txItem
330474 let shouldShowReclassify = self . shouldDisplayReclassifyTransaction && tx. date > reclassifyTransactionsActivatedAt
331-
332- if let groupIndex = self . txItems. firstIndex ( where: { $0. id == dateKey } ) {
475+
476+ if let groupIndex = self . txItems. firstIndex ( where: { $0. id == newDateKey } ) {
333477 // Add to an existing date group
334478 DispatchQueue . main. async {
335479 self . txItems [ groupIndex] . items. append ( txItem)
@@ -338,9 +482,9 @@ class HomeViewModel: ObservableObject {
338482 }
339483 } else {
340484 // Create a new date group
341- let newGroup = TransactionGroup ( id: dateKey , date: txItem. date, items: [ txItem] )
485+ let newGroup = TransactionGroup ( id: newDateKey , date: txItem. date, items: [ txItem] )
342486 let insertIndex = self . txItems. firstIndex ( where: { $0. date < txItem. date } )
343-
487+
344488 DispatchQueue . main. async {
345489 if let index = insertIndex {
346490 self . txItems. insert ( newGroup, at: index)
@@ -433,10 +577,22 @@ extension HomeViewModel {
433577 }
434578
435579 private func onSyncStateChanged( ) {
436- self . reloadTxsAndShortcuts ( )
437- #if DASHPAY
438- self . checkJoinDashPay ( )
439- #endif
580+ // Fix #4: Debounce sync state changes to prevent excessive reloads.
581+ // During active sync, state can change rapidly which would cause
582+ // multiple expensive full reloads in quick succession.
583+ syncStateDebounceWorkItem? . cancel ( )
584+
585+ let workItem = DispatchWorkItem { [ weak self] in
586+ guard let self = self else { return }
587+ DSLogger . log ( " HomeViewModel: Sync state changed (debounced), reloading " )
588+ self . reloadTxsAndShortcuts ( )
589+ #if DASHPAY
590+ self . checkJoinDashPay ( )
591+ #endif
592+ }
593+
594+ syncStateDebounceWorkItem = workItem
595+ DispatchQueue . main. asyncAfter ( deadline: . now( ) + syncStateDebounceInterval, execute: workItem)
440596 }
441597
442598 func reloadTxsAndShortcuts( ) {
0 commit comments