From 426d9353cdc9c3165531b37da89216927f81297b Mon Sep 17 00:00:00 2001 From: Barry Deen Date: Sun, 17 May 2026 12:49:53 -0400 Subject: [PATCH] feat(private-replies): gift-wrapped reactions + DIP-03 zaps on private replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-enable React and Zap on NIP-17 private replies as private-by-default flows. Reactions are kind-7 rumors gift-wrapped to every conversation participant + a self-copy with k=1 to distinguish from existing DM reactions. Zaps reuse the existing DIP-03 pipeline against the rumor id/created_at, locked private at the ZapDialog so a public fallback can't leak the rumor id. Repost and Quote remain hidden — they would publicly attach an e-tag pointing at the encrypted rumor. - New PrivateReactionPublisher fans out to rumor.pubkey + p-tag participants with DM relay + NIP-65 inbox fallback, optimistic local insert dedups against the relay-echoed self-copy via deterministic rumor id. - EventRouter.processGiftWrap dispatches kind-7 rumors by their k tag: k=1 routes through eventRepo.addEvent so counts and notifications surface alongside public reactions; k=14 (or absent) stays on the DM path. - SocialActionManager auto-routes reactions and zaps based on eventRepo.isPrivate(event.id); v1 is add-only — toggle-off via gift-wrapped NIP-09 deletion is a future enhancement. - ZapDialog gains forcePrivate which hides the anon/private toggles and holds isPrivate=true. ActionBar gains zapEnabled so the thread can gray out the zap button (with a toast) when the recipient lacks DM relays or the user is on a remote signer. - FlatNotificationItem.isPrivateReaction joins isPrivateReply/isPrivateZap; the lock-icon check now ORs all three (retroactively surfaces the lock on private zap notifications which had the flag populated but not rendered). - Rename eventRepo.isPrivateReply -> isPrivate (and markPrivateReply -> markPrivate) since the set now tracks any gift-wrap-materialised event. --- .../main/kotlin/com/wisp/app/Navigation.kt | 13 +- .../main/kotlin/com/wisp/app/nostr/Nip17.kt | 78 +++++++++-- .../com/wisp/app/nostr/NotificationItem.kt | 1 + .../com/wisp/app/repo/EventRepository.kt | 13 +- .../wisp/app/repo/NotificationRepository.kt | 3 +- .../wisp/app/repo/PrivateReactionPublisher.kt | 129 ++++++++++++++++++ .../wisp/app/repo/PrivateReplyPublisher.kt | 2 +- .../com/wisp/app/ui/component/ActionBar.kt | 84 +++++++----- .../com/wisp/app/ui/component/PostCard.kt | 4 + .../com/wisp/app/ui/component/ZapDialog.kt | 32 ++++- .../wisp/app/ui/screen/NotificationsScreen.kt | 4 +- .../com/wisp/app/ui/screen/ThreadScreen.kt | 20 ++- .../wisp/app/viewmodel/ComposeViewModel.kt | 2 +- .../com/wisp/app/viewmodel/EventRouter.kt | 42 +++++- .../com/wisp/app/viewmodel/FeedViewModel.kt | 3 +- .../wisp/app/viewmodel/SocialActionManager.kt | 41 ++++++ app/src/main/res/values/strings.xml | 2 + 17 files changed, 401 insertions(+), 72 deletions(-) create mode 100644 app/src/main/kotlin/com/wisp/app/repo/PrivateReactionPublisher.kt diff --git a/app/src/main/kotlin/com/wisp/app/Navigation.kt b/app/src/main/kotlin/com/wisp/app/Navigation.kt index d35a104..1fd2fd5 100644 --- a/app/src/main/kotlin/com/wisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/wisp/app/Navigation.kt @@ -1896,7 +1896,8 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.hasLocalKeypair && threadUserHasDmRelays && threadRecipientHasDmRelays + canPrivateZap = feedViewModel.hasLocalKeypair && threadUserHasDmRelays && threadRecipientHasDmRelays, + forcePrivate = threadZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true ) } val threadSetListedIds by feedViewModel.bookmarkSetRepo.allListedEventIds.collectAsState() @@ -1989,6 +1990,11 @@ fun WispNavHost( isEmojiSetAdded = { pubkey, dTag -> val ref = com.wisp.app.nostr.Nip30.buildSetReference(pubkey, dTag) feedViewModel.customEmojiRepo.userEmojiList.value?.setReferences?.contains(ref) ?: false + }, + canPrivateZapFor = { event -> + feedViewModel.hasLocalKeypair && + feedViewModel.relayPool.hasDmRelays() && + feedViewModel.relayListRepo.hasDmRelays(event.pubkey) } ) @@ -3144,7 +3150,8 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.hasLocalKeypair && notifUserHasDmRelays && notifRecipientHasDmRelays + canPrivateZap = feedViewModel.hasLocalKeypair && notifUserHasDmRelays && notifRecipientHasDmRelays, + forcePrivate = notifZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true ) } @@ -3196,7 +3203,7 @@ fun WispNavHost( // If the parent is a private reply we received, keep the thread encrypted // by gift-wrapping this reply too. Otherwise fall through to the public path. - if (feedViewModel.eventRepo.isPrivateReply(replyToEvent.id)) { + if (feedViewModel.eventRepo.isPrivate(replyToEvent.id)) { val difficulty = if (feedViewModel.powPrefs.isNotePowEnabled()) feedViewModel.powPrefs.getNoteDifficulty() else 0 com.wisp.app.repo.PrivateReplyPublisher.send( signer = signer, diff --git a/app/src/main/kotlin/com/wisp/app/nostr/Nip17.kt b/app/src/main/kotlin/com/wisp/app/nostr/Nip17.kt index b22e281..9e23fc3 100644 --- a/app/src/main/kotlin/com/wisp/app/nostr/Nip17.kt +++ b/app/src/main/kotlin/com/wisp/app/nostr/Nip17.kt @@ -349,24 +349,30 @@ object Nip17 { } /** - * Create a single gift-wrapped private DM reaction for [recipientPubkey]. + * Create a single gift-wrapped reaction (kind 7 rumor) for [recipientPubkey]. * Call in a loop over all conversation participants to broadcast the reaction. + * * The inner rumor carries the emoji as content and an e-tag pointing to - * [targetRumorId] so recipients can associate the reaction with the right message. + * [targetRumorId] so recipients can associate it with the right message. + * [targetKind] (the kind of the message being reacted to — 14 for DMs, 1 for + * a NIP-17 private reply) becomes the "k" tag, letting receivers route the + * reaction to the right repository (DM conversation vs. note thread). */ - suspend fun createDmReaction( + suspend fun createGiftWrappedReaction( senderPrivkey: ByteArray, senderPubkey: ByteArray, recipientPubkey: ByteArray, targetRumorId: String, - originalSenderPubkey: String, + targetAuthor: String, + targetKind: Int, emoji: String, - emojiUrl: String? = null + emojiUrl: String? = null, + createdAt: Long = System.currentTimeMillis() / 1000 ): NostrEvent { val tags = mutableListOf( listOf("e", targetRumorId), - listOf("p", originalSenderPubkey), - listOf("k", "14") + listOf("p", targetAuthor), + listOf("k", targetKind.toString()) ) if (emojiUrl != null) { tags.add(listOf("emoji", emoji.removeSurrounding(":"), emojiUrl)) @@ -377,23 +383,26 @@ object Nip17 { recipientPubkey = recipientPubkey, message = emoji, rumorKind = 7, - replyTags = tags + replyTags = tags, + createdAt = createdAt ) } - /** Remote-signer variant of [createDmReaction]. */ - suspend fun createDmReactionRemote( + /** Remote-signer variant of [createGiftWrappedReaction]. */ + suspend fun createGiftWrappedReactionRemote( signer: NostrSigner, recipientPubkeyHex: String, targetRumorId: String, - originalSenderPubkey: String, + targetAuthor: String, + targetKind: Int, emoji: String, - emojiUrl: String? = null + emojiUrl: String? = null, + createdAt: Long = System.currentTimeMillis() / 1000 ): NostrEvent { val tags = mutableListOf( listOf("e", targetRumorId), - listOf("p", originalSenderPubkey), - listOf("k", "14") + listOf("p", targetAuthor), + listOf("k", targetKind.toString()) ) if (emojiUrl != null) { tags.add(listOf("emoji", emoji.removeSurrounding(":"), emojiUrl)) @@ -403,10 +412,49 @@ object Nip17 { recipientPubkeyHex = recipientPubkeyHex, message = emoji, rumorKind = 7, - replyTags = tags + replyTags = tags, + createdAt = createdAt ) } + /** DM-targeted reaction. Thin wrapper around [createGiftWrappedReaction] with `k=14`. */ + suspend fun createDmReaction( + senderPrivkey: ByteArray, + senderPubkey: ByteArray, + recipientPubkey: ByteArray, + targetRumorId: String, + originalSenderPubkey: String, + emoji: String, + emojiUrl: String? = null + ): NostrEvent = createGiftWrappedReaction( + senderPrivkey = senderPrivkey, + senderPubkey = senderPubkey, + recipientPubkey = recipientPubkey, + targetRumorId = targetRumorId, + targetAuthor = originalSenderPubkey, + targetKind = 14, + emoji = emoji, + emojiUrl = emojiUrl + ) + + /** Remote-signer variant of [createDmReaction]. */ + suspend fun createDmReactionRemote( + signer: NostrSigner, + recipientPubkeyHex: String, + targetRumorId: String, + originalSenderPubkey: String, + emoji: String, + emojiUrl: String? = null + ): NostrEvent = createGiftWrappedReactionRemote( + signer = signer, + recipientPubkeyHex = recipientPubkeyHex, + targetRumorId = targetRumorId, + targetAuthor = originalSenderPubkey, + targetKind = 14, + emoji = emoji, + emojiUrl = emojiUrl + ) + private fun randomizeTimestamp(base: Long): Long { // 0 to 1 day in the past — NIP-17 spec allows up to 2 days, but keeping it to 1 day // ensures interop with clients (e.g. Amethyst) whose kind-1059 subscription may use diff --git a/app/src/main/kotlin/com/wisp/app/nostr/NotificationItem.kt b/app/src/main/kotlin/com/wisp/app/nostr/NotificationItem.kt index 42efb82..ae99c68 100644 --- a/app/src/main/kotlin/com/wisp/app/nostr/NotificationItem.kt +++ b/app/src/main/kotlin/com/wisp/app/nostr/NotificationItem.kt @@ -14,6 +14,7 @@ data class FlatNotificationItem( val zapMessage: String = "", val isPrivateZap: Boolean = false, val isPrivateReply: Boolean = false, + val isPrivateReaction: Boolean = false, val replyEventId: String? = null, val quoteEventId: String? = null, val voteOptionIds: List = emptyList(), diff --git a/app/src/main/kotlin/com/wisp/app/repo/EventRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/EventRepository.kt index 32510cc..f97fdd7 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/EventRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/EventRepository.kt @@ -46,12 +46,13 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo: private val eventCache = ConcurrentHashMap() private val seenEventIds = ConcurrentHashMap.newKeySet() // thread-safe dedup that doesn't evict - // NIP-17 private replies: event IDs (= rumor IDs) of kind 1 notes received via gift wrap. - // Screens read this to show a lock indicator next to the reply. - private val privateReplyIds = ConcurrentHashMap.newKeySet() + // NIP-17 private events: event IDs (= rumor IDs) of any rumor we materialised from a gift wrap + // (kind 1 private replies and kind 7 private reactions today). Screens read this to show a lock + // indicator and to route follow-on actions (e.g. zaps) through the private pipeline. + private val privateEventIds = ConcurrentHashMap.newKeySet() - fun markPrivateReply(eventId: String) { privateReplyIds.add(eventId) } - fun isPrivateReply(eventId: String): Boolean = eventId in privateReplyIds + fun markPrivate(eventId: String) { privateEventIds.add(eventId) } + fun isPrivate(eventId: String): Boolean = eventId in privateEventIds /** Emits event IDs that were removed (e.g. via NIP-09 deletion). Screens like ThreadScreen * observe this to prune their local state when a deletion arrives on any subscription. */ @@ -1450,7 +1451,7 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo: resetFeedDisplay() eventCache.clear() seenEventIds.clear() - privateReplyIds.clear() + privateEventIds.clear() } fun clearAll() { diff --git a/app/src/main/kotlin/com/wisp/app/repo/NotificationRepository.kt b/app/src/main/kotlin/com/wisp/app/repo/NotificationRepository.kt index f30dcff..ff774d8 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/NotificationRepository.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/NotificationRepository.kt @@ -582,6 +582,7 @@ class NotificationRepository( timestamp = event.created_at, emoji = emoji, emojiUrl = flatEmojiUrl, + isPrivateReaction = eventRepo?.isPrivate(event.id) == true, groupChatId = groupChatId )) } @@ -735,7 +736,7 @@ class NotificationRepository( referencedEventId = replyTarget, timestamp = event.created_at, replyEventId = event.id, - isPrivateReply = eventRepo?.isPrivateReply(event.id) == true, + isPrivateReply = eventRepo?.isPrivate(event.id) == true, groupChatId = groupChatId )) } diff --git a/app/src/main/kotlin/com/wisp/app/repo/PrivateReactionPublisher.kt b/app/src/main/kotlin/com/wisp/app/repo/PrivateReactionPublisher.kt new file mode 100644 index 0000000..e12dbd1 --- /dev/null +++ b/app/src/main/kotlin/com/wisp/app/repo/PrivateReactionPublisher.kt @@ -0,0 +1,129 @@ +package com.wisp.app.repo + +import com.wisp.app.nostr.ClientMessage +import com.wisp.app.nostr.Nip17 +import com.wisp.app.nostr.NostrEvent +import com.wisp.app.nostr.NostrSigner +import com.wisp.app.relay.RelayPool + +/** + * Sends a gift-wrapped private reaction (kind 7 rumor inside kind 1059) to every + * participant of a NIP-17 private reply thread. + * + * Mirrors [PrivateReplyPublisher] but fans out to multiple recipients (the reply + * author + every p-tagged participant carried in the reply's rumor tags) so the + * count updates wherever the reply is visible. A self-copy lands on our own DM + * relays so we see our own reaction immediately on this device and on other + * devices that share our keypair. + */ +object PrivateReactionPublisher { + data class Result(val sentCount: Int, val rumorId: String?) + + suspend fun send( + signer: NostrSigner, + relayPool: RelayPool, + dmRepo: DmRepository, + relayListRepo: RelayListRepository, + eventRepo: EventRepository, + targetEvent: NostrEvent, + emoji: String, + emojiUrl: String? = null + ): Result { + val myPubkey = signer.pubkeyHex + + // Participants = reply author + every p-tag carried in the reply's rumor, + // minus us. The reply author lands first so they always receive the wrap + // even when relay quotas would otherwise cut off later recipients. + val recipients = buildList { + add(targetEvent.pubkey) + for (tag in targetEvent.tags) { + if (tag.size >= 2 && tag[0] == "p") add(tag[1]) + } + }.distinct().filter { it != myPubkey } + + if (recipients.isEmpty()) return Result(0, null) + + // Pin the rumor timestamp so every wrap we build (per-recipient + self-copy) + // and the optimistic synthetic event share the same deterministic rumor id. + // That lets the relay-echoed self-copy dedup naturally via countedReactionIds. + val rumorCreatedAt = System.currentTimeMillis() / 1000 + + var sentCount = 0 + var rumorIdToReturn: String? = null + + for (recipient in recipients) { + val recipientWrap = Nip17.createGiftWrappedReactionRemote( + signer = signer, + recipientPubkeyHex = recipient, + targetRumorId = targetEvent.id, + targetAuthor = targetEvent.pubkey, + targetKind = 1, + emoji = emoji, + emojiUrl = emojiUrl, + createdAt = rumorCreatedAt + ) + + // Recipient resolution mirrors PrivateReplyPublisher: kind-10050 DM relays + // first, NIP-65 read relays as fallback, fetch fresh from indexers as a + // last resort. Public write relays are never used — the recipient won't + // poll there for kind-1059 wraps and the rumor would be silently lost. + val recipientRelays: List = run { + val dm = DmRelayLookup.fetch(recipient, relayPool, dmRepo) + if (dm.isNotEmpty()) return@run dm + relayListRepo.getReadRelays(recipient)?.takeIf { it.isNotEmpty() }?.let { return@run it } + PeerRelayListLookup.fetch(recipient, relayPool, relayListRepo) + relayListRepo.getReadRelays(recipient)?.takeIf { it.isNotEmpty() } ?: emptyList() + } + if (recipientRelays.isEmpty()) continue + + val msg = ClientMessage.event(recipientWrap) + for (url in recipientRelays) { + if (relayPool.sendToRelayOrEphemeral(url, msg, skipBadCheck = true)) sentCount++ + } + rumorIdToReturn = rumorIdToReturn ?: recipientWrap.id + } + + if (sentCount == 0) return Result(0, null) + + // Self-copy: also gift-wrap to ourselves so other devices on the same key + // see the reaction via their own kind-1059 subscription. + val selfWrap = Nip17.createGiftWrappedReactionRemote( + signer = signer, + recipientPubkeyHex = myPubkey, + targetRumorId = targetEvent.id, + targetAuthor = targetEvent.pubkey, + targetKind = 1, + emoji = emoji, + emojiUrl = emojiUrl, + createdAt = rumorCreatedAt + ) + val selfMsg = ClientMessage.event(selfWrap) + if (relayPool.hasDmRelays()) relayPool.sendToDmRelays(selfMsg) + else relayPool.sendToWriteRelays(selfMsg) + + // Optimistic local insert. The synthesized kind-7 event id matches the + // rumor id the wrap helpers computed (same pubkey/createdAt/tags/content), + // so when the self-copy echoes back through processGiftWrap the existing + // countedReactionIds dedup makes the redundant call a no-op. + val rumorTags = buildList> { + add(listOf("e", targetEvent.id)) + add(listOf("p", targetEvent.pubkey)) + add(listOf("k", "1")) + if (emojiUrl != null) add(listOf("emoji", emoji.removeSurrounding(":"), emojiUrl)) + } + val syntheticId = NostrEvent.computeId(myPubkey, rumorCreatedAt, 7, rumorTags, emoji) + val synthetic = NostrEvent( + id = syntheticId, + pubkey = myPubkey, + created_at = rumorCreatedAt, + kind = 7, + tags = rumorTags, + content = emoji, + sig = "" + ) + eventRepo.markPrivate(syntheticId) + eventRepo.addEvent(synthetic) + + return Result(sentCount, rumorIdToReturn) + } +} diff --git a/app/src/main/kotlin/com/wisp/app/repo/PrivateReplyPublisher.kt b/app/src/main/kotlin/com/wisp/app/repo/PrivateReplyPublisher.kt index be3b032..9758e70 100644 --- a/app/src/main/kotlin/com/wisp/app/repo/PrivateReplyPublisher.kt +++ b/app/src/main/kotlin/com/wisp/app/repo/PrivateReplyPublisher.kt @@ -111,7 +111,7 @@ object PrivateReplyPublisher { content = content, sig = "" ) - eventRepo?.markPrivateReply(rumorId) + eventRepo?.markPrivate(rumorId) eventRepo?.cacheEvent(synthetic) eventRepo?.addReplyCount(replyTo.id, rumorId) Nip10.getRootId(replyTo)?.takeIf { it != replyTo.id }?.let { rootId -> diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt b/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt index c2ca0d2..22a68f3 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt @@ -85,6 +85,8 @@ fun ActionBar( unicodeEmojis: List = emptyList(), onOpenEmojiLibrary: (() -> Unit)? = null, isPrivate: Boolean = false, + zapEnabled: Boolean = true, + onZapDisabledTap: () -> Unit = {}, modifier: Modifier = Modifier ) { val context = androidx.compose.ui.platform.LocalContext.current @@ -108,9 +110,9 @@ fun ActionBar( color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1 ) - // Private replies hide React / Repost / Quote / Zap to avoid leaking the rumor id - // on public relays. Reply (above) and Bookmark (below) remain available. - if (!isPrivate) { + // React + Zap are available on private replies as gift-wrapped/DIP-03 actions. + // Repost / Quote stay hidden on private replies because those events would + // publicly attach an e-tag pointing at the encrypted rumor id. Spacer(Modifier.width(8.dp)) Box { Box( @@ -164,60 +166,71 @@ fun ActionBar( color = if (userReactionEmojis.isNotEmpty()) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1 ) - Spacer(Modifier.width(8.dp)) - Box { - IconButton(onClick = { showRepostMenu = true }) { - Icon( - Icons.Outlined.Repeat, - contentDescription = stringResource(R.string.cd_repost), - tint = if (hasUserReposted) WispThemeColors.repostColor else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp) - ) - } - if (showRepostMenu) { - RepostPopup( - onRepost = { - onRepost() - showRepostMenu = false - }, - onQuote = { - onQuote() - showRepostMenu = false - }, - onDismiss = { showRepostMenu = false } - ) + if (!isPrivate) { + Spacer(Modifier.width(8.dp)) + Box { + IconButton(onClick = { showRepostMenu = true }) { + Icon( + Icons.Outlined.Repeat, + contentDescription = stringResource(R.string.cd_repost), + tint = if (hasUserReposted) WispThemeColors.repostColor else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } + if (showRepostMenu) { + RepostPopup( + onRepost = { + onRepost() + showRepostMenu = false + }, + onQuote = { + onQuote() + showRepostMenu = false + }, + onDismiss = { showRepostMenu = false } + ) + } } + Text( + text = repostCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = if (hasUserReposted) WispThemeColors.repostColor else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) } - Text( - text = repostCount.toString(), - style = MaterialTheme.typography.labelSmall, - color = if (hasUserReposted) WispThemeColors.repostColor else MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1 - ) Spacer(Modifier.width(8.dp)) Box { - IconButton(onClick = onZap, enabled = !isZapInProgress) { + val zapClickable = !isZapInProgress + IconButton( + onClick = { if (zapEnabled) onZap() else onZapDisabledTap() }, + enabled = zapClickable + ) { + val zapTint = when { + !zapEnabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + hasUserZapped -> WispThemeColors.zapColor + else -> MaterialTheme.colorScheme.onSurfaceVariant + } if (isZapInProgress) { LightningAnimation(modifier = Modifier.size(width = 14.dp, height = 22.dp)) } else if (fiatMode) { Icon( painter = painterResource(R.drawable.ic_coin_stack), contentDescription = stringResource(R.string.cd_zaps), - tint = if (hasUserZapped) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant, + tint = zapTint, modifier = Modifier.size(22.dp) ) } else if (useZapBoltIcon) { Icon( painter = painterResource(R.drawable.ic_bolt), contentDescription = stringResource(R.string.cd_zaps), - tint = if (hasUserZapped) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant, + tint = zapTint, modifier = Modifier.size(18.dp) ) } else { Icon( Icons.Outlined.CurrencyBitcoin, contentDescription = stringResource(R.string.cd_zaps), - tint = if (hasUserZapped) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant, + tint = zapTint, modifier = Modifier.size(22.dp) ) } @@ -247,7 +260,6 @@ fun ActionBar( overflow = TextOverflow.Ellipsis ) } - } // end !isPrivate Spacer(Modifier.width(8.dp)) IconButton(onClick = onAddToList) { Icon( diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt b/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt index a471673..7ce5c03 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/PostCard.kt @@ -124,6 +124,8 @@ fun PostCard( hasUserReposted: Boolean = false, repostCount: Int = 0, onZap: () -> Unit = {}, + onZapDisabledTap: () -> Unit = {}, + zapEnabled: Boolean = true, hasUserZapped: Boolean = false, likeCount: Int = 0, replyCount: Int = 0, @@ -809,6 +811,8 @@ fun PostCard( unicodeEmojis = unicodeEmojis, onOpenEmojiLibrary = onOpenEmojiLibrary, isPrivate = isPrivate, + zapEnabled = zapEnabled, + onZapDisabledTap = onZapDisabledTap, modifier = Modifier.weight(1f) ) Icon( diff --git a/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt b/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt index 0ba6a56..7394281 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/component/ZapDialog.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material.icons.automirrored.outlined.Message import androidx.compose.material3.AlertDialog @@ -113,6 +114,12 @@ fun ZapDialog( onZap: (amountMsats: Long, message: String, isAnonymous: Boolean, isPrivate: Boolean) -> Unit, onGoToWallet: () -> Unit, canPrivateZap: Boolean = false, + /** + * Lock the zap to DIP-03 private mode (private + anon toggles hidden, isPrivate held true). + * Used when zapping a NIP-17 private reply — falling back to a public zap would attach an + * e-tag pointing at the rumor id on public relays. + */ + forcePrivate: Boolean = false, /** When opening from a quick preset (e.g. chat actions sheet), pre-select that amount in sats. */ initialSatsHint: Int? = null ) { @@ -146,7 +153,7 @@ fun ZapDialog( var customAmount by remember { mutableStateOf("") } var message by remember { mutableStateOf("") } var isAnonymous by remember { mutableStateOf(false) } - var isPrivate by remember { mutableStateOf(false) } + var isPrivate by remember(forcePrivate) { mutableStateOf(forcePrivate) } var editMode by remember { mutableStateOf(false) } LaunchedEffect(initialSatsHint) { @@ -468,6 +475,28 @@ fun ZapDialog( Spacer(Modifier.height(16.dp)) + if (forcePrivate) { + // Parent is a private reply — zap is locked to DIP-03 mode and the + // anon/private toggles are hidden. A small label keeps the user + // oriented; the lock icon mirrors the orange lock used elsewhere. + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.VisibilityOff, + contentDescription = null, + tint = LightningOrange, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + text = stringResource(R.string.zap_private_locked_for_private_reply), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + } else { // Anonymous toggle Row( modifier = Modifier.fillMaxWidth(), @@ -532,6 +561,7 @@ fun ZapDialog( ) ) } + } // end !forcePrivate Spacer(Modifier.height(16.dp)) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt index 1f038d1..3a1d51f 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/NotificationsScreen.kt @@ -687,11 +687,11 @@ private fun ZenNotificationRow( color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1 ) - if (item.isPrivateReply) { + if (item.isPrivateReply || item.isPrivateReaction || item.isPrivateZap) { Spacer(Modifier.width(4.dp)) Icon( imageVector = Icons.Outlined.VisibilityOff, - contentDescription = "Private reply", + contentDescription = "Private", modifier = Modifier.size(14.dp), tint = androidx.compose.ui.graphics.Color(0xFFFF8C00) ) diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt index aa36761..98ff4d2 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/ThreadScreen.kt @@ -50,7 +50,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import android.widget.Toast import com.wisp.app.nostr.Nip69 import com.wisp.app.nostr.NostrEvent import com.wisp.app.repo.ContactRepository @@ -109,7 +111,9 @@ fun ThreadScreen( fetchGroupPreview: (suspend (String, String) -> com.wisp.app.repo.GroupPreview?)? = null, onAddEmojiSet: ((String, String) -> Unit)? = null, onRemoveEmojiSet: ((String, String) -> Unit)? = null, - isEmojiSetAdded: ((String, String) -> Boolean)? = null + isEmojiSetAdded: ((String, String) -> Boolean)? = null, + /** Whether the user can private-zap [event]'s author (local keypair + DM relays on both sides). */ + canPrivateZapFor: (NostrEvent) -> Boolean = { false } ) { val flatThread by viewModel.flatThread.collectAsState() val isLoading by viewModel.isLoading.collectAsState() @@ -199,6 +203,12 @@ fun ThreadScreen( ) } + val zapDisabledContext = LocalContext.current + val zapDisabledMessage = stringResource(R.string.zap_private_requires_dm_relays) + val onZapDisabledTap: () -> Unit = { + Toast.makeText(zapDisabledContext, zapDisabledMessage, Toast.LENGTH_SHORT).show() + } + Scaffold( contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { @@ -368,7 +378,9 @@ fun ThreadScreen( onBlockAuthor = { onBlockUser(event.pubkey) }, isFollowingAuthor = followList.let { contactRepo.isFollowing(event.pubkey) }, isOwnEvent = event.pubkey == userPubkey, - isPrivate = eventRepo.isPrivateReply(event.id), + isPrivate = eventRepo.isPrivate(event.id), + zapEnabled = !eventRepo.isPrivate(event.id) || canPrivateZapFor(event), + onZapDisabledTap = onZapDisabledTap, onAddToList = { onAddToList(event.id) }, isInList = event.id in listedIds, onPin = { onTogglePin(event.id) }, @@ -457,7 +469,9 @@ fun ThreadScreen( onBlockAuthor = { onBlockUser(event.pubkey) }, isFollowingAuthor = followList.let { contactRepo.isFollowing(event.pubkey) }, isOwnEvent = event.pubkey == userPubkey, - isPrivate = eventRepo.isPrivateReply(event.id), + isPrivate = eventRepo.isPrivate(event.id), + zapEnabled = !eventRepo.isPrivate(event.id) || canPrivateZapFor(event), + onZapDisabledTap = onZapDisabledTap, onAddToList = { onAddToList(event.id) }, isInList = event.id in listedIds, onPin = { onTogglePin(event.id) }, diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/ComposeViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/ComposeViewModel.kt index 2587d59..a5acc50 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/ComposeViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/ComposeViewModel.kt @@ -135,7 +135,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat /** Called by ComposeScreen when the screen mounts with [replyTo]; auto-enables * + locks the private toggle if [replyTo] is itself a private reply we received. */ fun configureForReply(replyTo: NostrEvent?) { - val isReplyingToPrivate = replyTo != null && eventRepo?.isPrivateReply(replyTo.id) == true + val isReplyingToPrivate = replyTo != null && eventRepo?.isPrivate(replyTo.id) == true _privateReplyLocked.value = isReplyingToPrivate if (isReplyingToPrivate) _privateReply.value = true } diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/EventRouter.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/EventRouter.kt index 08e9095..686b68e 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/EventRouter.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/EventRouter.kt @@ -602,8 +602,17 @@ class EventRouter( null } ?: return - // Private DM reaction — associate with the target message, not a new conversation entry + // Private reaction (kind 7 rumor). The "k" tag carries the kind of the message + // being reacted to: k=1 → reaction on a NIP-17 private reply, route through the + // note repository so thread/notification rendering picks it up the same way as + // public reactions; k=14 (or absent for back-compat) → DM reaction, route into + // the DM conversation entry. if (Nip17.isReaction(rumor)) { + val kTag = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "k" }?.get(1) + if (kTag == "1") { + handlePrivateReplyReaction(rumor, myPubkey) + return + } val targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) ?: return val participants = Nip17.getConversationParticipants(rumor, myPubkey) if (participants.any { muteRepo.isBlocked(it) }) return @@ -651,6 +660,35 @@ class EventRouter( dmRepo.addMessage(msg, convKey) } + private fun handlePrivateReplyReaction(rumor: Nip17.Rumor, myPubkey: String) { + if (muteRepo.isBlocked(rumor.pubkey)) return + val targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) ?: return + if (eventRepo.getEvent(targetId) == null) { + // Without the target rumor cached we can't bind ownership in NotificationRepository, + // which would surface reactions on threads we never received. Drop quietly — the + // target arrives via the same kind-1059 subscription, so a refetch will heal. + Log.d("EventRouter", "Skipping private reaction on uncached target ${targetId.take(12)}") + return + } + val rumorId = Nip17.computeRumorId(rumor) + val synthetic = NostrEvent( + id = rumorId, + pubkey = rumor.pubkey, + created_at = rumor.createdAt, + kind = 7, + tags = rumor.tags, + content = rumor.content, + sig = "" + ) + eventRepo.markPrivate(rumorId) + eventRepo.addEvent(synthetic) + if (rumor.pubkey == myPubkey) return + notifRepo.addEvent(synthetic, myPubkey, source = "gift-wrap-private-reaction") + if (eventRepo.getProfileData(rumor.pubkey) == null) { + metadataFetcher.addToPendingProfiles(rumor.pubkey) + } + } + private fun handlePrivateReply(wrap: NostrEvent, rumor: Nip17.Rumor, myPubkey: String) { if (muteRepo.isBlocked(rumor.pubkey)) return @@ -664,7 +702,7 @@ class EventRouter( content = rumor.content, sig = "" ) - eventRepo.markPrivateReply(rumorId) + eventRepo.markPrivate(rumorId) eventRepo.cacheEvent(synthetic) if (!Nip10.isStandaloneQuote(synthetic)) { val parentId = Nip10.getReplyTarget(synthetic) diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt index d1a53c7..01dae42 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/FeedViewModel.kt @@ -305,7 +305,8 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) { val socialActions: SocialActionManager = SocialActionManager( relayPool, outboxRouter, eventRepo, contactRepo, muteRepo, notifRepo, dmRepo, - pinRepo, deletedEventsRepo, { activeWalletProvider }, customEmojiRepo, zapSender, powPrefs, interfacePrefs, viewModelScope, + pinRepo, deletedEventsRepo, { activeWalletProvider }, customEmojiRepo, zapSender, powPrefs, interfacePrefs, + relayListRepo, viewModelScope, getSigner = { signer }, getUserPubkey = { getUserPubkey() } ) diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/SocialActionManager.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/SocialActionManager.kt index 2604e23..54d7858 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/SocialActionManager.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/SocialActionManager.kt @@ -21,6 +21,8 @@ import com.wisp.app.repo.DmRepository import com.wisp.app.repo.EventRepository import com.wisp.app.repo.MuteRepository import com.wisp.app.repo.NotificationRepository +import com.wisp.app.repo.PrivateReactionPublisher +import com.wisp.app.repo.RelayListRepository import com.wisp.app.repo.WalletProvider import com.wisp.app.repo.PinRepository import com.wisp.app.repo.CustomEmojiRepository @@ -59,6 +61,7 @@ class SocialActionManager( private val zapSender: ZapSender, private val powPrefs: PowPreferences, private val interfacePrefs: InterfacePreferences, + private val relayListRepo: RelayListRepository, private val scope: CoroutineScope, private val getSigner: () -> NostrSigner?, private val getUserPubkey: () -> String? @@ -238,6 +241,38 @@ class SocialActionManager( fun toggleReaction(event: NostrEvent, emoji: String) { val s = getSigner() ?: return val myPubkey = s.pubkeyHex + + // NIP-17 private replies are reacted to with a gift-wrapped kind-7 rumor so + // the target rumor id never lands on a public relay. v1 is add-only: if the + // user has already reacted with this emoji we no-op rather than publishing a + // gift-wrapped NIP-09 deletion (gift-wrapped deletion is a future enhancement). + if (eventRepo.isPrivate(event.id)) { + val existingEmoji = eventRepo.getUserReactionEmoji(event.id, myPubkey) + if (existingEmoji != null) return + val shortcodeMatch = Nip30.shortcodeRegex.matchEntire(emoji) + val emojiUrl = if (shortcodeMatch != null) { + val shortcode = shortcodeMatch.groupValues[1] + customEmojiRepo.resolvedEmojis.value[shortcode] + } else null + scope.launch { + try { + PrivateReactionPublisher.send( + signer = s, + relayPool = relayPool, + dmRepo = dmRepo, + relayListRepo = relayListRepo, + eventRepo = eventRepo, + targetEvent = event, + emoji = emoji, + emojiUrl = emojiUrl + ) + _reactionSent.tryEmit(Unit) + customEmojiRepo.recordEmojiUsage(emoji) + } catch (_: Exception) {} + } + return + } + val existingEventId = eventRepo.getUserReactionEventId(event.id, myPubkey, emoji) scope.launch { @@ -352,6 +387,12 @@ class SocialActionManager( * "a" tag instead of "e" so the receipt is associated with the addressable event. */ fun sendZap(event: NostrEvent, amountMsats: Long, message: String = "", isAnonymous: Boolean = false, isPrivate: Boolean = false, extraRelayHints: List = emptyList(), recipientOverride: String? = null, eventATag: String? = null) { + // NIP-17 private reply targets must use DIP-03. The synthetic event's id is the + // rumor id and its created_at is the rumor timestamp — both feed the existing + // ephemeral derivation cleanly. Force the flag even if the caller forgot it so a + // public-zap fallback can never leak the rumor id on public relays. + @Suppress("NAME_SHADOWING") + val isPrivate = isPrivate || eventRepo.isPrivate(event.id) val recipientPubkey = recipientOverride ?: event.pubkey val profileData = eventRepo.getProfileData(recipientPubkey) val lud16 = profileData?.lud16 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index baeab16..4aac595 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -483,6 +483,8 @@ Wallet Not Connected Connect a Lightning wallet to send zaps. Both parties need DM relays + Private — replying to a private thread + Private zap requires DM relays on both sides Zap %d sats Zap Failed Pay Now