fix(wallet): keep self-send legs distinct + deterministic tx order

A self-payment surfaces as two transaction records sharing one payment
hash — one outgoing, one incoming. Port the iOS WalletStore.dedupTransactions
hardening so they render correctly:

- Add dedupTransactions(): drop exact (paymentHash, type) repeats the
  backend returns, and sort newest-first with the incoming "received" leg
  above its outgoing "sent" leg on a timestamp tie. Apply on initial load,
  re-enrich, and load-more.
- Key the transaction LazyColumn by "paymentHash|type" so the two legs of a
  self-send keep distinct identities and both render (mirrors iOS
  WalletTransaction.id).
This commit is contained in:
The Daniel
2026-06-06 15:36:00 -04:00
parent 5175babef0
commit ff47689aaf
2 changed files with 24 additions and 4 deletions
@@ -2319,7 +2319,11 @@ private fun TransactionHistoryContent(
}
else -> {
LazyColumn {
items(transactions) { tx ->
// Key by paymentHash + direction so a self-send's two legs
// (same hash, opposite type) keep distinct identities and
// both render. The list is deduped by (paymentHash, type)
// upstream, so these keys are unique.
items(transactions, key = { "${it.paymentHash}|${it.type}" }) { tx ->
TransactionRow(tx, profileLookup, displayMode)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
@@ -1249,7 +1249,7 @@ class WalletViewModel(
}
mapped.fold(
onSuccess = { txs ->
_transactions.value = txs
_transactions.value = dedupTransactions(txs)
_hasMoreTransactions.value = txs.size >= 50
requestMissingProfiles(txs)
},
@@ -1266,13 +1266,29 @@ class WalletViewModel(
enrichTransactions(current, zapMaps)
}
if (reEnriched != current) {
_transactions.value = reEnriched
_transactions.value = dedupTransactions(reEnriched)
requestMissingProfiles(reEnriched)
}
}
}
}
/**
* Collapse exact backend duplicates and order transactions deterministically.
*
* A self-send surfaces as two records sharing one payment hash one
* outgoing, one incoming. Both legs must survive (we only drop repeats of
* the same (paymentHash, type)), and on a timestamp tie the incoming
* "received" leg sorts above its outgoing "sent" leg so the conclusion of
* the payment reads on top. Mirrors iOS WalletStore.dedupTransactions.
*/
private fun dedupTransactions(txs: List<WalletTransaction>): List<WalletTransaction> =
txs.distinctBy { it.paymentHash to it.type }
.sortedWith(
compareByDescending<WalletTransaction> { it.settledAt ?: it.createdAt }
.thenBy { if (it.type == "incoming") 0 else 1 }
)
private fun enrichTransactions(
txs: List<WalletTransaction>,
zapMaps: EventRepository.ZapCounterpartyMaps
@@ -1325,7 +1341,7 @@ class WalletViewModel(
}
mapped.fold(
onSuccess = { txs ->
_transactions.value = _transactions.value + txs
_transactions.value = dedupTransactions(_transactions.value + txs)
_hasMoreTransactions.value = txs.size >= 50
val missing = txs.mapNotNull { it.counterpartyPubkey }
.distinct()