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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user