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 01e3c82407..85138e1017 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -180,6 +180,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent @@ -225,8 +226,8 @@ import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.definition.tags.ThumbTag import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent @@ -746,8 +747,10 @@ class Account( val eventHint = note.toEventHint() ?: return null - // For NIP-17 private groups, we don't support tracked mode (too complex) - if (eventHint.event is NIP17Group) return null + // For NIP-17 private groups, we don't support tracked mode (too complex). + // Unsealed rumors (empty sig) must never get a public reaction — + // the e-tag would leak the private rumor id to public relays. + if (eventHint.event is NIP17Group || eventHint.event.sig.isEmpty()) return null val event = ReactionAction.reactTo(eventHint, reaction, signer) val relays = computeRelayListToBroadcast(event) @@ -981,7 +984,15 @@ class Account( note: Note, type: ReportType, content: String = "", - ) = sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer)) + ) { + if (note.isPrivateRumor()) { + // A kind-1984 e-tagging the rumor would leak the private id onto + // public relays. Report the author instead (p-tag only). + note.author?.let { report(it, type, content) } + } else { + sendMyPublicAndPrivateOutbox(ReportAction.report(note, type, content, userProfile(), signer)) + } + } suspend fun report( user: User, @@ -1011,6 +1022,28 @@ class Account( } } + /** + * Retracts rumor-only events (private reactions/replies) with a + * gift-wrapped NIP-09 deletion delivered to the same participants as + * the [target] rumor they referenced. A public deletion would e-tag + * the private rumor ids onto public relays. + */ + suspend fun deletePrivately( + notes: List, + target: Note, + ) { + if (!isWriteable()) return + val targetEvent = target.event ?: return + + val myRumors = notes.filter { it.author == userProfile() }.mapNotNull { it.event } + if (myRumors.isEmpty()) return + + val recipients = (targetEvent.taggedUserIds() + targetEvent.pubKey).distinct().minus(signer.pubKey) + broadcastPrivately( + NIP17Factory().createDeletionNIP17(DeletionEvent.build(myRumors), recipients, signer), + ) + } + suspend fun delete( event: Event, additionalRelays: Set, @@ -1211,7 +1244,9 @@ class Account( emptySet() } } - if (event is WrappedEvent) { + // Seals, inner DM messages, and unsigned rumors never get broadcast + // relays: they only travel inside gift wraps. + if (event is SealedRumorEvent || event is BaseDMGroupEvent || event.sig.isEmpty()) { return emptySet() } @@ -1334,26 +1369,39 @@ class Account( suspend fun broadcast(note: Note) { note.event?.let { noteEvent -> - if (noteEvent is WrappedEvent && noteEvent.host != null) { - // download the event and send it. - noteEvent.host?.let { host -> - client - .fetchFirst( - filters = - note.relays.associateWith { _ -> - listOf( - Filter( - kinds = listOf(host.kind), - tags = mapOf("p" to listOf(pubKey)), - ids = listOf(host.id), - ), - ) - }, - )?.let { downloadedEvent -> - val toRelays = computeRelayListToBroadcast(downloadedEvent) - client.publish(downloadedEvent, toRelays) - } - } + val host = note.rumorHost + if (host != null) { + // Rumors are rebroadcast as their delivering envelope: the + // cached copy is content-stripped, so download it and send it. + // A just-sent note has no relays until its self-wrap echoes + // back — fall back to our own DM inbox relays. Bare seals + // (kind 13) carry no p tag, so that filter is wrap-only. + val relays = note.relays.ifEmpty { dmRelays.flow.value.toList() } + val filter = + if (host.kind == SealedRumorEvent.KIND) { + Filter( + kinds = listOf(host.kind), + ids = listOf(host.id), + ) + } else { + Filter( + kinds = listOf(host.kind), + tags = mapOf("p" to listOf(pubKey)), + ids = listOf(host.id), + ) + } + client + .fetchFirst( + filters = relays.associateWith { _ -> listOf(filter) }, + )?.let { downloadedEvent -> + val toRelays = computeRelayListToBroadcast(downloadedEvent) + client.publish(downloadedEvent, toRelays) + } + } else if (noteEvent.sig.isEmpty()) { + // Rumor with no known wrap: publishing it would disclose the + // private content to relays even though they reject the + // missing signature. + return } else { client.publish(noteEvent, computeRelayListToBroadcast(note)) } @@ -2283,6 +2331,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/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index d880d85c74..81b4080ce8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -117,6 +117,7 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @@ -213,7 +214,6 @@ import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.profile.ProfileBadgesEvent -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent @@ -1383,30 +1383,39 @@ object LocalCache : ILocalCache, ICacheProvider { * resurrected by `computeReplyTo` as a second Note for the same id. * - prune (see [unlinkAndRemove] callers): the whole child subtree is removed. * - * Gift-wrapped events additionally drop their decrypted inner host. + * Rumors additionally drop the envelope notes that delivered them. */ private fun deleteNote(deleteNote: Note) { - (deleteNote.event as? WrappedEvent)?.let { deleteWraps(it) } + deleteEnvelopes(deleteNote) deleteNote.detachFromChildren() unlinkAndRemove(deleteNote) } - fun deleteWraps(event: WrappedEvent) { - event.host?.let { hostStub -> - // seal - getNoteIfExists(hostStub.id)?.let { hostNote -> - val noteEvent = hostNote.event - if (noteEvent is WrappedEvent) { - deleteWraps(noteEvent) - } - hostNote.clearFlow() - refreshDeletedNoteObservers(hostNote) - } + /** + * Removes the envelope notes that delivered [rumorNote]'s rumor: its + * host (normally the kind-1059 wrap; a bare kind-13 seal otherwise) + * and, when the host is a wrap, the seal layer it carried. Public + * events have no envelopes and are ignored. + */ + fun deleteEnvelopes(rumorNote: Note) { + val host = rumorNote.rumorHost ?: return - notes.remove(hostStub.id) + getNoteIfExists(host.id)?.let { hostNote -> + (hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId -> + getNoteIfExists(sealId)?.let { sealNote -> + sealNote.clearFlow() + refreshDeletedNoteObservers(sealNote) + } + notes.remove(sealId) + } + hostNote.clearFlow() + refreshDeletedNoteObservers(hostNote) } + + notes.remove(host.id) + rumorNote.rumorHost = null } fun consume( @@ -2706,7 +2715,7 @@ object LocalCache : ILocalCache, ICacheProvider { val childrenToBeRemoved = mutableListOf() // Newest pruned `created_at` per relay, in each window's cursor space, capped at < floor. - // Gift wraps page by the OUTER wrap time (from the rumor's host stub); NIP-04 by the event's + // Gift wraps page by the OUTER wrap time (from the rumor-host index); NIP-04 by the event's // own time, and a kind:4 belongs to BOTH the account (rooms-list) and per-conversation cursor. val giftWrapPruned = HashMap() val accountNip04Pruned = HashMap() @@ -2717,9 +2726,9 @@ object LocalCache : ILocalCache, ICacheProvider { toBeRemoved.forEach { note -> when (val ev = note.event) { - is WrappedEvent -> + is BaseDMGroupEvent -> if (giftWrapFloor != null) { - val outerUntil = ev.host?.createdAt ?: ev.createdAt + val outerUntil = note.rumorHost?.createdAt ?: ev.createdAt if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) } } is PrivateDmEvent -> { @@ -2762,21 +2771,21 @@ object LocalCache : ILocalCache, ICacheProvider { } fun removeIfWrap(note: Note): List { - val noteEvent = note.event + val host = note.rumorHost ?: return emptyList() - val children = - if (noteEvent is WrappedEvent) { - noteEvent.host?.id?.let { - getNoteIfExists(it)?.let { it2 -> - unlinkAndRemove(it2) - it2.clearChildLinks() - } + val children = mutableListOf() + getNoteIfExists(host.id)?.let { hostNote -> + (hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId -> + getNoteIfExists(sealId)?.let { sealNote -> + unlinkAndRemove(sealNote) + children.addAll(sealNote.clearChildLinks()) } - } else { - null } - - return children ?: emptyList() + unlinkAndRemove(hostNote) + children.addAll(hostNote.clearChildLinks()) + } + note.rumorHost = null + return children } fun prunePastVersionsOfReplaceables() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index c34b535e12..48413f0545 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -1764,6 +1764,10 @@ fun FirstUserInfoRow( DisplayDraft() } + if (baseNote.isPrivateRumor()) { + PrivateRumorMark() + } + if (isPinned) { PinnedMark() } @@ -1792,6 +1796,16 @@ fun PinnedMark() { ) } +@Composable +fun PrivateRumorMark() { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = stringRes(R.string.private_rumor_mark), + modifier = Modifier.padding(start = 5.dp).size(16.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) +} + @Composable fun JumpToParentReplyButton( baseNote: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 7591b7d582..7d4d6ce281 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -385,14 +385,19 @@ fun CardBody( } } - VerticalDivider(color = primaryLight) - NoteQuickActionItem( - icon = MaterialSymbols.Dns, - label = stringRes(R.string.broadcast), - ) { - accountViewModel.broadcast(note) - // showSelectTextDialog = true - onDismiss() + // Rumors are rebroadcast as their delivering gift wrap; hidden + // when the wrap is unknown (the unsigned rumor must never be + // published). + if (accountViewModel.canBroadcast(note)) { + VerticalDivider(color = primaryLight) + NoteQuickActionItem( + icon = MaterialSymbols.Dns, + label = stringRes(R.string.broadcast), + ) { + accountViewModel.broadcast(note) + // showSelectTextDialog = true + onDismiss() + } } VerticalDivider(color = primaryLight) if (isOwnNote && note.isDraft()) { @@ -402,6 +407,9 @@ fun CardBody( ) { onWantsToEditDraft() } + } else if (note.isPrivateRumor()) { + // No external share link for private rumors: nobody can + // resolve the id from relays and sharing it leaks the id. } else { NoteQuickActionItem( icon = MaterialSymbols.Share, 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 2e352ab051..9573f040ac 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 @@ -259,6 +259,13 @@ private fun InnerReactionRow( }, reactions = reactionRowItems, renderReaction = { item -> + // Unsealed rumors (private replies/posts) must not receive public + // reposts or quotes: each would e-tag the private rumor id onto + // public relays. Replies, reactions, and zaps stay enabled because + // the composer locks private mode for rumor parents, ReactionAction + // gift-wraps reactions to empty-sig targets, and zaps are forced to + // the PRIVATE type with public rails suppressed. + val isPrivateRumor = baseNote.isPrivateRumor() when (item.action) { ReactionRowAction.Reply -> { ReplyReactionWithDialog( @@ -273,7 +280,7 @@ private fun InnerReactionRow( ReactionRowAction.Boost -> { val isDM = baseNote.event is ChatroomKeyable - if (!isDM) { + if (!isDM && !isPrivateRumor) { BoostWithDialog( baseNote, editState, @@ -296,6 +303,9 @@ private fun InnerReactionRow( } ReactionRowAction.Zap -> { + // Zaps stay enabled on private rumors: AccountViewModel.zap + // forces the PRIVATE zap type, and the public nutzap/onchain + // rails are suppressed for them. ZapReaction( baseNote, MaterialTheme.colorScheme.placeholderText, @@ -306,10 +316,12 @@ private fun InnerReactionRow( } ReactionRowAction.Share -> { - ShareReaction( - note = baseNote, - grayTint = MaterialTheme.colorScheme.placeholderText, - ) + if (!isPrivateRumor) { + ShareReaction( + note = baseNote, + grayTint = MaterialTheme.colorScheme.placeholderText, + ) + } } ReactionRowAction.Pay -> { @@ -1242,10 +1254,16 @@ fun ZapReaction( nav.nav(Route.UpdateZapAmount()) } }, - onOnchainAmount = { amount -> - wantsToZap = false - onchainZapRequest = OnchainZapRequest(amount) - }, + onOnchainAmount = + if (baseNote.isPrivateRumor()) { + // Onchain zap events are public and would e-tag the rumor id. + null + } else { + { amount -> + wantsToZap = false + onchainZapRequest = OnchainZapRequest(amount) + } + }, onError = { _, message, user -> scope.launch { zappingProgress = 0f diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 5bd7d3624c..8c29e8e01c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -131,6 +131,10 @@ fun ZapCustomDialog( LaunchedEffect(accountViewModel) { postViewModel.load(accountViewModel.account) } + // Zaps on private rumors are forced private (AccountViewModel.zap), so + // only offer the choices that match what will actually be sent. + val isPrivateTarget = baseNote.isPrivateRumor() + val zapTypes = listOf( Triple( @@ -153,10 +157,21 @@ fun ZapCustomDialog( stringRes(id = R.string.zap_type_nonzap), stringRes(id = R.string.zap_type_nonzap_explainer), ), - ) + ).filter { + !isPrivateTarget || it.first == LnZapEvent.ZapType.PRIVATE || it.first == LnZapEvent.ZapType.NONZAP + } var selectedZapType by - remember(accountViewModel) { mutableStateOf(accountViewModel.defaultZapType()) } + remember(accountViewModel, baseNote) { + val default = accountViewModel.defaultZapType() + mutableStateOf( + if (isPrivateTarget && default != LnZapEvent.ZapType.NONZAP) { + LnZapEvent.ZapType.PRIVATE + } else { + default + }, + ) + } val presetAmounts = remember(accountViewModel) { accountViewModel.zapAmountChoices() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt index 3f7bd02376..00b361f211 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt @@ -52,20 +52,23 @@ import kotlinx.collections.immutable.ImmutableList fun Notifying( baseMentions: ImmutableList?, accountViewModel: AccountViewModel, + label: String? = null, + showWhenEmpty: Boolean = false, + onAddUser: (() -> Unit)? = null, onClick: (User) -> Unit, ) { val mentions = baseMentions?.toSet() FlowRow(horizontalArrangement = Arrangement.spacedBy(5.dp)) { - if (!mentions.isNullOrEmpty()) { + if (!mentions.isNullOrEmpty() || showWhenEmpty) { Text( - stringRes(R.string.reply_notify), + label ?: stringRes(R.string.reply_notify), fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.align(CenterVertically), ) - mentions.forEachIndexed { _, user -> + mentions?.forEachIndexed { _, user -> Button( shape = ButtonBorder, colors = @@ -77,6 +80,23 @@ fun Notifying( DisplayUserNameWithDeleteMark(user, accountViewModel) } } + + if (onAddUser != null) { + Button( + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.mediumImportanceLink, + ), + onClick = onAddUser, + ) { + Text( + text = stringRes(R.string.notify_add_user), + color = Color.White, + textAlign = TextAlign.Center, + ) + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index fd4d2b5695..f4043c7f95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -161,6 +161,12 @@ fun NoteDropDownMenu( val actContext = LocalContext.current val scope = rememberCoroutineScope() + // Unsealed rumors (private replies/posts received in gift wraps) are + // unsigned and must never be referenced by a public event: hide every + // action that would publish an e-tag of this note (broadcast, edit, + // OTS timestamp, pin, label, public bookmark, deletion request). + val isPrivateRumor = note.isPrivateRumor() + // Follow section M3ActionSection { if (!state.isFollowingAuthor) { @@ -220,17 +226,19 @@ fun NoteDropDownMenu( onDismiss() } } - M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) { - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, externalLinkForNote(note)) - putExtra(Intent.EXTRA_TITLE, stringRes(actContext, R.string.quick_action_share_browser_link)) - } - val shareIntent = Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) - actContext.startActivity(shareIntent) - onDismiss() + if (!isPrivateRumor) { + M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) { + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, externalLinkForNote(note)) + putExtra(Intent.EXTRA_TITLE, stringRes(actContext, R.string.quick_action_share_browser_link)) + } + val shareIntent = Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share)) + actContext.startActivity(shareIntent) + onDismiss() + } } } @@ -241,7 +249,7 @@ fun NoteDropDownMenu( nav.nav { routeEditDraftTo(note, accountViewModel.account) } } } - if (!note.isDraft()) { + if (!note.isDraft() && !isPrivateRumor) { if (note.event is TextNoteEvent) { if (state.isLoggedUser) { M3ActionRow(icon = MaterialSymbols.Edit, text = stringRes(R.string.edit_post)) { @@ -258,23 +266,30 @@ fun NoteDropDownMenu( } } } - M3ActionRow(icon = MaterialSymbols.CellTower, text = stringRes(R.string.broadcast)) { - accountViewModel.broadcast(note) - onDismiss() + // Rumors are rebroadcast as their delivering gift wrap; hidden + // when the wrap is unknown (the unsigned rumor must never be + // published). + if (accountViewModel.canBroadcast(note)) { + M3ActionRow(icon = MaterialSymbols.CellTower, text = stringRes(R.string.broadcast)) { + accountViewModel.broadcast(note) + onDismiss() + } } } // Timestamp & Bookmarks section M3ActionSection { - if (accountViewModel.account.otsState.hasPendingAttestations(note)) { - M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() } - } else { - M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_it)) { - accountViewModel.timestamp(note) - onDismiss() + if (!isPrivateRumor) { + if (accountViewModel.account.otsState.hasPendingAttestations(note)) { + M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_pending)) { onDismiss() } + } else { + M3ActionRow(icon = MaterialSymbols.Schedule, text = stringRes(R.string.timestamp_it)) { + accountViewModel.timestamp(note) + onDismiss() + } } } - if (state.isLoggedUser) { + if (state.isLoggedUser && !isPrivateRumor) { if (state.isPinnedNote) { M3ActionRow(icon = MaterialSymbols.PushPin, text = stringRes(R.string.unpin_from_profile)) { accountViewModel.removePin(note) @@ -287,14 +302,22 @@ fun NoteDropDownMenu( } } } - M3ActionRow(icon = MaterialSymbols.Tag, text = stringRes(R.string.add_hashtag_label)) { - addLabelDialogShowing = true + if (!isPrivateRumor) { + M3ActionRow(icon = MaterialSymbols.Tag, text = stringRes(R.string.add_hashtag_label)) { + addLabelDialogShowing = true + } } // Pick exactly one curation flow per kind: music tracks go to playlists, emoji // packs go to the emoji list, everything else gets the standard bookmark rows. // Showing both at once is noisy and makes "bookmark" feel like the catch-all when // it really isn't for these kinds. when { + isPrivateRumor -> { + // No bookmark/playlist/emoji-list rows for private rumors: + // those lists reference the note by id, which other devices + // can't resolve from relays and public lists would leak. + } + note.event is MusicTrackEvent && note is AddressableNote -> { // Music tracks (kind 36787) belong in playlists (kind 34139). The // sheet behind this nav lets the user toggle membership across all of @@ -372,7 +395,7 @@ fun NoteDropDownMenu( } onDismiss() } - if (state.isLoggedUser) { + if (state.isLoggedUser && !isPrivateRumor) { M3ActionRow(icon = MaterialSymbols.Delete, text = stringRes(R.string.request_deletion), isDestructive = true) { accountViewModel.delete(note) onDismiss() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 86149f61b4..20ca0b4d6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -491,9 +491,18 @@ class AccountViewModel( launchSigner { val currentReactions = note.allReactionsOfContentByAuthor(userProfile(), reaction) if (currentReactions.isNotEmpty()) { - account.delete(currentReactions) + // Gift-wrapped reactions are retracted with a gift-wrapped + // deletion to the same participants — a public NIP-09 would + // e-tag the private rumor id onto public relays. + val (privateRumors, publicReactions) = currentReactions.partition { it.isPrivateRumor() } + if (publicReactions.isNotEmpty()) { + account.delete(publicReactions) + } + if (privateRumors.isNotEmpty()) { + account.deletePrivately(privateRumors, note) + } } else { - if (settings.useTrackedBroadcasts() && note.event !is NIP17Group) { + if (settings.useTrackedBroadcasts() && note.event !is NIP17Group && !note.isPrivateRumor()) { // Tracked broadcasting with progress feedback account.createReactionEvent(note, reaction)?.let { (event, relays) -> broadcastTracker.trackBroadcast( @@ -905,6 +914,18 @@ class AccountViewModel( onPayViaIntent: (ImmutableList) -> Unit, zapType: LnZapEvent.ZapType? = null, ) = launchSigner { + val requestedType = zapType ?: defaultZapType() + + // Zaps on private rumors are forced to PRIVATE so the sender and + // comment stay encrypted. NONZAP is kept: paying without a zap + // request produces no receipt at all, which is even more private. + val effectiveType = + if (note.isPrivateRumor() && requestedType != LnZapEvent.ZapType.NONZAP) { + LnZapEvent.ZapType.PRIVATE + } else { + requestedType + } + ZapPaymentHandler(account).zap( note = note, amountMilliSats = amountInMillisats, @@ -916,7 +937,7 @@ class AccountViewModel( onError = onError, onProgress = onProgress, onPayViaIntent = onPayViaIntent, - zapType = zapType ?: defaultZapType(), + zapType = effectiveType, ) } @@ -934,6 +955,16 @@ class AccountViewModel( onError: (String, String, User?) -> Unit, onProgress: (Float) -> Unit = {}, ) = launchSigner { + // Nutzap events (kind 9321) are public and e-tag the zapped note — + // on a private rumor that would leak the rumor id to public relays. + if (baseNote.isPrivateRumor()) { + onError( + stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_title), + stringRes(com.vitorpamplona.amethyst.Amethyst.instance.appContext, R.string.nutzap_failed_private_note), + baseNote.author, + ) + return@launchSigner + } val recipient = baseNote.author?.pubkeyHex if (recipient == null) { onError( @@ -1132,6 +1163,17 @@ class AccountViewModel( fun broadcast(note: Note) = launchSigner { account.broadcast(note) } + /** + * Broadcast republishes public events directly and rumors as their + * delivering kind-1059 wrap. A rumor whose wrap is unknown can't be + * broadcast at all — publishing the unsigned event would disclose the + * private content. + */ + fun canBroadcast(note: Note): Boolean { + val event = note.event ?: return false + return event.sig.isNotEmpty() || note.rumorHost != null + } + fun timestamp(note: Note) = launchSigner { account.otsState.timestamp(note) } fun delete(notes: List) = launchSigner { account.delete(notes) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index a1e77258de..e05a9627a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -412,6 +412,11 @@ class SealedRumorEventHandler( if (rumorId == null) { processNewSealedRumor(event, eventNote, publicNote) } else { + // Replayed seal: re-link the rumor to its delivering envelope so + // broadcast can republish the wrap after a cache rebuild. + // publicNote is the outermost event of this unwrap chain — the + // kind-1059 wrap normally, the seal itself when it arrived bare. + publicNote.event?.let { envelope -> cache.getOrCreateNote(rumorId).recordRumorHost(envelope) } processExistingSealedRumor(rumorId, publicNote) } } @@ -442,6 +447,12 @@ class SealedRumorEventHandler( val innerRumorNote = cache.getOrCreateNote(innerRumor.id) + // Remember which envelope delivered this rumor (publicNote is the + // kind-1059 wrap normally, the seal itself when it arrived bare). + // Consumers cite/broadcast/prune/evict through this stub — the + // unsigned rumor itself must never be referenced publicly. + publicNote.event?.let { envelope -> innerRumorNote.recordRumorHost(envelope) } + // Marmot Welcome: GiftWrap → Seal → WelcomeEvent. The Seal handler // is the actual point at which we see the kind:444 inner. Route it // to the MLS flow for group joining in addition to caching — there's 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..67c08f787a 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 @@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationSection import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview +import com.vitorpamplona.amethyst.ui.components.OutlinedThinPaddingTextField import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -128,6 +129,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.persistentListOf @@ -306,11 +308,41 @@ private fun NewPostScreenBody( } Row { - Notifying(postViewModel.pTags?.toImmutableList(), accountViewModel) { + Notifying( + baseMentions = postViewModel.pTags?.toImmutableList(), + accountViewModel = accountViewModel, + label = if (postViewModel.wantsPrivateNote) stringRes(R.string.private_note_visible_to) else null, + showWhenEmpty = postViewModel.wantsPrivateNote, + onAddUser = { postViewModel.wantsToAddNotifyUser = !postViewModel.wantsToAddNotifyUser }, + ) { postViewModel.removeFromReplyList(it) } } + if (postViewModel.wantsToAddNotifyUser) { + OutlinedThinPaddingTextField( + state = postViewModel.notifyUserSearchText, + onTextChanged = postViewModel::onNotifyUserSearchTextChanged, + label = { Text(text = stringRes(R.string.notify_search_and_add_user)) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringRes(R.string.zap_split_search_and_add_user_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + ) + } + + if (postViewModel.wantsPrivateNote && postViewModel.pTags.isNullOrEmpty()) { + Text( + text = stringRes(R.string.private_note_no_receivers), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + // Only show text input if no voice message is being posted if (postViewModel.voiceMetadata == null && postViewModel.voiceRecording == null) { Row( @@ -332,7 +364,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 +744,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 +785,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 +818,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..5eeec24635 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 @@ -167,6 +167,7 @@ enum class UserSuggestionAnchor { MAIN_MESSAGE, FORWARD_ZAPS, TO_USERS, + NOTIFY, } @Stable @@ -310,6 +311,38 @@ 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 + } + + // Notify / Visible-to editor: lets the user p-tag people who aren't + // cited in the message. For private notes the Notify list IS the + // audience, so this is how receivers are picked. + var wantsToAddNotifyUser by mutableStateOf(false) + val notifyUserSearchText = TextFieldState() + + fun onNotifyUserSearchTextChanged() { + if (notifyUserSearchText.selection.collapsed) { + val lastWord = notifyUserSearchText.text.toString() + userSuggestionsMainMessage = UserSuggestionAnchor.NOTIFY + userSuggestions?.processCurrentWord(lastWord) + } + } + + fun addToReplyList(user: User) { + if (pTags?.contains(user) != true) { + pTags = (pTags ?: emptyList()).plus(user) + } + } + // 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 +486,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 +685,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 +889,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 +1305,10 @@ open class ShortNotePostViewModel : wantsAnonymousPost = false anonymousSignerCache = null scheduledForSec = null + wantsPrivateNote = false + privateNoteLocked = false + wantsToAddNotifyUser = false + notifyUserSearchText.clearText() forwardZapTo.value = SplitBuilder() forwardZapToEditting.clearText() @@ -1314,6 +1373,10 @@ open class ShortNotePostViewModel : } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { forwardZapTo.value.addItem(item) forwardZapToEditting.clearText() + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.NOTIFY) { + addToReplyList(item) + notifyUserSearchText.clearText() + wantsToAddNotifyUser = false } userSuggestionsMainMessage = null diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 15989c296d..4d0df7d88c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -984,6 +984,14 @@ Bookmark this article 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 + Visible to + No receivers yet: only you will be able to see this note. Add people to share it with. + + Add + Search and add a user to notify is not a bookmark here Remove bookmark from list Add bookmark to list @@ -2208,6 +2216,7 @@ Nutzap Nutzap failed No recipient pubkey on the note + Nutzaps are public and would reveal this private note. Use a Lightning zap instead. Cannot build event reference Create token Redeem diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 7c61246550..3f0de80179 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -61,7 +61,7 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent +import com.vitorpamplona.quartz.nip59Giftwrap.HostStub import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.utils.BigDecimal @@ -120,6 +120,24 @@ open class Note( var author: User? = null var replyTo: List? = null + /** + * The envelope that delivered this note when [event] is an unsealed + * rumor: normally the kind-1059 gift wrap, a bare kind-13 seal when + * one arrives unwrapped. Null for public events. + * + * Rumors are unsigned and must never be referenced or republished + * directly on public relays — consumers cite, broadcast, prune, and + * evict through this stub instead. Living on the Note (not on a + * global index, not on the quartz event) ties its lifetime to the + * note: whatever removes or garbage-collects the note frees the stub. + */ + var rumorHost: HostStub? = null + + /** Records the envelope that delivered this rumor. */ + fun recordRumorHost(envelope: Event) { + rumorHost = HostStub(envelope.id, envelope.pubKey, envelope.kind, envelope.createdAt) + } + var inGatherers: List? = null fun inGatherers() = inGatherers ?: listOf().also { inGatherers = it } @@ -237,19 +255,17 @@ open class Note( open fun idNote() = toNEvent() open fun toNEvent(): String { - val myEvent = event - return if (myEvent is WrappedEvent) { - val host = myEvent.host - if (host != null) { - NEvent.create( - host.id, - host.pubKey, - host.kind, - relayHintUrl(), - ) - } else { - NEvent.create(idHex, author?.pubkeyHex, event?.kind, relayHintUrl()) - } + // Rumors are cited by the envelope that delivered them: the rumor id + // resolves to nothing on public relays and exposing it would leak the + // private event's identity. + val host = rumorHost + return if (host != null) { + NEvent.create( + host.id, + host.pubKey, + host.kind, + relayHintUrl(), + ) } else { NEvent.create(idHex, author?.pubkeyHex, event?.kind, relayHintUrl()) } @@ -325,6 +341,16 @@ open class Note( fun isDraft() = event is DraftWrapEvent + /** + * True when this note's event is an unsealed NIP-59 rumor (a private + * reply, private reaction, or chat message that arrived inside a gift + * wrap). Rumors are unsigned by design — they are materialized with an + * empty signature — so they must never be e-tagged, quoted, reposted, + * or rebroadcast on public relays: any public event referencing this + * note's id leaks the private rumor id. + */ + fun isPrivateRumor() = event?.sig?.isEmpty() == true + fun loadEvent( event: Event, author: User, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip18Reposts/RepostAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip18Reposts/RepostAction.kt index 0da9932423..9034277c0a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip18Reposts/RepostAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip18Reposts/RepostAction.kt @@ -47,6 +47,11 @@ object RepostAction { if (!signer.isWriteable()) { throw IllegalStateException("Cannot repost: signer is not writeable") } + if (eventHint.event.sig.isEmpty()) { + // Unsealed private rumor: a public kind-6/16 would e-tag the + // private rumor id onto public relays. + throw IllegalStateException("Cannot repost a private rumor") + } // Use NIP-18 RepostEvent (kind 6) for text notes (kind 1) // Use GenericRepostEvent (kind 16) for all other kinds @@ -76,6 +81,7 @@ object RepostAction { ): Event? { // All validation in commons if (!signer.isWriteable()) return null + if (note.isPrivateRumor()) return null if (note.hasBoostedInTheLast5Minutes(signer.pubKey)) return null val hint = note.toEventHint() ?: return null diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt index 5a1511b74c..ffeeaff9d3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt @@ -23,8 +23,10 @@ package com.vitorpamplona.amethyst.commons.model.nip25Reactions import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent @@ -55,6 +57,12 @@ object ReactionAction { if (!signer.isWriteable()) { throw IllegalStateException("Cannot react: signer is not writeable") } + if (eventHint.event.sig.isEmpty()) { + // Unsealed private rumor: a public kind-7 would e-tag the private + // rumor id onto public relays. Use reactToWithGroupSupport, which + // gift-wraps reactions for empty-sig targets. + throw IllegalStateException("Cannot react publicly to a private rumor") + } // Handle custom emoji reactions (format: ":emoji_name:") val template = @@ -82,10 +90,14 @@ object ReactionAction { ): ReactionEvent = reactTo(event, "+", signer) /** - * Advanced: React to an event with support for NIP-17 private groups. + * Advanced: React to an event with support for NIP-17 private groups + * and unsealed rumors (private replies/posts in the public feed). * - * This method handles both public and private group reactions: + * This method handles both public and private reactions: * - For NIP17Group events: Creates private reactions within the group + * - For unsealed rumors (empty signature): Creates gift-wrapped + * reactions fanned out to the rumor's author and every tagged user, + * so the private rumor id never lands on a public relay * - For regular events: Creates public reactions * * @param event The event to react to @@ -108,10 +120,18 @@ object ReactionAction { val event = eventHint.event - // Check if this is a NIP-17 private group event - if (event is NIP17Group) { - val users = event.groupMembers().toList() + // Privacy is inherited from the target: reactions to private group + // messages and to unsealed rumors must themselves be gift-wrapped. + // createWraps adds the sender's self-copy back, so removing the + // signer here only avoids a redundant entry. + val privateRecipients: List? = + when { + event is NIP17Group -> event.groupMembers().toList() + event.sig.isEmpty() -> (event.taggedUserIds() + event.pubKey).distinct().minus(signer.pubKey) + else -> null + } + if (privateRecipients != null) { // Handle custom emoji reactions in groups if (reaction.startsWith(":")) { val emojiUrl = EmojiUrlTag.decode(reaction) @@ -120,7 +140,7 @@ object ReactionAction { NIP17Factory().createReactionWithinGroup( emojiUrl = emojiUrl, originalNote = eventHint, - to = users, + to = privateRecipients, signer = signer, ), ) @@ -133,7 +153,7 @@ object ReactionAction { NIP17Factory().createReactionWithinGroup( content = reaction, originalNote = eventHint, - to = users, + to = privateRecipients, signer = signer, ), ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt index 5c9f6cc7b3..3932d5f769 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/privateChats/Chatroom.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip14Subject.subject -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -145,9 +145,9 @@ class Chatroom : NotesGatherer { } else { // Old conversation, keep the last one. sorted.take(1).toSet() - } + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent && it.event !is WrappedEvent } + } + sorted.filter { it.flowSet?.isInUse() ?: false } + sorted.filter { it.event !is PrivateDmEvent && it.event !is BaseDMGroupEvent } // Both DM protocols are pruned by the recency rule above: NIP-04 (PrivateDmEvent) and NIP-17 - // (WrappedEvent rumors — ChatMessageEvent / file headers). Anything else that ever lands in a + // (BaseDMGroupEvent rumors — ChatMessageEvent / file headers). Anything else that ever lands in a // room is kept. The caller realigns the per-relay download window for the dropped messages so // they can be paged again later (see LocalCache.pruneOldMessages + RelayLoadingCursors.rewindTo). 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..29e8d2617e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/PrivateNoteFactoryTest.kt @@ -0,0 +1,112 @@ +/* + * 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.nip09Deletions.DeletionEvent +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") + } + + @Test + fun privateDeletion_wrapsToExplicitRecipients_andRetractsTheRumorId() = + runTest { + // Alice retracts a private reaction she previously wrapped to Bob. + val reaction = aliceSigner.sign(TextNoteEvent.build("the rumor being retracted")) + + val result = + NIP17Factory().createDeletionNIP17( + template = DeletionEvent.build(listOf(reaction)), + to = listOf(bobSigner.pubKey), + signer = aliceSigner, + ) + + assertEquals(DeletionEvent.KIND, result.msg.kind) + assertEquals( + setOf(aliceSigner.pubKey, bobSigner.pubKey), + result.wraps.mapNotNull { it.recipientPubKey() }.toSet(), + "deletion wraps must cover the explicit recipients plus the sender's self-copy", + ) + + val bobWrap = result.wraps.first { it.recipientPubKey() == bobSigner.pubKey } + val rumor = bobWrap.unwrapAndUnsealOrNull(bobSigner) + + assertNotNull(rumor, "recipient must be able to unwrap and unseal the deletion") + assertEquals(DeletionEvent.KIND, rumor.kind) + assertEquals(aliceSigner.pubKey, rumor.pubKey, "deletions only apply when the author matches") + assertTrue(rumor.sig.isEmpty(), "the deletion travels as an unsigned rumor") + assertTrue( + rumor.tags.any { it.size >= 2 && it[0] == "e" && it[1] == reaction.id }, + "the deletion must e-tag the retracted rumor id (inside the wrap only)", + ) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/RumorHostCitationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/RumorHostCitationTest.kt new file mode 100644 index 0000000000..55f863fc5d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/RumorHostCitationTest.kt @@ -0,0 +1,97 @@ +/* + * 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.model + +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 com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Pins the citation guarantee for private notes: an nevent of a rumor must + * encode the delivering envelope's id — never the rumor's own id, which is + * the private event's identity and resolves to nothing on public relays. + */ +class RumorHostCitationTest { + private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray())) + + private val bobPriv = "0000000000000000000000000000000000000000000000000000000000000008" + private val bobSigner = NostrSignerInternal(KeyPair(bobPriv.hexToByteArray())) + + @Test + fun rumorNote_toNEvent_citesTheDeliveringWrap() = + runTest { + // Alice sends Bob a private note; Bob unwraps his copy. + val template = + TextNoteEvent.build("psst") { + pTags(listOf(PTag(bobSigner.pubKey, null))) + } + val result = NIP17Factory().createNoteNIP17(template, aliceSigner) + val bobWrap = result.wraps.first { it.recipientPubKey() == bobSigner.pubKey } + val rumor = bobWrap.unwrapAndUnsealOrNull(bobSigner) + assertNotNull(rumor) + assertTrue(rumor.sig.isEmpty()) + + // Bob's cache materializes the rumor note and records the wrap. + val note = Note(rumor.id) + note.event = rumor + note.recordRumorHost(bobWrap) + + val nevent = note.toNEvent() + assertEquals( + NEvent.create(bobWrap.id, bobWrap.pubKey, bobWrap.kind, null), + nevent, + "rumor citations must encode the wrap, not the rumor", + ) + assertFalse( + nevent == NEvent.create(rumor.id, rumor.pubKey, rumor.kind, null), + "the private rumor id must never be encoded", + ) + } + + @Test + fun publicNote_toNEvent_citesItsOwnId() = + runTest { + val event = aliceSigner.sign(TextNoteEvent.build("hello world")) + + val note = Note(event.id) + note.event = event + + // toNEvent reads the author from the Note (unset here), so the + // expected nevent carries a null author too. + assertEquals( + NEvent.create(event.id, null, event.kind, null), + note.toNEvent(), + ) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt new file mode 100644 index 0000000000..c17e2aed36 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt @@ -0,0 +1,107 @@ +/* + * 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.model.nip25Reactions + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +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 kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +class ReactionActionTest { + private val alicePriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val aliceSigner = NostrSignerInternal(KeyPair(alicePriv.hexToByteArray())) + + private val bobPriv = "0000000000000000000000000000000000000000000000000000000000000008" + private val bobSigner = NostrSignerInternal(KeyPair(bobPriv.hexToByteArray())) + + private val carolPriv = "0000000000000000000000000000000000000000000000000000000000000009" + private val carolSigner = NostrSignerInternal(KeyPair(carolPriv.hexToByteArray())) + + @Test + fun reactionToPublicNote_isPublic() = + runTest { + val note = aliceSigner.sign(TextNoteEvent.build("hello world")) + + var publicCalls = 0 + ReactionAction.reactToWithGroupSupport( + eventHint = EventHintBundle(note, null), + reaction = "+", + signer = bobSigner, + onPublic = { reaction -> + publicCalls++ + assertTrue(reaction.sig.isNotEmpty(), "public reaction must be signed") + assertTrue(reaction.tags.any { it.size >= 2 && it[0] == "e" && it[1] == note.id }) + }, + onPrivate = { fail("reaction to a public note must not be gift-wrapped") }, + ) + assertEquals(1, publicCalls) + } + + @Test + fun reactionToUnsealedRumor_isGiftWrappedToAllParticipants() = + runTest { + // Alice's private reply (rumor) tagging Bob and Carol. Receivers + // materialize rumors with an empty signature. + val signed = + aliceSigner.sign( + TextNoteEvent.build("private reply") { + pTags( + listOf( + PTag(bobSigner.pubKey, null), + PTag(carolSigner.pubKey, null), + ), + ) + }, + ) + val rumor = TextNoteEvent(signed.id, signed.pubKey, signed.createdAt, signed.tags, signed.content, "") + + // Bob reacts: the reaction must be wrapped to every participant + // (Alice the author, Carol the other p-tag, and Bob's self-copy) + // and never reach the public callback. + var privateCalls = 0 + ReactionAction.reactToWithGroupSupport( + eventHint = EventHintBundle(rumor, null), + reaction = "+", + signer = bobSigner, + onPublic = { fail("reaction to a rumor must not be public: its e-tag would leak the rumor id") }, + onPrivate = { result -> + privateCalls++ + assertTrue(result.msg.tags.any { it.size >= 2 && it[0] == "e" && it[1] == rumor.id }) + + val recipients = result.wraps.mapNotNull { it.recipientPubKey() }.toSet() + assertEquals( + setOf(aliceSigner.pubKey, bobSigner.pubKey, carolSigner.pubKey), + recipients, + "wraps must cover the rumor author, every tagged user, and the sender's self-copy", + ) + }, + ) + assertEquals(1, privateCalls) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 298fa781a2..5f0def4eac 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1364,6 +1364,9 @@ fun MainContent( if (innerNote.event == null) { innerNote.loadEvent(innerEvent, innerAuthor, emptyList()) } + // Rumors are unsigned: citing or rebroadcasting them must + // go through the wrap that delivered them. + innerNote.recordRumorHost(event) iAccount.chatroomList.addMessage( innerEvent.chatroomKey(iAccount.pubKey), innerNote, diff --git a/quartz/plans/2026-06-12-giftwrap-deletion-requests.md b/quartz/plans/2026-06-12-giftwrap-deletion-requests.md new file mode 100644 index 0000000000..f2da7294af --- /dev/null +++ b/quartz/plans/2026-06-12-giftwrap-deletion-requests.md @@ -0,0 +1,61 @@ +# TODO: Deletion Requests (kind 5) for Gift Wraps + +**Date:** 2026-06-12 +**Status:** Open — design agreed, not implemented +**Modules:** quartz (`DeletionIndex`), amethyst (`LocalCache`) + +## The special case + +Gift wraps (kind 1059) are signed by a discarded throwaway key, so the +normal NIP-09 rule — a deletion only applies when its author equals the +target's author — can never match a wrap. The intended rule: **a kind-5 +authored by the wrap's `p` tag (the recipient) may delete it.** A +recipient deleting their own received wrap is also the only deletion a +client can express without leaking the private rumor id (the rumor id +must never appear in a public kind-5). + +## Current behavior (verified 2026-06-12) + +`DeletionIndex` is strictly author-keyed (`DeletionRequest(targetId, +deleterPubkey)`), the live-delete path requires `deleteNote.author == +deletion.pubKey`, and no downward cascade (wrap → seal → rumor) exists — +`deleteEnvelopes` only walks upward via `Note.rumorHost`. + +| Deletion e-tags | authored by | live message deleted? | blocks later insert? | +|---|---|---|---| +| wrap id | recipient (p tag) | no (key mismatch + no cascade + wrap note usually GC'd) | no (tombstone keyed `(wrapId, recipient)`, check uses `(wrapId, throwawayKey)`) | +| seal id | sender | seal note only; message survives (no cascade) | **yes** — accidental: seals are sender-signed, and `GiftWrapEventHandler` gates the unwrap on `justConsume(seal)` | +| rumor id | sender | yes (private un-react path; `deleteEnvelopes` cascades upward) | yes | + +Only the rumor-id direction works; the wrap-id direction — the one the +special case describes — does nothing. + +## Implementation sketch + +1. **Insertion blocking (quartz, small):** in `DeletionIndex.hasBeenDeleted`, + when the event is a `GiftWrapEvent`, additionally check + `DeletionRequest(event.id, event.recipientPubKey())`. A + recipient-authored tombstone then blocks the wrap in `justConsume` + before it is ever unwrapped, which blocks the message. + +2. **Recipient on the stub (commons, tiny):** add `recipient: HexKey?` to + `HostStub` (one shared-string reference per rumor), populated from + `GiftWrapEvent.recipientPubKey()` at unseal time, so the validation + below works after the wrap note is GC'd. + +3. **Live cascade (amethyst `LocalCache.consume(DeletionEvent)`):** the + wrap note that knew its `innerEventId` is GC'd by the time a deletion + arrives, so find the rumor by reverse lookup: scan notes for + `note.rumorHost?.id == deletedId` (precedent: the addressable pass in + the same function already does a full `notes.forEach`; deletions are + rare). Validate `deletion.pubKey == note.rumorHost.recipient`, then + `deleteNote(rumor)` — envelope cleanup and chatroom removal already + follow from the existing deleted-notes pipeline. + +4. **Tests:** block-before-unwrap (tombstone first, wrap second → message + never materializes) and delete-after-unwrap (message in a chatroom, + recipient-authored kind-5 for the wrap id → rumor and envelopes gone). + +Note: only wraps addressed to the local user are ever in the cache, so +the live-cascade case in practice means "another of my devices retracted +a DM" — rare, not a hot path. 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..62ef1da15d 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,9 @@ 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.nip09Deletions.DeletionEvent +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 +86,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, @@ -96,6 +118,25 @@ class NIP17Factory { ) } + /** + * Gift-wraps a NIP-09 deletion request that retracts rumor-only events + * (private reactions/replies). The deletion must reach the same + * participants the retracted rumor was wrapped to — published publicly + * it would e-tag the private rumor id onto public relays. + */ + suspend fun createDeletionNIP17( + template: EventTemplate, + to: List, + signer: NostrSigner, + ): Result { + val deletion = signer.sign(template) + val wraps = createWraps(deletion, to.plus(signer.pubKey).toSet(), signer) + return Result( + msg = deletion, + wraps = wraps, + ) + } + suspend fun createReactionWithinGroup( content: String, originalNote: EventHintBundle, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt index a44593e8a1..37b0912d3d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt @@ -21,11 +21,11 @@ package com.vitorpamplona.quartz.nip17Dm.base import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.any import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.tags.people.PTag -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import kotlinx.collections.immutable.toImmutableSet @Immutable @@ -37,7 +37,7 @@ open class BaseDMGroupEvent( tags: Array>, content: String, sig: HexKey, -) : WrappedEvent(id, pubKey, createdAt, kind, tags, content, sig), +) : Event(id, pubKey, createdAt, kind, tags, content, sig), ChatroomKeyable, NIP17Group, PubKeyHintProvider { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt index 8fdd3c92d8..05008be9bd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/HostStub.kt @@ -23,14 +23,14 @@ package com.vitorpamplona.quartz.nip59Giftwrap import com.vitorpamplona.quartz.nip01Core.core.HexKey /** - * A lightweight reference to the host event a [WrappedEvent] was extracted from — kept on the inner - * event so callers can broadcast / delete / locate the outer wrap without holding the full event. + * A lightweight reference to the envelope event a rumor was extracted from — kept by the caller's + * rumor-host index so it can broadcast / delete / locate the outer wrap without holding the full + * event. Delivery metadata is intentionally not stored on the event classes themselves. * * [createdAt] is the host's own `created_at` (e.g. the kind:1059 gift-wrap timestamp, randomized per - * NIP-59), carried here so a decrypted rumor self-describes its outer-wrap time. The history pager - * cursors page gift wraps by that outer time, so the prune path uses it to realign the per-relay - * download window when a wrapped message is pruned (the chatroom only keeps the inner rumor, whose - * `created_at` is the real message time, not the wrap time). + * NIP-59). The history pager cursors page gift wraps by that outer time, so the prune path uses it + * to realign the per-relay download window when a wrapped message is pruned (the chatroom only keeps + * the inner rumor, whose `created_at` is the real message time, not the wrap time). */ class HostStub( val id: HexKey, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt deleted file mode 100644 index 929d1f00dd..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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.quartz.nip59Giftwrap - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey - -@Immutable -open class WrappedEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - sig: HexKey, -) : Event(id, pubKey, createdAt, kind, tags, content, sig) { - @kotlinx.serialization.Transient - @kotlin.jvm.Transient - var host: HostStub? = null // host event to broadcast when needed -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt index ad2c9cf873..4dfbf62037 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/seals/SealedRumorEvent.kt @@ -26,8 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent -import com.vitorpamplona.quartz.nip59Giftwrap.HostStub -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -40,7 +38,7 @@ class SealedRumorEvent( tags: Array>, content: String, sig: HexKey, -) : WrappedEvent(id, pubKey, createdAt, KIND, tags, content, sig), +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), HasInnerEvent { @kotlinx.serialization.Transient @kotlin.jvm.Transient @@ -57,7 +55,6 @@ class SealedRumorEvent( sig, ) - copy.host = host copy.innerEventId = innerEventId return copy @@ -69,9 +66,6 @@ class SealedRumorEvent( val rumor = Rumor.fromJson(plainContent(signer)) val event = rumor.mergeWith(this) - if (event is WrappedEvent) { - event.host = host ?: HostStub(this.id, this.pubKey, this.kind, this.createdAt) - } innerEventId = event.id return event diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt index 7d19424ddc..292422646c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt @@ -31,8 +31,6 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent -import com.vitorpamplona.quartz.nip59Giftwrap.HostStub -import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -73,9 +71,6 @@ open class GiftWrapEvent( val giftStr = plainContent(signer) val gift = fromJson(giftStr) - if (gift is WrappedEvent) { - gift.host = HostStub(this.id, this.pubKey, this.kind, this.createdAt) - } innerEventId = gift.id return gift