Merge pull request #2 from barrydeen/feat/private-replies-reactions-zaps
feat: private replies, private reactions, and private zaps
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user