Merge pull request #557 from dmnyc/fix/collapse-zap-spam

fix(zaps): collapse same-actor zap spam in notifications and post drawer
This commit is contained in:
Barry Deen
2026-06-18 12:21:55 -04:00
committed by GitHub
4 changed files with 232 additions and 22 deletions
@@ -25,7 +25,22 @@ data class FlatNotificationItem(
val groupChatId: String? = null,
/** Zap poll option index — when set, this zap was a vote on a kind 6969 zap poll. */
val zapPollOptionIndex: Int? = null,
)
/**
* Additional zaps from the same actor against the same referenced event,
* folded into this row by NotificationsViewModel so a spammer can't push
* everything else off-screen. Always empty on freshly ingested items
* inside the repository — populated only at the view-model filter step.
*/
val mergedZaps: List<FlatNotificationItem> = emptyList(),
) {
/**
* Total sats across the primary zap and every merged duplicate. Used by
* the row + bolt-icon label so the displayed amount reflects the full
* contribution from this actor on this note.
*/
val totalZapSats: Long
get() = zapSats + mergedZaps.sumOf { it.zapSats }
}
data class NotificationSummary(
val replyCount: Int = 0,
@@ -238,8 +238,55 @@ fun ReactionDetailsSection(
modifier: Modifier = Modifier,
eventRepo: EventRepository? = null
) {
val sortedZaps = zapDetails.sortedByDescending { it.sats }
val hasZaps = sortedZaps.isNotEmpty()
// Group multiple zaps from the same pubkey into one row showing the
// combined sat total, matching the notifications-side same-actor
// collapse. Without this, a spammer hitting one note N times pushes
// legitimate zappers off the screen. See iOS PR
// barrydeen/wisp-ios#161 (NoteDetailsPanel.zapsSection) for the
// mirror change.
data class ZapGroup(
val pubkey: String,
val totalSats: Long,
val count: Int,
/** First non-empty zap message in the group; empty if no zap had one. */
val primaryMessage: String,
/** Receipt id of the first zap in the group; only used to enable long-press inspect. */
val firstReceiptEventId: String?,
val anyPrivate: Boolean,
)
val zapGroups: List<ZapGroup> = run {
val order = mutableListOf<String>()
val totals = mutableMapOf<String, Long>()
val counts = mutableMapOf<String, Int>()
val messages = mutableMapOf<String, String>()
val firstReceipts = mutableMapOf<String, String?>()
val anyPrivate = mutableMapOf<String, Boolean>()
for (zap in zapDetails) {
if (zap.pubkey !in totals) {
order.add(zap.pubkey)
firstReceipts[zap.pubkey] = zap.receiptEventId
}
totals[zap.pubkey] = (totals[zap.pubkey] ?: 0L) + zap.sats
counts[zap.pubkey] = (counts[zap.pubkey] ?: 0) + 1
if (messages[zap.pubkey].isNullOrEmpty() && zap.message.isNotEmpty()) {
messages[zap.pubkey] = zap.message
}
anyPrivate[zap.pubkey] = (anyPrivate[zap.pubkey] ?: false) || zap.isPrivate
}
order.map { pk ->
ZapGroup(
pubkey = pk,
totalSats = totals[pk] ?: 0L,
count = counts[pk] ?: 0,
primaryMessage = messages[pk].orEmpty(),
firstReceiptEventId = firstReceipts[pk],
anyPrivate = anyPrivate[pk] ?: false,
)
}.sortedByDescending { it.totalSats }
}
val hasZaps = zapGroups.isNotEmpty()
val hasReactions = reactionDetails.isNotEmpty()
val hasReposts = repostDetails.isNotEmpty()
@@ -252,16 +299,39 @@ fun ReactionDetailsSection(
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
if (hasZaps) {
sortedZaps.forEach { zap ->
zapGroups.forEach { group ->
ZapRow(
pubkey = zap.pubkey,
sats = zap.sats,
message = zap.message,
profile = resolveProfile(zap.pubkey),
pubkey = group.pubkey,
sats = group.totalSats,
// When N > 1 the row label shows "<msg-or-name> (×N)"
// so the rollup is obvious; for N == 1 it falls back
// to the original per-zap rendering. `ZapRow`'s own
// empty-message handling resolves to the actor name,
// so we mirror that fallback here before appending
// the suffix.
message = run {
val base = if (group.primaryMessage.isNotEmpty()) {
group.primaryMessage
} else {
resolveProfile(group.pubkey)?.displayString
?: group.pubkey.toNpub().let { "${it.take(12)}...${it.takeLast(4)}" }
}
if (group.count > 1) "$base (×${group.count})" else base
},
profile = resolveProfile(group.pubkey),
onProfileClick = onProfileClick,
isPrivate = zap.isPrivate,
onLongPress = if (zap.receiptEventId != null) {
{ inspectedZap = zap }
isPrivate = group.anyPrivate,
onLongPress = if (group.firstReceiptEventId != null) {
{
// Long-press inspects the first individual zap in
// the group — preserves the existing "inspect a
// single receipt" affordance without surfacing
// every duplicate behind its own modal.
inspectedZap = zapDetails.firstOrNull {
it.pubkey == group.pubkey
&& it.receiptEventId == group.firstReceiptEventId
}
}
} else null,
eventRepo = eventRepo
)
@@ -688,6 +688,24 @@ private fun ZenNotificationRow(
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
// +N more pill when same-actor zap spam has been folded
// into this row. Visual signal only — the breakdown of
// individual zaps shows up in the expanded section.
if (item.type == NotificationType.ZAP && item.mergedZaps.isNotEmpty()) {
Spacer(Modifier.width(6.dp))
val extra = item.mergedZaps.size
Text(
text = "+$extra more",
style = MaterialTheme.typography.labelSmall,
color = WispThemeColors.zapColor,
modifier = Modifier
.background(
color = WispThemeColors.zapColor.copy(alpha = 0.15f),
shape = RoundedCornerShape(50)
)
.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
if (item.isPrivateReply || item.isPrivateReaction || item.isPrivateZap) {
Spacer(Modifier.width(4.dp))
Icon(
@@ -835,10 +853,76 @@ private fun ZenNotificationRow(
} else if (item.type == NotificationType.DM_ZAP || item.type == NotificationType.PROFILE_ZAP) {
ZapMessageExpansion(item = item)
} else if (postCardParams != null && item.type != NotificationType.DM_REACTION) {
NoteExpansion(
item = item,
params = postCardParams
// Wrap in a Column — `AnimatedVisibility` lays its content
// out in a Box, so sibling composables would draw on top
// of each other.
//
// Embedded note first, then the merged-zaps breakdown
// below — mirrors iOS NotificationRowView, where the
// referenced-note card precedes the per-zap breakdown
// in the caption flow.
Column(modifier = Modifier.fillMaxWidth()) {
NoteExpansion(
item = item,
params = postCardParams
)
if (item.type == NotificationType.ZAP && item.mergedZaps.isNotEmpty()) {
MergedZapsBreakdown(item = item)
}
}
}
}
}
}
// ── Merged-zaps breakdown (NotificationType.ZAP folded by view model) ───
/**
* Inline list of every individual zap that was folded into this row by
* `NotificationsViewModel.collapseSameActorZapSpam`. Primary first, then
* merged duplicates in timestamp-desc order (the source list is already
* sorted that way by the view model). Each line shows the per-zap sat
* amount + optional comment so a genuine multi-zap conversation isn't
* lost inside the rollup.
*/
@Composable
private fun MergedZapsBreakdown(item: FlatNotificationItem) {
val all = listOf(item) + item.mergedZaps
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 56.dp, end = 16.dp, bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "${all.size} zaps · ${com.wisp.app.ui.util.AmountFormatter.formatShort(item.totalZapSats, context)} sats total",
style = MaterialTheme.typography.labelMedium,
color = WispThemeColors.zapColor
)
all.forEach { entry ->
Row(verticalAlignment = Alignment.Top) {
Text(
text = "${com.wisp.app.ui.util.AmountFormatter.formatShort(entry.zapSats, context)} sats",
style = MaterialTheme.typography.labelMedium,
color = WispThemeColors.zapColor,
modifier = Modifier.widthIn(min = 56.dp)
)
Spacer(Modifier.width(8.dp))
val msg = entry.zapMessage.trim()
if (msg.isNotEmpty()) {
Text(
text = "$msg",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
} else {
Text(
text = "",
modifier = Modifier.weight(1f)
)
}
}
}
}
@@ -1897,9 +1981,13 @@ private fun NotificationTypeIcon(item: FlatNotificationItem, showSats: Boolean =
modifier = Modifier.size(iconSize - 4.dp),
tint = WispThemeColors.zapColor
)
if (showSats && item.zapSats > 0) {
// Use totalZapSats so the bolt-icon label reflects the combined
// amount across every merged duplicate from the same actor on
// the same note, not just the primary zap.
val displaySats = item.totalZapSats
if (showSats && displaySats > 0) {
Text(
text = com.wisp.app.ui.util.AmountFormatter.formatShort(item.zapSats, LocalContext.current),
text = com.wisp.app.ui.util.AmountFormatter.formatShort(displaySats, LocalContext.current),
style = MaterialTheme.typography.labelSmall,
color = WispThemeColors.zapColor,
maxLines = 1
@@ -221,22 +221,59 @@ class NotificationsViewModel(app: Application) : AndroidViewModel(app) {
_enabledTypes
) { items: List<FlatNotificationItem>, dmItems: List<FlatNotificationItem>, enabled: Set<NotificationFilter> ->
val chatEnabled = _chatRoomsEnabled.value
(items + dmItems)
.filter { isItemEnabled(it, enabled, chatEnabled) }
.sortedByDescending { it.timestamp }
collapseSameActorZapSpam(
(items + dmItems)
.filter { isItemEnabled(it, enabled, chatEnabled) }
.sortedByDescending { it.timestamp }
)
}.collect { filtered -> _filteredFlatNotifications.value = filtered }
}
// Re-filter when chat rooms toggle changes
viewModelScope.launch {
_chatRoomsEnabled.collect { chatEnabled ->
val enabled = _enabledTypes.value
_filteredFlatNotifications.value = (flatNotifications.value + dmNotifications.value)
.filter { isItemEnabled(it, enabled, chatEnabled) }
.sortedByDescending { it.timestamp }
_filteredFlatNotifications.value = collapseSameActorZapSpam(
(flatNotifications.value + dmNotifications.value)
.filter { isItemEnabled(it, enabled, chatEnabled) }
.sortedByDescending { it.timestamp }
)
}
}
}
/**
* Folds consecutive zaps from the same actor against the same note into
* one row so a sender spamming 1-sat zaps can't drown out everything else.
* Walks `items` in the order given (newest-first by the caller), so the
* most-recent zap becomes the primary and older duplicates ride along in
* `mergedZaps`. Only `NotificationType.ZAP` with a non-empty
* `referencedEventId` participates DM_ZAP / PROFILE_ZAP have nothing
* to dedupe against.
*
* Mirrors the iOS `NotificationsViewModel.filteredItems` collapse logic
* in PR barrydeen/wisp-ios#161; see the iOS file for the design notes.
*/
private fun collapseSameActorZapSpam(items: List<FlatNotificationItem>): List<FlatNotificationItem> {
val result = mutableListOf<FlatNotificationItem>()
val zapIndexByKey = mutableMapOf<String, Int>()
for (item in items) {
if (item.type == NotificationType.ZAP && item.referencedEventId.isNotEmpty()) {
val key = "${item.actorPubkey}|${item.referencedEventId}"
val idx = zapIndexByKey[key]
if (idx != null) {
val primary = result[idx]
result[idx] = primary.copy(mergedZaps = primary.mergedZaps + item)
} else {
zapIndexByKey[key] = result.size
result.add(item)
}
} else {
result.add(item)
}
}
return result
}
private fun startSummaryCombine() {
val baseSummary = notifRepo?.summary24h ?: return
viewModelScope.launch {