mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
@@ -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 -> {
|
||||
|
||||
+49
-3
@@ -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,
|
||||
|
||||
+36
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
+78
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user