feat: compose private replies and private posts via NIP-17 gift wraps

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
This commit is contained in:
Claude
2026-06-11 20:53:52 +00:00
parent 7e7d8e5325
commit 0fc81cf79d
7 changed files with 211 additions and 16 deletions
@@ -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<TextNoteEvent>) {
if (!isWriteable()) return
broadcastPrivately(NIP17Factory().createNoteNIP17(template, signer))
}
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
wraps.forEach { wrap ->
val relayList = computeRelayListToBroadcast(wrap)
@@ -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 -> {
@@ -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,
@@ -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<TextNoteEvent>)
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()
+3
View File
@@ -870,6 +870,9 @@
<string name="public_bookmark_presence_indicator">is a public bookmark here</string>
<string name="private_bookmark_presence_indicator">is a private bookmark here</string>
<string name="private_rumor_mark">Private — only visible to tagged participants</string>
<string name="private_note">Make private: gift-wrap the note to the notified users only</string>
<string name="disable_private_note">Make public</string>
<string name="private_note_locked">Replies to a private note always stay private</string>
<string name="bookmark_absence_indicator">is not a bookmark here</string>
<string name="bookmark_remove_action_desc">Remove bookmark from list</string>
<string name="bookmark_add_action_desc">Add bookmark to list</string>
@@ -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")
}
}
@@ -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<TextNoteEvent>,
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<ChatMessageEncryptedFileHeaderEvent>,
signer: NostrSigner,