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

feat: private replies, private reactions, and private zaps
This commit is contained in:
Barry Deen
2026-06-10 12:12:35 -04:00
committed by GitHub
30 changed files with 1310 additions and 193 deletions
@@ -358,12 +358,20 @@ fun WispNavHost(
// Initialize compose viewmodel with shared repos // Initialize compose viewmodel with shared repos
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
composeViewModel.init(feedViewModel.profileRepo, feedViewModel.contactRepo, feedViewModel.relayPool, feedViewModel.eventRepo, feedViewModel.eventPersistence) composeViewModel.init(
feedViewModel.profileRepo,
feedViewModel.contactRepo,
feedViewModel.relayPool,
feedViewModel.eventRepo,
feedViewModel.eventPersistence,
feedViewModel.dmRepo,
feedViewModel.relayListRepo
)
} }
// Initialize DM list viewmodel with shared repo // Initialize DM list viewmodel with shared repo
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
dmListViewModel.init(feedViewModel.dmRepo, feedViewModel.muteRepo) dmListViewModel.init(feedViewModel.dmRepo, feedViewModel.muteRepo, feedViewModel.eventRepo, feedViewModel.notifRepo)
} }
// Initialize group list viewmodel with shared repo; key changes on account switch to re-init // Initialize group list viewmodel with shared repo; key changes on account switch to re-init
@@ -1104,7 +1112,7 @@ fun WispNavHost(
zapSuccess = feedViewModel.zapSuccess, zapSuccess = feedViewModel.zapSuccess,
zapError = feedViewModel.zapError, zapError = feedViewModel.zapError,
zapInProgressIds = profileZapInProgress, zapInProgressIds = profileZapInProgress,
canPrivateZap = feedViewModel.relayPool.hasDmRelays() && feedViewModel.relayListRepo.hasDmRelays(pubkey), canPrivateZap = feedViewModel.hasLocalKeypair && feedViewModel.relayPool.hasDmRelays() && feedViewModel.relayListRepo.hasDmRelays(pubkey),
fetchDmRelays = { pk -> feedViewModel.fetchDmRelaysIfMissing(pk) && feedViewModel.relayPool.hasDmRelays() }, fetchDmRelays = { pk -> feedViewModel.fetchDmRelaysIfMissing(pk) && feedViewModel.relayPool.hasDmRelays() },
ownLists = feedViewModel.listRepo.ownLists.collectAsState().value, ownLists = feedViewModel.listRepo.ownLists.collectAsState().value,
onAddToList = { dTag, pk -> feedViewModel.addToList(dTag, pk) }, onAddToList = { dTag, pk -> feedViewModel.addToList(dTag, pk) },
@@ -1214,7 +1222,7 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
}, },
onGoToWallet = { navController.navigate(Routes.WALLET) }, onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = userHasDmRelays && recipientHasDmRelays canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays
) )
} }
SearchScreen( SearchScreen(
@@ -1351,7 +1359,7 @@ fun WispNavHost(
powPreferences = feedViewModel.powPrefs, powPreferences = feedViewModel.powPrefs,
myPubkeyHex = userPubkey myPubkeyHex = userPubkey
) )
activeSigner?.let { dmConvoViewModel.decryptPending(it, feedViewModel.muteRepo) } activeSigner?.let { dmConvoViewModel.decryptPending(it, feedViewModel.muteRepo, feedViewModel.eventRepo, feedViewModel.notifRepo) }
} }
val peerProfile = feedViewModel.eventRepo.getProfileData(pubkey) val peerProfile = feedViewModel.eventRepo.getProfileData(pubkey)
val userProfile = userPubkey?.let { feedViewModel.eventRepo.getProfileData(it) } val userProfile = userPubkey?.let { feedViewModel.eventRepo.getProfileData(it) }
@@ -1434,7 +1442,7 @@ fun WispNavHost(
myPubkeyHex = userPubkey, myPubkeyHex = userPubkey,
participantPubkeys = participantList participantPubkeys = participantList
) )
activeSigner?.let { dmConvoViewModel.decryptPending(it, feedViewModel.muteRepo) } activeSigner?.let { dmConvoViewModel.decryptPending(it, feedViewModel.muteRepo, feedViewModel.eventRepo, feedViewModel.notifRepo) }
for (pubkey in participantList) { for (pubkey in participantList) {
feedViewModel.metadataFetcher.queueProfileFetch(pubkey) feedViewModel.metadataFetcher.queueProfileFetch(pubkey)
} }
@@ -1582,7 +1590,7 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
}, },
onGoToWallet = { navController.navigate(Routes.WALLET) }, onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.relayPool.hasDmRelays() && recipientHasDmRelays, canPrivateZap = feedViewModel.hasLocalKeypair && feedViewModel.relayPool.hasDmRelays() && recipientHasDmRelays,
initialSatsHint = groupRoomZapInitialSats initialSatsHint = groupRoomZapInitialSats
) )
} }
@@ -1859,7 +1867,8 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
}, },
onGoToWallet = { navController.navigate(Routes.WALLET) }, onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = threadUserHasDmRelays && threadRecipientHasDmRelays canPrivateZap = feedViewModel.hasLocalKeypair && threadUserHasDmRelays && threadRecipientHasDmRelays,
forcePrivate = threadZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true
) )
} }
val threadSetListedIds by feedViewModel.bookmarkSetRepo.allListedEventIds.collectAsState() val threadSetListedIds by feedViewModel.bookmarkSetRepo.allListedEventIds.collectAsState()
@@ -1951,6 +1960,11 @@ fun WispNavHost(
isEmojiSetAdded = { pubkey, dTag -> isEmojiSetAdded = { pubkey, dTag ->
val ref = com.darkwisp.app.nostr.Nip30.buildSetReference(pubkey, dTag) val ref = com.darkwisp.app.nostr.Nip30.buildSetReference(pubkey, dTag)
feedViewModel.customEmojiRepo.userEmojiList.value?.setReferences?.contains(ref) ?: false feedViewModel.customEmojiRepo.userEmojiList.value?.setReferences?.contains(ref) ?: false
},
canPrivateZapFor = { event ->
feedViewModel.hasLocalKeypair &&
feedViewModel.relayPool.hasDmRelays() &&
feedViewModel.relayListRepo.hasDmRelays(event.pubkey)
} }
) )
@@ -2023,7 +2037,7 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
}, },
onGoToWallet = { navController.navigate(Routes.WALLET) }, onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = hashtagUserHasDmRelays && hashtagRecipientHasDmRelays canPrivateZap = feedViewModel.hasLocalKeypair && hashtagUserHasDmRelays && hashtagRecipientHasDmRelays
) )
} }
@@ -2174,7 +2188,7 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
}, },
onGoToWallet = { navController.navigate(Routes.WALLET) }, onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = setFeedUserHasDmRelays && setFeedRecipientHasDmRelays canPrivateZap = feedViewModel.hasLocalKeypair && setFeedUserHasDmRelays && setFeedRecipientHasDmRelays
) )
} }
@@ -2338,7 +2352,7 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
}, },
onGoToWallet = { navController.navigate(Routes.WALLET) }, onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = articleUserHasDmRelays && articleRecipientHasDmRelays canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays
) )
} }
@@ -2542,7 +2556,10 @@ fun WispNavHost(
eventATag = aTag) eventATag = aTag)
}, },
onGoToWallet = { navController.navigate(Routes.WALLET) }, onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.relayPool.hasDmRelays() && recipientHasDmRelays // DIP-03 needs a concrete note id for the ephemeral key
// derivation; live-stream zaps target an addressable event
// (a-tag) instead, so private zaps don't apply here.
canPrivateZap = false
) )
} }
val streamActivityEventId = remember(hostPubkey, dTag) { val streamActivityEventId = remember(hostPubkey, dTag) {
@@ -3099,7 +3116,8 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
}, },
onGoToWallet = { navController.navigate(Routes.WALLET) }, onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = notifUserHasDmRelays && notifRecipientHasDmRelays canPrivateZap = feedViewModel.hasLocalKeypair && notifUserHasDmRelays && notifRecipientHasDmRelays,
forcePrivate = notifZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true
) )
} }
@@ -3149,6 +3167,24 @@ fun WispNavHost(
com.darkwisp.app.nostr.Nip30.buildEmojiTagsForContent(content, notifResolvedEmojis) + com.darkwisp.app.nostr.Nip30.buildEmojiTagsForContent(content, notifResolvedEmojis) +
if (notifInterfacePrefs.isClientTagEnabled()) listOf(listOf("client", "Dark Wisp")) else emptyList() if (notifInterfacePrefs.isClientTagEnabled()) listOf(listOf("client", "Dark Wisp")) else emptyList()
// 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.isPrivate(replyToEvent.id)) {
val difficulty = if (feedViewModel.powPrefs.isNotePowEnabled()) feedViewModel.powPrefs.getNoteDifficulty() else 0
com.darkwisp.app.repo.PrivateReplyPublisher.send(
signer = signer,
relayPool = feedViewModel.relayPool,
dmRepo = feedViewModel.dmRepo,
relayListRepo = feedViewModel.relayListRepo,
eventRepo = feedViewModel.eventRepo,
replyTo = replyToEvent,
content = content,
baseTags = tags,
targetDifficulty = difficulty
)
return@launch
}
if (feedViewModel.powPrefs.isNotePowEnabled()) { if (feedViewModel.powPrefs.isNotePowEnabled()) {
feedViewModel.powManager.submitNote( feedViewModel.powManager.submitNote(
signer = signer, signer = signer,
@@ -24,11 +24,7 @@ object Nip04 {
* Returns: base64(ciphertext) + "?iv=" + base64(iv) * Returns: base64(ciphertext) + "?iv=" + base64(iv)
*/ */
fun encrypt(plaintext: String, sharedSecret: ByteArray): String { fun encrypt(plaintext: String, sharedSecret: ByteArray): String {
val iv = ByteArray(16).also { random.nextBytes(it) } val (ciphertext, iv) = encryptRaw(plaintext, sharedSecret)
val key = SecretKeySpec(sharedSecret, "AES")
val cipher = cipherLocal.get()
cipher.init(Cipher.ENCRYPT_MODE, key, IvParameterSpec(iv))
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
val ctB64 = Base64.encodeToString(ciphertext, Base64.NO_WRAP) val ctB64 = Base64.encodeToString(ciphertext, Base64.NO_WRAP)
val ivB64 = Base64.encodeToString(iv, Base64.NO_WRAP) val ivB64 = Base64.encodeToString(iv, Base64.NO_WRAP)
return "$ctB64?iv=$ivB64" return "$ctB64?iv=$ivB64"
@@ -42,8 +38,24 @@ object Nip04 {
require(parts.size == 2) { "Invalid NIP-04 content format" } require(parts.size == 2) { "Invalid NIP-04 content format" }
val ciphertext = Base64.decode(parts[0], Base64.DEFAULT) val ciphertext = Base64.decode(parts[0], Base64.DEFAULT)
val iv = Base64.decode(parts[1], Base64.DEFAULT) val iv = Base64.decode(parts[1], Base64.DEFAULT)
return decryptRaw(ciphertext, iv, sharedSecret)
}
/** Raw AES-256-CBC encrypt with random IV. Returns (ciphertext, iv) so callers
* can package the bytes in a non-standard envelope (e.g. DIP-03 bech32). */
fun encryptRaw(plaintext: String, sharedSecret: ByteArray): Pair<ByteArray, ByteArray> {
val iv = ByteArray(16).also { random.nextBytes(it) }
val key = SecretKeySpec(sharedSecret, "AES") val key = SecretKeySpec(sharedSecret, "AES")
val cipher = cipherLocal.get() val cipher = cipherLocal.get()!!
cipher.init(Cipher.ENCRYPT_MODE, key, IvParameterSpec(iv))
val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
return ciphertext to iv
}
/** Raw AES-256-CBC decrypt — counterpart to [encryptRaw]. */
fun decryptRaw(ciphertext: ByteArray, iv: ByteArray, sharedSecret: ByteArray): String {
val key = SecretKeySpec(sharedSecret, "AES")
val cipher = cipherLocal.get()!!
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
return String(cipher.doFinal(ciphertext), Charsets.UTF_8) return String(cipher.doFinal(ciphertext), Charsets.UTF_8)
} }
@@ -140,7 +140,7 @@ object Nip17 {
// Parse rumor // Parse rumor
val rumorObj = json.parseToJsonElement(rumorJson).jsonObject val rumorObj = json.parseToJsonElement(rumorJson).jsonObject
val kind = rumorObj["kind"]?.jsonPrimitive?.content?.toIntOrNull() val kind = rumorObj["kind"]?.jsonPrimitive?.content?.toIntOrNull()
if (kind != 14 && kind != 7 && kind != 15) return null if (kind != 14 && kind != 7 && kind != 15 && kind != 1) return null
val tags = rumorObj["tags"]?.jsonArray?.map { tagArr -> val tags = rumorObj["tags"]?.jsonArray?.map { tagArr ->
tagArr.jsonArray.map { it.jsonPrimitive.content } tagArr.jsonArray.map { it.jsonPrimitive.content }
@@ -269,7 +269,7 @@ object Nip17 {
// Parse rumor // Parse rumor
val rumorObj = json.parseToJsonElement(rumorJson).jsonObject val rumorObj = json.parseToJsonElement(rumorJson).jsonObject
val kind = rumorObj["kind"]?.jsonPrimitive?.content?.toIntOrNull() val kind = rumorObj["kind"]?.jsonPrimitive?.content?.toIntOrNull()
if (kind != 14 && kind != 7 && kind != 15) return null if (kind != 14 && kind != 7 && kind != 15 && kind != 1) return null
val tags = rumorObj["tags"]?.jsonArray?.map { tagArr -> val tags = rumorObj["tags"]?.jsonArray?.map { tagArr ->
tagArr.jsonArray.map { it.jsonPrimitive.content } tagArr.jsonArray.map { it.jsonPrimitive.content }
@@ -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. * 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 * 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, senderPrivkey: ByteArray,
senderPubkey: ByteArray, senderPubkey: ByteArray,
recipientPubkey: ByteArray, recipientPubkey: ByteArray,
targetRumorId: String, targetRumorId: String,
originalSenderPubkey: String, targetAuthor: String,
targetKind: Int,
emoji: String, emoji: String,
emojiUrl: String? = null emojiUrl: String? = null,
createdAt: Long = System.currentTimeMillis() / 1000
): NostrEvent { ): NostrEvent {
val tags = mutableListOf( val tags = mutableListOf(
listOf("e", targetRumorId), listOf("e", targetRumorId),
listOf("p", originalSenderPubkey), listOf("p", targetAuthor),
listOf("k", "14") listOf("k", targetKind.toString())
) )
if (emojiUrl != null) { if (emojiUrl != null) {
tags.add(listOf("emoji", emoji.removeSurrounding(":"), emojiUrl)) tags.add(listOf("emoji", emoji.removeSurrounding(":"), emojiUrl))
@@ -377,23 +383,26 @@ object Nip17 {
recipientPubkey = recipientPubkey, recipientPubkey = recipientPubkey,
message = emoji, message = emoji,
rumorKind = 7, rumorKind = 7,
replyTags = tags replyTags = tags,
createdAt = createdAt
) )
} }
/** Remote-signer variant of [createDmReaction]. */ /** Remote-signer variant of [createGiftWrappedReaction]. */
suspend fun createDmReactionRemote( suspend fun createGiftWrappedReactionRemote(
signer: NostrSigner, signer: NostrSigner,
recipientPubkeyHex: String, recipientPubkeyHex: String,
targetRumorId: String, targetRumorId: String,
originalSenderPubkey: String, targetAuthor: String,
targetKind: Int,
emoji: String, emoji: String,
emojiUrl: String? = null emojiUrl: String? = null,
createdAt: Long = System.currentTimeMillis() / 1000
): NostrEvent { ): NostrEvent {
val tags = mutableListOf( val tags = mutableListOf(
listOf("e", targetRumorId), listOf("e", targetRumorId),
listOf("p", originalSenderPubkey), listOf("p", targetAuthor),
listOf("k", "14") listOf("k", targetKind.toString())
) )
if (emojiUrl != null) { if (emojiUrl != null) {
tags.add(listOf("emoji", emoji.removeSurrounding(":"), emojiUrl)) tags.add(listOf("emoji", emoji.removeSurrounding(":"), emojiUrl))
@@ -403,10 +412,49 @@ object Nip17 {
recipientPubkeyHex = recipientPubkeyHex, recipientPubkeyHex = recipientPubkeyHex,
message = emoji, message = emoji,
rumorKind = 7, 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 { 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 // 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 // ensures interop with clients (e.g. Amethyst) whose kind-1059 subscription may use
@@ -186,7 +186,7 @@ object Nip19 {
} }
} }
private fun bech32Encode(hrp: String, data: ByteArray): String { internal fun bech32Encode(hrp: String, data: ByteArray): String {
val values = convertBits(data, 8, 5, true) val values = convertBits(data, 8, 5, true)
val checksum = bech32Checksum(hrp, values) val checksum = bech32Checksum(hrp, values)
return buildString(hrp.length + 1 + values.size + 6) { return buildString(hrp.length + 1 + values.size + 6) {
@@ -197,7 +197,7 @@ object Nip19 {
} }
} }
private fun bech32Decode(str: String): Pair<String, ByteArray> { internal fun bech32Decode(str: String): Pair<String, ByteArray> {
val lower = str.lowercase() val lower = str.lowercase()
val pos = lower.lastIndexOf('1') val pos = lower.lastIndexOf('1')
require(pos >= 1) { "Invalid bech32 string" } require(pos >= 1) { "Invalid bech32 string" }
@@ -10,11 +10,19 @@ import kotlinx.serialization.json.long
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.net.URLEncoder import java.net.URLEncoder
import java.security.MessageDigest
object Nip57 { object Nip57 {
const val KIND_PRIVATE_ZAP_EVENT = 9733
private const val ANON_TAG = "anon"
private const val PZAP_HRP = "pzap"
private const val IV_HRP = "iv"
private val bolt11AmountRegex = Regex("""lnbc(\d+)([munp]?)1""") private val bolt11AmountRegex = Regex("""lnbc(\d+)([munp]?)1""")
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
data class DecryptedPrivateZap(val senderPubkey: String, val message: String)
fun getZappedEventId(event: NostrEvent): String? { fun getZappedEventId(event: NostrEvent): String? {
return event.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) return event.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
} }
@@ -157,6 +165,142 @@ object Nip57 {
return signer.signEvent(kind = 9734, content = message, tags = tags) return signer.signEvent(kind = 9734, content = message, tags = tags)
} }
/**
* DIP-03 deterministic ephemeral private key:
* sha256(utf8(senderPrivkeyHex + targetEventIdHex + createdAtString)).
* Re-derivable from public note data so the sender can identify their own
* outgoing private zaps after the fact.
*/
fun deriveEphemeralPrivkey(
senderPrivkey: ByteArray,
targetEventId: String,
targetCreatedAt: Long
): ByteArray {
val input = senderPrivkey.toHex() + targetEventId + targetCreatedAt.toString()
return MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray(Charsets.UTF_8))
}
/**
* DIP-03 private zap request. Inner kind 9733 (real sender, NIP-04 encrypted
* to recipient via ephemeral ECDH) is packed into the outer kind 9734's
* `anon` tag; the outer event is signed by the deterministic ephemeral key
* so the LNURL provider and relays only see an unlinkable pubkey.
*/
fun buildPrivateZapRequest(
senderPrivkey: ByteArray,
senderPubkey: ByteArray,
recipientPubkey: String,
eventId: String,
eventCreatedAt: Long,
amountMsats: Long,
relayUrls: List<String>,
lnurl: String,
message: String,
extraTags: List<List<String>> = emptyList()
): NostrEvent {
val ephPrivkey = deriveEphemeralPrivkey(senderPrivkey, eventId, eventCreatedAt)
val ephPubkey = Keys.xOnlyPubkey(ephPrivkey)
val inner = NostrEvent.create(
privkey = senderPrivkey,
pubkey = senderPubkey,
kind = KIND_PRIVATE_ZAP_EVENT,
content = message,
tags = listOf(listOf("p", recipientPubkey))
)
val shared = Nip04.computeSharedSecret(ephPrivkey, recipientPubkey.hexToByteArray())
val (ct, iv) = Nip04.encryptRaw(inner.toJson(), shared)
val anonValue = Nip19.bech32Encode(PZAP_HRP, ct) + "_" + Nip19.bech32Encode(IV_HRP, iv)
val outerTags = buildList {
add(listOf("p", recipientPubkey))
add(listOf("e", eventId))
add(listOf("relays") + relayUrls)
add(listOf("amount", amountMsats.toString()))
add(listOf("lnurl", lnurl))
add(listOf(ANON_TAG, anonValue))
addAll(extraTags)
}
return NostrEvent.create(
privkey = ephPrivkey,
pubkey = ephPubkey,
kind = 9734,
content = "",
tags = outerTags
)
}
/** True if the receipt's embedded zap request carries a DIP-03 `anon` tag. */
fun isPrivateZap(receipt: NostrEvent): Boolean {
val request = parseEmbeddedRequest(receipt) ?: return false
return request.tags.any { it.size >= 2 && it[0] == ANON_TAG }
}
/**
* Decrypt a DIP-03 private zap addressed to us (we are the recipient).
* Returns null unless the `anon` envelope decodes, AES-decrypts, and the
* inner kind 9733's Schnorr signature checks out — the anon tag is
* otherwise unauthenticated, so signature verification is mandatory.
*/
fun decryptPrivateZap(receipt: NostrEvent, myPrivkey: ByteArray): DecryptedPrivateZap? {
val request = parseEmbeddedRequest(receipt) ?: return null
val anon = request.tags.firstOrNull { it.size >= 2 && it[0] == ANON_TAG }?.get(1)
?: return null
val shared = runCatching {
Nip04.computeSharedSecret(myPrivkey, request.pubkey.hexToByteArray())
}.getOrNull() ?: return null
return decryptAnonTag(anon, shared)
}
/**
* Decrypt a DIP-03 private zap we sent ourselves. Re-derives the
* deterministic ephemeral key from [target], confirms the outer pubkey
* matches (i.e. this is ours), then runs ECDH(ephPriv, recipient) to
* recover the same shared secret used at encryption. Returns null for any
* receipt that isn't one of ours.
*/
fun decryptOwnOutgoingPrivateZap(
receipt: NostrEvent,
myPrivkey: ByteArray,
target: NostrEvent
): DecryptedPrivateZap? {
val request = parseEmbeddedRequest(receipt) ?: return null
val anon = request.tags.firstOrNull { it.size >= 2 && it[0] == ANON_TAG }?.get(1)
?: return null
val ephPriv = deriveEphemeralPrivkey(myPrivkey, target.id, target.created_at)
val ephPubHex = runCatching { Keys.xOnlyPubkey(ephPriv).toHex() }.getOrNull()
?: return null
if (ephPubHex != request.pubkey) return null
val recipientHex = request.tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
?: return null
val shared = runCatching {
Nip04.computeSharedSecret(ephPriv, recipientHex.hexToByteArray())
}.getOrNull() ?: return null
return decryptAnonTag(anon, shared)
}
private fun decryptAnonTag(anon: String, sharedSecret: ByteArray): DecryptedPrivateZap? {
val parts = anon.split("_", limit = 2)
if (parts.size != 2) return null
val (hrpCt, ct) = runCatching { Nip19.bech32Decode(parts[0]) }.getOrNull() ?: return null
val (hrpIv, iv) = runCatching { Nip19.bech32Decode(parts[1]) }.getOrNull() ?: return null
if (hrpCt != PZAP_HRP || hrpIv != IV_HRP) return null
val plaintext = runCatching { Nip04.decryptRaw(ct, iv, sharedSecret) }.getOrNull()
?: return null
val inner = runCatching { NostrEvent.fromJson(plaintext) }.getOrNull() ?: return null
if (inner.kind != KIND_PRIVATE_ZAP_EVENT) return null
if (!inner.verifySignature()) return null
return DecryptedPrivateZap(inner.pubkey, inner.content)
}
private fun parseEmbeddedRequest(receipt: NostrEvent): NostrEvent? {
val description = receipt.tags.firstOrNull { it.size >= 2 && it[0] == "description" }?.get(1)
?: return null
return runCatching { NostrEvent.fromJson(description) }.getOrNull()
}
suspend fun fetchSimpleInvoice( suspend fun fetchSimpleInvoice(
callbackUrl: String, callbackUrl: String,
amountMsats: Long, amountMsats: Long,
@@ -13,6 +13,8 @@ data class FlatNotificationItem(
val zapSats: Long = 0, val zapSats: Long = 0,
val zapMessage: String = "", val zapMessage: String = "",
val isPrivateZap: Boolean = false, val isPrivateZap: Boolean = false,
val isPrivateReply: Boolean = false,
val isPrivateReaction: Boolean = false,
val replyEventId: String? = null, val replyEventId: String? = null,
val quoteEventId: String? = null, val quoteEventId: String? = null,
val voteOptionIds: List<String> = emptyList(), val voteOptionIds: List<String> = emptyList(),
@@ -0,0 +1,65 @@
package com.darkwisp.app.repo
import com.darkwisp.app.nostr.ClientMessage
import com.darkwisp.app.nostr.Filter
import com.darkwisp.app.nostr.Nip51
import com.darkwisp.app.relay.RelayConfig
import com.darkwisp.app.relay.RelayEvent
import com.darkwisp.app.relay.RelayPool
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.withTimeoutOrNull
/**
* Fetch a peer's kind 10050 DM relays from indexer relays + the connected pool.
*
* Shared by [com.darkwisp.app.viewmodel.DmConversationViewModel] (peer DM send) and
* [com.darkwisp.app.viewmodel.ComposeViewModel] (private reply send) so both code paths
* use the same indexer set, 4s collection window, and LRU cache via [DmRepository].
*/
object DmRelayLookup {
suspend fun fetch(
pubkey: String,
relayPool: RelayPool,
dmRepo: DmRepository,
forceRefresh: Boolean = false
): List<String> {
if (!forceRefresh) {
dmRepo.getCachedDmRelays(pubkey)?.let { return it }
}
val subId = "dm_relay_${pubkey.take(8)}"
val filter = Filter(
kinds = listOf(Nip51.KIND_DM_RELAYS),
authors = listOf(pubkey),
limit = 1
)
val reqMsg = ClientMessage.req(subId, filter)
for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) {
relayPool.sendToRelayOrEphemeral(url, reqMsg, skipBadCheck = true)
}
relayPool.sendToAll(reqMsg)
val results = mutableListOf<RelayEvent>()
withTimeoutOrNull(4000L) {
relayPool.relayEvents
.filter { it.subscriptionId == subId }
.collect { results.add(it) }
}
val closeMsg = ClientMessage.close(subId)
for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) {
relayPool.sendToRelay(url, closeMsg)
}
relayPool.sendToAll(closeMsg)
val best = results.maxByOrNull { it.event.created_at }
if (best != null) {
val urls = Nip51.parseRelaySet(best.event)
if (urls.isNotEmpty()) {
dmRepo.cacheDmRelays(pubkey, urls)
return urls
}
}
return emptyList()
}
}
@@ -41,11 +41,19 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo:
var contactRepo: ContactRepository? = null var contactRepo: ContactRepository? = null
var safetyPrefs: SafetyPreferences? = null var safetyPrefs: SafetyPreferences? = null
var extendedNetworkRepo: ExtendedNetworkRepository? = null var extendedNetworkRepo: ExtendedNetworkRepository? = null
/** Set of current user's DM relay URLs — used to detect private zaps. */ /** Late-bound for DIP-03 private zap decryption (recipient + self-attribution). */
var dmRelayUrls: Set<String> = emptySet() var keyRepo: KeyRepository? = null
private val eventCache = ConcurrentHashMap<String, NostrEvent>() private val eventCache = ConcurrentHashMap<String, NostrEvent>()
private val seenEventIds = ConcurrentHashMap.newKeySet<String>() // thread-safe dedup that doesn't evict private val seenEventIds = ConcurrentHashMap.newKeySet<String>() // thread-safe dedup that doesn't evict
// 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 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 /** 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. */ * observe this to prune their local state when a deletion arrives on any subscription. */
private val _removedEvents = MutableSharedFlow<String>(extraBufferCapacity = 64) private val _removedEvents = MutableSharedFlow<String>(extraBufferCapacity = 64)
@@ -449,7 +457,7 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo:
7 -> addReaction(event) 7 -> addReaction(event)
30315 -> processUserStatus(event) 30315 -> processUserStatus(event)
9735 -> { 9735 -> {
val zapperPk = Nip57.getZapperPubkey(event) val (zapperPk, zapMessage) = resolveZapSender(event)
if (zapperPk != null && isWotFiltered(zapperPk, 9735)) return if (zapperPk != null && isWotFiltered(zapperPk, 9735)) return
val targetId = Nip57.getZappedEventId(event) val targetId = Nip57.getZappedEventId(event)
?: resolveAddressableTarget(event) ?: resolveAddressableTarget(event)
@@ -463,31 +471,27 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo:
synchronized(dedupSet) { if (!dedupSet.add(event.id)) return } synchronized(dedupSet) { if (!dedupSet.add(event.id)) return }
val sats = Nip57.getZapAmountSats(event) val sats = Nip57.getZapAmountSats(event)
if (sats > 0) { if (sats > 0) {
val zapperPubkey = Nip57.getZapperPubkey(event)
// Skip if this is our own zap and we already added it optimistically // Skip if this is our own zap and we already added it optimistically
val isOwnOptimistic = zapperPubkey == currentUserPubkey && optimisticZaps.remove(targetId) val isOwnOptimistic = zapperPk == currentUserPubkey && optimisticZaps.remove(targetId)
if (!isOwnOptimistic) { if (!isOwnOptimistic) {
addZapSats(targetId, sats) addZapSats(targetId, sats)
if (zapperPubkey != null) { if (zapperPk != null) {
val zapMessage = Nip57.getZapMessage(event) val isPrivateZap = Nip57.isPrivateZap(event)
val isPrivateZap = dmRelayUrls.isNotEmpty() && Nip57.getZapRequestRelays(event).let { reqRelays ->
reqRelays.isNotEmpty() && reqRelays.all { it in dmRelayUrls }
}
val zaps = zapDetails.get(targetId) val zaps = zapDetails.get(targetId)
?: java.util.Collections.synchronizedList(mutableListOf<ZapDetail>()).also { ?: java.util.Collections.synchronizedList(mutableListOf<ZapDetail>()).also {
zapDetails.put(targetId, it) zapDetails.put(targetId, it)
} }
zaps.add(ZapDetail(zapperPubkey, sats, zapMessage, isPrivate = isPrivateZap, receiptEventId = event.id)) zaps.add(ZapDetail(zapperPk, sats, zapMessage, isPrivate = isPrivateZap, receiptEventId = event.id))
} }
} }
// Always mark user zap flag from receipts // Always mark user zap flag from receipts
if (zapperPubkey == currentUserPubkey) { if (zapperPk == currentUserPubkey) {
userZaps.put(targetId, true) userZaps.put(targetId, true)
} }
// Check if this zap is a zap poll vote (kind 6969) // Check if this zap is a zap poll vote (kind 6969)
val targetEvent = eventCache[targetId] val targetEvent = eventCache[targetId]
if (targetEvent?.kind == Nip69.KIND_ZAP_POLL && sats > 0) { if (targetEvent?.kind == Nip69.KIND_ZAP_POLL && sats > 0) {
addZapPollVote(event, targetId, sats, zapperPubkey) addZapPollVote(event, targetId, sats, zapperPk)
} }
} }
} }
@@ -801,6 +805,30 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo:
return event return event
} }
/**
* Resolve the real sender + message of a kind 9735 zap receipt:
* 1. DIP-03 recipient path — decrypt the anon tag with our privkey.
* 2. DIP-03 self-attribution — match the outer ephemeral against one
* derived from our privkey + the target note's (id, created_at).
* 3. Public-zap fallback — read the embedded request's pubkey/content.
*
* Returns (senderPubkey, message). senderPubkey is null only if the
* embedded request is missing/malformed.
*/
fun resolveZapSender(receipt: NostrEvent): Pair<String?, String> {
val priv = keyRepo?.getKeypair()?.privkey
if (priv != null) {
Nip57.decryptPrivateZap(receipt, priv)?.let { return it.senderPubkey to it.message }
val targetId = receipt.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
val target = targetId?.let { getEvent(it) }
if (target != null) {
Nip57.decryptOwnOutgoingPrivateZap(receipt, priv, target)
?.let { return it.senderPubkey to it.message }
}
}
return Nip57.getZapperPubkey(receipt) to Nip57.getZapMessage(receipt)
}
/** /**
* Bulk-load events from ObjectBox into eventCache and seenEventIds without running * Bulk-load events from ObjectBox into eventCache and seenEventIds without running
* the full addEvent pipeline (no engagement counts, no feed insertion). Profiles * the full addEvent pipeline (no engagement counts, no feed insertion). Profiles
@@ -861,8 +889,10 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo:
val bolt11 = event.tags.firstOrNull { it.size >= 2 && it[0] == "bolt11" }?.get(1) ?: continue val bolt11 = event.tags.firstOrNull { it.size >= 2 && it[0] == "bolt11" }?.get(1) ?: continue
val decoded = Bolt11.decode(bolt11) val decoded = Bolt11.decode(bolt11)
val hash = decoded?.paymentHash ?: continue val hash = decoded?.paymentHash ?: continue
// Sender from embedded kind 9734 zap request // Sender from embedded kind 9734 zap request — for DIP-03 private
val sender = Nip57.getZapperPubkey(event) // zaps, resolveZapSender decrypts so wallet history shows the real
// counterparty rather than the ephemeral pubkey.
val sender = resolveZapSender(event).first
if (sender != null) senders[hash] = sender if (sender != null) senders[hash] = sender
// Recipient from 'p' tag of the receipt // Recipient from 'p' tag of the receipt
val recipient = event.tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) val recipient = event.tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
@@ -1421,6 +1451,7 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo:
resetFeedDisplay() resetFeedDisplay()
eventCache.clear() eventCache.clear()
seenEventIds.clear() seenEventIds.clear()
privateEventIds.clear()
} }
fun clearAll() { fun clearAll() {
@@ -181,14 +181,14 @@ class NotificationRepository(
// For zap receipts, event.pubkey is the lightning service — check the actual zapper too. // For zap receipts, event.pubkey is the lightning service — check the actual zapper too.
if (muteRepo?.isBlocked(event.pubkey) == true) return if (muteRepo?.isBlocked(event.pubkey) == true) return
if (event.kind == 9735) { if (event.kind == 9735) {
val zapperPubkey = Nip57.getZapperPubkey(event) val zapperPubkey = eventRepo?.resolveZapSender(event)?.first
if (zapperPubkey != null && muteRepo?.isBlocked(zapperPubkey) == true) return if (zapperPubkey != null && muteRepo?.isBlocked(zapperPubkey) == true) return
} }
if (safetyPrefs?.wotFilterEnabled?.value == true) { if (safetyPrefs?.wotFilterEnabled?.value == true) {
val netRepo = extendedNetworkRepo val netRepo = extendedNetworkRepo
if (netRepo != null && netRepo.isNetworkReady()) { if (netRepo != null && netRepo.isNetworkReady()) {
val pubkeyToCheck = if (event.kind == 9735) { val pubkeyToCheck = if (event.kind == 9735) {
Nip57.getZapperPubkey(event) ?: event.pubkey eventRepo?.resolveZapSender(event)?.first ?: event.pubkey
} else event.pubkey } else event.pubkey
if (!netRepo.isInQualifiedNetwork(pubkeyToCheck)) return if (!netRepo.isInQualifiedNetwork(pubkeyToCheck)) return
} }
@@ -582,6 +582,7 @@ class NotificationRepository(
timestamp = event.created_at, timestamp = event.created_at,
emoji = emoji, emoji = emoji,
emojiUrl = flatEmojiUrl, emojiUrl = flatEmojiUrl,
isPrivateReaction = eventRepo?.isPrivate(event.id) == true,
groupChatId = groupChatId groupChatId = groupChatId
)) ))
} }
@@ -592,7 +593,8 @@ class NotificationRepository(
private fun mergeZap(event: NostrEvent): Boolean { private fun mergeZap(event: NostrEvent): Boolean {
val amount = Nip57.getZapAmountSats(event) val amount = Nip57.getZapAmountSats(event)
if (amount <= 0) return false if (amount <= 0) return false
val zapperPubkey = Nip57.getZapperPubkey(event) ?: return false val (zapperPubkey, message) = eventRepo?.resolveZapSender(event) ?: (null to "")
if (zapperPubkey == null) return false
val zapETag = event.tags.firstOrNull { it.size >= 2 && it[0] == "e" } val zapETag = event.tags.firstOrNull { it.size >= 2 && it[0] == "e" }
?: return mergeProfileZap(event, zapperPubkey, amount) ?: return mergeProfileZap(event, zapperPubkey, amount)
val referencedId = zapETag[1] val referencedId = zapETag[1]
@@ -602,11 +604,7 @@ class NotificationRepository(
// the same 9735 event to be re-processed on periodic refresh cycles. // the same 9735 event to be re-processed on periodic refresh cycles.
val zapEventIds = zapEventIdsByGroup.getOrPut(key) { mutableSetOf() } val zapEventIds = zapEventIdsByGroup.getOrPut(key) { mutableSetOf() }
if (!zapEventIds.add(event.id)) return false if (!zapEventIds.add(event.id)) return false
val message = Nip57.getZapMessage(event) val isPrivate = Nip57.isPrivateZap(event)
val dmRelays = eventRepo?.dmRelayUrls ?: emptySet()
val isPrivate = dmRelays.isNotEmpty() && Nip57.getZapRequestRelays(event).let { reqRelays ->
reqRelays.isNotEmpty() && reqRelays.all { it in dmRelays }
}
val entry = ZapEntry(pubkey = zapperPubkey, sats = amount, message = message, createdAt = event.created_at, receiptEventId = event.id, isPrivate = isPrivate) val entry = ZapEntry(pubkey = zapperPubkey, sats = amount, message = message, createdAt = event.created_at, receiptEventId = event.id, isPrivate = isPrivate)
val emoji = NotificationGroup.ZAP_EMOJI val emoji = NotificationGroup.ZAP_EMOJI
val existing = groupMap[key] as? NotificationGroup.ReactionGroup val existing = groupMap[key] as? NotificationGroup.ReactionGroup
@@ -663,7 +661,7 @@ class NotificationRepository(
} }
private fun mergeProfileZap(event: NostrEvent, zapperPubkey: String, amount: Long): Boolean { private fun mergeProfileZap(event: NostrEvent, zapperPubkey: String, amount: Long): Boolean {
val message = Nip57.getZapMessage(event) val message = eventRepo?.resolveZapSender(event)?.second ?: Nip57.getZapMessage(event)
val recipientPubkey = event.tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) ?: return false val recipientPubkey = event.tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) ?: return false
val flatZapId = "profilezap:${event.id}" val flatZapId = "profilezap:${event.id}"
if (flatItemIds.add(flatZapId)) { if (flatItemIds.add(flatZapId)) {
@@ -738,6 +736,7 @@ class NotificationRepository(
referencedEventId = replyTarget, referencedEventId = replyTarget,
timestamp = event.created_at, timestamp = event.created_at,
replyEventId = event.id, replyEventId = event.id,
isPrivateReply = eventRepo?.isPrivate(event.id) == true,
groupChatId = groupChatId groupChatId = groupChatId
)) ))
} }
@@ -0,0 +1,52 @@
package com.darkwisp.app.repo
import com.darkwisp.app.nostr.ClientMessage
import com.darkwisp.app.nostr.Filter
import com.darkwisp.app.relay.RelayConfig
import com.darkwisp.app.relay.RelayEvent
import com.darkwisp.app.relay.RelayPool
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.withTimeoutOrNull
/**
* Fetch a peer's kind 10002 relay list (NIP-65) from indexer relays + the connected pool,
* then populate [RelayListRepository] with the freshest result. Caller can then ask the
* repository for the peer's read/inbox or write/outbox relays.
*/
object PeerRelayListLookup {
suspend fun fetch(
pubkey: String,
relayPool: RelayPool,
relayListRepo: RelayListRepository
) {
val subId = "rl_${pubkey.take(8)}"
val filter = Filter(
kinds = listOf(10002),
authors = listOf(pubkey),
limit = 1
)
val reqMsg = ClientMessage.req(subId, filter)
for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) {
relayPool.sendToRelayOrEphemeral(url, reqMsg, skipBadCheck = true)
}
relayPool.sendToAll(reqMsg)
val results = mutableListOf<RelayEvent>()
withTimeoutOrNull(4000L) {
relayPool.relayEvents
.filter { it.subscriptionId == subId }
.collect { results.add(it) }
}
val closeMsg = ClientMessage.close(subId)
for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) {
relayPool.sendToRelay(url, closeMsg)
}
relayPool.sendToAll(closeMsg)
val best = results.maxByOrNull { it.event.created_at }
if (best != null) {
relayListRepo.updateFromEvent(best.event)
}
}
}
@@ -0,0 +1,129 @@
package com.darkwisp.app.repo
import com.darkwisp.app.nostr.ClientMessage
import com.darkwisp.app.nostr.Nip17
import com.darkwisp.app.nostr.NostrEvent
import com.darkwisp.app.nostr.NostrSigner
import com.darkwisp.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)
}
}
@@ -0,0 +1,124 @@
package com.darkwisp.app.repo
import com.darkwisp.app.nostr.ClientMessage
import com.darkwisp.app.nostr.Nip10
import com.darkwisp.app.nostr.Nip13
import com.darkwisp.app.nostr.Nip17
import com.darkwisp.app.nostr.NostrEvent
import com.darkwisp.app.nostr.NostrSigner
import com.darkwisp.app.relay.RelayPool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Sends a NIP-17 gift-wrapped private reply (kind 1 rumor inside kind 1059 wrap).
*
* Shared by [com.darkwisp.app.viewmodel.ComposeViewModel] (full-screen compose) and the
* notifications quick-reply path so both apply the same recipient relay resolution,
* PoW mining on the kind 1 rumor, self-copy, and optimistic local insert.
*/
object PrivateReplyPublisher {
data class Result(val sentCount: Int, val rumorId: String?)
suspend fun send(
signer: NostrSigner,
relayPool: RelayPool,
dmRepo: DmRepository,
relayListRepo: RelayListRepository?,
eventRepo: EventRepository?,
replyTo: NostrEvent,
content: String,
baseTags: List<List<String>>,
targetDifficulty: Int = 0,
onPowProgress: ((Long) -> Unit)? = null
): Result {
val userPubkey = signer.pubkeyHex
// Mine PoW on the kind 1 rumor first so its committed nonce + difficulty travel inside
// the encrypted wrap. The recipient renders the rumor as a normal kind 1 reply with a
// PoW badge when they decrypt — the wrap layer itself stays at difficulty 0.
var rumorTags = baseTags
var rumorCreatedAt = System.currentTimeMillis() / 1000
if (targetDifficulty > 0) {
val mined = withContext(Dispatchers.Default) {
Nip13.mine(
pubkeyHex = userPubkey,
kind = 1,
content = content,
tags = baseTags,
targetDifficulty = targetDifficulty,
createdAt = rumorCreatedAt,
onProgress = onPowProgress
)
}
rumorTags = mined.tags
rumorCreatedAt = mined.createdAt
}
val recipientWrap = Nip17.createGiftWrapRemote(
signer = signer,
recipientPubkeyHex = replyTo.pubkey,
message = content,
replyTags = rumorTags,
rumorKind = 1,
createdAt = rumorCreatedAt
)
val selfWrap = Nip17.createGiftWrapRemote(
signer = signer,
recipientPubkeyHex = userPubkey,
message = content,
replyTags = rumorTags,
rumorKind = 1,
createdAt = rumorCreatedAt
)
// Recipient resolution: their kind 10050 DM relays, or — if none — their NIP-65
// inbox (read) relays. The recipient never queries our write relays, so we don't
// fall back to those: a wrap there would be silently lost.
val recipientRelays: List<String> = run {
val dmRelays = DmRelayLookup.fetch(replyTo.pubkey, relayPool, dmRepo)
if (dmRelays.isNotEmpty()) return@run dmRelays
if (relayListRepo != null) {
relayListRepo.getReadRelays(replyTo.pubkey)?.takeIf { it.isNotEmpty() }?.let { return@run it }
// Cache miss — fetch kind 10002 fresh from indexers, then re-check.
PeerRelayListLookup.fetch(replyTo.pubkey, relayPool, relayListRepo)
relayListRepo.getReadRelays(replyTo.pubkey)?.takeIf { it.isNotEmpty() }?.let { return@run it }
}
emptyList()
}
if (recipientRelays.isEmpty()) return Result(0, null)
val recipientMsg = ClientMessage.event(recipientWrap)
var sentCount = 0
for (url in recipientRelays) {
if (relayPool.sendToRelayOrEphemeral(url, recipientMsg, skipBadCheck = true)) sentCount++
}
if (sentCount == 0) return Result(0, null)
val selfMsg = ClientMessage.event(selfWrap)
if (relayPool.hasDmRelays()) relayPool.sendToDmRelays(selfMsg)
else relayPool.sendToWriteRelays(selfMsg)
val rumorId = NostrEvent.computeId(userPubkey, rumorCreatedAt, 1, rumorTags, content)
val synthetic = NostrEvent(
id = rumorId,
pubkey = userPubkey,
created_at = rumorCreatedAt,
kind = 1,
tags = rumorTags,
content = content,
sig = ""
)
eventRepo?.markPrivate(rumorId)
eventRepo?.cacheEvent(synthetic)
eventRepo?.addReplyCount(replyTo.id, rumorId)
Nip10.getRootId(replyTo)?.takeIf { it != replyTo.id }?.let { rootId ->
eventRepo?.addReplyCount(rootId, rumorId)
}
dmRepo.markGiftWrapSeen(selfWrap.id, rumorId)
return Result(sentCount, rumorId)
}
}
@@ -0,0 +1,99 @@
package com.darkwisp.app.repo
import android.util.Log
import com.darkwisp.app.nostr.Nip10
import com.darkwisp.app.nostr.Nip17
import com.darkwisp.app.nostr.NostrEvent
/**
* Routes non-DM rumors received via NIP-17 gift wrap (kind 1 private replies and kind 7
* reactions carrying `k=1`) into the note + notification repositories.
*
* Shared by [com.darkwisp.app.viewmodel.EventRouter] (local-signer path, wraps decrypted
* as they arrive) and the remote-signer pending-decrypt paths in
* [com.darkwisp.app.viewmodel.DmListViewModel] and
* [com.darkwisp.app.viewmodel.DmConversationViewModel], which would otherwise misfile
* these rumors as DM messages/reactions.
*/
object PrivateRumorHandler {
/** True when a kind 7 rumor targets a NIP-17 private reply (k=1) rather than a DM (k=14). */
fun isPrivateReplyReaction(rumor: Nip17.Rumor): Boolean =
Nip17.isReaction(rumor) &&
rumor.tags.firstOrNull { it.size >= 2 && it[0] == "k" }?.get(1) == "1"
/** Materialise a kind 1 private-reply rumor: mark it private, cache a synthetic event,
* bump the parent's reply count, and notify (unless it's our own self-copy wrap). */
fun handlePrivateReply(
rumor: Nip17.Rumor,
myPubkey: String,
eventRepo: EventRepository,
notifRepo: NotificationRepository,
muteRepo: MuteRepository?,
onMissingProfile: (String) -> Unit = {}
) {
if (muteRepo?.isBlocked(rumor.pubkey) == true) return
val rumorId = Nip17.computeRumorId(rumor)
val synthetic = NostrEvent(
id = rumorId,
pubkey = rumor.pubkey,
created_at = rumor.createdAt,
kind = 1,
tags = rumor.tags,
content = rumor.content,
sig = ""
)
eventRepo.markPrivate(rumorId)
eventRepo.cacheEvent(synthetic)
if (!Nip10.isStandaloneQuote(synthetic)) {
val parentId = Nip10.getReplyTarget(synthetic)
if (parentId != null) eventRepo.addReplyCount(parentId, synthetic.id)
}
// Self-copy wraps from another device land here too — skip the notification, but
// the cache write above still surfaces the reply in our thread view.
if (rumor.pubkey == myPubkey) return
notifRepo.addEvent(synthetic, myPubkey, replyToMyEvent = true, source = "gift-wrap-private-reply")
if (eventRepo.getProfileData(rumor.pubkey) == null) {
onMissingProfile(rumor.pubkey)
}
}
/** Materialise a kind 7 reaction on a private reply: mark it private, feed it through the
* normal reaction pipeline, and notify (unless it's our own self-copy wrap). */
fun handlePrivateReaction(
rumor: Nip17.Rumor,
myPubkey: String,
eventRepo: EventRepository,
notifRepo: NotificationRepository,
muteRepo: MuteRepository?,
onMissingProfile: (String) -> Unit = {}
) {
if (muteRepo?.isBlocked(rumor.pubkey) == true) 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("PrivateRumorHandler", "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) {
onMissingProfile(rumor.pubkey)
}
}
}
@@ -79,7 +79,8 @@ class ZapSender(
isAnonymous: Boolean = false, isAnonymous: Boolean = false,
isPrivate: Boolean = false, isPrivate: Boolean = false,
extraTags: List<List<String>> = emptyList(), extraTags: List<List<String>> = emptyList(),
extraRelayHints: List<String> = emptyList() extraRelayHints: List<String> = emptyList(),
eventCreatedAt: Long? = null
): Result<Unit> { ): Result<Unit> {
// 1. LNURL discovery // 1. LNURL discovery
val payInfo = Nip57.resolveLud16(recipientLud16, httpClient) val payInfo = Nip57.resolveLud16(recipientLud16, httpClient)
@@ -93,20 +94,22 @@ class ZapSender(
return Result.failure(Exception("Amount out of range (${payInfo.minSendable / 1000}-${payInfo.maxSendable / 1000} sats)")) return Result.failure(Exception("Amount out of range (${payInfo.minSendable / 1000}-${payInfo.maxSendable / 1000} sats)"))
} }
// 2. Build zap request (kind 9734) // 2. Build zap request (kind 9734). For DIP-03 private zaps we route
// the receipt only to both parties' NIP-51 DM relays — assumed to be
// AUTH-gated for reads — so the LNURL-published kind 9735 (which
// carries the recipient pubkey, amount, and target note id) never lands
// on a publicly readable relay. The anon-tag envelope hides the sender
// identity as a second layer in case the LNURL ignores `relays` or one
// of the DM relays turns out to serve reads unauthed.
val relayUrls = if (isPrivate) { val relayUrls = if (isPrivate) {
// Private zap: route receipt through DM relays only
val recipientDmRelays = relayListRepo.getDmRelays(recipientPubkey) ?: emptyList() val recipientDmRelays = relayListRepo.getDmRelays(recipientPubkey) ?: emptyList()
val ourDmRelays = relayPool.getDmRelayUrls() val ourDmRelays = relayPool.getDmRelayUrls()
val combined = (recipientDmRelays + ourDmRelays).distinct().take(5) val combined = (ourDmRelays + recipientDmRelays).distinct().take(5)
if (combined.isEmpty()) { if (combined.isEmpty()) {
return Result.failure(Exception("No DM relays available for private zap")) return Result.failure(Exception("Private zaps require DM relays on both sides"))
} }
combined combined
} else { } else {
// Extra relay hints first (e.g. live stream chat relays), then recipient's
// read relays (so they see the receipt), then our own read relays (so we can
// verify it), deduped, capped at 5.
val recipientRelays = relayListRepo.getReadRelays(recipientPubkey) ?: emptyList() val recipientRelays = relayListRepo.getReadRelays(recipientPubkey) ?: emptyList()
val ourRelays = relayPool.getReadRelayUrls() val ourRelays = relayPool.getReadRelayUrls()
(extraRelayHints + recipientRelays + ourRelays).distinct().take(5) (extraRelayHints + recipientRelays + ourRelays).distinct().take(5)
@@ -118,26 +121,12 @@ class ZapSender(
addAll(extraTags) addAll(extraTags)
} }
val zapRequest = if (isAnonymous) { val zapRequest = when {
val throwaway = Keys.generate() isAnonymous -> {
Nip57.buildZapRequest( val throwaway = Keys.generate()
senderPrivkey = throwaway.privkey, Nip57.buildZapRequest(
senderPubkey = throwaway.pubkey, senderPrivkey = throwaway.privkey,
recipientPubkey = recipientPubkey, senderPubkey = throwaway.pubkey,
eventId = eventId,
amountMsats = amountMsats,
relayUrls = relayUrls,
lnurl = recipientLud16,
message = message,
extraTags = allExtraTags
)
} else {
val s = signer
val keypair = keyRepo.getKeypair()
when {
s != null -> Nip57.buildZapRequestWithSigner(
signer = s,
recipientPubkey = recipientPubkey, recipientPubkey = recipientPubkey,
eventId = eventId, eventId = eventId,
amountMsats = amountMsats, amountMsats = amountMsats,
@@ -146,18 +135,57 @@ class ZapSender(
message = message, message = message,
extraTags = allExtraTags extraTags = allExtraTags
) )
keypair != null -> Nip57.buildZapRequest( }
isPrivate -> {
// DIP-03 requires a concrete note target (id + created_at) to
// derive the deterministic ephemeral key. Profile / addressable
// zaps fall back to non-private at the UI gate; this is a
// defensive guard.
if (eventId == null || eventCreatedAt == null) {
return Result.failure(Exception("Private zaps require a note target"))
}
val keypair = keyRepo.getKeypair()
?: return Result.failure(Exception("Private zaps require a local private key"))
Nip57.buildPrivateZapRequest(
senderPrivkey = keypair.privkey, senderPrivkey = keypair.privkey,
senderPubkey = keypair.pubkey, senderPubkey = keypair.pubkey,
recipientPubkey = recipientPubkey, recipientPubkey = recipientPubkey,
eventId = eventId, eventId = eventId,
eventCreatedAt = eventCreatedAt,
amountMsats = amountMsats, amountMsats = amountMsats,
relayUrls = relayUrls, relayUrls = relayUrls,
lnurl = recipientLud16, lnurl = recipientLud16,
message = message, message = message,
extraTags = allExtraTags extraTags = allExtraTags
) )
else -> return Result.failure(Exception("No signer or keypair available")) }
else -> {
val s = signer
val keypair = keyRepo.getKeypair()
when {
s != null -> Nip57.buildZapRequestWithSigner(
signer = s,
recipientPubkey = recipientPubkey,
eventId = eventId,
amountMsats = amountMsats,
relayUrls = relayUrls,
lnurl = recipientLud16,
message = message,
extraTags = allExtraTags
)
keypair != null -> Nip57.buildZapRequest(
senderPrivkey = keypair.privkey,
senderPubkey = keypair.pubkey,
recipientPubkey = recipientPubkey,
eventId = eventId,
amountMsats = amountMsats,
relayUrls = relayUrls,
lnurl = recipientLud16,
message = message,
extraTags = allExtraTags
)
else -> return Result.failure(Exception("No signer or keypair available"))
}
} }
} }
@@ -84,6 +84,9 @@ fun ActionBar(
resolvedEmojis: Map<String, String> = emptyMap(), resolvedEmojis: Map<String, String> = emptyMap(),
unicodeEmojis: List<String> = emptyList(), unicodeEmojis: List<String> = emptyList(),
onOpenEmojiLibrary: (() -> Unit)? = null, onOpenEmojiLibrary: (() -> Unit)? = null,
isPrivate: Boolean = false,
zapEnabled: Boolean = true,
onZapDisabledTap: () -> Unit = {},
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
@@ -107,6 +110,9 @@ fun ActionBar(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1 maxLines = 1
) )
// 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)) Spacer(Modifier.width(8.dp))
Box { Box {
Box( Box(
@@ -160,60 +166,71 @@ fun ActionBar(
color = if (userReactionEmojis.isNotEmpty()) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant, color = if (userReactionEmojis.isNotEmpty()) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1 maxLines = 1
) )
Spacer(Modifier.width(8.dp)) if (!isPrivate) {
Box { Spacer(Modifier.width(8.dp))
IconButton(onClick = { showRepostMenu = true }) { Box {
Icon( IconButton(onClick = { showRepostMenu = true }) {
Icons.Outlined.Repeat, Icon(
contentDescription = stringResource(R.string.cd_repost), Icons.Outlined.Repeat,
tint = if (hasUserReposted) WispThemeColors.repostColor else MaterialTheme.colorScheme.onSurfaceVariant, contentDescription = stringResource(R.string.cd_repost),
modifier = Modifier.size(22.dp) tint = if (hasUserReposted) WispThemeColors.repostColor else MaterialTheme.colorScheme.onSurfaceVariant,
) modifier = Modifier.size(22.dp)
} )
if (showRepostMenu) { }
RepostPopup( if (showRepostMenu) {
onRepost = { RepostPopup(
onRepost() onRepost = {
showRepostMenu = false onRepost()
}, showRepostMenu = false
onQuote = { },
onQuote() onQuote = {
showRepostMenu = false onQuote()
}, showRepostMenu = false
onDismiss = { 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)) Spacer(Modifier.width(8.dp))
Box { 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) { if (isZapInProgress) {
LightningAnimation(modifier = Modifier.size(width = 14.dp, height = 22.dp)) LightningAnimation(modifier = Modifier.size(width = 14.dp, height = 22.dp))
} else if (fiatMode) { } else if (fiatMode) {
Icon( Icon(
painter = painterResource(R.drawable.ic_coin_stack), painter = painterResource(R.drawable.ic_coin_stack),
contentDescription = stringResource(R.string.cd_zaps), contentDescription = stringResource(R.string.cd_zaps),
tint = if (hasUserZapped) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant, tint = zapTint,
modifier = Modifier.size(22.dp) modifier = Modifier.size(22.dp)
) )
} else if (useZapBoltIcon) { } else if (useZapBoltIcon) {
Icon( Icon(
painter = painterResource(R.drawable.ic_bolt), painter = painterResource(R.drawable.ic_bolt),
contentDescription = stringResource(R.string.cd_zaps), contentDescription = stringResource(R.string.cd_zaps),
tint = if (hasUserZapped) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant, tint = zapTint,
modifier = Modifier.size(18.dp) modifier = Modifier.size(18.dp)
) )
} else { } else {
Icon( Icon(
Icons.Outlined.CurrencyBitcoin, Icons.Outlined.CurrencyBitcoin,
contentDescription = stringResource(R.string.cd_zaps), contentDescription = stringResource(R.string.cd_zaps),
tint = if (hasUserZapped) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant, tint = zapTint,
modifier = Modifier.size(22.dp) modifier = Modifier.size(22.dp)
) )
} }
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.Repeat import androidx.compose.material.icons.outlined.Repeat
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
@@ -122,6 +123,8 @@ fun PostCard(
hasUserReposted: Boolean = false, hasUserReposted: Boolean = false,
repostCount: Int = 0, repostCount: Int = 0,
onZap: () -> Unit = {}, onZap: () -> Unit = {},
onZapDisabledTap: () -> Unit = {},
zapEnabled: Boolean = true,
hasUserZapped: Boolean = false, hasUserZapped: Boolean = false,
likeCount: Int = 0, likeCount: Int = 0,
replyCount: Int = 0, replyCount: Int = 0,
@@ -140,6 +143,7 @@ fun PostCard(
onBlockAuthor: () -> Unit = {}, onBlockAuthor: () -> Unit = {},
isFollowingAuthor: Boolean = false, isFollowingAuthor: Boolean = false,
isOwnEvent: Boolean = false, isOwnEvent: Boolean = false,
isPrivate: Boolean = false,
nip05Repo: Nip05Repository? = null, nip05Repo: Nip05Repository? = null,
onAddToList: () -> Unit = {}, onAddToList: () -> Unit = {},
isInList: Boolean = false, isInList: Boolean = false,
@@ -340,6 +344,15 @@ fun PostCard(
) )
} }
} }
if (isPrivate) {
Icon(
imageVector = Icons.Outlined.VisibilityOff,
contentDescription = "Private reply",
modifier = Modifier.size(14.dp),
tint = Color(0xFFFF8C00)
)
Spacer(Modifier.width(4.dp))
}
Text( Text(
text = timestamp, text = timestamp,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -789,6 +802,9 @@ fun PostCard(
resolvedEmojis = resolvedEmojis, resolvedEmojis = resolvedEmojis,
unicodeEmojis = unicodeEmojis, unicodeEmojis = unicodeEmojis,
onOpenEmojiLibrary = onOpenEmojiLibrary, onOpenEmojiLibrary = onOpenEmojiLibrary,
isPrivate = isPrivate,
zapEnabled = zapEnabled,
onZapDisabledTap = onZapDisabledTap,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
Icon( Icon(
@@ -37,6 +37,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close 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.material.icons.automirrored.outlined.Message
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -107,6 +108,12 @@ fun ZapDialog(
onZap: (amountMsats: Long, message: String, isAnonymous: Boolean, isPrivate: Boolean) -> Unit, onZap: (amountMsats: Long, message: String, isAnonymous: Boolean, isPrivate: Boolean) -> Unit,
onGoToWallet: () -> Unit, onGoToWallet: () -> Unit,
canPrivateZap: Boolean = false, 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. */ /** When opening from a quick preset (e.g. chat actions sheet), pre-select that amount in sats. */
initialSatsHint: Int? = null initialSatsHint: Int? = null
) { ) {
@@ -140,7 +147,7 @@ fun ZapDialog(
var customAmount by remember { mutableStateOf("") } var customAmount by remember { mutableStateOf("") }
var message by remember { mutableStateOf("") } var message by remember { mutableStateOf("") }
var isAnonymous by remember { mutableStateOf(false) } 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) } var editMode by remember { mutableStateOf(false) }
LaunchedEffect(initialSatsHint) { LaunchedEffect(initialSatsHint) {
@@ -404,6 +411,28 @@ fun ZapDialog(
Spacer(Modifier.height(16.dp)) 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 // Anonymous toggle
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -468,6 +497,7 @@ fun ZapDialog(
) )
) )
} }
} // end !forcePrivate
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -62,6 +62,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.outlined.BarChart import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.PhotoLibrary import androidx.compose.material.icons.outlined.PhotoLibrary
import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.outlined.Schedule
@@ -180,6 +181,12 @@ fun ComposeScreen(
val zapPollConsensus by viewModel.zapPollConsensus.collectAsState() val zapPollConsensus by viewModel.zapPollConsensus.collectAsState()
val scheduleEnabled by viewModel.scheduleEnabled.collectAsState() val scheduleEnabled by viewModel.scheduleEnabled.collectAsState()
val scheduleTimestamp by viewModel.scheduleTimestamp.collectAsState() val scheduleTimestamp by viewModel.scheduleTimestamp.collectAsState()
val privateReply by viewModel.privateReply.collectAsState()
val privateReplyLocked by viewModel.privateReplyLocked.collectAsState()
LaunchedEffect(replyTo) {
viewModel.configureForReply(replyTo)
}
val powStatus = powManager?.status?.collectAsState()?.value ?: PowStatus.Idle val powStatus = powManager?.status?.collectAsState()?.value ?: PowStatus.Idle
val isMiningBusy = powStatus is PowStatus.Mining val isMiningBusy = powStatus is PowStatus.Mining
val context = LocalContext.current val context = LocalContext.current
@@ -332,7 +339,11 @@ fun ComposeScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = { viewModel.toggleExplicit() }) { IconButton(onClick = {
val nextState = !explicit
viewModel.toggleExplicit()
android.widget.Toast.makeText(context, "NSFW ${if (nextState) "ON" else "OFF"}", android.widget.Toast.LENGTH_SHORT).show()
}) {
Icon( Icon(
Icons.Outlined.Warning, Icons.Outlined.Warning,
contentDescription = "Mark as explicit", contentDescription = "Mark as explicit",
@@ -341,7 +352,14 @@ fun ComposeScreen(
) )
} }
IconButton(onClick = { powPrefs?.let { viewModel.togglePow(it) } }) { IconButton(onClick = {
val prefs = powPrefs
if (prefs != null) {
val nextState = !powEnabled
viewModel.togglePow(prefs)
android.widget.Toast.makeText(context, "Mining ${if (nextState) "ON" else "OFF"}", android.widget.Toast.LENGTH_SHORT).show()
}
}) {
Icon( Icon(
Icons.Outlined.Shield, Icons.Outlined.Shield,
contentDescription = "Proof of Work", contentDescription = "Proof of Work",
@@ -726,7 +744,11 @@ fun ComposeScreen(
Icon(Icons.Outlined.Image, contentDescription = "Attach media") Icon(Icons.Outlined.Image, contentDescription = "Attach media")
} }
IconButton(onClick = { viewModel.toggleExplicit() }) { IconButton(onClick = {
val nextState = !explicit
viewModel.toggleExplicit()
android.widget.Toast.makeText(context, "NSFW ${if (nextState) "ON" else "OFF"}", android.widget.Toast.LENGTH_SHORT).show()
}) {
Icon( Icon(
Icons.Outlined.Warning, Icons.Outlined.Warning,
contentDescription = "Mark as explicit", contentDescription = "Mark as explicit",
@@ -735,7 +757,14 @@ fun ComposeScreen(
) )
} }
IconButton(onClick = { powPrefs?.let { viewModel.togglePow(it) } }) { IconButton(onClick = {
val prefs = powPrefs
if (prefs != null) {
val nextState = !powEnabled
viewModel.togglePow(prefs)
android.widget.Toast.makeText(context, "Mining ${if (nextState) "ON" else "OFF"}", android.widget.Toast.LENGTH_SHORT).show()
}
}) {
Icon( Icon(
Icons.Outlined.Shield, Icons.Outlined.Shield,
contentDescription = "Proof of Work", contentDescription = "Proof of Work",
@@ -753,6 +782,24 @@ fun ComposeScreen(
) )
} }
// Private reply toggle: only meaningful for replies in plain text mode
// (private replies don't carry gallery/poll/schedule/quote payloads in v1).
if (replyTo != null && quoteTo == null && !galleryMode && !pollEnabled && !scheduleEnabled) {
IconButton(onClick = {
// Locked toggles short-circuit in the VM, so the state stays ON.
val nextState = if (privateReplyLocked) true else !privateReply
viewModel.togglePrivateReply()
android.widget.Toast.makeText(context, "Private Reply ${if (nextState) "ON" else "OFF"}", android.widget.Toast.LENGTH_SHORT).show()
}) {
Icon(
imageVector = Icons.Outlined.VisibilityOff,
contentDescription = "Private reply",
tint = if (privateReply) androidx.compose.ui.graphics.Color(0xFFFF8C00)
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
IconButton(onClick = { IconButton(onClick = {
if (scheduleEnabled) { if (scheduleEnabled) {
viewModel.toggleSchedule() viewModel.toggleSchedule()
@@ -1168,6 +1215,7 @@ fun ComposeScreen(
signer = signer, signer = signer,
onNotePublished = onNotePublished, onNotePublished = onNotePublished,
powManager = powManager, powManager = powManager,
powPrefs = powPrefs,
resolvedEmojis = resolvedEmojis resolvedEmojis = resolvedEmojis
) )
}, },
@@ -35,6 +35,7 @@ import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material.icons.outlined.AlternateEmail import androidx.compose.material.icons.outlined.AlternateEmail
import androidx.compose.material.icons.outlined.BarChart import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material.icons.outlined.CurrencyBitcoin import androidx.compose.material.icons.outlined.CurrencyBitcoin
import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.Favorite
import androidx.compose.material.icons.outlined.FormatQuote import androidx.compose.material.icons.outlined.FormatQuote
@@ -684,6 +685,15 @@ private fun ZenNotificationRow(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1 maxLines = 1
) )
if (item.isPrivateReply || item.isPrivateReaction || item.isPrivateZap) {
Spacer(Modifier.width(4.dp))
Icon(
imageVector = Icons.Outlined.VisibilityOff,
contentDescription = "Private",
modifier = Modifier.size(14.dp),
tint = androidx.compose.ui.graphics.Color(0xFFFF8C00)
)
}
} }
// Show voted option labels for NIP-88 polls // Show voted option labels for NIP-88 polls
if (item.type == NotificationType.VOTE && item.voteOptionIds.isNotEmpty()) { if (item.type == NotificationType.VOTE && item.voteOptionIds.isNotEmpty()) {
@@ -50,7 +50,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import android.widget.Toast
import com.darkwisp.app.nostr.Nip69 import com.darkwisp.app.nostr.Nip69
import com.darkwisp.app.nostr.NostrEvent import com.darkwisp.app.nostr.NostrEvent
import com.darkwisp.app.repo.ContactRepository import com.darkwisp.app.repo.ContactRepository
@@ -108,7 +110,9 @@ fun ThreadScreen(
fetchGroupPreview: (suspend (String, String) -> com.darkwisp.app.repo.GroupPreview?)? = null, fetchGroupPreview: (suspend (String, String) -> com.darkwisp.app.repo.GroupPreview?)? = null,
onAddEmojiSet: ((String, String) -> Unit)? = null, onAddEmojiSet: ((String, String) -> Unit)? = null,
onRemoveEmojiSet: ((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 flatThread by viewModel.flatThread.collectAsState()
val isLoading by viewModel.isLoading.collectAsState() val isLoading by viewModel.isLoading.collectAsState()
@@ -198,6 +202,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( Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0), contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = { topBar = {
@@ -367,6 +377,9 @@ fun ThreadScreen(
onBlockAuthor = { onBlockUser(event.pubkey) }, onBlockAuthor = { onBlockUser(event.pubkey) },
isFollowingAuthor = followList.let { contactRepo.isFollowing(event.pubkey) }, isFollowingAuthor = followList.let { contactRepo.isFollowing(event.pubkey) },
isOwnEvent = event.pubkey == userPubkey, isOwnEvent = event.pubkey == userPubkey,
isPrivate = eventRepo.isPrivate(event.id),
zapEnabled = !eventRepo.isPrivate(event.id) || canPrivateZapFor(event),
onZapDisabledTap = onZapDisabledTap,
onAddToList = { onAddToList(event.id) }, onAddToList = { onAddToList(event.id) },
isInList = event.id in listedIds, isInList = event.id in listedIds,
onPin = { onTogglePin(event.id) }, onPin = { onTogglePin(event.id) },
@@ -454,6 +467,9 @@ fun ThreadScreen(
onBlockAuthor = { onBlockUser(event.pubkey) }, onBlockAuthor = { onBlockUser(event.pubkey) },
isFollowingAuthor = followList.let { contactRepo.isFollowing(event.pubkey) }, isFollowingAuthor = followList.let { contactRepo.isFollowing(event.pubkey) },
isOwnEvent = event.pubkey == userPubkey, isOwnEvent = event.pubkey == userPubkey,
isPrivate = eventRepo.isPrivate(event.id),
zapEnabled = !eventRepo.isPrivate(event.id) || canPrivateZapFor(event),
onZapDisabledTap = onZapDisabledTap,
onAddToList = { onAddToList(event.id) }, onAddToList = { onAddToList(event.id) },
isInList = event.id in listedIds, isInList = event.id in listedIds,
onPin = { onTogglePin(event.id) }, onPin = { onTogglePin(event.id) },
@@ -151,7 +151,7 @@ class ArticleViewModel : ViewModel() {
7, 6 -> eventRepo.addEvent(event) 7, 6 -> eventRepo.addEvent(event)
9735 -> { 9735 -> {
eventRepo.addEvent(event) eventRepo.addEvent(event)
val zapperPubkey = Nip57.getZapperPubkey(event) val zapperPubkey = eventRepo.resolveZapSender(event).first
if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) { if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) {
metadataFetcher.addToPendingProfiles(zapperPubkey) metadataFetcher.addToPendingProfiles(zapperPubkey)
} }
@@ -31,12 +31,15 @@ import com.darkwisp.app.relay.OutboxRouter
import com.darkwisp.app.relay.RelayPool import com.darkwisp.app.relay.RelayPool
import com.darkwisp.app.repo.BlossomRepository import com.darkwisp.app.repo.BlossomRepository
import com.darkwisp.app.repo.ContactRepository import com.darkwisp.app.repo.ContactRepository
import com.darkwisp.app.repo.DmRepository
import com.darkwisp.app.repo.KeyRepository import com.darkwisp.app.repo.KeyRepository
import com.darkwisp.app.repo.PrivateReplyPublisher
import com.darkwisp.app.repo.MentionCandidate import com.darkwisp.app.repo.MentionCandidate
import com.darkwisp.app.repo.MentionSearchRepository import com.darkwisp.app.repo.MentionSearchRepository
import com.darkwisp.app.repo.EventRepository import com.darkwisp.app.repo.EventRepository
import com.darkwisp.app.repo.InterfacePreferences import com.darkwisp.app.repo.InterfacePreferences
import com.darkwisp.app.repo.ProfileRepository import com.darkwisp.app.repo.ProfileRepository
import com.darkwisp.app.repo.RelayListRepository
import com.darkwisp.app.R import com.darkwisp.app.R
import com.darkwisp.app.ui.util.GifToMp4Converter import com.darkwisp.app.ui.util.GifToMp4Converter
import com.darkwisp.app.ui.util.MediaCompressor import com.darkwisp.app.ui.util.MediaCompressor
@@ -116,6 +119,27 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
_explicit.value = !_explicit.value _explicit.value = !_explicit.value
} }
private val _privateReply = MutableStateFlow(false)
val privateReply: StateFlow<Boolean> = _privateReply
// Locked = the user is replying to a private reply, so the new reply must also be private
// (sending publicly would attach an e-tag to the rumor id on public relays, leaking metadata).
private val _privateReplyLocked = MutableStateFlow(false)
val privateReplyLocked: StateFlow<Boolean> = _privateReplyLocked
fun togglePrivateReply() {
if (_privateReplyLocked.value) return
_privateReply.value = !_privateReply.value
}
/** 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?.isPrivate(replyTo.id) == true
_privateReplyLocked.value = isReplyingToPrivate
if (isReplyingToPrivate) _privateReply.value = true
}
private val _powEnabled = MutableStateFlow(false) private val _powEnabled = MutableStateFlow(false)
val powEnabled: StateFlow<Boolean> = _powEnabled val powEnabled: StateFlow<Boolean> = _powEnabled
@@ -236,15 +260,27 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
private var pendingPublish: (() -> Unit)? = null private var pendingPublish: (() -> Unit)? = null
private var mentionSearchRepo: MentionSearchRepository? = null private var mentionSearchRepo: MentionSearchRepository? = null
private var eventRepo: EventRepository? = null private var eventRepo: EventRepository? = null
private var dmRepo: DmRepository? = null
private var relayListRepo: RelayListRepository? = null
private var initialized = false private var initialized = false
var currentDraftId: String? = null var currentDraftId: String? = null
private set private set
fun init(profileRepo: ProfileRepository, contactRepo: ContactRepository, relayPool: RelayPool, eventRepo: EventRepository? = null, eventPersistence: com.darkwisp.app.db.EventPersistence? = null) { fun init(
profileRepo: ProfileRepository,
contactRepo: ContactRepository,
relayPool: RelayPool,
eventRepo: EventRepository? = null,
eventPersistence: com.darkwisp.app.db.EventPersistence? = null,
dmRepo: DmRepository? = null,
relayListRepo: RelayListRepository? = null
) {
if (initialized) return if (initialized) return
initialized = true initialized = true
this.eventRepo = eventRepo this.eventRepo = eventRepo
this.dmRepo = dmRepo
this.relayListRepo = relayListRepo
mentionSearchRepo = MentionSearchRepository(profileRepo, contactRepo, relayPool, keyRepo).also { mentionSearchRepo = MentionSearchRepository(profileRepo, contactRepo, relayPool, keyRepo).also {
it.eventPersistence = eventPersistence it.eventPersistence = eventPersistence
} }
@@ -488,6 +524,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
signer: NostrSigner? = null, signer: NostrSigner? = null,
onNotePublished: (() -> Unit)? = null, onNotePublished: (() -> Unit)? = null,
powManager: PowManager? = null, powManager: PowManager? = null,
powPrefs: com.darkwisp.app.repo.PowPreferences? = null,
resolvedEmojis: Map<String, String> = emptyMap() resolvedEmojis: Map<String, String> = emptyMap()
) { ) {
val rawText = _content.value.text val rawText = _content.value.text
@@ -531,7 +568,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
if (!useTimer || timerSeconds <= 0) { if (!useTimer || timerSeconds <= 0) {
viewModelScope.launch { viewModelScope.launch {
try { try {
val sentCount = publishNote(text, s, relayPool, replyTo, quoteTo, outboxRouter, powManager, resolvedEmojis) val sentCount = publishNote(text, s, relayPool, replyTo, quoteTo, outboxRouter, powManager, powPrefs, resolvedEmojis)
if (sentCount == 0) return@launch if (sentCount == 0) return@launch
onNotePublished?.invoke() onNotePublished?.invoke()
onSuccess() onSuccess()
@@ -542,7 +579,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
} }
return return
} }
startCountdown(text, s, relayPool, replyTo, quoteTo, outboxRouter, onSuccess, onNotePublished, powManager, resolvedEmojis, timerSeconds) startCountdown(text, s, relayPool, replyTo, quoteTo, outboxRouter, onSuccess, onNotePublished, powManager, powPrefs, resolvedEmojis, timerSeconds)
} }
private fun startCountdown( private fun startCountdown(
@@ -555,6 +592,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
onSuccess: () -> Unit, onSuccess: () -> Unit,
onNotePublished: (() -> Unit)? = null, onNotePublished: (() -> Unit)? = null,
powManager: PowManager? = null, powManager: PowManager? = null,
powPrefs: com.darkwisp.app.repo.PowPreferences? = null,
resolvedEmojis: Map<String, String> = emptyMap(), resolvedEmojis: Map<String, String> = emptyMap(),
seconds: Int = 10 seconds: Int = 10
) { ) {
@@ -562,7 +600,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
pendingPublish = { pendingPublish = {
viewModelScope.launch { viewModelScope.launch {
try { try {
val sentCount = publishNote(content, signer, relayPool, replyTo, quoteTo, outboxRouter, powManager, resolvedEmojis) val sentCount = publishNote(content, signer, relayPool, replyTo, quoteTo, outboxRouter, powManager, powPrefs, resolvedEmojis)
if (sentCount == 0) return@launch if (sentCount == 0) return@launch
onNotePublished?.invoke() onNotePublished?.invoke()
onSuccess() onSuccess()
@@ -610,6 +648,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
quoteTo: NostrEvent? = null, quoteTo: NostrEvent? = null,
outboxRouter: OutboxRouter? = null, outboxRouter: OutboxRouter? = null,
powManager: PowManager? = null, powManager: PowManager? = null,
powPrefs: com.darkwisp.app.repo.PowPreferences? = null,
resolvedEmojis: Map<String, String> = emptyMap() resolvedEmojis: Map<String, String> = emptyMap()
): Int { ): Int {
val tags = mutableListOf<List<String>>() val tags = mutableListOf<List<String>>()
@@ -628,6 +667,18 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
tags.add(listOf("p", pubkey)) tags.add(listOf("p", pubkey))
} }
} }
// NIP-17 private reply: gift-wrap to the recipient's DM relays instead of publishing
// publicly. The compose UI hides the toggle in gallery/poll/schedule/quote modes, so we
// branch before those tag-building paths and emit just the reply + mentions + hashtags
// + emojis inside the encrypted rumor.
if (replyTo != null && _privateReply.value) {
for (hashtag in _hashtags.value) tags.add(listOf("t", hashtag))
tags.addAll(Nip30.buildEmojiTagsForContent(content, resolvedEmojis))
if (interfacePrefs.isClientTagEnabled()) tags.add(listOf("client", "Dark Wisp"))
return publishPrivateReply(content, replyTo, tags, signer, relayPool, powPrefs)
}
val finalContent = if (quoteTo != null) { val finalContent = if (quoteTo != null) {
val quoteHint = outboxRouter?.getRelayHint(quoteTo.pubkey) ?: "" val quoteHint = outboxRouter?.getRelayHint(quoteTo.pubkey) ?: ""
tags.addAll(Nip18.buildQuoteTags(quoteTo, quoteHint)) tags.addAll(Nip18.buildQuoteTags(quoteTo, quoteHint))
@@ -853,6 +904,61 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
return sentCount return sentCount
} }
private suspend fun publishPrivateReply(
content: String,
replyTo: NostrEvent,
replyTags: List<List<String>>,
signer: NostrSigner,
relayPool: RelayPool,
powPrefs: com.darkwisp.app.repo.PowPreferences? = null
): Int {
val dmRepoLocal = dmRepo
if (dmRepoLocal == null) {
_error.value = getApplication<Application>().getString(R.string.error_publish_failed, "DM repo unavailable")
_publishing.value = false
return 0
}
val difficulty = if (_powEnabled.value && powPrefs != null) powPrefs.getNoteDifficulty() else 0
val result = try {
PrivateReplyPublisher.send(
signer = signer,
relayPool = relayPool,
dmRepo = dmRepoLocal,
relayListRepo = relayListRepo,
eventRepo = eventRepo,
replyTo = replyTo,
content = content,
baseTags = replyTags,
targetDifficulty = difficulty
)
} catch (e: Exception) {
_error.value = getApplication<Application>().getString(R.string.error_publish_failed, e.message ?: "wrap failed")
_publishing.value = false
return 0
}
if (result.sentCount == 0) {
_error.value = getApplication<Application>().getString(R.string.error_no_relays_connected)
_publishing.value = false
return 0
}
deleteDraftOnPublish(relayPool, signer)
_content.value = TextFieldValue()
_mentions.value = emptyList()
savedStateHandle.remove<String>("draft_content")
savedStateHandle.remove<Array<String>>("draft_mentions")
_uploadedUrls.value = emptyList()
_uploadedMediaMeta.clear()
_error.value = null
_publishing.value = false
_privateReply.value = false
return result.sentCount
}
private fun saveMentionsToState() { private fun saveMentionsToState() {
savedStateHandle["draft_mentions"] = _mentions.value.map { "${it.start},${it.end},${it.pubkey}" }.toTypedArray() savedStateHandle["draft_mentions"] = _mentions.value.map { "${it.start},${it.end},${it.pubkey}" }.toTypedArray()
} }
@@ -1065,6 +1171,8 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
_explicit.value = false _explicit.value = false
_hashtags.value = emptyList() _hashtags.value = emptyList()
_powEnabled.value = false _powEnabled.value = false
_privateReply.value = false
_privateReplyLocked.value = false
_galleryMode.value = false _galleryMode.value = false
_galleryHasVideo.value = false _galleryHasVideo.value = false
_uploadedMediaMeta.clear() _uploadedMediaMeta.clear()
@@ -226,7 +226,12 @@ class DmConversationViewModel(app: Application) : AndroidViewModel(app) {
* Shares the same pending queue as DmListViewModel both screens can drive * Shares the same pending queue as DmListViewModel both screens can drive
* decryption and progress is visible from either. * decryption and progress is visible from either.
*/ */
fun decryptPending(signer: NostrSigner, muteRepo: MuteRepository? = null) { fun decryptPending(
signer: NostrSigner,
muteRepo: MuteRepository? = null,
eventRepo: com.darkwisp.app.repo.EventRepository? = null,
notifRepo: com.darkwisp.app.repo.NotificationRepository? = null
) {
val repo = dmRepo ?: return val repo = dmRepo ?: return
val myPubkey = signer.pubkeyHex val myPubkey = signer.pubkeyHex
@@ -240,6 +245,25 @@ class DmConversationViewModel(app: Application) : AndroidViewModel(app) {
try { try {
val rumor = Nip17.unwrapGiftWrapRemote(signer, wrap.event) ?: continue val rumor = Nip17.unwrapGiftWrapRemote(signer, wrap.event) ?: continue
// NIP-17 private reply (kind 1 rumor) or a reaction on one (k=1) —
// belongs in threads, not DMs
val isPrivateReplyReaction =
com.darkwisp.app.repo.PrivateRumorHandler.isPrivateReplyReaction(rumor)
if (rumor.kind == 1 || isPrivateReplyReaction) {
if (eventRepo != null && notifRepo != null) {
if (isPrivateReplyReaction) {
com.darkwisp.app.repo.PrivateRumorHandler.handlePrivateReaction(
rumor, myPubkey, eventRepo, notifRepo, muteRepo
)
} else {
com.darkwisp.app.repo.PrivateRumorHandler.handlePrivateReply(
rumor, myPubkey, eventRepo, notifRepo, muteRepo
)
}
}
continue
}
if (Nip17.isReaction(rumor)) { if (Nip17.isReaction(rumor)) {
val targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) val targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
?: continue ?: continue
@@ -533,50 +557,7 @@ class DmConversationViewModel(app: Application) : AndroidViewModel(app) {
*/ */
private suspend fun fetchRecipientDmRelays(relayPool: RelayPool, forceRefresh: Boolean = false): List<String> { private suspend fun fetchRecipientDmRelays(relayPool: RelayPool, forceRefresh: Boolean = false): List<String> {
val repo = dmRepo ?: return emptyList() val repo = dmRepo ?: return emptyList()
return com.darkwisp.app.repo.DmRelayLookup.fetch(peerPubkey, relayPool, repo, forceRefresh)
// Cache hit (skip when forcing a refresh)
if (!forceRefresh) {
repo.getCachedDmRelays(peerPubkey)?.let { return it }
}
// Send REQ for kind 10050 to indexer relays (most likely to have relay metadata)
val subId = "dm_relay_${peerPubkey.take(8)}"
val filter = Filter(
kinds = listOf(Nip51.KIND_DM_RELAYS),
authors = listOf(peerPubkey),
limit = 1
)
val reqMsg = ClientMessage.req(subId, filter)
for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) {
relayPool.sendToRelayOrEphemeral(url, reqMsg, skipBadCheck = true)
}
// Also broadcast to connected relays for additional coverage
relayPool.sendToAll(reqMsg)
// Collect all responses within 4s; pick the freshest (highest created_at)
val results = mutableListOf<RelayEvent>()
withTimeoutOrNull(4000L) {
relayPool.relayEvents
.filter { it.subscriptionId == subId }
.collect { results.add(it) }
}
// Close subscription
val closeMsg = ClientMessage.close(subId)
for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) {
relayPool.sendToRelay(url, closeMsg)
}
relayPool.sendToAll(closeMsg)
val best = results.maxByOrNull { it.event.created_at }
if (best != null) {
val urls = Nip51.parseRelaySet(best.event)
if (urls.isNotEmpty()) {
repo.cacheDmRelays(peerPubkey, urls)
return urls
}
}
return emptyList()
} }
/** /**
@@ -12,7 +12,10 @@ import com.darkwisp.app.nostr.NostrEvent
import com.darkwisp.app.nostr.NostrSigner import com.darkwisp.app.nostr.NostrSigner
import com.darkwisp.app.nostr.toHex import com.darkwisp.app.nostr.toHex
import com.darkwisp.app.repo.DmRepository import com.darkwisp.app.repo.DmRepository
import com.darkwisp.app.repo.EventRepository
import com.darkwisp.app.repo.KeyRepository import com.darkwisp.app.repo.KeyRepository
import com.darkwisp.app.repo.NotificationRepository
import com.darkwisp.app.repo.PrivateRumorHandler
import com.darkwisp.app.repo.SigningMode import com.darkwisp.app.repo.SigningMode
import com.darkwisp.app.repo.MuteRepository import com.darkwisp.app.repo.MuteRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -31,10 +34,34 @@ class DmListViewModel(app: Application) : AndroidViewModel(app) {
private var dmRepo: DmRepository? = null private var dmRepo: DmRepository? = null
private var muteRepo: MuteRepository? = null private var muteRepo: MuteRepository? = null
private var eventRepo: EventRepository? = null
private var notifRepo: NotificationRepository? = null
fun init(dmRepository: DmRepository, muteRepository: MuteRepository? = null) { fun init(
dmRepository: DmRepository,
muteRepository: MuteRepository? = null,
eventRepository: EventRepository? = null,
notificationRepository: NotificationRepository? = null
) {
dmRepo = dmRepository dmRepo = dmRepository
muteRepo = muteRepository muteRepo = muteRepository
eventRepo = eventRepository
notifRepo = notificationRepository
}
/** Route a non-DM rumor (kind 1 private reply, or a kind 7 reaction on one) out of the
* DM pipeline. Returns true when the rumor was consumed. */
private fun routePrivateRumor(rumor: Nip17.Rumor, myPubkey: String): Boolean {
val isPrivateReplyReaction = PrivateRumorHandler.isPrivateReplyReaction(rumor)
if (rumor.kind != 1 && !isPrivateReplyReaction) return false
val eRepo = eventRepo ?: return true // can't materialise without repos — drop, don't misfile as DM
val nRepo = notifRepo ?: return true
if (isPrivateReplyReaction) {
PrivateRumorHandler.handlePrivateReaction(rumor, myPubkey, eRepo, nRepo, muteRepo)
} else {
PrivateRumorHandler.handlePrivateReply(rumor, myPubkey, eRepo, nRepo, muteRepo)
}
return true
} }
fun markDmsRead() { fun markDmsRead() {
@@ -57,6 +84,9 @@ class DmListViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
val rumor = Nip17.unwrapGiftWrap(keypair.privkey, event) ?: return@launch val rumor = Nip17.unwrapGiftWrap(keypair.privkey, event) ?: return@launch
// NIP-17 private reply (kind 1 rumor) or a reaction on one (k=1) — belongs in threads, not DMs
if (routePrivateRumor(rumor, myPubkey)) return@launch
// Private DM reaction — associate with the target message // Private DM reaction — associate with the target message
if (Nip17.isReaction(rumor)) { if (Nip17.isReaction(rumor)) {
val targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) val targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
@@ -146,6 +176,9 @@ class DmListViewModel(app: Application) : AndroidViewModel(app) {
try { try {
val rumor = Nip17.unwrapGiftWrapRemote(signer, wrap.event) ?: continue val rumor = Nip17.unwrapGiftWrapRemote(signer, wrap.event) ?: continue
// NIP-17 private reply (kind 1 rumor) or a reaction on one (k=1) — belongs in threads, not DMs
if (routePrivateRumor(rumor, myPubkey)) continue
if (Nip17.isReaction(rumor)) { if (Nip17.isReaction(rumor)) {
val targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) val targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
?: continue ?: continue
@@ -134,7 +134,7 @@ class EventRouter(
if (convKey != null && msgId != null) { if (convKey != null && msgId != null) {
val sats = Nip57.getZapAmountSats(event) val sats = Nip57.getZapAmountSats(event)
if (sats > 0) { if (sats > 0) {
val zapperPubkey = Nip57.getZapperPubkey(event) ?: event.pubkey val zapperPubkey = eventRepo.resolveZapSender(event).first ?: event.pubkey
dmRepo.addZap(convKey, msgId, DmZap(zapperPubkey, sats, event.created_at)) dmRepo.addZap(convKey, msgId, DmZap(zapperPubkey, sats, event.created_at))
} }
} }
@@ -154,7 +154,7 @@ class EventRouter(
metadataFetcher.addToPendingProfiles(event.pubkey) metadataFetcher.addToPendingProfiles(event.pubkey)
} }
if (event.kind == 9735) { if (event.kind == 9735) {
val zapperPubkey = Nip57.getZapperPubkey(event) val zapperPubkey = eventRepo.resolveZapSender(event).first
if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) { if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) {
metadataFetcher.addToPendingProfiles(zapperPubkey) metadataFetcher.addToPendingProfiles(zapperPubkey)
} }
@@ -220,7 +220,7 @@ class EventRouter(
if (event.kind == 9735) { if (event.kind == 9735) {
eventRepo.addEvent(event) eventRepo.addEvent(event)
eventRepo.addEventRelay(event.id, relayUrl) eventRepo.addEventRelay(event.id, relayUrl)
val zapperPubkey = Nip57.getZapperPubkey(event) val zapperPubkey = eventRepo.resolveZapSender(event).first
if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) { if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) {
metadataFetcher.addToPendingProfiles(zapperPubkey) metadataFetcher.addToPendingProfiles(zapperPubkey)
} }
@@ -253,7 +253,7 @@ class EventRouter(
9735 -> { 9735 -> {
eventRepo.addEvent(event) eventRepo.addEvent(event)
eventRepo.addEventRelay(event.id, relayUrl) eventRepo.addEventRelay(event.id, relayUrl)
val zapperPubkey = Nip57.getZapperPubkey(event) val zapperPubkey = eventRepo.resolveZapSender(event).first
if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) { if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) {
metadataFetcher.addToPendingProfiles(zapperPubkey) metadataFetcher.addToPendingProfiles(zapperPubkey)
} }
@@ -389,7 +389,6 @@ class EventRouter(
val urls = Nip51.parseRelaySet(event) val urls = Nip51.parseRelaySet(event)
keyRepo.saveDmRelays(urls) keyRepo.saveDmRelays(urls)
relayPool.updateDmRelays(urls) relayPool.updateDmRelays(urls)
eventRepo.dmRelayUrls = urls.toSet()
} }
} }
if (event.kind == Nip51.KIND_SEARCH_RELAYS) { if (event.kind == Nip51.KIND_SEARCH_RELAYS) {
@@ -609,8 +608,17 @@ class EventRouter(
null null
} ?: return } ?: 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)) { 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 targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) ?: return
val participants = Nip17.getConversationParticipants(rumor, myPubkey) val participants = Nip17.getConversationParticipants(rumor, myPubkey)
if (participants.any { muteRepo.isBlocked(it) }) return if (participants.any { muteRepo.isBlocked(it) }) return
@@ -623,6 +631,13 @@ class EventRouter(
return return
} }
// NIP-17 private reply — kind 1 rumor with NIP-10 reply tags.
// Surface as a normal reply in threads + notifications, flagged as private.
if (rumor.kind == 1) {
handlePrivateReply(event, rumor, myPubkey)
return
}
val participants = Nip17.getConversationParticipants(rumor, myPubkey) val participants = Nip17.getConversationParticipants(rumor, myPubkey)
if (participants.any { muteRepo.isBlocked(it) }) return if (participants.any { muteRepo.isBlocked(it) }) return
@@ -650,4 +665,26 @@ class EventRouter(
) )
dmRepo.addMessage(msg, convKey) dmRepo.addMessage(msg, convKey)
} }
private fun handlePrivateReplyReaction(rumor: Nip17.Rumor, myPubkey: String) {
com.darkwisp.app.repo.PrivateRumorHandler.handlePrivateReaction(
rumor = rumor,
myPubkey = myPubkey,
eventRepo = eventRepo,
notifRepo = notifRepo,
muteRepo = muteRepo,
onMissingProfile = { metadataFetcher.addToPendingProfiles(it) }
)
}
private fun handlePrivateReply(wrap: NostrEvent, rumor: Nip17.Rumor, myPubkey: String) {
com.darkwisp.app.repo.PrivateRumorHandler.handlePrivateReply(
rumor = rumor,
myPubkey = myPubkey,
eventRepo = eventRepo,
notifRepo = notifRepo,
muteRepo = muteRepo,
onMissingProfile = { metadataFetcher.addToPendingProfiles(it) }
)
}
} }
@@ -135,6 +135,11 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
val keyRepo = KeyRepository(app) val keyRepo = KeyRepository(app)
private val pubkeyHex: String? = keyRepo.getPubkeyHex() private val pubkeyHex: String? = keyRepo.getPubkeyHex()
/** True when the account has a local private key (i.e. not a remote/NIP-07 signer).
* DIP-03 private zaps require local signing for both ephemeral derivation and
* ECDH decryption, so this gates the UI toggle. */
val hasLocalKeypair: Boolean = keyRepo.getKeypair() != null
var signer: NostrSigner? = null var signer: NostrSigner? = null
private set private set
@@ -181,6 +186,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
it.currentUserPubkey = pubkeyHex it.currentUserPubkey = pubkeyHex
it.deletedEventsRepo = deletedEventsRepo it.deletedEventsRepo = deletedEventsRepo
it.eventPersistence = eventPersistence it.eventPersistence = eventPersistence
it.keyRepo = keyRepo
} }
val contactRepo = ContactRepository(app, pubkeyHex).also { val contactRepo = ContactRepository(app, pubkeyHex).also {
eventRepo.contactRepo = it eventRepo.contactRepo = it
@@ -299,7 +305,8 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
val socialActions: SocialActionManager = SocialActionManager( val socialActions: SocialActionManager = SocialActionManager(
relayPool, outboxRouter, eventRepo, contactRepo, muteRepo, notifRepo, dmRepo, 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 }, getSigner = { signer },
getUserPubkey = { getUserPubkey() } getUserPubkey = { getUserPubkey() }
) )
@@ -21,6 +21,8 @@ import com.darkwisp.app.repo.DmRepository
import com.darkwisp.app.repo.EventRepository import com.darkwisp.app.repo.EventRepository
import com.darkwisp.app.repo.MuteRepository import com.darkwisp.app.repo.MuteRepository
import com.darkwisp.app.repo.NotificationRepository import com.darkwisp.app.repo.NotificationRepository
import com.darkwisp.app.repo.PrivateReactionPublisher
import com.darkwisp.app.repo.RelayListRepository
import com.darkwisp.app.repo.WalletProvider import com.darkwisp.app.repo.WalletProvider
import com.darkwisp.app.repo.PinRepository import com.darkwisp.app.repo.PinRepository
import com.darkwisp.app.repo.CustomEmojiRepository import com.darkwisp.app.repo.CustomEmojiRepository
@@ -59,6 +61,7 @@ class SocialActionManager(
private val zapSender: ZapSender, private val zapSender: ZapSender,
private val powPrefs: PowPreferences, private val powPrefs: PowPreferences,
private val interfacePrefs: InterfacePreferences, private val interfacePrefs: InterfacePreferences,
private val relayListRepo: RelayListRepository,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val getSigner: () -> NostrSigner?, private val getSigner: () -> NostrSigner?,
private val getUserPubkey: () -> String? private val getUserPubkey: () -> String?
@@ -238,6 +241,38 @@ class SocialActionManager(
fun toggleReaction(event: NostrEvent, emoji: String) { fun toggleReaction(event: NostrEvent, emoji: String) {
val s = getSigner() ?: return val s = getSigner() ?: return
val myPubkey = s.pubkeyHex 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) val existingEventId = eventRepo.getUserReactionEventId(event.id, myPubkey, emoji)
scope.launch { scope.launch {
@@ -352,6 +387,12 @@ class SocialActionManager(
* "a" tag instead of "e" so the receipt is associated with the addressable event. * "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) { 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 recipientPubkey = recipientOverride ?: event.pubkey
val profileData = eventRepo.getProfileData(recipientPubkey) val profileData = eventRepo.getProfileData(recipientPubkey)
val lud16 = profileData?.lud16 val lud16 = profileData?.lud16
@@ -381,7 +422,10 @@ class SocialActionManager(
} else { } else {
subscribeZapReceipt(event.id) subscribeZapReceipt(event.id)
} }
// For private zaps, also subscribe on DM relays for the receipt // For private zaps, also subscribe on our DM relays — that's where
// the LNURL will publish the receipt (per ZapSender's `relays` tag).
// Subscriptions over DM relays go through NIP-42 AUTH automatically
// via the relay's existing auth handshake.
if (isPrivate && relayPool.hasDmRelays()) { if (isPrivate && relayPool.hasDmRelays()) {
val dmFilter = if (eventATag != null) { val dmFilter = if (eventATag != null) {
Filter(kinds = listOf(9735), aTags = listOf(eventATag)) Filter(kinds = listOf(9735), aTags = listOf(eventATag))
@@ -418,7 +462,8 @@ class SocialActionManager(
isAnonymous = isAnonymous, isAnonymous = isAnonymous,
isPrivate = isPrivate, isPrivate = isPrivate,
extraTags = zapExtraTags, extraTags = zapExtraTags,
extraRelayHints = extraRelayHints extraRelayHints = extraRelayHints,
eventCreatedAt = event.created_at
) )
_zapInProgress.value = _zapInProgress.value - event.id _zapInProgress.value = _zapInProgress.value - event.id
result.fold( result.fold(
@@ -225,7 +225,6 @@ class StartupCoordinator(
relayPool.updateRelays(initialRelays) relayPool.updateRelays(initialRelays)
val dmRelays = keyRepo.getDmRelays() val dmRelays = keyRepo.getDmRelays()
relayPool.updateDmRelays(dmRelays) relayPool.updateDmRelays(dmRelays)
eventRepo.dmRelayUrls = dmRelays.toSet()
// Connect local relay if configured // Connect local relay if configured
relayPool.updateLocalRelay(keyRepo.getLocalRelay(), getUserPubkey()) relayPool.updateLocalRelay(keyRepo.getLocalRelay(), getUserPubkey())
@@ -987,7 +986,6 @@ class StartupCoordinator(
relayPool.updateRelays(relays) relayPool.updateRelays(relays)
val dmRelays = keyRepo.getDmRelays() val dmRelays = keyRepo.getDmRelays()
relayPool.updateDmRelays(dmRelays) relayPool.updateDmRelays(dmRelays)
eventRepo.dmRelayUrls = dmRelays.toSet()
feedSub.subscribeFeed() feedSub.subscribeFeed()
} }
} }
@@ -229,7 +229,7 @@ class ThreadViewModel : ViewModel() {
5, 7, 6, 1018 -> eventRepo.addEvent(event) 5, 7, 6, 1018 -> eventRepo.addEvent(event)
9735 -> { 9735 -> {
eventRepo.addEvent(event) eventRepo.addEvent(event)
val zapperPubkey = com.darkwisp.app.nostr.Nip57.getZapperPubkey(event) val zapperPubkey = eventRepo.resolveZapSender(event).first
if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) { if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) {
metadataFetcher.addToPendingProfiles(zapperPubkey) metadataFetcher.addToPendingProfiles(zapperPubkey)
} }
+2
View File
@@ -445,6 +445,8 @@
<string name="zap_wallet_not_connected">Wallet Not Connected</string> <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_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_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_x_sats">Zap %d sats</string>
<string name="zap_failed">Zap Failed</string> <string name="zap_failed">Zap Failed</string>
<string name="lightning_invoice_pay_now">Pay Now</string> <string name="lightning_invoice_pay_now">Pay Now</string>