From 0fc81cf79ddcebe455c15775c8b615c73d8720f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 20:17:40 +0000 Subject: [PATCH] feat: compose private replies and private posts via NIP-17 gift wraps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the private-notes plan: the short-note composer gains a private (lock) toggle that gift-wraps the kind-1 to its p-tagged users plus a self-copy instead of publishing it. - NIP17Factory.createNoteNIP17: wraps a TextNoteEvent template to its taggedUserIds + the sender (only the unsigned rumor form travels) - Account.sendPrivateNote: signs, wraps, and routes each wrap to the recipient's DM relays via the existing broadcastPrivately path - ShortNotePostViewModel: wantsPrivateNote/privateNoteLocked state; forced ON and locked when replying to an unsealed rumor (and when reloading a drafted private reply); private wins over anonymous and scheduled modes so a locked reply can never fall through to a public publish path - ShortNotePostScreen: lock toggle in the bottom action row; mutually exclusive with polls; schedule and anonymous hidden while private - ReactionsRow: reply re-enabled on private rumors now that the composer locks privacy for them Drafts stay enabled: TextNoteEvent does not implement ExposeInDraft, so draft wrappers carry no anchor e-tags — the parent rumor id only exists inside the NIP-44 encrypted draft content. Verified by PrivateNoteFactoryTest: wraps cover p-tags + self, and the recipient's unwrap yields a rumor with the same id and an empty sig (the Note.isPrivateRumor() discriminator). https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo --- .../vitorpamplona/amethyst/model/Account.kt | 12 +++ .../amethyst/ui/note/ReactionsRow.kt | 25 +++--- .../loggedIn/home/ShortNotePostScreen.kt | 52 ++++++++++++- .../loggedIn/home/ShortNotePostViewModel.kt | 36 +++++++++ amethyst/src/main/res/values/strings.xml | 3 + .../commons/actions/PrivateNoteFactoryTest.kt | 78 +++++++++++++++++++ .../quartz/nip17Dm/NIP17Factory.kt | 21 +++++ 7 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 417ebcd712..b1e3ba8258 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2247,6 +2247,18 @@ class Account( broadcastPrivately(events) } + /** + * Publishes a kind-1 note privately: signs the template, then gift-wraps + * the rumor to every p-tagged user plus a self-copy and sends each wrap + * to the recipient's DM relays. Used for private replies (the parent's + * author and participants are already p-tagged) and for private posts + * (the Notify list is the audience). Nothing reaches public relays. + */ + suspend fun sendPrivateNote(template: EventTemplate) { + if (!isWriteable()) return + broadcastPrivately(NIP17Factory().createNoteNIP17(template, signer)) + } + override suspend fun sendGiftWraps(wraps: List) { wraps.forEach { wrap -> val relayList = computeRelayListToBroadcast(wrap) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index a4015bed95..097d628b32 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -260,22 +260,21 @@ private fun InnerReactionRow( reactions = reactionRowItems, renderReaction = { item -> // Unsealed rumors (private replies/posts) must not receive public - // replies, reposts, quotes, or zaps: each would e-tag the private - // rumor id onto public relays. Reactions stay enabled because - // ReactionAction gift-wraps them for empty-sig targets. + // reposts, quotes, or zaps: each would e-tag the private rumor id + // onto public relays. Replies and reactions stay enabled because + // the composer locks private mode for rumor parents and + // ReactionAction gift-wraps reactions to empty-sig targets. val isPrivateRumor = baseNote.isPrivateRumor() when (item.action) { ReactionRowAction.Reply -> { - if (!isPrivateRumor) { - ReplyReactionWithDialog( - baseNote, - MaterialTheme.colorScheme.placeholderText, - accountViewModel, - nav, - showCounter = item.showCounter, - voiceRecordingState = voiceRecordingState, - ) - } + ReplyReactionWithDialog( + baseNote, + MaterialTheme.colorScheme.placeholderText, + accountViewModel, + nav, + showCounter = item.showCounter, + voiceRecordingState = voiceRecordingState, + ) } ReactionRowAction.Boost -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 01d66f089e..7eeaf1723d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -332,7 +332,11 @@ private fun NewPostScreenBody( Box( modifier = Modifier.clickable { - postViewModel.wantsAnonymousPost = true + // Private notes are wrapped with the real key — the + // recipients must know who is talking to them. + if (!postViewModel.wantsPrivateNote) { + postViewModel.wantsAnonymousPost = true + } }, ) { BaseUserPicture( @@ -708,7 +712,18 @@ private fun BottomRowActions( maxDurationSeconds = MAX_VOICE_RECORD_SECONDS, ) - if (postViewModel.canUsePoll || postViewModel.canUseZapPoll) { + // Polls publish kinds that can't travel inside a private wrap, so the + // two toggles are mutually exclusive. + if (!postViewModel.wantsPoll && !postViewModel.wantsZapPoll) { + AddPrivateNoteButton( + isActive = postViewModel.wantsPrivateNote, + isLocked = postViewModel.privateNoteLocked, + ) { + postViewModel.togglePrivateNote() + } + } + + if ((postViewModel.canUsePoll || postViewModel.canUseZapPoll) && !postViewModel.wantsPrivateNote) { AddPollButton(postViewModel.wantsPoll || postViewModel.wantsZapPoll) { val isActive = postViewModel.wantsPoll || postViewModel.wantsZapPoll if (isActive) { @@ -738,7 +753,11 @@ private fun BottomRowActions( postViewModel.toggleExpirationDate() } - ScheduleAtButton(postViewModel.scheduledForSec != null, onScheduleClicked) + // Private wraps are built and sent immediately; scheduling them would + // require wrapping at publish time, so the option is hidden for now. + if (!postViewModel.wantsPrivateNote) { + ScheduleAtButton(postViewModel.scheduledForSec != null, onScheduleClicked) + } AddGeoHashButton(postViewModel.wantsToAddGeoHash) { postViewModel.wantsToAddGeoHash = !postViewModel.wantsToAddGeoHash @@ -767,6 +786,33 @@ private fun BottomRowActionsPreview() { } } +@Composable +private fun AddPrivateNoteButton( + isActive: Boolean, + isLocked: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = { onClick() }, + enabled = !isLocked, + ) { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = + stringRes( + id = + when { + isLocked -> R.string.private_note_locked + isActive -> R.string.disable_private_note + else -> R.string.private_note + }, + ), + modifier = Modifier.height(22.dp), + tint = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground, + ) + } +} + @Composable private fun AddPollButton( isPollActive: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 19852adb1f..214e53d88c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -310,6 +310,18 @@ open class ShortNotePostViewModel : // Anonymous Reply var wantsAnonymousPost by mutableStateOf(false) + // Private (gift-wrapped) note: instead of publishing, the kind-1 is + // wrapped to every p-tagged user plus a self-copy and sent to their DM + // relays. Locked ON when replying to an unsealed rumor — a public reply + // would e-tag the parent's private id onto public relays. + var wantsPrivateNote by mutableStateOf(false) + var privateNoteLocked by mutableStateOf(false) + + fun togglePrivateNote() { + if (privateNoteLocked) return + wantsPrivateNote = !wantsPrivateNote + } + // A single ephemeral signer reused for the whole compose session so that media // uploads (Blossom/NIP-96 auth events) and the final anonymous post are all signed // by the same throwaway key, instead of leaking the real account's pubkey into the @@ -453,6 +465,8 @@ open class ShortNotePostViewModel : } } else { originalNote = replyingTo + privateNoteLocked = replyingTo?.isPrivateRumor() == true + wantsPrivateNote = privateNoteLocked replyingTo?.let { replyNote -> if (replyNote.event is BaseThreadedEvent) { this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote) @@ -650,6 +664,12 @@ open class ShortNotePostViewModel : canUsePoll = originalNote == null canUseZapPoll = originalNote == null + // A drafted private reply must come back locked private: the parent + // rumor's id is inside the draft's e-tags, and posting it publicly + // would leak that id. + privateNoteLocked = originalNote?.isPrivateRumor() == true + wantsPrivateNote = privateNoteLocked + if (forwardZapTo.value.items.isNotEmpty()) { wantsForwardZapTo = true } @@ -848,8 +868,22 @@ open class ShortNotePostViewModel : val version = draftTag.current val anonymous = wantsAnonymousPost val scheduledFor = scheduledForSec + val privately = wantsPrivateNote cancel() + if (privately && template.kind == TextNoteEvent.KIND) { + // Gift-wrap to the p-tagged users instead of publishing. Private + // wins over the anonymous and scheduled modes: a locked private + // reply must never fall through to a public publish path (the UI + // hides those toggles while private mode is on). + @Suppress("UNCHECKED_CAST") + accountViewModel.account.sendPrivateNote(template as EventTemplate) + accountViewModel.launchSigner { + accountViewModel.account.deleteDraftIgnoreErrors(version) + } + return + } + if (scheduledFor != null && !anonymous) { // Re-stamp the template with created_at = scheduled time so the post, // when published later, shows up at its scheduled moment in feeds @@ -1250,6 +1284,8 @@ open class ShortNotePostViewModel : wantsAnonymousPost = false anonymousSignerCache = null scheduledForSec = null + wantsPrivateNote = false + privateNoteLocked = false forwardZapTo.value = SplitBuilder() forwardZapToEditting.clearText() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index eec58147de..c933180082 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -870,6 +870,9 @@ is a public bookmark here is a private bookmark here Private — only visible to tagged participants + Make private: gift-wrap the note to the notified users only + Make public + Replies to a private note always stay private is not a bookmark here Remove bookmark from list Add bookmark to list diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt new file mode 100644 index 0000000000..6ef4c3533f --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.pTags +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * End-to-end check of the private note path: a kind-1 template is wrapped + * to its p-tagged users plus the sender's self-copy, and a recipient who + * unwraps it lands on a rumor with the same id and an EMPTY signature — + * the discriminator Note.isPrivateRumor() relies on. + */ +class PrivateNoteFactoryTest { + private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray())) + + private val bobPriv = "0000000000000000000000000000000000000000000000000000000000000008" + private val bobSigner = NostrSignerInternal(KeyPair(bobPriv.hexToByteArray())) + + @Test + fun privateNote_wrapsToTaggedUsersAndSelf_andUnwrapsToEmptySigRumor() = + runTest { + val template = + TextNoteEvent.build("for your eyes only") { + pTags(listOf(PTag(bobSigner.pubKey, null))) + } + + val result = NIP17Factory().createNoteNIP17(template, aliceSigner) + + assertEquals(TextNoteEvent.KIND, result.msg.kind) + assertEquals( + setOf(aliceSigner.pubKey, bobSigner.pubKey), + result.wraps.mapNotNull { it.recipientPubKey() }.toSet(), + "wraps must cover every p-tagged user plus the sender's self-copy", + ) + + // Bob unwraps his copy: same note id, but materialized as an + // unsigned rumor. + val bobWrap = result.wraps.first { it.recipientPubKey() == bobSigner.pubKey } + val rumor = bobWrap.unwrapAndUnsealOrNull(bobSigner) + + assertNotNull(rumor, "recipient must be able to unwrap and unseal") + assertEquals(result.msg.id, rumor.id, "rumor id must match the signed inner event's id") + assertEquals(aliceSigner.pubKey, rumor.pubKey) + assertEquals("for your eyes only", rumor.content) + assertTrue(rumor.sig.isEmpty(), "unsealed rumors must carry an empty signature") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt index 5c1d6e4916..2818dbdaf8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent @@ -83,6 +85,25 @@ class NIP17Factory { ) } + /** + * Gift-wraps a kind-1 note (a private reply or private post for the + * public feed) instead of publishing it. Recipients are exactly the + * p-tags carried by the template, plus the sender's self-copy. The + * signed inner event never leaves the device — only its unsigned rumor + * form travels inside the seals. + */ + suspend fun createNoteNIP17( + template: EventTemplate, + signer: NostrSigner, + ): Result { + val senderNote = signer.sign(template) + val wraps = createWraps(senderNote, senderNote.taggedUserIds().plus(signer.pubKey).toSet(), signer) + return Result( + msg = senderNote, + wraps = wraps, + ) + } + suspend fun createEncryptedFileNIP17( template: EventTemplate, signer: NostrSigner,