From 0308bad87c793e8bcc94c2d925d2cc757ee9020b Mon Sep 17 00:00:00 2001 From: Barry Deen Date: Wed, 10 Jun 2026 12:00:20 -0400 Subject: [PATCH 1/3] feat(compose): private replies via NIP-17 gift wrap (port wisp#540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kind 1 rumors inside kind 1059 gift wraps, delivered to the recipient's kind 10050 DM relays (NIP-65 read relays as fallback). PoW is mined on the rumor so the badge survives unwrapping; a self-copy wrap keeps other devices in sync; optimistic local insert updates reply counts at once. Compose gains a private-reply toggle that auto-enables and locks when replying to a private reply; thread and notifications show a lock icon and hide repost/quote/react/zap on private replies. Dark Wisp addition over the upstream PR: private reply rumor handling is extracted into PrivateRumorHandler and wired into the remote-signer pending-decrypt paths (DmListViewModel/DmConversationViewModel), which upstream dropped along with NIP-55 support — without this, gift-wrapped replies would be misfiled as DM messages for remote-signer accounts. --- .../kotlin/com/darkwisp/app/Navigation.kt | 34 ++++- .../kotlin/com/darkwisp/app/nostr/Nip17.kt | 4 +- .../darkwisp/app/nostr/NotificationItem.kt | 1 + .../com/darkwisp/app/repo/DmRelayLookup.kt | 65 +++++++++ .../com/darkwisp/app/repo/EventRepository.kt | 8 ++ .../app/repo/NotificationRepository.kt | 1 + .../darkwisp/app/repo/PeerRelayListLookup.kt | 52 ++++++++ .../app/repo/PrivateReplyPublisher.kt | 124 ++++++++++++++++++ .../darkwisp/app/repo/PrivateRumorHandler.kt | 55 ++++++++ .../darkwisp/app/ui/component/ActionBar.kt | 5 + .../com/darkwisp/app/ui/component/PostCard.kt | 12 ++ .../darkwisp/app/ui/screen/ComposeScreen.kt | 56 +++++++- .../app/ui/screen/NotificationsScreen.kt | 10 ++ .../darkwisp/app/ui/screen/ThreadScreen.kt | 2 + .../app/viewmodel/ComposeViewModel.kt | 116 +++++++++++++++- .../app/viewmodel/DmConversationViewModel.kt | 62 +++------ .../darkwisp/app/viewmodel/DmListViewModel.kt | 30 ++++- .../com/darkwisp/app/viewmodel/EventRouter.kt | 18 +++ 18 files changed, 595 insertions(+), 60 deletions(-) create mode 100644 app/src/main/kotlin/com/darkwisp/app/repo/DmRelayLookup.kt create mode 100644 app/src/main/kotlin/com/darkwisp/app/repo/PeerRelayListLookup.kt create mode 100644 app/src/main/kotlin/com/darkwisp/app/repo/PrivateReplyPublisher.kt create mode 100644 app/src/main/kotlin/com/darkwisp/app/repo/PrivateRumorHandler.kt diff --git a/app/src/main/kotlin/com/darkwisp/app/Navigation.kt b/app/src/main/kotlin/com/darkwisp/app/Navigation.kt index 469fe12..84583f4 100644 --- a/app/src/main/kotlin/com/darkwisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/darkwisp/app/Navigation.kt @@ -358,12 +358,20 @@ fun WispNavHost( // Initialize compose viewmodel with shared repos LaunchedEffect(Unit) { - composeViewModel.init(feedViewModel.profileRepo, feedViewModel.contactRepo, feedViewModel.relayPool, feedViewModel.eventRepo, feedViewModel.eventPersistence) + composeViewModel.init( + feedViewModel.profileRepo, + feedViewModel.contactRepo, + feedViewModel.relayPool, + feedViewModel.eventRepo, + feedViewModel.eventPersistence, + feedViewModel.dmRepo, + feedViewModel.relayListRepo + ) } // Initialize DM list viewmodel with shared repo LaunchedEffect(Unit) { - dmListViewModel.init(feedViewModel.dmRepo, feedViewModel.muteRepo) + dmListViewModel.init(feedViewModel.dmRepo, feedViewModel.muteRepo, feedViewModel.eventRepo, feedViewModel.notifRepo) } // Initialize group list viewmodel with shared repo; key changes on account switch to re-init @@ -1351,7 +1359,7 @@ fun WispNavHost( powPreferences = feedViewModel.powPrefs, myPubkeyHex = userPubkey ) - activeSigner?.let { dmConvoViewModel.decryptPending(it, feedViewModel.muteRepo) } + activeSigner?.let { dmConvoViewModel.decryptPending(it, feedViewModel.muteRepo, feedViewModel.eventRepo, feedViewModel.notifRepo) } } val peerProfile = feedViewModel.eventRepo.getProfileData(pubkey) val userProfile = userPubkey?.let { feedViewModel.eventRepo.getProfileData(it) } @@ -1434,7 +1442,7 @@ fun WispNavHost( myPubkeyHex = userPubkey, participantPubkeys = participantList ) - activeSigner?.let { dmConvoViewModel.decryptPending(it, feedViewModel.muteRepo) } + activeSigner?.let { dmConvoViewModel.decryptPending(it, feedViewModel.muteRepo, feedViewModel.eventRepo, feedViewModel.notifRepo) } for (pubkey in participantList) { feedViewModel.metadataFetcher.queueProfileFetch(pubkey) } @@ -3149,6 +3157,24 @@ fun WispNavHost( com.darkwisp.app.nostr.Nip30.buildEmojiTagsForContent(content, notifResolvedEmojis) + if (notifInterfacePrefs.isClientTagEnabled()) listOf(listOf("client", "Dark Wisp")) else emptyList() + // If the parent is a private reply we received, keep the thread encrypted + // by gift-wrapping this reply too. Otherwise fall through to the public path. + if (feedViewModel.eventRepo.isPrivateReply(replyToEvent.id)) { + val difficulty = if (feedViewModel.powPrefs.isNotePowEnabled()) feedViewModel.powPrefs.getNoteDifficulty() else 0 + com.darkwisp.app.repo.PrivateReplyPublisher.send( + signer = signer, + relayPool = feedViewModel.relayPool, + dmRepo = feedViewModel.dmRepo, + relayListRepo = feedViewModel.relayListRepo, + eventRepo = feedViewModel.eventRepo, + replyTo = replyToEvent, + content = content, + baseTags = tags, + targetDifficulty = difficulty + ) + return@launch + } + if (feedViewModel.powPrefs.isNotePowEnabled()) { feedViewModel.powManager.submitNote( signer = signer, diff --git a/app/src/main/kotlin/com/darkwisp/app/nostr/Nip17.kt b/app/src/main/kotlin/com/darkwisp/app/nostr/Nip17.kt index d1c085e..8b76500 100644 --- a/app/src/main/kotlin/com/darkwisp/app/nostr/Nip17.kt +++ b/app/src/main/kotlin/com/darkwisp/app/nostr/Nip17.kt @@ -140,7 +140,7 @@ object Nip17 { // Parse rumor val rumorObj = json.parseToJsonElement(rumorJson).jsonObject val kind = rumorObj["kind"]?.jsonPrimitive?.content?.toIntOrNull() - if (kind != 14 && kind != 7 && kind != 15) return null + if (kind != 14 && kind != 7 && kind != 15 && kind != 1) return null val tags = rumorObj["tags"]?.jsonArray?.map { tagArr -> tagArr.jsonArray.map { it.jsonPrimitive.content } @@ -269,7 +269,7 @@ object Nip17 { // Parse rumor val rumorObj = json.parseToJsonElement(rumorJson).jsonObject val kind = rumorObj["kind"]?.jsonPrimitive?.content?.toIntOrNull() - if (kind != 14 && kind != 7 && kind != 15) return null + if (kind != 14 && kind != 7 && kind != 15 && kind != 1) return null val tags = rumorObj["tags"]?.jsonArray?.map { tagArr -> tagArr.jsonArray.map { it.jsonPrimitive.content } diff --git a/app/src/main/kotlin/com/darkwisp/app/nostr/NotificationItem.kt b/app/src/main/kotlin/com/darkwisp/app/nostr/NotificationItem.kt index 06beb09..28b1f75 100644 --- a/app/src/main/kotlin/com/darkwisp/app/nostr/NotificationItem.kt +++ b/app/src/main/kotlin/com/darkwisp/app/nostr/NotificationItem.kt @@ -13,6 +13,7 @@ data class FlatNotificationItem( val zapSats: Long = 0, val zapMessage: String = "", val isPrivateZap: Boolean = false, + val isPrivateReply: Boolean = false, val replyEventId: String? = null, val quoteEventId: String? = null, val voteOptionIds: List = emptyList(), diff --git a/app/src/main/kotlin/com/darkwisp/app/repo/DmRelayLookup.kt b/app/src/main/kotlin/com/darkwisp/app/repo/DmRelayLookup.kt new file mode 100644 index 0000000..e96f623 --- /dev/null +++ b/app/src/main/kotlin/com/darkwisp/app/repo/DmRelayLookup.kt @@ -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 { + 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() + 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() + } +} diff --git a/app/src/main/kotlin/com/darkwisp/app/repo/EventRepository.kt b/app/src/main/kotlin/com/darkwisp/app/repo/EventRepository.kt index d388d5a..6121103 100644 --- a/app/src/main/kotlin/com/darkwisp/app/repo/EventRepository.kt +++ b/app/src/main/kotlin/com/darkwisp/app/repo/EventRepository.kt @@ -46,6 +46,13 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo: private val eventCache = ConcurrentHashMap() private val seenEventIds = ConcurrentHashMap.newKeySet() // thread-safe dedup that doesn't evict + // NIP-17 private replies: event IDs (= rumor IDs) of kind 1 notes received via gift wrap. + // Screens read this to show a lock indicator next to the reply. + private val privateReplyIds = ConcurrentHashMap.newKeySet() + + fun markPrivateReply(eventId: String) { privateReplyIds.add(eventId) } + fun isPrivateReply(eventId: String): Boolean = eventId in privateReplyIds + /** Emits event IDs that were removed (e.g. via NIP-09 deletion). Screens like ThreadScreen * observe this to prune their local state when a deletion arrives on any subscription. */ private val _removedEvents = MutableSharedFlow(extraBufferCapacity = 64) @@ -1421,6 +1428,7 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo: resetFeedDisplay() eventCache.clear() seenEventIds.clear() + privateReplyIds.clear() } fun clearAll() { diff --git a/app/src/main/kotlin/com/darkwisp/app/repo/NotificationRepository.kt b/app/src/main/kotlin/com/darkwisp/app/repo/NotificationRepository.kt index d840046..fbc78e5 100644 --- a/app/src/main/kotlin/com/darkwisp/app/repo/NotificationRepository.kt +++ b/app/src/main/kotlin/com/darkwisp/app/repo/NotificationRepository.kt @@ -738,6 +738,7 @@ class NotificationRepository( referencedEventId = replyTarget, timestamp = event.created_at, replyEventId = event.id, + isPrivateReply = eventRepo?.isPrivateReply(event.id) == true, groupChatId = groupChatId )) } diff --git a/app/src/main/kotlin/com/darkwisp/app/repo/PeerRelayListLookup.kt b/app/src/main/kotlin/com/darkwisp/app/repo/PeerRelayListLookup.kt new file mode 100644 index 0000000..21b8a15 --- /dev/null +++ b/app/src/main/kotlin/com/darkwisp/app/repo/PeerRelayListLookup.kt @@ -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() + 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) + } + } +} diff --git a/app/src/main/kotlin/com/darkwisp/app/repo/PrivateReplyPublisher.kt b/app/src/main/kotlin/com/darkwisp/app/repo/PrivateReplyPublisher.kt new file mode 100644 index 0000000..b339a84 --- /dev/null +++ b/app/src/main/kotlin/com/darkwisp/app/repo/PrivateReplyPublisher.kt @@ -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>, + 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 = 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?.markPrivateReply(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) + } +} diff --git a/app/src/main/kotlin/com/darkwisp/app/repo/PrivateRumorHandler.kt b/app/src/main/kotlin/com/darkwisp/app/repo/PrivateRumorHandler.kt new file mode 100644 index 0000000..894458f --- /dev/null +++ b/app/src/main/kotlin/com/darkwisp/app/repo/PrivateRumorHandler.kt @@ -0,0 +1,55 @@ +package com.darkwisp.app.repo + +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) 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. + */ +object PrivateRumorHandler { + + /** 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.markPrivateReply(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) + } + } +} diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/component/ActionBar.kt b/app/src/main/kotlin/com/darkwisp/app/ui/component/ActionBar.kt index 63b9e61..0c0d380 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/component/ActionBar.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/component/ActionBar.kt @@ -84,6 +84,7 @@ fun ActionBar( resolvedEmojis: Map = emptyMap(), unicodeEmojis: List = emptyList(), onOpenEmojiLibrary: (() -> Unit)? = null, + isPrivate: Boolean = false, modifier: Modifier = Modifier ) { val context = androidx.compose.ui.platform.LocalContext.current @@ -107,6 +108,9 @@ fun ActionBar( color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1 ) + // Private replies hide React / Repost / Quote / Zap to avoid leaking the rumor id + // on public relays. Reply (above) and Bookmark (below) remain available. + if (!isPrivate) { Spacer(Modifier.width(8.dp)) Box { Box( @@ -243,6 +247,7 @@ fun ActionBar( overflow = TextOverflow.Ellipsis ) } + } // end !isPrivate Spacer(Modifier.width(8.dp)) IconButton(onClick = onAddToList) { Icon( diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/component/PostCard.kt b/app/src/main/kotlin/com/darkwisp/app/ui/component/PostCard.kt index 5ec5ee0..7b1d3df 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/component/PostCard.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/component/PostCard.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.Repeat +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -140,6 +141,7 @@ fun PostCard( onBlockAuthor: () -> Unit = {}, isFollowingAuthor: Boolean = false, isOwnEvent: Boolean = false, + isPrivate: Boolean = false, nip05Repo: Nip05Repository? = null, onAddToList: () -> Unit = {}, isInList: Boolean = false, @@ -340,6 +342,15 @@ fun PostCard( ) } } + if (isPrivate) { + Icon( + imageVector = Icons.Outlined.VisibilityOff, + contentDescription = "Private reply", + modifier = Modifier.size(14.dp), + tint = Color(0xFFFF8C00) + ) + Spacer(Modifier.width(4.dp)) + } Text( text = timestamp, style = MaterialTheme.typography.labelSmall, @@ -789,6 +800,7 @@ fun PostCard( resolvedEmojis = resolvedEmojis, unicodeEmojis = unicodeEmojis, onOpenEmojiLibrary = onOpenEmojiLibrary, + isPrivate = isPrivate, modifier = Modifier.weight(1f) ) Icon( diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/screen/ComposeScreen.kt b/app/src/main/kotlin/com/darkwisp/app/ui/screen/ComposeScreen.kt index 8d9fb2c..ba1297b 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/screen/ComposeScreen.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/screen/ComposeScreen.kt @@ -62,6 +62,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.outlined.BarChart +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.PhotoLibrary import androidx.compose.material.icons.outlined.Schedule @@ -180,6 +181,12 @@ fun ComposeScreen( val zapPollConsensus by viewModel.zapPollConsensus.collectAsState() val scheduleEnabled by viewModel.scheduleEnabled.collectAsState() val scheduleTimestamp by viewModel.scheduleTimestamp.collectAsState() + val privateReply by viewModel.privateReply.collectAsState() + val privateReplyLocked by viewModel.privateReplyLocked.collectAsState() + + LaunchedEffect(replyTo) { + viewModel.configureForReply(replyTo) + } val powStatus = powManager?.status?.collectAsState()?.value ?: PowStatus.Idle val isMiningBusy = powStatus is PowStatus.Mining val context = LocalContext.current @@ -332,7 +339,11 @@ fun ComposeScreen( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - IconButton(onClick = { viewModel.toggleExplicit() }) { + IconButton(onClick = { + val nextState = !explicit + viewModel.toggleExplicit() + android.widget.Toast.makeText(context, "NSFW ${if (nextState) "ON" else "OFF"}", android.widget.Toast.LENGTH_SHORT).show() + }) { Icon( Icons.Outlined.Warning, contentDescription = "Mark as explicit", @@ -341,7 +352,14 @@ fun ComposeScreen( ) } - IconButton(onClick = { powPrefs?.let { viewModel.togglePow(it) } }) { + IconButton(onClick = { + val prefs = powPrefs + if (prefs != null) { + val nextState = !powEnabled + viewModel.togglePow(prefs) + android.widget.Toast.makeText(context, "Mining ${if (nextState) "ON" else "OFF"}", android.widget.Toast.LENGTH_SHORT).show() + } + }) { Icon( Icons.Outlined.Shield, contentDescription = "Proof of Work", @@ -726,7 +744,11 @@ fun ComposeScreen( Icon(Icons.Outlined.Image, contentDescription = "Attach media") } - IconButton(onClick = { viewModel.toggleExplicit() }) { + IconButton(onClick = { + val nextState = !explicit + viewModel.toggleExplicit() + android.widget.Toast.makeText(context, "NSFW ${if (nextState) "ON" else "OFF"}", android.widget.Toast.LENGTH_SHORT).show() + }) { Icon( Icons.Outlined.Warning, contentDescription = "Mark as explicit", @@ -735,7 +757,14 @@ fun ComposeScreen( ) } - IconButton(onClick = { powPrefs?.let { viewModel.togglePow(it) } }) { + IconButton(onClick = { + val prefs = powPrefs + if (prefs != null) { + val nextState = !powEnabled + viewModel.togglePow(prefs) + android.widget.Toast.makeText(context, "Mining ${if (nextState) "ON" else "OFF"}", android.widget.Toast.LENGTH_SHORT).show() + } + }) { Icon( Icons.Outlined.Shield, contentDescription = "Proof of Work", @@ -753,6 +782,24 @@ fun ComposeScreen( ) } + // Private reply toggle: only meaningful for replies in plain text mode + // (private replies don't carry gallery/poll/schedule/quote payloads in v1). + if (replyTo != null && quoteTo == null && !galleryMode && !pollEnabled && !scheduleEnabled) { + IconButton(onClick = { + // Locked toggles short-circuit in the VM, so the state stays ON. + val nextState = if (privateReplyLocked) true else !privateReply + viewModel.togglePrivateReply() + android.widget.Toast.makeText(context, "Private Reply ${if (nextState) "ON" else "OFF"}", android.widget.Toast.LENGTH_SHORT).show() + }) { + Icon( + imageVector = Icons.Outlined.VisibilityOff, + contentDescription = "Private reply", + tint = if (privateReply) androidx.compose.ui.graphics.Color(0xFFFF8C00) + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + IconButton(onClick = { if (scheduleEnabled) { viewModel.toggleSchedule() @@ -1168,6 +1215,7 @@ fun ComposeScreen( signer = signer, onNotePublished = onNotePublished, powManager = powManager, + powPrefs = powPrefs, resolvedEmojis = resolvedEmojis ) }, diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/screen/NotificationsScreen.kt b/app/src/main/kotlin/com/darkwisp/app/ui/screen/NotificationsScreen.kt index e929e58..19e1cb2 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/screen/NotificationsScreen.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/screen/NotificationsScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material.icons.outlined.AlternateEmail import androidx.compose.material.icons.outlined.BarChart import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material.icons.outlined.CurrencyBitcoin import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.FormatQuote @@ -684,6 +685,15 @@ private fun ZenNotificationRow( color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1 ) + if (item.isPrivateReply) { + Spacer(Modifier.width(4.dp)) + Icon( + imageVector = Icons.Outlined.VisibilityOff, + contentDescription = "Private reply", + modifier = Modifier.size(14.dp), + tint = androidx.compose.ui.graphics.Color(0xFFFF8C00) + ) + } } // Show voted option labels for NIP-88 polls if (item.type == NotificationType.VOTE && item.voteOptionIds.isNotEmpty()) { diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/screen/ThreadScreen.kt b/app/src/main/kotlin/com/darkwisp/app/ui/screen/ThreadScreen.kt index ba7447f..0a80210 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/screen/ThreadScreen.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/screen/ThreadScreen.kt @@ -367,6 +367,7 @@ fun ThreadScreen( onBlockAuthor = { onBlockUser(event.pubkey) }, isFollowingAuthor = followList.let { contactRepo.isFollowing(event.pubkey) }, isOwnEvent = event.pubkey == userPubkey, + isPrivate = eventRepo.isPrivateReply(event.id), onAddToList = { onAddToList(event.id) }, isInList = event.id in listedIds, onPin = { onTogglePin(event.id) }, @@ -454,6 +455,7 @@ fun ThreadScreen( onBlockAuthor = { onBlockUser(event.pubkey) }, isFollowingAuthor = followList.let { contactRepo.isFollowing(event.pubkey) }, isOwnEvent = event.pubkey == userPubkey, + isPrivate = eventRepo.isPrivateReply(event.id), onAddToList = { onAddToList(event.id) }, isInList = event.id in listedIds, onPin = { onTogglePin(event.id) }, diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/ComposeViewModel.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/ComposeViewModel.kt index c8f9490..e0d99c6 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/ComposeViewModel.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/ComposeViewModel.kt @@ -31,12 +31,15 @@ import com.darkwisp.app.relay.OutboxRouter import com.darkwisp.app.relay.RelayPool import com.darkwisp.app.repo.BlossomRepository import com.darkwisp.app.repo.ContactRepository +import com.darkwisp.app.repo.DmRepository import com.darkwisp.app.repo.KeyRepository +import com.darkwisp.app.repo.PrivateReplyPublisher import com.darkwisp.app.repo.MentionCandidate import com.darkwisp.app.repo.MentionSearchRepository import com.darkwisp.app.repo.EventRepository import com.darkwisp.app.repo.InterfacePreferences import com.darkwisp.app.repo.ProfileRepository +import com.darkwisp.app.repo.RelayListRepository import com.darkwisp.app.R import com.darkwisp.app.ui.util.GifToMp4Converter import com.darkwisp.app.ui.util.MediaCompressor @@ -116,6 +119,27 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat _explicit.value = !_explicit.value } + private val _privateReply = MutableStateFlow(false) + val privateReply: StateFlow = _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 = _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?.isPrivateReply(replyTo.id) == true + _privateReplyLocked.value = isReplyingToPrivate + if (isReplyingToPrivate) _privateReply.value = true + } + private val _powEnabled = MutableStateFlow(false) val powEnabled: StateFlow = _powEnabled @@ -236,15 +260,27 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat private var pendingPublish: (() -> Unit)? = null private var mentionSearchRepo: MentionSearchRepository? = null private var eventRepo: EventRepository? = null + private var dmRepo: DmRepository? = null + private var relayListRepo: RelayListRepository? = null private var initialized = false var currentDraftId: String? = null private set - fun init(profileRepo: ProfileRepository, contactRepo: ContactRepository, relayPool: RelayPool, eventRepo: EventRepository? = null, eventPersistence: com.darkwisp.app.db.EventPersistence? = null) { + fun init( + profileRepo: ProfileRepository, + contactRepo: ContactRepository, + relayPool: RelayPool, + eventRepo: EventRepository? = null, + eventPersistence: com.darkwisp.app.db.EventPersistence? = null, + dmRepo: DmRepository? = null, + relayListRepo: RelayListRepository? = null + ) { if (initialized) return initialized = true this.eventRepo = eventRepo + this.dmRepo = dmRepo + this.relayListRepo = relayListRepo mentionSearchRepo = MentionSearchRepository(profileRepo, contactRepo, relayPool, keyRepo).also { it.eventPersistence = eventPersistence } @@ -488,6 +524,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat signer: NostrSigner? = null, onNotePublished: (() -> Unit)? = null, powManager: PowManager? = null, + powPrefs: com.darkwisp.app.repo.PowPreferences? = null, resolvedEmojis: Map = emptyMap() ) { val rawText = _content.value.text @@ -531,7 +568,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat if (!useTimer || timerSeconds <= 0) { viewModelScope.launch { try { - val sentCount = publishNote(text, s, relayPool, replyTo, quoteTo, outboxRouter, powManager, resolvedEmojis) + val sentCount = publishNote(text, s, relayPool, replyTo, quoteTo, outboxRouter, powManager, powPrefs, resolvedEmojis) if (sentCount == 0) return@launch onNotePublished?.invoke() onSuccess() @@ -542,7 +579,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat } return } - startCountdown(text, s, relayPool, replyTo, quoteTo, outboxRouter, onSuccess, onNotePublished, powManager, resolvedEmojis, timerSeconds) + startCountdown(text, s, relayPool, replyTo, quoteTo, outboxRouter, onSuccess, onNotePublished, powManager, powPrefs, resolvedEmojis, timerSeconds) } private fun startCountdown( @@ -555,6 +592,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat onSuccess: () -> Unit, onNotePublished: (() -> Unit)? = null, powManager: PowManager? = null, + powPrefs: com.darkwisp.app.repo.PowPreferences? = null, resolvedEmojis: Map = emptyMap(), seconds: Int = 10 ) { @@ -562,7 +600,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat pendingPublish = { viewModelScope.launch { try { - val sentCount = publishNote(content, signer, relayPool, replyTo, quoteTo, outboxRouter, powManager, resolvedEmojis) + val sentCount = publishNote(content, signer, relayPool, replyTo, quoteTo, outboxRouter, powManager, powPrefs, resolvedEmojis) if (sentCount == 0) return@launch onNotePublished?.invoke() onSuccess() @@ -610,6 +648,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat quoteTo: NostrEvent? = null, outboxRouter: OutboxRouter? = null, powManager: PowManager? = null, + powPrefs: com.darkwisp.app.repo.PowPreferences? = null, resolvedEmojis: Map = emptyMap() ): Int { val tags = mutableListOf>() @@ -628,6 +667,18 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat tags.add(listOf("p", pubkey)) } } + + // NIP-17 private reply: gift-wrap to the recipient's DM relays instead of publishing + // publicly. The compose UI hides the toggle in gallery/poll/schedule/quote modes, so we + // branch before those tag-building paths and emit just the reply + mentions + hashtags + // + emojis inside the encrypted rumor. + if (replyTo != null && _privateReply.value) { + for (hashtag in _hashtags.value) tags.add(listOf("t", hashtag)) + tags.addAll(Nip30.buildEmojiTagsForContent(content, resolvedEmojis)) + if (interfacePrefs.isClientTagEnabled()) tags.add(listOf("client", "Dark Wisp")) + return publishPrivateReply(content, replyTo, tags, signer, relayPool, powPrefs) + } + val finalContent = if (quoteTo != null) { val quoteHint = outboxRouter?.getRelayHint(quoteTo.pubkey) ?: "" tags.addAll(Nip18.buildQuoteTags(quoteTo, quoteHint)) @@ -853,6 +904,61 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat return sentCount } + private suspend fun publishPrivateReply( + content: String, + replyTo: NostrEvent, + replyTags: List>, + signer: NostrSigner, + relayPool: RelayPool, + powPrefs: com.darkwisp.app.repo.PowPreferences? = null + ): Int { + val dmRepoLocal = dmRepo + if (dmRepoLocal == null) { + _error.value = getApplication().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().getString(R.string.error_publish_failed, e.message ?: "wrap failed") + _publishing.value = false + return 0 + } + + if (result.sentCount == 0) { + _error.value = getApplication().getString(R.string.error_no_relays_connected) + _publishing.value = false + return 0 + } + + deleteDraftOnPublish(relayPool, signer) + _content.value = TextFieldValue() + _mentions.value = emptyList() + savedStateHandle.remove("draft_content") + savedStateHandle.remove>("draft_mentions") + _uploadedUrls.value = emptyList() + _uploadedMediaMeta.clear() + _error.value = null + _publishing.value = false + _privateReply.value = false + + return result.sentCount + } + private fun saveMentionsToState() { savedStateHandle["draft_mentions"] = _mentions.value.map { "${it.start},${it.end},${it.pubkey}" }.toTypedArray() } @@ -1065,6 +1171,8 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat _explicit.value = false _hashtags.value = emptyList() _powEnabled.value = false + _privateReply.value = false + _privateReplyLocked.value = false _galleryMode.value = false _galleryHasVideo.value = false _uploadedMediaMeta.clear() diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/DmConversationViewModel.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/DmConversationViewModel.kt index 00011cf..af6c82c 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/DmConversationViewModel.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/DmConversationViewModel.kt @@ -226,7 +226,12 @@ class DmConversationViewModel(app: Application) : AndroidViewModel(app) { * Shares the same pending queue as DmListViewModel — both screens can drive * decryption and progress is visible from either. */ - fun decryptPending(signer: NostrSigner, muteRepo: MuteRepository? = null) { + fun decryptPending( + signer: NostrSigner, + muteRepo: MuteRepository? = null, + eventRepo: com.darkwisp.app.repo.EventRepository? = null, + notifRepo: com.darkwisp.app.repo.NotificationRepository? = null + ) { val repo = dmRepo ?: return val myPubkey = signer.pubkeyHex @@ -240,6 +245,16 @@ class DmConversationViewModel(app: Application) : AndroidViewModel(app) { try { val rumor = Nip17.unwrapGiftWrapRemote(signer, wrap.event) ?: continue + // NIP-17 private reply (kind 1 rumor) — belongs in threads, not DMs + if (rumor.kind == 1) { + if (eventRepo != null && notifRepo != null) { + com.darkwisp.app.repo.PrivateRumorHandler.handlePrivateReply( + rumor, myPubkey, eventRepo, notifRepo, muteRepo + ) + } + continue + } + if (Nip17.isReaction(rumor)) { val targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) ?: continue @@ -533,50 +548,7 @@ class DmConversationViewModel(app: Application) : AndroidViewModel(app) { */ private suspend fun fetchRecipientDmRelays(relayPool: RelayPool, forceRefresh: Boolean = false): List { val repo = dmRepo ?: return emptyList() - - // Cache hit (skip when forcing a refresh) - if (!forceRefresh) { - repo.getCachedDmRelays(peerPubkey)?.let { return it } - } - - // Send REQ for kind 10050 to indexer relays (most likely to have relay metadata) - val subId = "dm_relay_${peerPubkey.take(8)}" - val filter = Filter( - kinds = listOf(Nip51.KIND_DM_RELAYS), - authors = listOf(peerPubkey), - limit = 1 - ) - val reqMsg = ClientMessage.req(subId, filter) - for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) { - relayPool.sendToRelayOrEphemeral(url, reqMsg, skipBadCheck = true) - } - // Also broadcast to connected relays for additional coverage - relayPool.sendToAll(reqMsg) - - // Collect all responses within 4s; pick the freshest (highest created_at) - val results = mutableListOf() - withTimeoutOrNull(4000L) { - relayPool.relayEvents - .filter { it.subscriptionId == subId } - .collect { results.add(it) } - } - - // Close subscription - val closeMsg = ClientMessage.close(subId) - for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) { - relayPool.sendToRelay(url, closeMsg) - } - relayPool.sendToAll(closeMsg) - - val best = results.maxByOrNull { it.event.created_at } - if (best != null) { - val urls = Nip51.parseRelaySet(best.event) - if (urls.isNotEmpty()) { - repo.cacheDmRelays(peerPubkey, urls) - return urls - } - } - return emptyList() + return com.darkwisp.app.repo.DmRelayLookup.fetch(peerPubkey, relayPool, repo, forceRefresh) } /** diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/DmListViewModel.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/DmListViewModel.kt index 649e90d..52965c9 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/DmListViewModel.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/DmListViewModel.kt @@ -12,7 +12,10 @@ import com.darkwisp.app.nostr.NostrEvent import com.darkwisp.app.nostr.NostrSigner import com.darkwisp.app.nostr.toHex import com.darkwisp.app.repo.DmRepository +import com.darkwisp.app.repo.EventRepository import com.darkwisp.app.repo.KeyRepository +import com.darkwisp.app.repo.NotificationRepository +import com.darkwisp.app.repo.PrivateRumorHandler import com.darkwisp.app.repo.SigningMode import com.darkwisp.app.repo.MuteRepository import kotlinx.coroutines.Dispatchers @@ -31,10 +34,29 @@ class DmListViewModel(app: Application) : AndroidViewModel(app) { private var dmRepo: DmRepository? = null private var muteRepo: MuteRepository? = null + private var eventRepo: EventRepository? = null + private var notifRepo: NotificationRepository? = null - fun init(dmRepository: DmRepository, muteRepository: MuteRepository? = null) { + fun init( + dmRepository: DmRepository, + muteRepository: MuteRepository? = null, + eventRepository: EventRepository? = null, + notificationRepository: NotificationRepository? = null + ) { dmRepo = dmRepository muteRepo = muteRepository + eventRepo = eventRepository + notifRepo = notificationRepository + } + + /** Route a non-DM rumor (kind 1 private reply) out of the DM pipeline. + * Returns true when the rumor was consumed. */ + private fun routePrivateRumor(rumor: Nip17.Rumor, myPubkey: String): Boolean { + if (rumor.kind != 1) return false + val eRepo = eventRepo ?: return true // can't materialise without repos — drop, don't misfile as DM + val nRepo = notifRepo ?: return true + PrivateRumorHandler.handlePrivateReply(rumor, myPubkey, eRepo, nRepo, muteRepo) + return true } fun markDmsRead() { @@ -57,6 +79,9 @@ class DmListViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch(Dispatchers.Default) { val rumor = Nip17.unwrapGiftWrap(keypair.privkey, event) ?: return@launch + // NIP-17 private reply (kind 1 rumor) — belongs in threads, not DMs + if (routePrivateRumor(rumor, myPubkey)) return@launch + // Private DM reaction — associate with the target message if (Nip17.isReaction(rumor)) { val targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) @@ -146,6 +171,9 @@ class DmListViewModel(app: Application) : AndroidViewModel(app) { try { val rumor = Nip17.unwrapGiftWrapRemote(signer, wrap.event) ?: continue + // NIP-17 private reply (kind 1 rumor) — belongs in threads, not DMs + if (routePrivateRumor(rumor, myPubkey)) continue + if (Nip17.isReaction(rumor)) { val targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) ?: continue diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/EventRouter.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/EventRouter.kt index 0254cf1..c46af23 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/EventRouter.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/EventRouter.kt @@ -623,6 +623,13 @@ class EventRouter( return } + // NIP-17 private reply — kind 1 rumor with NIP-10 reply tags. + // Surface as a normal reply in threads + notifications, flagged as private. + if (rumor.kind == 1) { + handlePrivateReply(event, rumor, myPubkey) + return + } + val participants = Nip17.getConversationParticipants(rumor, myPubkey) if (participants.any { muteRepo.isBlocked(it) }) return @@ -650,4 +657,15 @@ class EventRouter( ) dmRepo.addMessage(msg, convKey) } + + 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) } + ) + } } From 4a8710394418e2bcf57fddec92b7891ffc2bed4b Mon Sep 17 00:00:00 2001 From: Barry Deen Date: Wed, 10 Jun 2026 12:01:23 -0400 Subject: [PATCH 2/3] feat(zaps): private zaps via DIP-03 + DM-relay routing (port wisp#541) Replaces the relays-tag heuristic with DIP-03: the real sender signs an inner kind 9733 event, NIP-04-encrypted into the outer kind 9734's anon tag using an ephemeral key derived as sha256(privkey + eventId + createdAt). Relays and the LNURL provider only see the unlinkable ephemeral pubkey; receipts route through both parties' DM relays. EventRepository.resolveZapSender() decrypts incoming private zaps (as recipient) and re-derives the ephemeral key to self-attribute our own outgoing ones, so notifications, zap lists, and wallet history show the real counterparty. Private zaps require a local keypair, so the UI gate adds hasLocalKeypair; live-stream (addressable) zaps can't derive the ephemeral key and stay public. --- .../kotlin/com/darkwisp/app/Navigation.kt | 21 +-- .../kotlin/com/darkwisp/app/nostr/Nip04.kt | 24 ++- .../kotlin/com/darkwisp/app/nostr/Nip19.kt | 4 +- .../kotlin/com/darkwisp/app/nostr/Nip57.kt | 144 ++++++++++++++++++ .../com/darkwisp/app/repo/EventRepository.kt | 52 +++++-- .../app/repo/NotificationRepository.kt | 15 +- .../kotlin/com/darkwisp/app/repo/ZapSender.kt | 88 +++++++---- .../app/viewmodel/ArticleViewModel.kt | 2 +- .../com/darkwisp/app/viewmodel/EventRouter.kt | 9 +- .../darkwisp/app/viewmodel/FeedViewModel.kt | 6 + .../app/viewmodel/SocialActionManager.kt | 8 +- .../app/viewmodel/StartupCoordinator.kt | 2 - .../darkwisp/app/viewmodel/ThreadViewModel.kt | 2 +- 13 files changed, 295 insertions(+), 82 deletions(-) diff --git a/app/src/main/kotlin/com/darkwisp/app/Navigation.kt b/app/src/main/kotlin/com/darkwisp/app/Navigation.kt index 84583f4..ef123cf 100644 --- a/app/src/main/kotlin/com/darkwisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/darkwisp/app/Navigation.kt @@ -1112,7 +1112,7 @@ fun WispNavHost( zapSuccess = feedViewModel.zapSuccess, zapError = feedViewModel.zapError, zapInProgressIds = profileZapInProgress, - canPrivateZap = feedViewModel.relayPool.hasDmRelays() && feedViewModel.relayListRepo.hasDmRelays(pubkey), + canPrivateZap = feedViewModel.hasLocalKeypair && feedViewModel.relayPool.hasDmRelays() && feedViewModel.relayListRepo.hasDmRelays(pubkey), fetchDmRelays = { pk -> feedViewModel.fetchDmRelaysIfMissing(pk) && feedViewModel.relayPool.hasDmRelays() }, ownLists = feedViewModel.listRepo.ownLists.collectAsState().value, onAddToList = { dTag, pk -> feedViewModel.addToList(dTag, pk) }, @@ -1222,7 +1222,7 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = userHasDmRelays && recipientHasDmRelays + canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays ) } SearchScreen( @@ -1590,7 +1590,7 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.relayPool.hasDmRelays() && recipientHasDmRelays, + canPrivateZap = feedViewModel.hasLocalKeypair && feedViewModel.relayPool.hasDmRelays() && recipientHasDmRelays, initialSatsHint = groupRoomZapInitialSats ) } @@ -1867,7 +1867,7 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = threadUserHasDmRelays && threadRecipientHasDmRelays + canPrivateZap = feedViewModel.hasLocalKeypair && threadUserHasDmRelays && threadRecipientHasDmRelays ) } val threadSetListedIds by feedViewModel.bookmarkSetRepo.allListedEventIds.collectAsState() @@ -2031,7 +2031,7 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = hashtagUserHasDmRelays && hashtagRecipientHasDmRelays + canPrivateZap = feedViewModel.hasLocalKeypair && hashtagUserHasDmRelays && hashtagRecipientHasDmRelays ) } @@ -2182,7 +2182,7 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = setFeedUserHasDmRelays && setFeedRecipientHasDmRelays + canPrivateZap = feedViewModel.hasLocalKeypair && setFeedUserHasDmRelays && setFeedRecipientHasDmRelays ) } @@ -2346,7 +2346,7 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = articleUserHasDmRelays && articleRecipientHasDmRelays + canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays ) } @@ -2550,7 +2550,10 @@ fun WispNavHost( eventATag = aTag) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.relayPool.hasDmRelays() && recipientHasDmRelays + // DIP-03 needs a concrete note id for the ephemeral key + // derivation; live-stream zaps target an addressable event + // (a-tag) instead, so private zaps don't apply here. + canPrivateZap = false ) } val streamActivityEventId = remember(hostPubkey, dTag) { @@ -3107,7 +3110,7 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = notifUserHasDmRelays && notifRecipientHasDmRelays + canPrivateZap = feedViewModel.hasLocalKeypair && notifUserHasDmRelays && notifRecipientHasDmRelays ) } diff --git a/app/src/main/kotlin/com/darkwisp/app/nostr/Nip04.kt b/app/src/main/kotlin/com/darkwisp/app/nostr/Nip04.kt index 9f537d9..5474034 100644 --- a/app/src/main/kotlin/com/darkwisp/app/nostr/Nip04.kt +++ b/app/src/main/kotlin/com/darkwisp/app/nostr/Nip04.kt @@ -24,11 +24,7 @@ object Nip04 { * Returns: base64(ciphertext) + "?iv=" + base64(iv) */ fun encrypt(plaintext: String, sharedSecret: ByteArray): String { - val iv = ByteArray(16).also { random.nextBytes(it) } - val key = SecretKeySpec(sharedSecret, "AES") - val cipher = cipherLocal.get() - cipher.init(Cipher.ENCRYPT_MODE, key, IvParameterSpec(iv)) - val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + val (ciphertext, iv) = encryptRaw(plaintext, sharedSecret) val ctB64 = Base64.encodeToString(ciphertext, Base64.NO_WRAP) val ivB64 = Base64.encodeToString(iv, Base64.NO_WRAP) return "$ctB64?iv=$ivB64" @@ -42,8 +38,24 @@ object Nip04 { require(parts.size == 2) { "Invalid NIP-04 content format" } val ciphertext = Base64.decode(parts[0], Base64.DEFAULT) val iv = Base64.decode(parts[1], Base64.DEFAULT) + return decryptRaw(ciphertext, iv, sharedSecret) + } + + /** Raw AES-256-CBC encrypt with random IV. Returns (ciphertext, iv) so callers + * can package the bytes in a non-standard envelope (e.g. DIP-03 bech32). */ + fun encryptRaw(plaintext: String, sharedSecret: ByteArray): Pair { + val iv = ByteArray(16).also { random.nextBytes(it) } val key = SecretKeySpec(sharedSecret, "AES") - val cipher = cipherLocal.get() + val cipher = cipherLocal.get()!! + cipher.init(Cipher.ENCRYPT_MODE, key, IvParameterSpec(iv)) + val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + return ciphertext to iv + } + + /** Raw AES-256-CBC decrypt — counterpart to [encryptRaw]. */ + fun decryptRaw(ciphertext: ByteArray, iv: ByteArray, sharedSecret: ByteArray): String { + val key = SecretKeySpec(sharedSecret, "AES") + val cipher = cipherLocal.get()!! cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv)) return String(cipher.doFinal(ciphertext), Charsets.UTF_8) } diff --git a/app/src/main/kotlin/com/darkwisp/app/nostr/Nip19.kt b/app/src/main/kotlin/com/darkwisp/app/nostr/Nip19.kt index b225009..f0e4ad9 100644 --- a/app/src/main/kotlin/com/darkwisp/app/nostr/Nip19.kt +++ b/app/src/main/kotlin/com/darkwisp/app/nostr/Nip19.kt @@ -186,7 +186,7 @@ object Nip19 { } } - private fun bech32Encode(hrp: String, data: ByteArray): String { + internal fun bech32Encode(hrp: String, data: ByteArray): String { val values = convertBits(data, 8, 5, true) val checksum = bech32Checksum(hrp, values) return buildString(hrp.length + 1 + values.size + 6) { @@ -197,7 +197,7 @@ object Nip19 { } } - private fun bech32Decode(str: String): Pair { + internal fun bech32Decode(str: String): Pair { val lower = str.lowercase() val pos = lower.lastIndexOf('1') require(pos >= 1) { "Invalid bech32 string" } diff --git a/app/src/main/kotlin/com/darkwisp/app/nostr/Nip57.kt b/app/src/main/kotlin/com/darkwisp/app/nostr/Nip57.kt index f6a68fd..6f56c8d 100644 --- a/app/src/main/kotlin/com/darkwisp/app/nostr/Nip57.kt +++ b/app/src/main/kotlin/com/darkwisp/app/nostr/Nip57.kt @@ -10,11 +10,19 @@ import kotlinx.serialization.json.long import okhttp3.OkHttpClient import okhttp3.Request import java.net.URLEncoder +import java.security.MessageDigest object Nip57 { + const val KIND_PRIVATE_ZAP_EVENT = 9733 + private const val ANON_TAG = "anon" + private const val PZAP_HRP = "pzap" + private const val IV_HRP = "iv" + private val bolt11AmountRegex = Regex("""lnbc(\d+)([munp]?)1""") private val json = Json { ignoreUnknownKeys = true } + data class DecryptedPrivateZap(val senderPubkey: String, val message: String) + fun getZappedEventId(event: NostrEvent): String? { return event.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) } @@ -157,6 +165,142 @@ object Nip57 { return signer.signEvent(kind = 9734, content = message, tags = tags) } + /** + * DIP-03 deterministic ephemeral private key: + * sha256(utf8(senderPrivkeyHex + targetEventIdHex + createdAtString)). + * Re-derivable from public note data so the sender can identify their own + * outgoing private zaps after the fact. + */ + fun deriveEphemeralPrivkey( + senderPrivkey: ByteArray, + targetEventId: String, + targetCreatedAt: Long + ): ByteArray { + val input = senderPrivkey.toHex() + targetEventId + targetCreatedAt.toString() + return MessageDigest.getInstance("SHA-256") + .digest(input.toByteArray(Charsets.UTF_8)) + } + + /** + * DIP-03 private zap request. Inner kind 9733 (real sender, NIP-04 encrypted + * to recipient via ephemeral ECDH) is packed into the outer kind 9734's + * `anon` tag; the outer event is signed by the deterministic ephemeral key + * so the LNURL provider and relays only see an unlinkable pubkey. + */ + fun buildPrivateZapRequest( + senderPrivkey: ByteArray, + senderPubkey: ByteArray, + recipientPubkey: String, + eventId: String, + eventCreatedAt: Long, + amountMsats: Long, + relayUrls: List, + lnurl: String, + message: String, + extraTags: List> = emptyList() + ): NostrEvent { + val ephPrivkey = deriveEphemeralPrivkey(senderPrivkey, eventId, eventCreatedAt) + val ephPubkey = Keys.xOnlyPubkey(ephPrivkey) + + val inner = NostrEvent.create( + privkey = senderPrivkey, + pubkey = senderPubkey, + kind = KIND_PRIVATE_ZAP_EVENT, + content = message, + tags = listOf(listOf("p", recipientPubkey)) + ) + + val shared = Nip04.computeSharedSecret(ephPrivkey, recipientPubkey.hexToByteArray()) + val (ct, iv) = Nip04.encryptRaw(inner.toJson(), shared) + val anonValue = Nip19.bech32Encode(PZAP_HRP, ct) + "_" + Nip19.bech32Encode(IV_HRP, iv) + + val outerTags = buildList { + add(listOf("p", recipientPubkey)) + add(listOf("e", eventId)) + add(listOf("relays") + relayUrls) + add(listOf("amount", amountMsats.toString())) + add(listOf("lnurl", lnurl)) + add(listOf(ANON_TAG, anonValue)) + addAll(extraTags) + } + return NostrEvent.create( + privkey = ephPrivkey, + pubkey = ephPubkey, + kind = 9734, + content = "", + tags = outerTags + ) + } + + /** True if the receipt's embedded zap request carries a DIP-03 `anon` tag. */ + fun isPrivateZap(receipt: NostrEvent): Boolean { + val request = parseEmbeddedRequest(receipt) ?: return false + return request.tags.any { it.size >= 2 && it[0] == ANON_TAG } + } + + /** + * Decrypt a DIP-03 private zap addressed to us (we are the recipient). + * Returns null unless the `anon` envelope decodes, AES-decrypts, and the + * inner kind 9733's Schnorr signature checks out — the anon tag is + * otherwise unauthenticated, so signature verification is mandatory. + */ + fun decryptPrivateZap(receipt: NostrEvent, myPrivkey: ByteArray): DecryptedPrivateZap? { + val request = parseEmbeddedRequest(receipt) ?: return null + val anon = request.tags.firstOrNull { it.size >= 2 && it[0] == ANON_TAG }?.get(1) + ?: return null + val shared = runCatching { + Nip04.computeSharedSecret(myPrivkey, request.pubkey.hexToByteArray()) + }.getOrNull() ?: return null + return decryptAnonTag(anon, shared) + } + + /** + * Decrypt a DIP-03 private zap we sent ourselves. Re-derives the + * deterministic ephemeral key from [target], confirms the outer pubkey + * matches (i.e. this is ours), then runs ECDH(ephPriv, recipient) to + * recover the same shared secret used at encryption. Returns null for any + * receipt that isn't one of ours. + */ + fun decryptOwnOutgoingPrivateZap( + receipt: NostrEvent, + myPrivkey: ByteArray, + target: NostrEvent + ): DecryptedPrivateZap? { + val request = parseEmbeddedRequest(receipt) ?: return null + val anon = request.tags.firstOrNull { it.size >= 2 && it[0] == ANON_TAG }?.get(1) + ?: return null + val ephPriv = deriveEphemeralPrivkey(myPrivkey, target.id, target.created_at) + val ephPubHex = runCatching { Keys.xOnlyPubkey(ephPriv).toHex() }.getOrNull() + ?: return null + if (ephPubHex != request.pubkey) return null + val recipientHex = request.tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + ?: return null + val shared = runCatching { + Nip04.computeSharedSecret(ephPriv, recipientHex.hexToByteArray()) + }.getOrNull() ?: return null + return decryptAnonTag(anon, shared) + } + + private fun decryptAnonTag(anon: String, sharedSecret: ByteArray): DecryptedPrivateZap? { + val parts = anon.split("_", limit = 2) + if (parts.size != 2) return null + val (hrpCt, ct) = runCatching { Nip19.bech32Decode(parts[0]) }.getOrNull() ?: return null + val (hrpIv, iv) = runCatching { Nip19.bech32Decode(parts[1]) }.getOrNull() ?: return null + if (hrpCt != PZAP_HRP || hrpIv != IV_HRP) return null + val plaintext = runCatching { Nip04.decryptRaw(ct, iv, sharedSecret) }.getOrNull() + ?: return null + val inner = runCatching { NostrEvent.fromJson(plaintext) }.getOrNull() ?: return null + if (inner.kind != KIND_PRIVATE_ZAP_EVENT) return null + if (!inner.verifySignature()) return null + return DecryptedPrivateZap(inner.pubkey, inner.content) + } + + private fun parseEmbeddedRequest(receipt: NostrEvent): NostrEvent? { + val description = receipt.tags.firstOrNull { it.size >= 2 && it[0] == "description" }?.get(1) + ?: return null + return runCatching { NostrEvent.fromJson(description) }.getOrNull() + } + suspend fun fetchSimpleInvoice( callbackUrl: String, amountMsats: Long, diff --git a/app/src/main/kotlin/com/darkwisp/app/repo/EventRepository.kt b/app/src/main/kotlin/com/darkwisp/app/repo/EventRepository.kt index 6121103..2c72b42 100644 --- a/app/src/main/kotlin/com/darkwisp/app/repo/EventRepository.kt +++ b/app/src/main/kotlin/com/darkwisp/app/repo/EventRepository.kt @@ -41,8 +41,8 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo: var contactRepo: ContactRepository? = null var safetyPrefs: SafetyPreferences? = null var extendedNetworkRepo: ExtendedNetworkRepository? = null - /** Set of current user's DM relay URLs — used to detect private zaps. */ - var dmRelayUrls: Set = emptySet() + /** Late-bound for DIP-03 private zap decryption (recipient + self-attribution). */ + var keyRepo: KeyRepository? = null private val eventCache = ConcurrentHashMap() private val seenEventIds = ConcurrentHashMap.newKeySet() // thread-safe dedup that doesn't evict @@ -456,7 +456,7 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo: 7 -> addReaction(event) 30315 -> processUserStatus(event) 9735 -> { - val zapperPk = Nip57.getZapperPubkey(event) + val (zapperPk, zapMessage) = resolveZapSender(event) if (zapperPk != null && isWotFiltered(zapperPk, 9735)) return val targetId = Nip57.getZappedEventId(event) ?: resolveAddressableTarget(event) @@ -470,31 +470,27 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo: synchronized(dedupSet) { if (!dedupSet.add(event.id)) return } val sats = Nip57.getZapAmountSats(event) if (sats > 0) { - val zapperPubkey = Nip57.getZapperPubkey(event) // Skip if this is our own zap and we already added it optimistically - val isOwnOptimistic = zapperPubkey == currentUserPubkey && optimisticZaps.remove(targetId) + val isOwnOptimistic = zapperPk == currentUserPubkey && optimisticZaps.remove(targetId) if (!isOwnOptimistic) { addZapSats(targetId, sats) - if (zapperPubkey != null) { - val zapMessage = Nip57.getZapMessage(event) - val isPrivateZap = dmRelayUrls.isNotEmpty() && Nip57.getZapRequestRelays(event).let { reqRelays -> - reqRelays.isNotEmpty() && reqRelays.all { it in dmRelayUrls } - } + if (zapperPk != null) { + val isPrivateZap = Nip57.isPrivateZap(event) val zaps = zapDetails.get(targetId) ?: java.util.Collections.synchronizedList(mutableListOf()).also { zapDetails.put(targetId, it) } - zaps.add(ZapDetail(zapperPubkey, sats, zapMessage, isPrivate = isPrivateZap, receiptEventId = event.id)) + zaps.add(ZapDetail(zapperPk, sats, zapMessage, isPrivate = isPrivateZap, receiptEventId = event.id)) } } // Always mark user zap flag from receipts - if (zapperPubkey == currentUserPubkey) { + if (zapperPk == currentUserPubkey) { userZaps.put(targetId, true) } // Check if this zap is a zap poll vote (kind 6969) val targetEvent = eventCache[targetId] if (targetEvent?.kind == Nip69.KIND_ZAP_POLL && sats > 0) { - addZapPollVote(event, targetId, sats, zapperPubkey) + addZapPollVote(event, targetId, sats, zapperPk) } } } @@ -808,6 +804,30 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo: return event } + /** + * Resolve the real sender + message of a kind 9735 zap receipt: + * 1. DIP-03 recipient path — decrypt the anon tag with our privkey. + * 2. DIP-03 self-attribution — match the outer ephemeral against one + * derived from our privkey + the target note's (id, created_at). + * 3. Public-zap fallback — read the embedded request's pubkey/content. + * + * Returns (senderPubkey, message). senderPubkey is null only if the + * embedded request is missing/malformed. + */ + fun resolveZapSender(receipt: NostrEvent): Pair { + val priv = keyRepo?.getKeypair()?.privkey + if (priv != null) { + Nip57.decryptPrivateZap(receipt, priv)?.let { return it.senderPubkey to it.message } + val targetId = receipt.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) + val target = targetId?.let { getEvent(it) } + if (target != null) { + Nip57.decryptOwnOutgoingPrivateZap(receipt, priv, target) + ?.let { return it.senderPubkey to it.message } + } + } + return Nip57.getZapperPubkey(receipt) to Nip57.getZapMessage(receipt) + } + /** * Bulk-load events from ObjectBox into eventCache and seenEventIds without running * the full addEvent pipeline (no engagement counts, no feed insertion). Profiles @@ -868,8 +888,10 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo: val bolt11 = event.tags.firstOrNull { it.size >= 2 && it[0] == "bolt11" }?.get(1) ?: continue val decoded = Bolt11.decode(bolt11) val hash = decoded?.paymentHash ?: continue - // Sender from embedded kind 9734 zap request - val sender = Nip57.getZapperPubkey(event) + // Sender from embedded kind 9734 zap request — for DIP-03 private + // zaps, resolveZapSender decrypts so wallet history shows the real + // counterparty rather than the ephemeral pubkey. + val sender = resolveZapSender(event).first if (sender != null) senders[hash] = sender // Recipient from 'p' tag of the receipt val recipient = event.tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) diff --git a/app/src/main/kotlin/com/darkwisp/app/repo/NotificationRepository.kt b/app/src/main/kotlin/com/darkwisp/app/repo/NotificationRepository.kt index fbc78e5..4281a18 100644 --- a/app/src/main/kotlin/com/darkwisp/app/repo/NotificationRepository.kt +++ b/app/src/main/kotlin/com/darkwisp/app/repo/NotificationRepository.kt @@ -181,14 +181,14 @@ class NotificationRepository( // For zap receipts, event.pubkey is the lightning service — check the actual zapper too. if (muteRepo?.isBlocked(event.pubkey) == true) return if (event.kind == 9735) { - val zapperPubkey = Nip57.getZapperPubkey(event) + val zapperPubkey = eventRepo?.resolveZapSender(event)?.first if (zapperPubkey != null && muteRepo?.isBlocked(zapperPubkey) == true) return } if (safetyPrefs?.wotFilterEnabled?.value == true) { val netRepo = extendedNetworkRepo if (netRepo != null && netRepo.isNetworkReady()) { val pubkeyToCheck = if (event.kind == 9735) { - Nip57.getZapperPubkey(event) ?: event.pubkey + eventRepo?.resolveZapSender(event)?.first ?: event.pubkey } else event.pubkey if (!netRepo.isInQualifiedNetwork(pubkeyToCheck)) return } @@ -592,7 +592,8 @@ class NotificationRepository( private fun mergeZap(event: NostrEvent): Boolean { val amount = Nip57.getZapAmountSats(event) if (amount <= 0) return false - val zapperPubkey = Nip57.getZapperPubkey(event) ?: return false + val (zapperPubkey, message) = eventRepo?.resolveZapSender(event) ?: (null to "") + if (zapperPubkey == null) return false val zapETag = event.tags.firstOrNull { it.size >= 2 && it[0] == "e" } ?: return mergeProfileZap(event, zapperPubkey, amount) val referencedId = zapETag[1] @@ -602,11 +603,7 @@ class NotificationRepository( // the same 9735 event to be re-processed on periodic refresh cycles. val zapEventIds = zapEventIdsByGroup.getOrPut(key) { mutableSetOf() } if (!zapEventIds.add(event.id)) return false - val message = Nip57.getZapMessage(event) - val dmRelays = eventRepo?.dmRelayUrls ?: emptySet() - val isPrivate = dmRelays.isNotEmpty() && Nip57.getZapRequestRelays(event).let { reqRelays -> - reqRelays.isNotEmpty() && reqRelays.all { it in dmRelays } - } + val isPrivate = Nip57.isPrivateZap(event) val entry = ZapEntry(pubkey = zapperPubkey, sats = amount, message = message, createdAt = event.created_at, receiptEventId = event.id, isPrivate = isPrivate) val emoji = NotificationGroup.ZAP_EMOJI val existing = groupMap[key] as? NotificationGroup.ReactionGroup @@ -663,7 +660,7 @@ class NotificationRepository( } private fun mergeProfileZap(event: NostrEvent, zapperPubkey: String, amount: Long): Boolean { - val message = Nip57.getZapMessage(event) + val message = eventRepo?.resolveZapSender(event)?.second ?: Nip57.getZapMessage(event) val recipientPubkey = event.tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) ?: return false val flatZapId = "profilezap:${event.id}" if (flatItemIds.add(flatZapId)) { diff --git a/app/src/main/kotlin/com/darkwisp/app/repo/ZapSender.kt b/app/src/main/kotlin/com/darkwisp/app/repo/ZapSender.kt index 9ecedfe..e92d753 100644 --- a/app/src/main/kotlin/com/darkwisp/app/repo/ZapSender.kt +++ b/app/src/main/kotlin/com/darkwisp/app/repo/ZapSender.kt @@ -79,7 +79,8 @@ class ZapSender( isAnonymous: Boolean = false, isPrivate: Boolean = false, extraTags: List> = emptyList(), - extraRelayHints: List = emptyList() + extraRelayHints: List = emptyList(), + eventCreatedAt: Long? = null ): Result { // 1. LNURL discovery val payInfo = Nip57.resolveLud16(recipientLud16, httpClient) @@ -93,20 +94,22 @@ class ZapSender( return Result.failure(Exception("Amount out of range (${payInfo.minSendable / 1000}-${payInfo.maxSendable / 1000} sats)")) } - // 2. Build zap request (kind 9734) + // 2. Build zap request (kind 9734). For DIP-03 private zaps we route + // the receipt only to both parties' NIP-51 DM relays — assumed to be + // AUTH-gated for reads — so the LNURL-published kind 9735 (which + // carries the recipient pubkey, amount, and target note id) never lands + // on a publicly readable relay. The anon-tag envelope hides the sender + // identity as a second layer in case the LNURL ignores `relays` or one + // of the DM relays turns out to serve reads unauthed. val relayUrls = if (isPrivate) { - // Private zap: route receipt through DM relays only val recipientDmRelays = relayListRepo.getDmRelays(recipientPubkey) ?: emptyList() val ourDmRelays = relayPool.getDmRelayUrls() - val combined = (recipientDmRelays + ourDmRelays).distinct().take(5) + val combined = (ourDmRelays + recipientDmRelays).distinct().take(5) if (combined.isEmpty()) { - return Result.failure(Exception("No DM relays available for private zap")) + return Result.failure(Exception("Private zaps require DM relays on both sides")) } combined } else { - // Extra relay hints first (e.g. live stream chat relays), then recipient's - // read relays (so they see the receipt), then our own read relays (so we can - // verify it), deduped, capped at 5. val recipientRelays = relayListRepo.getReadRelays(recipientPubkey) ?: emptyList() val ourRelays = relayPool.getReadRelayUrls() (extraRelayHints + recipientRelays + ourRelays).distinct().take(5) @@ -118,26 +121,12 @@ class ZapSender( addAll(extraTags) } - val zapRequest = if (isAnonymous) { - val throwaway = Keys.generate() - Nip57.buildZapRequest( - senderPrivkey = throwaway.privkey, - senderPubkey = throwaway.pubkey, - recipientPubkey = recipientPubkey, - eventId = eventId, - amountMsats = amountMsats, - relayUrls = relayUrls, - lnurl = recipientLud16, - message = message, - extraTags = allExtraTags - ) - } else { - val s = signer - val keypair = keyRepo.getKeypair() - - when { - s != null -> Nip57.buildZapRequestWithSigner( - signer = s, + val zapRequest = when { + isAnonymous -> { + val throwaway = Keys.generate() + Nip57.buildZapRequest( + senderPrivkey = throwaway.privkey, + senderPubkey = throwaway.pubkey, recipientPubkey = recipientPubkey, eventId = eventId, amountMsats = amountMsats, @@ -146,18 +135,57 @@ class ZapSender( message = message, extraTags = allExtraTags ) - keypair != null -> Nip57.buildZapRequest( + } + isPrivate -> { + // DIP-03 requires a concrete note target (id + created_at) to + // derive the deterministic ephemeral key. Profile / addressable + // zaps fall back to non-private at the UI gate; this is a + // defensive guard. + if (eventId == null || eventCreatedAt == null) { + return Result.failure(Exception("Private zaps require a note target")) + } + val keypair = keyRepo.getKeypair() + ?: return Result.failure(Exception("Private zaps require a local private key")) + Nip57.buildPrivateZapRequest( senderPrivkey = keypair.privkey, senderPubkey = keypair.pubkey, recipientPubkey = recipientPubkey, eventId = eventId, + eventCreatedAt = eventCreatedAt, amountMsats = amountMsats, relayUrls = relayUrls, lnurl = recipientLud16, message = message, extraTags = allExtraTags ) - else -> return Result.failure(Exception("No signer or keypair available")) + } + else -> { + val s = signer + val keypair = keyRepo.getKeypair() + when { + s != null -> Nip57.buildZapRequestWithSigner( + signer = s, + recipientPubkey = recipientPubkey, + eventId = eventId, + amountMsats = amountMsats, + relayUrls = relayUrls, + lnurl = recipientLud16, + message = message, + extraTags = allExtraTags + ) + keypair != null -> Nip57.buildZapRequest( + senderPrivkey = keypair.privkey, + senderPubkey = keypair.pubkey, + recipientPubkey = recipientPubkey, + eventId = eventId, + amountMsats = amountMsats, + relayUrls = relayUrls, + lnurl = recipientLud16, + message = message, + extraTags = allExtraTags + ) + else -> return Result.failure(Exception("No signer or keypair available")) + } } } diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/ArticleViewModel.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/ArticleViewModel.kt index 958ba46..62bbc61 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/ArticleViewModel.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/ArticleViewModel.kt @@ -151,7 +151,7 @@ class ArticleViewModel : ViewModel() { 7, 6 -> eventRepo.addEvent(event) 9735 -> { eventRepo.addEvent(event) - val zapperPubkey = Nip57.getZapperPubkey(event) + val zapperPubkey = eventRepo.resolveZapSender(event).first if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) { metadataFetcher.addToPendingProfiles(zapperPubkey) } diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/EventRouter.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/EventRouter.kt index c46af23..04cdc5f 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/EventRouter.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/EventRouter.kt @@ -134,7 +134,7 @@ class EventRouter( if (convKey != null && msgId != null) { val sats = Nip57.getZapAmountSats(event) if (sats > 0) { - val zapperPubkey = Nip57.getZapperPubkey(event) ?: event.pubkey + val zapperPubkey = eventRepo.resolveZapSender(event).first ?: event.pubkey dmRepo.addZap(convKey, msgId, DmZap(zapperPubkey, sats, event.created_at)) } } @@ -154,7 +154,7 @@ class EventRouter( metadataFetcher.addToPendingProfiles(event.pubkey) } if (event.kind == 9735) { - val zapperPubkey = Nip57.getZapperPubkey(event) + val zapperPubkey = eventRepo.resolveZapSender(event).first if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) { metadataFetcher.addToPendingProfiles(zapperPubkey) } @@ -220,7 +220,7 @@ class EventRouter( if (event.kind == 9735) { eventRepo.addEvent(event) eventRepo.addEventRelay(event.id, relayUrl) - val zapperPubkey = Nip57.getZapperPubkey(event) + val zapperPubkey = eventRepo.resolveZapSender(event).first if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) { metadataFetcher.addToPendingProfiles(zapperPubkey) } @@ -253,7 +253,7 @@ class EventRouter( 9735 -> { eventRepo.addEvent(event) eventRepo.addEventRelay(event.id, relayUrl) - val zapperPubkey = Nip57.getZapperPubkey(event) + val zapperPubkey = eventRepo.resolveZapSender(event).first if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) { metadataFetcher.addToPendingProfiles(zapperPubkey) } @@ -389,7 +389,6 @@ class EventRouter( val urls = Nip51.parseRelaySet(event) keyRepo.saveDmRelays(urls) relayPool.updateDmRelays(urls) - eventRepo.dmRelayUrls = urls.toSet() } } if (event.kind == Nip51.KIND_SEARCH_RELAYS) { diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/FeedViewModel.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/FeedViewModel.kt index 35c32bd..1750609 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/FeedViewModel.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/FeedViewModel.kt @@ -135,6 +135,11 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) { val keyRepo = KeyRepository(app) private val pubkeyHex: String? = keyRepo.getPubkeyHex() + /** True when the account has a local private key (i.e. not a remote/NIP-07 signer). + * DIP-03 private zaps require local signing for both ephemeral derivation and + * ECDH decryption, so this gates the UI toggle. */ + val hasLocalKeypair: Boolean = keyRepo.getKeypair() != null + var signer: NostrSigner? = null private set @@ -181,6 +186,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) { it.currentUserPubkey = pubkeyHex it.deletedEventsRepo = deletedEventsRepo it.eventPersistence = eventPersistence + it.keyRepo = keyRepo } val contactRepo = ContactRepository(app, pubkeyHex).also { eventRepo.contactRepo = it diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/SocialActionManager.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/SocialActionManager.kt index bf0170b..f8fb7cf 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/SocialActionManager.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/SocialActionManager.kt @@ -381,7 +381,10 @@ class SocialActionManager( } else { subscribeZapReceipt(event.id) } - // For private zaps, also subscribe on DM relays for the receipt + // For private zaps, also subscribe on our DM relays — that's where + // the LNURL will publish the receipt (per ZapSender's `relays` tag). + // Subscriptions over DM relays go through NIP-42 AUTH automatically + // via the relay's existing auth handshake. if (isPrivate && relayPool.hasDmRelays()) { val dmFilter = if (eventATag != null) { Filter(kinds = listOf(9735), aTags = listOf(eventATag)) @@ -418,7 +421,8 @@ class SocialActionManager( isAnonymous = isAnonymous, isPrivate = isPrivate, extraTags = zapExtraTags, - extraRelayHints = extraRelayHints + extraRelayHints = extraRelayHints, + eventCreatedAt = event.created_at ) _zapInProgress.value = _zapInProgress.value - event.id result.fold( diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/StartupCoordinator.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/StartupCoordinator.kt index 7fa3991..6b5834e 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/StartupCoordinator.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/StartupCoordinator.kt @@ -225,7 +225,6 @@ class StartupCoordinator( relayPool.updateRelays(initialRelays) val dmRelays = keyRepo.getDmRelays() relayPool.updateDmRelays(dmRelays) - eventRepo.dmRelayUrls = dmRelays.toSet() // Connect local relay if configured relayPool.updateLocalRelay(keyRepo.getLocalRelay(), getUserPubkey()) @@ -987,7 +986,6 @@ class StartupCoordinator( relayPool.updateRelays(relays) val dmRelays = keyRepo.getDmRelays() relayPool.updateDmRelays(dmRelays) - eventRepo.dmRelayUrls = dmRelays.toSet() feedSub.subscribeFeed() } } diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/ThreadViewModel.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/ThreadViewModel.kt index c8c2c33..0ae9999 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/ThreadViewModel.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/ThreadViewModel.kt @@ -229,7 +229,7 @@ class ThreadViewModel : ViewModel() { 5, 7, 6, 1018 -> eventRepo.addEvent(event) 9735 -> { eventRepo.addEvent(event) - val zapperPubkey = com.darkwisp.app.nostr.Nip57.getZapperPubkey(event) + val zapperPubkey = eventRepo.resolveZapSender(event).first if (zapperPubkey != null && eventRepo.getProfileData(zapperPubkey) == null) { metadataFetcher.addToPendingProfiles(zapperPubkey) } From 8177cfc115fac67c0bc037c82a2405bfc16872f4 Mon Sep 17 00:00:00 2001 From: Barry Deen Date: Wed, 10 Jun 2026 12:04:58 -0400 Subject: [PATCH 3/3] feat(private-replies): gift-wrapped reactions + DIP-03 zaps (port wisp#543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes the private-event marker (markPrivateReply/isPrivateReply → markPrivate/isPrivate) and re-enables React and Zap on private replies. Reactions on a private reply are kind 7 rumors gift-wrapped to every thread participant (the k tag distinguishes k=1 reply reactions from k=14 DM reactions); PrivateReactionPublisher mirrors the reply publisher with per-recipient relay resolution and a self-copy. Zapping a private reply locks the ZapDialog to DIP-03 private mode (forcePrivate) so a public zap can never attach an e-tag to the rumor id, and sendZap forces the flag server-side as a defensive guard. Repost/Quote stay hidden. Dark Wisp addition over the upstream PR: PrivateRumorHandler gains handlePrivateReaction and the remote-signer pending-decrypt paths route k=1 reaction rumors through it, matching the EventRouter behavior. --- .../kotlin/com/darkwisp/app/Navigation.kt | 13 +- .../kotlin/com/darkwisp/app/nostr/Nip17.kt | 78 +++++++++-- .../darkwisp/app/nostr/NotificationItem.kt | 1 + .../com/darkwisp/app/repo/EventRepository.kt | 13 +- .../app/repo/NotificationRepository.kt | 3 +- .../app/repo/PrivateReactionPublisher.kt | 129 ++++++++++++++++++ .../app/repo/PrivateReplyPublisher.kt | 2 +- .../darkwisp/app/repo/PrivateRumorHandler.kt | 52 ++++++- .../darkwisp/app/ui/component/ActionBar.kt | 84 +++++++----- .../com/darkwisp/app/ui/component/PostCard.kt | 4 + .../darkwisp/app/ui/component/ZapDialog.kt | 32 ++++- .../app/ui/screen/NotificationsScreen.kt | 4 +- .../darkwisp/app/ui/screen/ThreadScreen.kt | 20 ++- .../app/viewmodel/ComposeViewModel.kt | 2 +- .../app/viewmodel/DmConversationViewModel.kt | 19 ++- .../darkwisp/app/viewmodel/DmListViewModel.kt | 17 ++- .../com/darkwisp/app/viewmodel/EventRouter.kt | 22 ++- .../darkwisp/app/viewmodel/FeedViewModel.kt | 3 +- .../app/viewmodel/SocialActionManager.kt | 41 ++++++ app/src/main/res/values/strings.xml | 2 + 20 files changed, 455 insertions(+), 86 deletions(-) create mode 100644 app/src/main/kotlin/com/darkwisp/app/repo/PrivateReactionPublisher.kt diff --git a/app/src/main/kotlin/com/darkwisp/app/Navigation.kt b/app/src/main/kotlin/com/darkwisp/app/Navigation.kt index ef123cf..9b36fe4 100644 --- a/app/src/main/kotlin/com/darkwisp/app/Navigation.kt +++ b/app/src/main/kotlin/com/darkwisp/app/Navigation.kt @@ -1867,7 +1867,8 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.hasLocalKeypair && threadUserHasDmRelays && threadRecipientHasDmRelays + canPrivateZap = feedViewModel.hasLocalKeypair && threadUserHasDmRelays && threadRecipientHasDmRelays, + forcePrivate = threadZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true ) } val threadSetListedIds by feedViewModel.bookmarkSetRepo.allListedEventIds.collectAsState() @@ -1959,6 +1960,11 @@ fun WispNavHost( isEmojiSetAdded = { pubkey, dTag -> val ref = com.darkwisp.app.nostr.Nip30.buildSetReference(pubkey, dTag) feedViewModel.customEmojiRepo.userEmojiList.value?.setReferences?.contains(ref) ?: false + }, + canPrivateZapFor = { event -> + feedViewModel.hasLocalKeypair && + feedViewModel.relayPool.hasDmRelays() && + feedViewModel.relayListRepo.hasDmRelays(event.pubkey) } ) @@ -3110,7 +3116,8 @@ fun WispNavHost( feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate) }, onGoToWallet = { navController.navigate(Routes.WALLET) }, - canPrivateZap = feedViewModel.hasLocalKeypair && notifUserHasDmRelays && notifRecipientHasDmRelays + canPrivateZap = feedViewModel.hasLocalKeypair && notifUserHasDmRelays && notifRecipientHasDmRelays, + forcePrivate = notifZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true ) } @@ -3162,7 +3169,7 @@ fun WispNavHost( // If the parent is a private reply we received, keep the thread encrypted // by gift-wrapping this reply too. Otherwise fall through to the public path. - if (feedViewModel.eventRepo.isPrivateReply(replyToEvent.id)) { + if (feedViewModel.eventRepo.isPrivate(replyToEvent.id)) { val difficulty = if (feedViewModel.powPrefs.isNotePowEnabled()) feedViewModel.powPrefs.getNoteDifficulty() else 0 com.darkwisp.app.repo.PrivateReplyPublisher.send( signer = signer, diff --git a/app/src/main/kotlin/com/darkwisp/app/nostr/Nip17.kt b/app/src/main/kotlin/com/darkwisp/app/nostr/Nip17.kt index 8b76500..eed76dd 100644 --- a/app/src/main/kotlin/com/darkwisp/app/nostr/Nip17.kt +++ b/app/src/main/kotlin/com/darkwisp/app/nostr/Nip17.kt @@ -349,24 +349,30 @@ object Nip17 { } /** - * Create a single gift-wrapped private DM reaction for [recipientPubkey]. + * Create a single gift-wrapped reaction (kind 7 rumor) for [recipientPubkey]. * Call in a loop over all conversation participants to broadcast the reaction. + * * The inner rumor carries the emoji as content and an e-tag pointing to - * [targetRumorId] so recipients can associate the reaction with the right message. + * [targetRumorId] so recipients can associate it with the right message. + * [targetKind] (the kind of the message being reacted to — 14 for DMs, 1 for + * a NIP-17 private reply) becomes the "k" tag, letting receivers route the + * reaction to the right repository (DM conversation vs. note thread). */ - suspend fun createDmReaction( + suspend fun createGiftWrappedReaction( senderPrivkey: ByteArray, senderPubkey: ByteArray, recipientPubkey: ByteArray, targetRumorId: String, - originalSenderPubkey: String, + targetAuthor: String, + targetKind: Int, emoji: String, - emojiUrl: String? = null + emojiUrl: String? = null, + createdAt: Long = System.currentTimeMillis() / 1000 ): NostrEvent { val tags = mutableListOf( listOf("e", targetRumorId), - listOf("p", originalSenderPubkey), - listOf("k", "14") + listOf("p", targetAuthor), + listOf("k", targetKind.toString()) ) if (emojiUrl != null) { tags.add(listOf("emoji", emoji.removeSurrounding(":"), emojiUrl)) @@ -377,23 +383,26 @@ object Nip17 { recipientPubkey = recipientPubkey, message = emoji, rumorKind = 7, - replyTags = tags + replyTags = tags, + createdAt = createdAt ) } - /** Remote-signer variant of [createDmReaction]. */ - suspend fun createDmReactionRemote( + /** Remote-signer variant of [createGiftWrappedReaction]. */ + suspend fun createGiftWrappedReactionRemote( signer: NostrSigner, recipientPubkeyHex: String, targetRumorId: String, - originalSenderPubkey: String, + targetAuthor: String, + targetKind: Int, emoji: String, - emojiUrl: String? = null + emojiUrl: String? = null, + createdAt: Long = System.currentTimeMillis() / 1000 ): NostrEvent { val tags = mutableListOf( listOf("e", targetRumorId), - listOf("p", originalSenderPubkey), - listOf("k", "14") + listOf("p", targetAuthor), + listOf("k", targetKind.toString()) ) if (emojiUrl != null) { tags.add(listOf("emoji", emoji.removeSurrounding(":"), emojiUrl)) @@ -403,10 +412,49 @@ object Nip17 { recipientPubkeyHex = recipientPubkeyHex, message = emoji, rumorKind = 7, - replyTags = tags + replyTags = tags, + createdAt = createdAt ) } + /** DM-targeted reaction. Thin wrapper around [createGiftWrappedReaction] with `k=14`. */ + suspend fun createDmReaction( + senderPrivkey: ByteArray, + senderPubkey: ByteArray, + recipientPubkey: ByteArray, + targetRumorId: String, + originalSenderPubkey: String, + emoji: String, + emojiUrl: String? = null + ): NostrEvent = createGiftWrappedReaction( + senderPrivkey = senderPrivkey, + senderPubkey = senderPubkey, + recipientPubkey = recipientPubkey, + targetRumorId = targetRumorId, + targetAuthor = originalSenderPubkey, + targetKind = 14, + emoji = emoji, + emojiUrl = emojiUrl + ) + + /** Remote-signer variant of [createDmReaction]. */ + suspend fun createDmReactionRemote( + signer: NostrSigner, + recipientPubkeyHex: String, + targetRumorId: String, + originalSenderPubkey: String, + emoji: String, + emojiUrl: String? = null + ): NostrEvent = createGiftWrappedReactionRemote( + signer = signer, + recipientPubkeyHex = recipientPubkeyHex, + targetRumorId = targetRumorId, + targetAuthor = originalSenderPubkey, + targetKind = 14, + emoji = emoji, + emojiUrl = emojiUrl + ) + private fun randomizeTimestamp(base: Long): Long { // 0 to 1 day in the past — NIP-17 spec allows up to 2 days, but keeping it to 1 day // ensures interop with clients (e.g. Amethyst) whose kind-1059 subscription may use diff --git a/app/src/main/kotlin/com/darkwisp/app/nostr/NotificationItem.kt b/app/src/main/kotlin/com/darkwisp/app/nostr/NotificationItem.kt index 28b1f75..51d9472 100644 --- a/app/src/main/kotlin/com/darkwisp/app/nostr/NotificationItem.kt +++ b/app/src/main/kotlin/com/darkwisp/app/nostr/NotificationItem.kt @@ -14,6 +14,7 @@ data class FlatNotificationItem( val zapMessage: String = "", val isPrivateZap: Boolean = false, val isPrivateReply: Boolean = false, + val isPrivateReaction: Boolean = false, val replyEventId: String? = null, val quoteEventId: String? = null, val voteOptionIds: List = emptyList(), diff --git a/app/src/main/kotlin/com/darkwisp/app/repo/EventRepository.kt b/app/src/main/kotlin/com/darkwisp/app/repo/EventRepository.kt index 2c72b42..99e7424 100644 --- a/app/src/main/kotlin/com/darkwisp/app/repo/EventRepository.kt +++ b/app/src/main/kotlin/com/darkwisp/app/repo/EventRepository.kt @@ -46,12 +46,13 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo: private val eventCache = ConcurrentHashMap() private val seenEventIds = ConcurrentHashMap.newKeySet() // thread-safe dedup that doesn't evict - // NIP-17 private replies: event IDs (= rumor IDs) of kind 1 notes received via gift wrap. - // Screens read this to show a lock indicator next to the reply. - private val privateReplyIds = ConcurrentHashMap.newKeySet() + // 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() - fun markPrivateReply(eventId: String) { privateReplyIds.add(eventId) } - fun isPrivateReply(eventId: String): Boolean = eventId in privateReplyIds + fun markPrivate(eventId: String) { privateEventIds.add(eventId) } + fun isPrivate(eventId: String): Boolean = eventId in privateEventIds /** Emits event IDs that were removed (e.g. via NIP-09 deletion). Screens like ThreadScreen * observe this to prune their local state when a deletion arrives on any subscription. */ @@ -1450,7 +1451,7 @@ class EventRepository(val profileRepo: ProfileRepository? = null, val muteRepo: resetFeedDisplay() eventCache.clear() seenEventIds.clear() - privateReplyIds.clear() + privateEventIds.clear() } fun clearAll() { diff --git a/app/src/main/kotlin/com/darkwisp/app/repo/NotificationRepository.kt b/app/src/main/kotlin/com/darkwisp/app/repo/NotificationRepository.kt index 4281a18..f1d004c 100644 --- a/app/src/main/kotlin/com/darkwisp/app/repo/NotificationRepository.kt +++ b/app/src/main/kotlin/com/darkwisp/app/repo/NotificationRepository.kt @@ -582,6 +582,7 @@ class NotificationRepository( timestamp = event.created_at, emoji = emoji, emojiUrl = flatEmojiUrl, + isPrivateReaction = eventRepo?.isPrivate(event.id) == true, groupChatId = groupChatId )) } @@ -735,7 +736,7 @@ class NotificationRepository( referencedEventId = replyTarget, timestamp = event.created_at, replyEventId = event.id, - isPrivateReply = eventRepo?.isPrivateReply(event.id) == true, + isPrivateReply = eventRepo?.isPrivate(event.id) == true, groupChatId = groupChatId )) } diff --git a/app/src/main/kotlin/com/darkwisp/app/repo/PrivateReactionPublisher.kt b/app/src/main/kotlin/com/darkwisp/app/repo/PrivateReactionPublisher.kt new file mode 100644 index 0000000..429444e --- /dev/null +++ b/app/src/main/kotlin/com/darkwisp/app/repo/PrivateReactionPublisher.kt @@ -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 = 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> { + 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) + } +} diff --git a/app/src/main/kotlin/com/darkwisp/app/repo/PrivateReplyPublisher.kt b/app/src/main/kotlin/com/darkwisp/app/repo/PrivateReplyPublisher.kt index b339a84..7ef4818 100644 --- a/app/src/main/kotlin/com/darkwisp/app/repo/PrivateReplyPublisher.kt +++ b/app/src/main/kotlin/com/darkwisp/app/repo/PrivateReplyPublisher.kt @@ -111,7 +111,7 @@ object PrivateReplyPublisher { content = content, sig = "" ) - eventRepo?.markPrivateReply(rumorId) + eventRepo?.markPrivate(rumorId) eventRepo?.cacheEvent(synthetic) eventRepo?.addReplyCount(replyTo.id, rumorId) Nip10.getRootId(replyTo)?.takeIf { it != replyTo.id }?.let { rootId -> diff --git a/app/src/main/kotlin/com/darkwisp/app/repo/PrivateRumorHandler.kt b/app/src/main/kotlin/com/darkwisp/app/repo/PrivateRumorHandler.kt index 894458f..c082a8f 100644 --- a/app/src/main/kotlin/com/darkwisp/app/repo/PrivateRumorHandler.kt +++ b/app/src/main/kotlin/com/darkwisp/app/repo/PrivateRumorHandler.kt @@ -1,21 +1,27 @@ 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) into the - * note + notification repositories. + * 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. + * 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( @@ -38,7 +44,7 @@ object PrivateRumorHandler { content = rumor.content, sig = "" ) - eventRepo.markPrivateReply(rumorId) + eventRepo.markPrivate(rumorId) eventRepo.cacheEvent(synthetic) if (!Nip10.isStandaloneQuote(synthetic)) { val parentId = Nip10.getReplyTarget(synthetic) @@ -52,4 +58,42 @@ object PrivateRumorHandler { 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) + } + } } diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/component/ActionBar.kt b/app/src/main/kotlin/com/darkwisp/app/ui/component/ActionBar.kt index 0c0d380..38851a5 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/component/ActionBar.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/component/ActionBar.kt @@ -85,6 +85,8 @@ fun ActionBar( unicodeEmojis: List = emptyList(), onOpenEmojiLibrary: (() -> Unit)? = null, isPrivate: Boolean = false, + zapEnabled: Boolean = true, + onZapDisabledTap: () -> Unit = {}, modifier: Modifier = Modifier ) { val context = androidx.compose.ui.platform.LocalContext.current @@ -108,9 +110,9 @@ fun ActionBar( color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1 ) - // Private replies hide React / Repost / Quote / Zap to avoid leaking the rumor id - // on public relays. Reply (above) and Bookmark (below) remain available. - if (!isPrivate) { + // React + Zap are available on private replies as gift-wrapped/DIP-03 actions. + // Repost / Quote stay hidden on private replies because those events would + // publicly attach an e-tag pointing at the encrypted rumor id. Spacer(Modifier.width(8.dp)) Box { Box( @@ -164,60 +166,71 @@ fun ActionBar( color = if (userReactionEmojis.isNotEmpty()) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1 ) - Spacer(Modifier.width(8.dp)) - Box { - IconButton(onClick = { showRepostMenu = true }) { - Icon( - Icons.Outlined.Repeat, - contentDescription = stringResource(R.string.cd_repost), - tint = if (hasUserReposted) WispThemeColors.repostColor else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp) - ) - } - if (showRepostMenu) { - RepostPopup( - onRepost = { - onRepost() - showRepostMenu = false - }, - onQuote = { - onQuote() - showRepostMenu = false - }, - onDismiss = { showRepostMenu = false } - ) + if (!isPrivate) { + Spacer(Modifier.width(8.dp)) + Box { + IconButton(onClick = { showRepostMenu = true }) { + Icon( + Icons.Outlined.Repeat, + contentDescription = stringResource(R.string.cd_repost), + tint = if (hasUserReposted) WispThemeColors.repostColor else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp) + ) + } + if (showRepostMenu) { + RepostPopup( + onRepost = { + onRepost() + showRepostMenu = false + }, + onQuote = { + onQuote() + showRepostMenu = false + }, + onDismiss = { showRepostMenu = false } + ) + } } + Text( + text = repostCount.toString(), + style = MaterialTheme.typography.labelSmall, + color = if (hasUserReposted) WispThemeColors.repostColor else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1 + ) } - Text( - text = repostCount.toString(), - style = MaterialTheme.typography.labelSmall, - color = if (hasUserReposted) WispThemeColors.repostColor else MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1 - ) Spacer(Modifier.width(8.dp)) Box { - IconButton(onClick = onZap, enabled = !isZapInProgress) { + val zapClickable = !isZapInProgress + IconButton( + onClick = { if (zapEnabled) onZap() else onZapDisabledTap() }, + enabled = zapClickable + ) { + val zapTint = when { + !zapEnabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + hasUserZapped -> WispThemeColors.zapColor + else -> MaterialTheme.colorScheme.onSurfaceVariant + } if (isZapInProgress) { LightningAnimation(modifier = Modifier.size(width = 14.dp, height = 22.dp)) } else if (fiatMode) { Icon( painter = painterResource(R.drawable.ic_coin_stack), contentDescription = stringResource(R.string.cd_zaps), - tint = if (hasUserZapped) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant, + tint = zapTint, modifier = Modifier.size(22.dp) ) } else if (useZapBoltIcon) { Icon( painter = painterResource(R.drawable.ic_bolt), contentDescription = stringResource(R.string.cd_zaps), - tint = if (hasUserZapped) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant, + tint = zapTint, modifier = Modifier.size(18.dp) ) } else { Icon( Icons.Outlined.CurrencyBitcoin, contentDescription = stringResource(R.string.cd_zaps), - tint = if (hasUserZapped) WispThemeColors.zapColor else MaterialTheme.colorScheme.onSurfaceVariant, + tint = zapTint, modifier = Modifier.size(22.dp) ) } @@ -247,7 +260,6 @@ fun ActionBar( overflow = TextOverflow.Ellipsis ) } - } // end !isPrivate Spacer(Modifier.width(8.dp)) IconButton(onClick = onAddToList) { Icon( diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/component/PostCard.kt b/app/src/main/kotlin/com/darkwisp/app/ui/component/PostCard.kt index 7b1d3df..7f00698 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/component/PostCard.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/component/PostCard.kt @@ -123,6 +123,8 @@ fun PostCard( hasUserReposted: Boolean = false, repostCount: Int = 0, onZap: () -> Unit = {}, + onZapDisabledTap: () -> Unit = {}, + zapEnabled: Boolean = true, hasUserZapped: Boolean = false, likeCount: Int = 0, replyCount: Int = 0, @@ -801,6 +803,8 @@ fun PostCard( unicodeEmojis = unicodeEmojis, onOpenEmojiLibrary = onOpenEmojiLibrary, isPrivate = isPrivate, + zapEnabled = zapEnabled, + onZapDisabledTap = onZapDisabledTap, modifier = Modifier.weight(1f) ) Icon( diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/component/ZapDialog.kt b/app/src/main/kotlin/com/darkwisp/app/ui/component/ZapDialog.kt index 693a86c..fff1c66 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/component/ZapDialog.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/component/ZapDialog.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material.icons.automirrored.outlined.Message import androidx.compose.material3.AlertDialog @@ -107,6 +108,12 @@ fun ZapDialog( onZap: (amountMsats: Long, message: String, isAnonymous: Boolean, isPrivate: Boolean) -> Unit, onGoToWallet: () -> Unit, canPrivateZap: Boolean = false, + /** + * Lock the zap to DIP-03 private mode (private + anon toggles hidden, isPrivate held true). + * Used when zapping a NIP-17 private reply — falling back to a public zap would attach an + * e-tag pointing at the rumor id on public relays. + */ + forcePrivate: Boolean = false, /** When opening from a quick preset (e.g. chat actions sheet), pre-select that amount in sats. */ initialSatsHint: Int? = null ) { @@ -140,7 +147,7 @@ fun ZapDialog( var customAmount by remember { mutableStateOf("") } var message by remember { mutableStateOf("") } var isAnonymous by remember { mutableStateOf(false) } - var isPrivate by remember { mutableStateOf(false) } + var isPrivate by remember(forcePrivate) { mutableStateOf(forcePrivate) } var editMode by remember { mutableStateOf(false) } LaunchedEffect(initialSatsHint) { @@ -404,6 +411,28 @@ fun ZapDialog( Spacer(Modifier.height(16.dp)) + if (forcePrivate) { + // Parent is a private reply — zap is locked to DIP-03 mode and the + // anon/private toggles are hidden. A small label keeps the user + // oriented; the lock icon mirrors the orange lock used elsewhere. + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.VisibilityOff, + contentDescription = null, + tint = LightningOrange, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + text = stringResource(R.string.zap_private_locked_for_private_reply), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + } else { // Anonymous toggle Row( modifier = Modifier.fillMaxWidth(), @@ -468,6 +497,7 @@ fun ZapDialog( ) ) } + } // end !forcePrivate Spacer(Modifier.height(16.dp)) diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/screen/NotificationsScreen.kt b/app/src/main/kotlin/com/darkwisp/app/ui/screen/NotificationsScreen.kt index 19e1cb2..4d2d758 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/screen/NotificationsScreen.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/screen/NotificationsScreen.kt @@ -685,11 +685,11 @@ private fun ZenNotificationRow( color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1 ) - if (item.isPrivateReply) { + if (item.isPrivateReply || item.isPrivateReaction || item.isPrivateZap) { Spacer(Modifier.width(4.dp)) Icon( imageVector = Icons.Outlined.VisibilityOff, - contentDescription = "Private reply", + contentDescription = "Private", modifier = Modifier.size(14.dp), tint = androidx.compose.ui.graphics.Color(0xFFFF8C00) ) diff --git a/app/src/main/kotlin/com/darkwisp/app/ui/screen/ThreadScreen.kt b/app/src/main/kotlin/com/darkwisp/app/ui/screen/ThreadScreen.kt index 0a80210..c2e4e92 100644 --- a/app/src/main/kotlin/com/darkwisp/app/ui/screen/ThreadScreen.kt +++ b/app/src/main/kotlin/com/darkwisp/app/ui/screen/ThreadScreen.kt @@ -50,7 +50,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import android.widget.Toast import com.darkwisp.app.nostr.Nip69 import com.darkwisp.app.nostr.NostrEvent import com.darkwisp.app.repo.ContactRepository @@ -108,7 +110,9 @@ fun ThreadScreen( fetchGroupPreview: (suspend (String, String) -> com.darkwisp.app.repo.GroupPreview?)? = null, onAddEmojiSet: ((String, String) -> Unit)? = null, onRemoveEmojiSet: ((String, String) -> Unit)? = null, - isEmojiSetAdded: ((String, String) -> Boolean)? = null + isEmojiSetAdded: ((String, String) -> Boolean)? = null, + /** Whether the user can private-zap [event]'s author (local keypair + DM relays on both sides). */ + canPrivateZapFor: (NostrEvent) -> Boolean = { false } ) { val flatThread by viewModel.flatThread.collectAsState() val isLoading by viewModel.isLoading.collectAsState() @@ -198,6 +202,12 @@ fun ThreadScreen( ) } + val zapDisabledContext = LocalContext.current + val zapDisabledMessage = stringResource(R.string.zap_private_requires_dm_relays) + val onZapDisabledTap: () -> Unit = { + Toast.makeText(zapDisabledContext, zapDisabledMessage, Toast.LENGTH_SHORT).show() + } + Scaffold( contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { @@ -367,7 +377,9 @@ fun ThreadScreen( onBlockAuthor = { onBlockUser(event.pubkey) }, isFollowingAuthor = followList.let { contactRepo.isFollowing(event.pubkey) }, isOwnEvent = event.pubkey == userPubkey, - isPrivate = eventRepo.isPrivateReply(event.id), + isPrivate = eventRepo.isPrivate(event.id), + zapEnabled = !eventRepo.isPrivate(event.id) || canPrivateZapFor(event), + onZapDisabledTap = onZapDisabledTap, onAddToList = { onAddToList(event.id) }, isInList = event.id in listedIds, onPin = { onTogglePin(event.id) }, @@ -455,7 +467,9 @@ fun ThreadScreen( onBlockAuthor = { onBlockUser(event.pubkey) }, isFollowingAuthor = followList.let { contactRepo.isFollowing(event.pubkey) }, isOwnEvent = event.pubkey == userPubkey, - isPrivate = eventRepo.isPrivateReply(event.id), + isPrivate = eventRepo.isPrivate(event.id), + zapEnabled = !eventRepo.isPrivate(event.id) || canPrivateZapFor(event), + onZapDisabledTap = onZapDisabledTap, onAddToList = { onAddToList(event.id) }, isInList = event.id in listedIds, onPin = { onTogglePin(event.id) }, diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/ComposeViewModel.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/ComposeViewModel.kt index e0d99c6..016a368 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/ComposeViewModel.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/ComposeViewModel.kt @@ -135,7 +135,7 @@ class ComposeViewModel(app: Application, private val savedStateHandle: SavedStat /** Called by ComposeScreen when the screen mounts with [replyTo]; auto-enables * + locks the private toggle if [replyTo] is itself a private reply we received. */ fun configureForReply(replyTo: NostrEvent?) { - val isReplyingToPrivate = replyTo != null && eventRepo?.isPrivateReply(replyTo.id) == true + val isReplyingToPrivate = replyTo != null && eventRepo?.isPrivate(replyTo.id) == true _privateReplyLocked.value = isReplyingToPrivate if (isReplyingToPrivate) _privateReply.value = true } diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/DmConversationViewModel.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/DmConversationViewModel.kt index af6c82c..649487e 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/DmConversationViewModel.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/DmConversationViewModel.kt @@ -245,12 +245,21 @@ class DmConversationViewModel(app: Application) : AndroidViewModel(app) { try { val rumor = Nip17.unwrapGiftWrapRemote(signer, wrap.event) ?: continue - // NIP-17 private reply (kind 1 rumor) — belongs in threads, not DMs - if (rumor.kind == 1) { + // 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) { - com.darkwisp.app.repo.PrivateRumorHandler.handlePrivateReply( - rumor, myPubkey, eventRepo, notifRepo, muteRepo - ) + 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 } diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/DmListViewModel.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/DmListViewModel.kt index 52965c9..21ddb93 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/DmListViewModel.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/DmListViewModel.kt @@ -49,13 +49,18 @@ class DmListViewModel(app: Application) : AndroidViewModel(app) { notifRepo = notificationRepository } - /** Route a non-DM rumor (kind 1 private reply) out of the DM pipeline. - * Returns true when the rumor was consumed. */ + /** 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 { - if (rumor.kind != 1) return false + 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 - PrivateRumorHandler.handlePrivateReply(rumor, myPubkey, eRepo, nRepo, muteRepo) + if (isPrivateReplyReaction) { + PrivateRumorHandler.handlePrivateReaction(rumor, myPubkey, eRepo, nRepo, muteRepo) + } else { + PrivateRumorHandler.handlePrivateReply(rumor, myPubkey, eRepo, nRepo, muteRepo) + } return true } @@ -79,7 +84,7 @@ class DmListViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch(Dispatchers.Default) { val rumor = Nip17.unwrapGiftWrap(keypair.privkey, event) ?: return@launch - // NIP-17 private reply (kind 1 rumor) — belongs in threads, not DMs + // 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 @@ -171,7 +176,7 @@ class DmListViewModel(app: Application) : AndroidViewModel(app) { try { val rumor = Nip17.unwrapGiftWrapRemote(signer, wrap.event) ?: continue - // NIP-17 private reply (kind 1 rumor) — belongs in threads, not DMs + // 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)) { diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/EventRouter.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/EventRouter.kt index 04cdc5f..1cee0d6 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/EventRouter.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/EventRouter.kt @@ -608,8 +608,17 @@ class EventRouter( null } ?: return - // Private DM reaction — associate with the target message, not a new conversation entry + // Private reaction (kind 7 rumor). The "k" tag carries the kind of the message + // being reacted to: k=1 → reaction on a NIP-17 private reply, route through the + // note repository so thread/notification rendering picks it up the same way as + // public reactions; k=14 (or absent for back-compat) → DM reaction, route into + // the DM conversation entry. if (Nip17.isReaction(rumor)) { + val kTag = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "k" }?.get(1) + if (kTag == "1") { + handlePrivateReplyReaction(rumor, myPubkey) + return + } val targetId = rumor.tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) ?: return val participants = Nip17.getConversationParticipants(rumor, myPubkey) if (participants.any { muteRepo.isBlocked(it) }) return @@ -657,6 +666,17 @@ class EventRouter( dmRepo.addMessage(msg, convKey) } + private fun handlePrivateReplyReaction(rumor: Nip17.Rumor, myPubkey: String) { + com.darkwisp.app.repo.PrivateRumorHandler.handlePrivateReaction( + rumor = rumor, + myPubkey = myPubkey, + eventRepo = eventRepo, + notifRepo = notifRepo, + muteRepo = muteRepo, + onMissingProfile = { metadataFetcher.addToPendingProfiles(it) } + ) + } + private fun handlePrivateReply(wrap: NostrEvent, rumor: Nip17.Rumor, myPubkey: String) { com.darkwisp.app.repo.PrivateRumorHandler.handlePrivateReply( rumor = rumor, diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/FeedViewModel.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/FeedViewModel.kt index 1750609..b0dbe6b 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/FeedViewModel.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/FeedViewModel.kt @@ -305,7 +305,8 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) { val socialActions: SocialActionManager = SocialActionManager( relayPool, outboxRouter, eventRepo, contactRepo, muteRepo, notifRepo, dmRepo, - pinRepo, deletedEventsRepo, { activeWalletProvider }, customEmojiRepo, zapSender, powPrefs, interfacePrefs, viewModelScope, + pinRepo, deletedEventsRepo, { activeWalletProvider }, customEmojiRepo, zapSender, powPrefs, interfacePrefs, + relayListRepo, viewModelScope, getSigner = { signer }, getUserPubkey = { getUserPubkey() } ) diff --git a/app/src/main/kotlin/com/darkwisp/app/viewmodel/SocialActionManager.kt b/app/src/main/kotlin/com/darkwisp/app/viewmodel/SocialActionManager.kt index f8fb7cf..d613b18 100644 --- a/app/src/main/kotlin/com/darkwisp/app/viewmodel/SocialActionManager.kt +++ b/app/src/main/kotlin/com/darkwisp/app/viewmodel/SocialActionManager.kt @@ -21,6 +21,8 @@ import com.darkwisp.app.repo.DmRepository import com.darkwisp.app.repo.EventRepository import com.darkwisp.app.repo.MuteRepository import com.darkwisp.app.repo.NotificationRepository +import com.darkwisp.app.repo.PrivateReactionPublisher +import com.darkwisp.app.repo.RelayListRepository import com.darkwisp.app.repo.WalletProvider import com.darkwisp.app.repo.PinRepository import com.darkwisp.app.repo.CustomEmojiRepository @@ -59,6 +61,7 @@ class SocialActionManager( private val zapSender: ZapSender, private val powPrefs: PowPreferences, private val interfacePrefs: InterfacePreferences, + private val relayListRepo: RelayListRepository, private val scope: CoroutineScope, private val getSigner: () -> NostrSigner?, private val getUserPubkey: () -> String? @@ -238,6 +241,38 @@ class SocialActionManager( fun toggleReaction(event: NostrEvent, emoji: String) { val s = getSigner() ?: return val myPubkey = s.pubkeyHex + + // NIP-17 private replies are reacted to with a gift-wrapped kind-7 rumor so + // the target rumor id never lands on a public relay. v1 is add-only: if the + // user has already reacted with this emoji we no-op rather than publishing a + // gift-wrapped NIP-09 deletion (gift-wrapped deletion is a future enhancement). + if (eventRepo.isPrivate(event.id)) { + val existingEmoji = eventRepo.getUserReactionEmoji(event.id, myPubkey) + if (existingEmoji != null) return + val shortcodeMatch = Nip30.shortcodeRegex.matchEntire(emoji) + val emojiUrl = if (shortcodeMatch != null) { + val shortcode = shortcodeMatch.groupValues[1] + customEmojiRepo.resolvedEmojis.value[shortcode] + } else null + scope.launch { + try { + PrivateReactionPublisher.send( + signer = s, + relayPool = relayPool, + dmRepo = dmRepo, + relayListRepo = relayListRepo, + eventRepo = eventRepo, + targetEvent = event, + emoji = emoji, + emojiUrl = emojiUrl + ) + _reactionSent.tryEmit(Unit) + customEmojiRepo.recordEmojiUsage(emoji) + } catch (_: Exception) {} + } + return + } + val existingEventId = eventRepo.getUserReactionEventId(event.id, myPubkey, emoji) scope.launch { @@ -352,6 +387,12 @@ class SocialActionManager( * "a" tag instead of "e" so the receipt is associated with the addressable event. */ fun sendZap(event: NostrEvent, amountMsats: Long, message: String = "", isAnonymous: Boolean = false, isPrivate: Boolean = false, extraRelayHints: List = emptyList(), recipientOverride: String? = null, eventATag: String? = null) { + // NIP-17 private reply targets must use DIP-03. The synthetic event's id is the + // rumor id and its created_at is the rumor timestamp — both feed the existing + // ephemeral derivation cleanly. Force the flag even if the caller forgot it so a + // public-zap fallback can never leak the rumor id on public relays. + @Suppress("NAME_SHADOWING") + val isPrivate = isPrivate || eventRepo.isPrivate(event.id) val recipientPubkey = recipientOverride ?: event.pubkey val profileData = eventRepo.getProfileData(recipientPubkey) val lud16 = profileData?.lud16 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f10589a..702c35c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -445,6 +445,8 @@ Wallet Not Connected Connect a Lightning wallet to send zaps. Both parties need DM relays + Private — replying to a private thread + Private zap requires DM relays on both sides Zap %d sats Zap Failed Pay Now