Merge pull request #543 from barrydeen/feat/private-reactions-zaps

feat(private-replies): gift-wrapped reactions + DIP-03 zaps on private replies
This commit is contained in:
Barry Deen
2026-05-17 12:50:49 -04:00
committed by GitHub
17 changed files with 401 additions and 72 deletions
+10 -3
View File
@@ -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,
+63 -15
View File
@@ -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
@@ -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<String> = emptyList(),
@@ -46,12 +46,13 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo:
private val eventCache = ConcurrentHashMap<String, NostrEvent>()
private val seenEventIds = ConcurrentHashMap.newKeySet<String>() // 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<String>()
// 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<String>()
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() {
@@ -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
))
}
@@ -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<String> = 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<List<String>> {
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)
}
}
@@ -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 ->
@@ -85,6 +85,8 @@ fun ActionBar(
unicodeEmojis: List<String> = 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(
@@ -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(
@@ -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))
@@ -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)
)
@@ -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) },
@@ -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
}
@@ -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)
@@ -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() }
)
@@ -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<String> = 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
+2
View File
@@ -483,6 +483,8 @@
<string name="zap_wallet_not_connected">Wallet Not Connected</string>
<string name="zap_connect_wallet">Connect a Lightning wallet to send zaps.</string>
<string name="zap_both_parties_need_dm_relays">Both parties need DM relays</string>
<string name="zap_private_locked_for_private_reply">Private — replying to a private thread</string>
<string name="zap_private_requires_dm_relays">Private zap requires DM relays on both sides</string>
<string name="zap_x_sats">Zap %d sats</string>
<string name="zap_failed">Zap Failed</string>
<string name="lightning_invoice_pay_now">Pay Now</string>