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