mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
@@ -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() }
|
||||
|
||||
|
||||
+23
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+48
-25
@@ -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()
|
||||
|
||||
+45
-3
@@ -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) }
|
||||
|
||||
+11
@@ -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
|
||||
|
||||
+82
-4
@@ -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,
|
||||
|
||||
+63
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+27
-7
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
+3
-3
@@ -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).
|
||||
|
||||
|
||||
+112
@@ -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)",
|
||||
)
|
||||
}
|
||||
}
|
||||
+97
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+107
@@ -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>,
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
+1
-7
@@ -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
|
||||
|
||||
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user