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:
@@ -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) {
|
||||
// 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
|
||||
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)
|
||||
_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 {
|
||||
|
||||
Reference in New Issue
Block a user