Merge pull request #3201 from vitorpamplona/claude/beautiful-ride-n6y3s2

Support private notes: gift-wrap kind-1 replies and posts to p-tagged users
This commit is contained in:
Vitor Pamplona
2026-06-12 21:17:55 -04:00
committed by GitHub
28 changed files with 985 additions and 193 deletions
@@ -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<Event>() ?: 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<Note>,
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<NormalizedRelayUrl>,
@@ -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<TextNoteEvent>) {
if (!isWriteable()) return
broadcastPrivately(NIP17Factory().createNoteNIP17(template, signer))
}
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
wraps.forEach { wrap ->
val relayList = computeRelayListToBroadcast(wrap)
@@ -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<Note>()
// 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<NormalizedRelayUrl, Long>()
val accountNip04Pruned = HashMap<NormalizedRelayUrl, Long>()
@@ -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<Note> {
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<Note>()
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() {
@@ -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,
@@ -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,
@@ -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
@@ -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() }
@@ -52,20 +52,23 @@ import kotlinx.collections.immutable.ImmutableList
fun Notifying(
baseMentions: ImmutableList<User>?,
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,
)
}
}
}
}
}
@@ -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()
@@ -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<ZapPaymentHandler.Payable>) -> 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<Note>) = launchSigner { account.delete(notes) }
@@ -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
@@ -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,
@@ -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<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 +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
+9
View File
@@ -984,6 +984,14 @@
<string name="article_bookmark_management_title">Bookmark this article</string>
<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="private_note_visible_to">Visible to</string>
<string name="private_note_no_receivers">No receivers yet: only you will be able to see this note. Add people to share it with.</string>
<string name="notify_add_user">+ Add</string>
<string name="notify_search_and_add_user">Search and add a user to notify</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>
@@ -2208,6 +2216,7 @@
<string name="nutzap">Nutzap</string>
<string name="nutzap_failed_title">Nutzap failed</string>
<string name="nutzap_failed_no_recipient">No recipient pubkey on the note</string>
<string name="nutzap_failed_private_note">Nutzaps are public and would reveal this private note. Use a Lightning zap instead.</string>
<string name="nutzap_failed_no_event">Cannot build event reference</string>
<string name="cashu_create_token">Create token</string>
<string name="cashu_redeem_button">Redeem</string>
@@ -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<Note>? = 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<NotesGatherer>? = null
fun inGatherers() = inGatherers ?: listOf<NotesGatherer>().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,
@@ -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<Event>() ?: return null
@@ -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<HexKey>? =
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,
),
)
@@ -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).
@@ -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)",
)
}
}
@@ -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(),
)
}
}
@@ -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)
}
}
@@ -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,
@@ -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.
@@ -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<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,
@@ -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<DeletionEvent>,
to: List<HexKey>,
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<Event>,
@@ -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<Array<String>>,
content: String,
sig: HexKey,
) : WrappedEvent(id, pubKey, createdAt, kind, tags, content, sig),
) : Event(id, pubKey, createdAt, kind, tags, content, sig),
ChatroomKeyable,
NIP17Group,
PubKeyHintProvider {
@@ -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,
@@ -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<Array<String>>,
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
}
@@ -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<Array<String>>,
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
@@ -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