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
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
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
@@ -1104,7 +1112,7 @@ fun WispNavHost(
zapSuccess = feedViewModel.zapSuccess,
zapError = feedViewModel.zapError,
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() },
ownLists = feedViewModel.listRepo.ownLists.collectAsState().value,
onAddToList = { dTag, pk -> feedViewModel.addToList(dTag, pk) },
@@ -1214,7 +1222,7 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = userHasDmRelays && recipientHasDmRelays
canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays
)
}
SearchScreen(
@@ -1351,7 +1359,7 @@ fun WispNavHost(
powPreferences = feedViewModel.powPrefs,
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 userProfile = userPubkey?.let { feedViewModel.eventRepo.getProfileData(it) }
@@ -1434,7 +1442,7 @@ fun WispNavHost(
myPubkeyHex = userPubkey,
participantPubkeys = participantList
)
activeSigner?.let { dmConvoViewModel.decryptPending(it, feedViewModel.muteRepo) }
activeSigner?.let { dmConvoViewModel.decryptPending(it, feedViewModel.muteRepo, feedViewModel.eventRepo, feedViewModel.notifRepo) }
for (pubkey in participantList) {
feedViewModel.metadataFetcher.queueProfileFetch(pubkey)
}
@@ -1582,7 +1590,7 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.relayPool.hasDmRelays() && recipientHasDmRelays,
canPrivateZap = feedViewModel.hasLocalKeypair && feedViewModel.relayPool.hasDmRelays() && recipientHasDmRelays,
initialSatsHint = groupRoomZapInitialSats
)
}
@@ -1859,7 +1867,8 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
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()
@@ -1951,6 +1960,11 @@ fun WispNavHost(
isEmojiSetAdded = { pubkey, dTag ->
val ref = com.darkwisp.app.nostr.Nip30.buildSetReference(pubkey, dTag)
feedViewModel.customEmojiRepo.userEmojiList.value?.setReferences?.contains(ref) ?: false
},
canPrivateZapFor = { event ->
feedViewModel.hasLocalKeypair &&
feedViewModel.relayPool.hasDmRelays() &&
feedViewModel.relayListRepo.hasDmRelays(event.pubkey)
}
)
@@ -2023,7 +2037,7 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
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)
},
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)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = articleUserHasDmRelays && articleRecipientHasDmRelays
canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays
)
}
@@ -2542,7 +2556,10 @@ fun WispNavHost(
eventATag = aTag)
},
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) {
@@ -3099,7 +3116,8 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
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) +
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()) {
feedViewModel.powManager.submitNote(
signer = signer,
@@ -24,11 +24,7 @@ object Nip04 {
* Returns: base64(ciphertext) + "?iv=" + base64(iv)
*/
fun encrypt(plaintext: String, sharedSecret: ByteArray): String {
val iv = ByteArray(16).also { random.nextBytes(it) }
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 (ciphertext, iv) = encryptRaw(plaintext, sharedSecret)
val ctB64 = Base64.encodeToString(ciphertext, Base64.NO_WRAP)
val ivB64 = Base64.encodeToString(iv, Base64.NO_WRAP)
return "$ctB64?iv=$ivB64"
@@ -42,8 +38,24 @@ object Nip04 {
require(parts.size == 2) { "Invalid NIP-04 content format" }
val ciphertext = Base64.decode(parts[0], 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 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))
return String(cipher.doFinal(ciphertext), Charsets.UTF_8)
}
@@ -140,7 +140,7 @@ object Nip17 {
// Parse rumor
val rumorObj = json.parseToJsonElement(rumorJson).jsonObject
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 ->
tagArr.jsonArray.map { it.jsonPrimitive.content }
@@ -269,7 +269,7 @@ object Nip17 {
// Parse rumor
val rumorObj = json.parseToJsonElement(rumorJson).jsonObject
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 ->
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.
*
* The inner rumor carries the emoji as content and an e-tag pointing to
* [targetRumorId] so recipients can associate the reaction with the right message.
* [targetRumorId] so recipients can associate it with the right message.
* [targetKind] (the kind of the message being reacted to — 14 for DMs, 1 for
* a NIP-17 private reply) becomes the "k" tag, letting receivers route the
* reaction to the right repository (DM conversation vs. note thread).
*/
suspend fun createDmReaction(
suspend fun createGiftWrappedReaction(
senderPrivkey: ByteArray,
senderPubkey: ByteArray,
recipientPubkey: ByteArray,
targetRumorId: String,
originalSenderPubkey: String,
targetAuthor: String,
targetKind: Int,
emoji: String,
emojiUrl: String? = null
emojiUrl: String? = null,
createdAt: Long = System.currentTimeMillis() / 1000
): NostrEvent {
val tags = mutableListOf(
listOf("e", targetRumorId),
listOf("p", originalSenderPubkey),
listOf("k", "14")
listOf("p", targetAuthor),
listOf("k", targetKind.toString())
)
if (emojiUrl != null) {
tags.add(listOf("emoji", emoji.removeSurrounding(":"), emojiUrl))
@@ -377,23 +383,26 @@ object Nip17 {
recipientPubkey = recipientPubkey,
message = emoji,
rumorKind = 7,
replyTags = tags
replyTags = tags,
createdAt = createdAt
)
}
/** Remote-signer variant of [createDmReaction]. */
suspend fun createDmReactionRemote(
/** Remote-signer variant of [createGiftWrappedReaction]. */
suspend fun createGiftWrappedReactionRemote(
signer: NostrSigner,
recipientPubkeyHex: String,
targetRumorId: String,
originalSenderPubkey: String,
targetAuthor: String,
targetKind: Int,
emoji: String,
emojiUrl: String? = null
emojiUrl: String? = null,
createdAt: Long = System.currentTimeMillis() / 1000
): NostrEvent {
val tags = mutableListOf(
listOf("e", targetRumorId),
listOf("p", originalSenderPubkey),
listOf("k", "14")
listOf("p", targetAuthor),
listOf("k", targetKind.toString())
)
if (emojiUrl != null) {
tags.add(listOf("emoji", emoji.removeSurrounding(":"), emojiUrl))
@@ -403,10 +412,49 @@ object Nip17 {
recipientPubkeyHex = recipientPubkeyHex,
message = emoji,
rumorKind = 7,
replyTags = tags
replyTags = tags,
createdAt = createdAt
)
}
/** DM-targeted reaction. Thin wrapper around [createGiftWrappedReaction] with `k=14`. */
suspend fun createDmReaction(
senderPrivkey: ByteArray,
senderPubkey: ByteArray,
recipientPubkey: ByteArray,
targetRumorId: String,
originalSenderPubkey: String,
emoji: String,
emojiUrl: String? = null
): NostrEvent = createGiftWrappedReaction(
senderPrivkey = senderPrivkey,
senderPubkey = senderPubkey,
recipientPubkey = recipientPubkey,
targetRumorId = targetRumorId,
targetAuthor = originalSenderPubkey,
targetKind = 14,
emoji = emoji,
emojiUrl = emojiUrl
)
/** Remote-signer variant of [createDmReaction]. */
suspend fun createDmReactionRemote(
signer: NostrSigner,
recipientPubkeyHex: String,
targetRumorId: String,
originalSenderPubkey: String,
emoji: String,
emojiUrl: String? = null
): NostrEvent = createGiftWrappedReactionRemote(
signer = signer,
recipientPubkeyHex = recipientPubkeyHex,
targetRumorId = targetRumorId,
targetAuthor = originalSenderPubkey,
targetKind = 14,
emoji = emoji,
emojiUrl = emojiUrl
)
private fun randomizeTimestamp(base: Long): Long {
// 0 to 1 day in the past — NIP-17 spec allows up to 2 days, but keeping it to 1 day
// ensures interop with clients (e.g. Amethyst) whose kind-1059 subscription may use
@@ -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 checksum = bech32Checksum(hrp, values)
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 pos = lower.lastIndexOf('1')
require(pos >= 1) { "Invalid bech32 string" }
@@ -10,11 +10,19 @@ import kotlinx.serialization.json.long
import okhttp3.OkHttpClient
import okhttp3.Request
import java.net.URLEncoder
import java.security.MessageDigest
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 json = Json { ignoreUnknownKeys = true }
data class DecryptedPrivateZap(val senderPubkey: String, val message: String)
fun getZappedEventId(event: NostrEvent): String? {
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)
}
/**
* 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(
callbackUrl: String,
amountMsats: Long,
@@ -13,6 +13,8 @@ data class FlatNotificationItem(
val zapSats: Long = 0,
val zapMessage: String = "",
val isPrivateZap: Boolean = false,
val isPrivateReply: Boolean = false,
val isPrivateReaction: Boolean = false,
val replyEventId: String? = null,
val quoteEventId: String? = null,
val voteOptionIds: List<String> = emptyList(),
@@ -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 safetyPrefs: SafetyPreferences? = null
var extendedNetworkRepo: ExtendedNetworkRepository? = null
/** Set of current user's DM relay URLs — used to detect private zaps. */
var dmRelayUrls: Set<String> = emptySet()
/** Late-bound for DIP-03 private zap decryption (recipient + self-attribution). */
var keyRepo: KeyRepository? = null
private val eventCache = ConcurrentHashMap<String, NostrEvent>()
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
* observe this to prune their local state when a deletion arrives on any subscription. */
private val _removedEvents = MutableSharedFlow<String>(extraBufferCapacity = 64)
@@ -449,7 +457,7 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo:
7 -> addReaction(event)
30315 -> processUserStatus(event)
9735 -> {
val zapperPk = Nip57.getZapperPubkey(event)
val (zapperPk, zapMessage) = resolveZapSender(event)
if (zapperPk != null && isWotFiltered(zapperPk, 9735)) return
val targetId = Nip57.getZappedEventId(event)
?: resolveAddressableTarget(event)
@@ -463,31 +471,27 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo:
synchronized(dedupSet) { if (!dedupSet.add(event.id)) return }
val sats = Nip57.getZapAmountSats(event)
if (sats > 0) {
val zapperPubkey = Nip57.getZapperPubkey(event)
// 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) {
addZapSats(targetId, sats)
if (zapperPubkey != null) {
val zapMessage = Nip57.getZapMessage(event)
val isPrivateZap = dmRelayUrls.isNotEmpty() && Nip57.getZapRequestRelays(event).let { reqRelays ->
reqRelays.isNotEmpty() && reqRelays.all { it in dmRelayUrls }
}
if (zapperPk != null) {
val isPrivateZap = Nip57.isPrivateZap(event)
val zaps = zapDetails.get(targetId)
?: java.util.Collections.synchronizedList(mutableListOf<ZapDetail>()).also {
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
if (zapperPubkey == currentUserPubkey) {
if (zapperPk == currentUserPubkey) {
userZaps.put(targetId, true)
}
// Check if this zap is a zap poll vote (kind 6969)
val targetEvent = eventCache[targetId]
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
}
/**
* 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
* 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 decoded = Bolt11.decode(bolt11)
val hash = decoded?.paymentHash ?: continue
// Sender from embedded kind 9734 zap request
val sender = Nip57.getZapperPubkey(event)
// Sender from embedded kind 9734 zap request — for DIP-03 private
// 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
// Recipient from 'p' tag of the receipt
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()
eventCache.clear()
seenEventIds.clear()
privateEventIds.clear()
}
fun clearAll() {
@@ -181,14 +181,14 @@ class NotificationRepository(
// For zap receipts, event.pubkey is the lightning service — check the actual zapper too.
if (muteRepo?.isBlocked(event.pubkey) == true) return
if (event.kind == 9735) {
val zapperPubkey = Nip57.getZapperPubkey(event)
val zapperPubkey = eventRepo?.resolveZapSender(event)?.first
if (zapperPubkey != null && muteRepo?.isBlocked(zapperPubkey) == true) return
}
if (safetyPrefs?.wotFilterEnabled?.value == true) {
val netRepo = extendedNetworkRepo
if (netRepo != null && netRepo.isNetworkReady()) {
val pubkeyToCheck = if (event.kind == 9735) {
Nip57.getZapperPubkey(event) ?: event.pubkey
eventRepo?.resolveZapSender(event)?.first ?: event.pubkey
} else event.pubkey
if (!netRepo.isInQualifiedNetwork(pubkeyToCheck)) return
}
@@ -582,6 +582,7 @@ class NotificationRepository(
timestamp = event.created_at,
emoji = emoji,
emojiUrl = flatEmojiUrl,
isPrivateReaction = eventRepo?.isPrivate(event.id) == true,
groupChatId = groupChatId
))
}
@@ -592,7 +593,8 @@ class NotificationRepository(
private fun mergeZap(event: NostrEvent): Boolean {
val amount = Nip57.getZapAmountSats(event)
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" }
?: return mergeProfileZap(event, zapperPubkey, amount)
val referencedId = zapETag[1]
@@ -602,11 +604,7 @@ class NotificationRepository(
// the same 9735 event to be re-processed on periodic refresh cycles.
val zapEventIds = zapEventIdsByGroup.getOrPut(key) { mutableSetOf() }
if (!zapEventIds.add(event.id)) return false
val message = Nip57.getZapMessage(event)
val dmRelays = eventRepo?.dmRelayUrls ?: emptySet()
val isPrivate = dmRelays.isNotEmpty() && Nip57.getZapRequestRelays(event).let { reqRelays ->
reqRelays.isNotEmpty() && reqRelays.all { it in dmRelays }
}
val isPrivate = Nip57.isPrivateZap(event)
val entry = ZapEntry(pubkey = zapperPubkey, sats = amount, message = message, createdAt = event.created_at, receiptEventId = event.id, isPrivate = isPrivate)
val emoji = NotificationGroup.ZAP_EMOJI
val existing = groupMap[key] as? NotificationGroup.ReactionGroup
@@ -663,7 +661,7 @@ class NotificationRepository(
}
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 flatZapId = "profilezap:${event.id}"
if (flatItemIds.add(flatZapId)) {
@@ -738,6 +736,7 @@ class NotificationRepository(
referencedEventId = replyTarget,
timestamp = event.created_at,
replyEventId = event.id,
isPrivateReply = eventRepo?.isPrivate(event.id) == true,
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,
isPrivate: Boolean = false,
extraTags: List<List<String>> = emptyList(),
extraRelayHints: List<String> = emptyList()
extraRelayHints: List<String> = emptyList(),
eventCreatedAt: Long? = null
): Result<Unit> {
// 1. LNURL discovery
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)"))
}
// 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) {
// Private zap: route receipt through DM relays only
val recipientDmRelays = relayListRepo.getDmRelays(recipientPubkey) ?: emptyList()
val ourDmRelays = relayPool.getDmRelayUrls()
val combined = (recipientDmRelays + ourDmRelays).distinct().take(5)
val combined = (ourDmRelays + recipientDmRelays).distinct().take(5)
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
} 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 ourRelays = relayPool.getReadRelayUrls()
(extraRelayHints + recipientRelays + ourRelays).distinct().take(5)
@@ -118,26 +121,12 @@ class ZapSender(
addAll(extraTags)
}
val zapRequest = if (isAnonymous) {
val throwaway = Keys.generate()
Nip57.buildZapRequest(
senderPrivkey = throwaway.privkey,
senderPubkey = throwaway.pubkey,
recipientPubkey = recipientPubkey,
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,
val zapRequest = when {
isAnonymous -> {
val throwaway = Keys.generate()
Nip57.buildZapRequest(
senderPrivkey = throwaway.privkey,
senderPubkey = throwaway.pubkey,
recipientPubkey = recipientPubkey,
eventId = eventId,
amountMsats = amountMsats,
@@ -146,18 +135,57 @@ class ZapSender(
message = message,
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,
senderPubkey = keypair.pubkey,
recipientPubkey = recipientPubkey,
eventId = eventId,
eventCreatedAt = eventCreatedAt,
amountMsats = amountMsats,
relayUrls = relayUrls,
lnurl = recipientLud16,
message = message,
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(),
unicodeEmojis: List<String> = emptyList(),
onOpenEmojiLibrary: (() -> Unit)? = null,
isPrivate: Boolean = false,
zapEnabled: Boolean = true,
onZapDisabledTap: () -> Unit = {},
modifier: Modifier = Modifier
) {
val context = androidx.compose.ui.platform.LocalContext.current
@@ -107,6 +110,9 @@ fun ActionBar(
color = MaterialTheme.colorScheme.onSurfaceVariant,
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))
Box {
Box(
@@ -160,60 +166,71 @@ fun ActionBar(
color = if (userReactionEmojis.isNotEmpty()) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
Spacer(Modifier.width(8.dp))
Box {
IconButton(onClick = { showRepostMenu = true }) {
Icon(
Icons.Outlined.Repeat,
contentDescription = stringResource(R.string.cd_repost),
tint = if (hasUserReposted) WispThemeColors.repostColor else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp)
)
}
if (showRepostMenu) {
RepostPopup(
onRepost = {
onRepost()
showRepostMenu = false
},
onQuote = {
onQuote()
showRepostMenu = false
},
onDismiss = { showRepostMenu = false }
)
if (!isPrivate) {
Spacer(Modifier.width(8.dp))
Box {
IconButton(onClick = { showRepostMenu = true }) {
Icon(
Icons.Outlined.Repeat,
contentDescription = stringResource(R.string.cd_repost),
tint = if (hasUserReposted) WispThemeColors.repostColor else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp)
)
}
if (showRepostMenu) {
RepostPopup(
onRepost = {
onRepost()
showRepostMenu = false
},
onQuote = {
onQuote()
showRepostMenu = false
},
onDismiss = { showRepostMenu = false }
)
}
}
Text(
text = repostCount.toString(),
style = MaterialTheme.typography.labelSmall,
color = if (hasUserReposted) WispThemeColors.repostColor else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
Text(
text = repostCount.toString(),
style = MaterialTheme.typography.labelSmall,
color = if (hasUserReposted) WispThemeColors.repostColor else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
Spacer(Modifier.width(8.dp))
Box {
IconButton(onClick = onZap, enabled = !isZapInProgress) {
val zapClickable = !isZapInProgress
IconButton(
onClick = { if (zapEnabled) onZap() else onZapDisabledTap() },
enabled = zapClickable
) {
val zapTint = when {
!zapEnabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
hasUserZapped -> WispThemeColors.zapColor
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
if (isZapInProgress) {
LightningAnimation(modifier = Modifier.size(width = 14.dp, height = 22.dp))
} else if (fiatMode) {
Icon(
painter = painterResource(R.drawable.ic_coin_stack),
contentDescription = stringResource(R.string.cd_zaps),
tint = if (hasUserZapped) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant,
tint = zapTint,
modifier = Modifier.size(22.dp)
)
} else if (useZapBoltIcon) {
Icon(
painter = painterResource(R.drawable.ic_bolt),
contentDescription = stringResource(R.string.cd_zaps),
tint = if (hasUserZapped) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant,
tint = zapTint,
modifier = Modifier.size(18.dp)
)
} else {
Icon(
Icons.Outlined.CurrencyBitcoin,
contentDescription = stringResource(R.string.cd_zaps),
tint = if (hasUserZapped) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant,
tint = zapTint,
modifier = Modifier.size(22.dp)
)
}
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.outlined.Repeat
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -122,6 +123,8 @@ fun PostCard(
hasUserReposted: Boolean = false,
repostCount: Int = 0,
onZap: () -> Unit = {},
onZapDisabledTap: () -> Unit = {},
zapEnabled: Boolean = true,
hasUserZapped: Boolean = false,
likeCount: Int = 0,
replyCount: Int = 0,
@@ -140,6 +143,7 @@ fun PostCard(
onBlockAuthor: () -> Unit = {},
isFollowingAuthor: Boolean = false,
isOwnEvent: Boolean = false,
isPrivate: Boolean = false,
nip05Repo: Nip05Repository? = null,
onAddToList: () -> Unit = {},
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 = timestamp,
style = MaterialTheme.typography.labelSmall,
@@ -789,6 +802,9 @@ fun PostCard(
resolvedEmojis = resolvedEmojis,
unicodeEmojis = unicodeEmojis,
onOpenEmojiLibrary = onOpenEmojiLibrary,
isPrivate = isPrivate,
zapEnabled = zapEnabled,
onZapDisabledTap = onZapDisabledTap,
modifier = Modifier.weight(1f)
)
Icon(
@@ -37,6 +37,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material.icons.automirrored.outlined.Message
import androidx.compose.material3.AlertDialog
@@ -107,6 +108,12 @@ fun ZapDialog(
onZap: (amountMsats: Long, message: String, isAnonymous: Boolean, isPrivate: Boolean) -> Unit,
onGoToWallet: () -> Unit,
canPrivateZap: Boolean = false,
/**
* Lock the zap to DIP-03 private mode (private + anon toggles hidden, isPrivate held true).
* Used when zapping a NIP-17 private reply falling back to a public zap would attach an
* e-tag pointing at the rumor id on public relays.
*/
forcePrivate: Boolean = false,
/** When opening from a quick preset (e.g. chat actions sheet), pre-select that amount in sats. */
initialSatsHint: Int? = null
) {
@@ -140,7 +147,7 @@ fun ZapDialog(
var customAmount by remember { mutableStateOf("") }
var message by remember { mutableStateOf("") }
var isAnonymous by remember { mutableStateOf(false) }
var isPrivate by remember { mutableStateOf(false) }
var isPrivate by remember(forcePrivate) { mutableStateOf(forcePrivate) }
var editMode by remember { mutableStateOf(false) }
LaunchedEffect(initialSatsHint) {
@@ -404,6 +411,28 @@ fun ZapDialog(
Spacer(Modifier.height(16.dp))
if (forcePrivate) {
// Parent is a private reply — zap is locked to DIP-03 mode and the
// anon/private toggles are hidden. A small label keeps the user
// oriented; the lock icon mirrors the orange lock used elsewhere.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.VisibilityOff,
contentDescription = null,
tint = LightningOrange,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(R.string.zap_private_locked_for_private_reply),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
)
}
} else {
// Anonymous toggle
Row(
modifier = Modifier.fillMaxWidth(),
@@ -468,6 +497,7 @@ fun ZapDialog(
)
)
}
} // end !forcePrivate
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.KeyboardArrowUp
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.PhotoLibrary
import androidx.compose.material.icons.outlined.Schedule
@@ -180,6 +181,12 @@ fun ComposeScreen(
val zapPollConsensus by viewModel.zapPollConsensus.collectAsState()
val scheduleEnabled by viewModel.scheduleEnabled.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 isMiningBusy = powStatus is PowStatus.Mining
val context = LocalContext.current
@@ -332,7 +339,11 @@ fun ComposeScreen(
modifier = Modifier.fillMaxWidth(),
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(
Icons.Outlined.Warning,
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(
Icons.Outlined.Shield,
contentDescription = "Proof of Work",
@@ -726,7 +744,11 @@ fun ComposeScreen(
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(
Icons.Outlined.Warning,
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(
Icons.Outlined.Shield,
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 = {
if (scheduleEnabled) {
viewModel.toggleSchedule()
@@ -1168,6 +1215,7 @@ fun ComposeScreen(
signer = signer,
onNotePublished = onNotePublished,
powManager = powManager,
powPrefs = powPrefs,
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.BarChart
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.Favorite
import androidx.compose.material.icons.outlined.FormatQuote
@@ -684,6 +685,15 @@ private fun ZenNotificationRow(
color = MaterialTheme.colorScheme.onSurfaceVariant,
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
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.geometry.Offset
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import android.widget.Toast
import com.darkwisp.app.nostr.Nip69
import com.darkwisp.app.nostr.NostrEvent
import com.darkwisp.app.repo.ContactRepository
@@ -108,7 +110,9 @@ fun ThreadScreen(
fetchGroupPreview: (suspend (String, String) -> com.darkwisp.app.repo.GroupPreview?)? = null,
onAddEmojiSet: ((String, String) -> Unit)? = null,
onRemoveEmojiSet: ((String, String) -> Unit)? = null,
isEmojiSetAdded: ((String, String) -> Boolean)? = null
isEmojiSetAdded: ((String, String) -> Boolean)? = null,
/** Whether the user can private-zap [event]'s author (local keypair + DM relays on both sides). */
canPrivateZapFor: (NostrEvent) -> Boolean = { false }
) {
val flatThread by viewModel.flatThread.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
@@ -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(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
topBar = {
@@ -367,6 +377,9 @@ fun ThreadScreen(
onBlockAuthor = { onBlockUser(event.pubkey) },
isFollowingAuthor = followList.let { contactRepo.isFollowing(event.pubkey) },
isOwnEvent = event.pubkey == userPubkey,
isPrivate = eventRepo.isPrivate(event.id),
zapEnabled = !eventRepo.isPrivate(event.id) || canPrivateZapFor(event),
onZapDisabledTap = onZapDisabledTap,
onAddToList = { onAddToList(event.id) },
isInList = event.id in listedIds,
onPin = { onTogglePin(event.id) },
@@ -454,6 +467,9 @@ fun ThreadScreen(
onBlockAuthor = { onBlockUser(event.pubkey) },
isFollowingAuthor = followList.let { contactRepo.isFollowing(event.pubkey) },
isOwnEvent = event.pubkey == userPubkey,
isPrivate = eventRepo.isPrivate(event.id),
zapEnabled = !eventRepo.isPrivate(event.id) || canPrivateZapFor(event),
onZapDisabledTap = onZapDisabledTap,
onAddToList = { onAddToList(event.id) },
isInList = event.id in listedIds,
onPin = { onTogglePin(event.id) },
@@ -151,7 +151,7 @@ class ArticleViewModel : ViewModel() {
7, 6 -> eventRepo.addEvent(event)
9735 -> {
eventRepo.addEvent(event)
val zapperPubkey = Nip57.getZapperPubkey(event)
val zapperPubkey = eventRepo.resolveZapSender(event).first
if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) {
metadataFetcher.addToPendingProfiles(zapperPubkey)
}
@@ -31,12 +31,15 @@ import com.darkwisp.app.relay.OutboxRouter
import com.darkwisp.app.relay.RelayPool
import com.darkwisp.app.repo.BlossomRepository
import com.darkwisp.app.repo.ContactRepository
import com.darkwisp.app.repo.DmRepository
import com.darkwisp.app.repo.KeyRepository
import com.darkwisp.app.repo.PrivateReplyPublisher
import com.darkwisp.app.repo.MentionCandidate
import com.darkwisp.app.repo.MentionSearchRepository
import com.darkwisp.app.repo.EventRepository
import com.darkwisp.app.repo.InterfacePreferences
import com.darkwisp.app.repo.ProfileRepository
import com.darkwisp.app.repo.RelayListRepository
import com.darkwisp.app.R
import com.darkwisp.app.ui.util.GifToMp4Converter
import com.darkwisp.app.ui.util.MediaCompressor
@@ -116,6 +119,27 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
_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)
val powEnabled: StateFlow<Boolean> = _powEnabled
@@ -236,15 +260,27 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
private var pendingPublish: (() -> Unit)? = null
private var mentionSearchRepo: MentionSearchRepository? = null
private var eventRepo: EventRepository? = null
private var dmRepo: DmRepository? = null
private var relayListRepo: RelayListRepository? = null
private var initialized = false
var currentDraftId: String? = null
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
initialized = true
this.eventRepo = eventRepo
this.dmRepo = dmRepo
this.relayListRepo = relayListRepo
mentionSearchRepo = MentionSearchRepository(profileRepo, contactRepo, relayPool, keyRepo).also {
it.eventPersistence = eventPersistence
}
@@ -488,6 +524,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
signer: NostrSigner? = null,
onNotePublished: (() -> Unit)? = null,
powManager: PowManager? = null,
powPrefs: com.darkwisp.app.repo.PowPreferences? = null,
resolvedEmojis: Map<String, String> = emptyMap()
) {
val rawText = _content.value.text
@@ -531,7 +568,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
if (!useTimer || timerSeconds <= 0) {
viewModelScope.launch {
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
onNotePublished?.invoke()
onSuccess()
@@ -542,7 +579,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
}
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(
@@ -555,6 +592,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
onSuccess: () -> Unit,
onNotePublished: (() -> Unit)? = null,
powManager: PowManager? = null,
powPrefs: com.darkwisp.app.repo.PowPreferences? = null,
resolvedEmojis: Map<String, String> = emptyMap(),
seconds: Int = 10
) {
@@ -562,7 +600,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
pendingPublish = {
viewModelScope.launch {
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
onNotePublished?.invoke()
onSuccess()
@@ -610,6 +648,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
quoteTo: NostrEvent? = null,
outboxRouter: OutboxRouter? = null,
powManager: PowManager? = null,
powPrefs: com.darkwisp.app.repo.PowPreferences? = null,
resolvedEmojis: Map<String, String> = emptyMap()
): Int {
val tags = mutableListOf<List<String>>()
@@ -628,6 +667,18 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
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 quoteHint = outboxRouter?.getRelayHint(quoteTo.pubkey) ?: ""
tags.addAll(Nip18.buildQuoteTags(quoteTo, quoteHint))
@@ -853,6 +904,61 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat
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() {
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
_hashtags.value = emptyList()
_powEnabled.value = false
_privateReply.value = false
_privateReplyLocked.value = false
_galleryMode.value = false
_galleryHasVideo.value = false
_uploadedMediaMeta.clear()
@@ -226,7 +226,12 @@ class DmConversationViewModel(app: Application) : AndroidViewModel(app) {
* Shares the same pending queue as DmListViewModel both screens can drive
* 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 myPubkey = signer.pubkeyHex
@@ -240,6 +245,25 @@ class DmConversationViewModel(app: Application) : AndroidViewModel(app) {
try {
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)) {
val targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
?: continue
@@ -533,50 +557,7 @@ class DmConversationViewModel(app: Application) : AndroidViewModel(app) {
*/
private suspend fun fetchRecipientDmRelays(relayPool: RelayPool, forceRefresh: Boolean = false): List<String> {
val repo = dmRepo ?: return emptyList()
// 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()
return com.darkwisp.app.repo.DmRelayLookup.fetch(peerPubkey, relayPool, repo, forceRefresh)
}
/**
@@ -12,7 +12,10 @@ import com.darkwisp.app.nostr.NostrEvent
import com.darkwisp.app.nostr.NostrSigner
import com.darkwisp.app.nostr.toHex
import com.darkwisp.app.repo.DmRepository
import com.darkwisp.app.repo.EventRepository
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.MuteRepository
import kotlinx.coroutines.Dispatchers
@@ -31,10 +34,34 @@ class DmListViewModel(app: Application) : AndroidViewModel(app) {
private var dmRepo: DmRepository? = 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
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() {
@@ -57,6 +84,9 @@ class DmListViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch(Dispatchers.Default) {
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
if (Nip17.isReaction(rumor)) {
val targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
@@ -146,6 +176,9 @@ class DmListViewModel(app: Application) : AndroidViewModel(app) {
try {
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)) {
val targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1)
?: continue
@@ -134,7 +134,7 @@ class EventRouter(
if (convKey != null && msgId != null) {
val sats = Nip57.getZapAmountSats(event)
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))
}
}
@@ -154,7 +154,7 @@ class EventRouter(
metadataFetcher.addToPendingProfiles(event.pubkey)
}
if (event.kind == 9735) {
val zapperPubkey = Nip57.getZapperPubkey(event)
val zapperPubkey = eventRepo.resolveZapSender(event).first
if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) {
metadataFetcher.addToPendingProfiles(zapperPubkey)
}
@@ -220,7 +220,7 @@ class EventRouter(
if (event.kind == 9735) {
eventRepo.addEvent(event)
eventRepo.addEventRelay(event.id, relayUrl)
val zapperPubkey = Nip57.getZapperPubkey(event)
val zapperPubkey = eventRepo.resolveZapSender(event).first
if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) {
metadataFetcher.addToPendingProfiles(zapperPubkey)
}
@@ -253,7 +253,7 @@ class EventRouter(
9735 -> {
eventRepo.addEvent(event)
eventRepo.addEventRelay(event.id, relayUrl)
val zapperPubkey = Nip57.getZapperPubkey(event)
val zapperPubkey = eventRepo.resolveZapSender(event).first
if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) {
metadataFetcher.addToPendingProfiles(zapperPubkey)
}
@@ -389,7 +389,6 @@ class EventRouter(
val urls = Nip51.parseRelaySet(event)
keyRepo.saveDmRelays(urls)
relayPool.updateDmRelays(urls)
eventRepo.dmRelayUrls = urls.toSet()
}
}
if (event.kind == Nip51.KIND_SEARCH_RELAYS) {
@@ -609,8 +608,17 @@ class EventRouter(
null
} ?: return
// Private DM reaction — associate with the target message, not a new conversation entry
// Private reaction (kind 7 rumor). The "k" tag carries the kind of the message
// being reacted to: k=1 → reaction on a NIP-17 private reply, route through the
// note repository so thread/notification rendering picks it up the same way as
// public reactions; k=14 (or absent for back-compat) → DM reaction, route into
// the DM conversation entry.
if (Nip17.isReaction(rumor)) {
val kTag = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "k" }?.get(1)
if (kTag == "1") {
handlePrivateReplyReaction(rumor, myPubkey)
return
}
val targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) ?: return
val participants = Nip17.getConversationParticipants(rumor, myPubkey)
if (participants.any { muteRepo.isBlocked(it) }) return
@@ -623,6 +631,13 @@ class EventRouter(
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)
if (participants.any { muteRepo.isBlocked(it) }) return
@@ -650,4 +665,26 @@ class EventRouter(
)
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)
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
private set
@@ -181,6 +186,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
it.currentUserPubkey = pubkeyHex
it.deletedEventsRepo = deletedEventsRepo
it.eventPersistence = eventPersistence
it.keyRepo = keyRepo
}
val contactRepo = ContactRepository(app, pubkeyHex).also {
eventRepo.contactRepo = it
@@ -299,7 +305,8 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
val socialActions: SocialActionManager = SocialActionManager(
relayPool, outboxRouter, eventRepo, contactRepo, muteRepo, notifRepo, dmRepo,
pinRepo, deletedEventsRepo, { activeWalletProvider }, customEmojiRepo, zapSender, powPrefs, interfacePrefs, viewModelScope,
pinRepo, deletedEventsRepo, { activeWalletProvider }, customEmojiRepo, zapSender, powPrefs, interfacePrefs,
relayListRepo, viewModelScope,
getSigner = { signer },
getUserPubkey = { getUserPubkey() }
)
@@ -21,6 +21,8 @@ import com.darkwisp.app.repo.DmRepository
import com.darkwisp.app.repo.EventRepository
import com.darkwisp.app.repo.MuteRepository
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.PinRepository
import com.darkwisp.app.repo.CustomEmojiRepository
@@ -59,6 +61,7 @@ class SocialActionManager(
private val zapSender: ZapSender,
private val powPrefs: PowPreferences,
private val interfacePrefs: InterfacePreferences,
private val relayListRepo: RelayListRepository,
private val scope: CoroutineScope,
private val getSigner: () -> NostrSigner?,
private val getUserPubkey: () -> String?
@@ -238,6 +241,38 @@ class SocialActionManager(
fun toggleReaction(event: NostrEvent, emoji: String) {
val s = getSigner() ?: return
val myPubkey = s.pubkeyHex
// NIP-17 private replies are reacted to with a gift-wrapped kind-7 rumor so
// the target rumor id never lands on a public relay. v1 is add-only: if the
// user has already reacted with this emoji we no-op rather than publishing a
// gift-wrapped NIP-09 deletion (gift-wrapped deletion is a future enhancement).
if (eventRepo.isPrivate(event.id)) {
val existingEmoji = eventRepo.getUserReactionEmoji(event.id, myPubkey)
if (existingEmoji != null) return
val shortcodeMatch = Nip30.shortcodeRegex.matchEntire(emoji)
val emojiUrl = if (shortcodeMatch != null) {
val shortcode = shortcodeMatch.groupValues[1]
customEmojiRepo.resolvedEmojis.value[shortcode]
} else null
scope.launch {
try {
PrivateReactionPublisher.send(
signer = s,
relayPool = relayPool,
dmRepo = dmRepo,
relayListRepo = relayListRepo,
eventRepo = eventRepo,
targetEvent = event,
emoji = emoji,
emojiUrl = emojiUrl
)
_reactionSent.tryEmit(Unit)
customEmojiRepo.recordEmojiUsage(emoji)
} catch (_: Exception) {}
}
return
}
val existingEventId = eventRepo.getUserReactionEventId(event.id, myPubkey, emoji)
scope.launch {
@@ -352,6 +387,12 @@ class SocialActionManager(
* "a" tag instead of "e" so the receipt is associated with the addressable event.
*/
fun sendZap(event: NostrEvent, amountMsats: Long, message: String = "", isAnonymous: Boolean = false, isPrivate: Boolean = false, extraRelayHints: List<String> = emptyList(), recipientOverride: String? = null, eventATag: String? = null) {
// NIP-17 private reply targets must use DIP-03. The synthetic event's id is the
// rumor id and its created_at is the rumor timestamp — both feed the existing
// ephemeral derivation cleanly. Force the flag even if the caller forgot it so a
// public-zap fallback can never leak the rumor id on public relays.
@Suppress("NAME_SHADOWING")
val isPrivate = isPrivate || eventRepo.isPrivate(event.id)
val recipientPubkey = recipientOverride ?: event.pubkey
val profileData = eventRepo.getProfileData(recipientPubkey)
val lud16 = profileData?.lud16
@@ -381,7 +422,10 @@ class SocialActionManager(
} else {
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()) {
val dmFilter = if (eventATag != null) {
Filter(kinds = listOf(9735), aTags = listOf(eventATag))
@@ -418,7 +462,8 @@ class SocialActionManager(
isAnonymous = isAnonymous,
isPrivate = isPrivate,
extraTags = zapExtraTags,
extraRelayHints = extraRelayHints
extraRelayHints = extraRelayHints,
eventCreatedAt = event.created_at
)
_zapInProgress.value = _zapInProgress.value - event.id
result.fold(
@@ -225,7 +225,6 @@ class StartupCoordinator(
relayPool.updateRelays(initialRelays)
val dmRelays = keyRepo.getDmRelays()
relayPool.updateDmRelays(dmRelays)
eventRepo.dmRelayUrls = dmRelays.toSet()
// Connect local relay if configured
relayPool.updateLocalRelay(keyRepo.getLocalRelay(), getUserPubkey())
@@ -987,7 +986,6 @@ class StartupCoordinator(
relayPool.updateRelays(relays)
val dmRelays = keyRepo.getDmRelays()
relayPool.updateDmRelays(dmRelays)
eventRepo.dmRelayUrls = dmRelays.toSet()
feedSub.subscribeFeed()
}
}
@@ -229,7 +229,7 @@ class ThreadViewModel : ViewModel() {
5, 7, 6, 1018 -> eventRepo.addEvent(event)
9735 -> {
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) {
metadataFetcher.addToPendingProfiles(zapperPubkey)
}
+2
View File
@@ -445,6 +445,8 @@
<string name="zap_wallet_not_connected">Wallet Not Connected</string>
<string name="zap_connect_wallet">Connect a Lightning wallet to send zaps.</string>
<string name="zap_both_parties_need_dm_relays">Both parties need DM relays</string>
<string name="zap_private_locked_for_private_reply">Private — replying to a private thread</string>
<string name="zap_private_requires_dm_relays">Private zap requires DM relays on both sides</string>
<string name="zap_x_sats">Zap %d sats</string>
<string name="zap_failed">Zap Failed</string>
<string name="lightning_invoice_pay_now">Pay Now</string>