mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge pull request #3202 from vitorpamplona/claude/sweet-shannon-smjotq
Redesign activity cards (reactions, zaps, nutzaps) with unified UI
This commit is contained in:
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.HasInnerEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
@@ -328,6 +329,25 @@ fun routeReplyTo(
|
||||
}
|
||||
}
|
||||
|
||||
is LnZapEvent -> {
|
||||
// A public reply can't tag a private zapper without exposing them.
|
||||
// When we hold the decrypted sender (we are the zap recipient), reply
|
||||
// in their DM room instead of the public comment composer.
|
||||
val request = noteEvent.zapRequest
|
||||
val privateSender =
|
||||
if (request?.isPrivateZap() == true) {
|
||||
account.privateZapsDecryptionCache.cachedPrivateZap(request)?.pubKey
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (privateSender != null) {
|
||||
routeToMessage(ChatroomKey(setOf(privateSender)), null, account = account)
|
||||
} else {
|
||||
Route.GenericCommentPost(replyTo = note.idHex)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Route.GenericCommentPost(replyTo = note.idHex)
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.authorRouteFor
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.NoteDropDownMenu
|
||||
@@ -256,7 +257,9 @@ fun RenderLikeGallery(
|
||||
}
|
||||
}
|
||||
|
||||
AuthorGallery(likeEvents, nav, accountViewModel)
|
||||
// Opens the reaction's own thread (where it can be replied to,
|
||||
// boosted, or zapped) instead of the reactor's profile.
|
||||
AuthorGallery(likeEvents, nav, accountViewModel) { Route.Note(it.idHex) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,6 +375,7 @@ fun RenderNutzapGallery(
|
||||
user = note.author,
|
||||
comment = event?.content?.ifBlank { null },
|
||||
amount = showAmount(java.math.BigDecimal(sats)),
|
||||
zapNote = note,
|
||||
)
|
||||
}.toImmutableList()
|
||||
}
|
||||
@@ -469,6 +473,9 @@ data class ZapAmountCommentNotification(
|
||||
val user: User?,
|
||||
val comment: String?,
|
||||
val amount: String?,
|
||||
// The zap receipt (kind 9735) note, when available, so the chip can offer
|
||||
// actions that target the zap itself (e.g. replying to it).
|
||||
val zapNote: Note? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -485,6 +492,7 @@ private fun ParseAuthorCommentAndAmount(
|
||||
user = zapRequest.author,
|
||||
comment = null,
|
||||
amount = null,
|
||||
zapNote = zapEvent,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -504,7 +512,14 @@ fun click(
|
||||
content: ZapAmountCommentNotification,
|
||||
nav: INav,
|
||||
) {
|
||||
content.user?.let { nav.nav(routeFor(it)) }
|
||||
val zapNote = content.zapNote
|
||||
if (zapNote != null) {
|
||||
// Opens the zap's own thread, where anyone can reply, boost,
|
||||
// zap, or share it. The sender's profile is one tap away there.
|
||||
nav.nav(Route.Note(zapNote.idHex))
|
||||
} else {
|
||||
content.user?.let { nav.nav(routeFor(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -614,9 +629,10 @@ fun AuthorGallery(
|
||||
authorNotes: ImmutableList<Note>,
|
||||
nav: INav,
|
||||
accountViewModel: AccountViewModel,
|
||||
clickRoute: (Note) -> Route? = ::authorRouteFor,
|
||||
) {
|
||||
Column(modifier = StdStartPadding) {
|
||||
FlowRow { authorNotes.forEach { note -> BoxedAuthor(note, nav, accountViewModel) } }
|
||||
FlowRow { authorNotes.forEach { note -> BoxedAuthor(note, nav, accountViewModel, clickRoute) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,8 +655,9 @@ private fun BoxedAuthor(
|
||||
note: Note,
|
||||
nav: INav,
|
||||
accountViewModel: AccountViewModel,
|
||||
clickRoute: (Note) -> Route? = ::authorRouteFor,
|
||||
) {
|
||||
Box(modifier = Size35Modifier.clickable(onClick = { authorRouteFor(note)?.let { nav.nav(it) } })) {
|
||||
Box(modifier = Size35Modifier.clickable(onClick = { clickRoute(note)?.let { nav.nav(it) } })) {
|
||||
WatchAuthorWithBlank(note, Size35Modifier, accountViewModel) { author ->
|
||||
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(
|
||||
author,
|
||||
|
||||
@@ -157,6 +157,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryRespo
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderNipContent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderNutzap
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastEpisode
|
||||
@@ -187,6 +188,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll
|
||||
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
|
||||
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.observeZapSender
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
|
||||
@@ -292,6 +294,7 @@ import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent
|
||||
@@ -1012,11 +1015,15 @@ private fun RenderNoteRow(
|
||||
}
|
||||
|
||||
is LnZapEvent -> {
|
||||
RenderLnZap(baseNote, backgroundColor, accountViewModel, nav)
|
||||
RenderLnZap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is NutzapEvent -> {
|
||||
RenderNutzap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is OnchainZapEvent -> {
|
||||
RenderOnchainZap(baseNote, backgroundColor, accountViewModel, nav)
|
||||
RenderOnchainZap(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is LiveActivitiesClipEvent -> {
|
||||
@@ -1720,7 +1727,22 @@ fun FirstUserInfoRow(
|
||||
val isDraft = baseNote.isDraft()
|
||||
val textColor = if (isRepost) MaterialTheme.colorScheme.grayText else Color.Unspecified
|
||||
|
||||
if (showAuthorPicture) {
|
||||
// Zap receipts are signed by the recipient's lightning provider; show the
|
||||
// sender from the embedded zap request instead of the service key.
|
||||
val zapSender =
|
||||
if (baseNote.event is LnZapEvent) {
|
||||
observeZapSender(baseNote, accountViewModel).value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (zapSender != null) {
|
||||
if (showAuthorPicture) {
|
||||
UserPicture(zapSender, Size25dp, accountViewModel = accountViewModel, nav = nav)
|
||||
Spacer(HalfPadding)
|
||||
}
|
||||
UsernameDisplay(zapSender, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel)
|
||||
} else if (showAuthorPicture) {
|
||||
NoteAuthorPicture(baseNote, Size25dp, accountViewModel = accountViewModel, nav = nav)
|
||||
Spacer(HalfPadding)
|
||||
NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel)
|
||||
|
||||
@@ -84,6 +84,7 @@ fun NutzapUserSetCompose(
|
||||
user = note.author,
|
||||
comment = event?.content?.ifBlank { null },
|
||||
amount = showAmount(java.math.BigDecimal(sats)),
|
||||
zapNote = note,
|
||||
)
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
+40
@@ -80,6 +80,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.hasGeohashes
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.references.references
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
|
||||
@@ -96,6 +97,7 @@ import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
|
||||
@@ -269,9 +271,27 @@ open class CommentPostViewModel :
|
||||
open fun reply(post: Note) {
|
||||
this.replyingTo = post
|
||||
this.externalIdentity = (post.event as? CommentEvent)?.scope()
|
||||
(post.event as? LnZapEvent)?.let { zap ->
|
||||
notifying = listOfNotNull(zapSenderToNotify(zap))
|
||||
}
|
||||
observeCommunityRules(post)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zap receipts (kind 9735) are signed by the recipient's lightning provider, so
|
||||
* the reply tags built from the receipt alone would notify the custodian instead
|
||||
* of the person who zapped. The actual sender is the author of the embedded zap
|
||||
* request. Requests carrying an `anon` tag (anonymous or private zaps) are signed
|
||||
* by an ephemeral key: skip those — tagging the throwaway key is useless, and
|
||||
* tagging the decrypted sender would publicly expose a private zapper.
|
||||
*/
|
||||
private fun zapSenderToNotify(zapEvent: LnZapEvent): User? {
|
||||
val request = zapEvent.zapRequest ?: return null
|
||||
if (request.hasAnonTag()) return null
|
||||
if (request.pubKey == account.signer.pubKey) return null
|
||||
return LocalCache.checkGetOrCreateUser(request.pubKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to the latest [CommunityRulesEvent] when [target] is a NIP-72
|
||||
* community, and clears the rules state otherwise. Re-evaluates the current
|
||||
@@ -433,6 +453,17 @@ open class CommentPostViewModel :
|
||||
notifying = draftEvent.rootAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } +
|
||||
draftEvent.replyAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
|
||||
// Replies to zaps notify the zap sender through a plain p tag (the receipt's
|
||||
// author keys above are the lightning provider). Restore the sender chip only
|
||||
// if the draft still tags them — its absence means the user removed it.
|
||||
(replyingTo?.event as? LnZapEvent)?.let { zap ->
|
||||
zapSenderToNotify(zap)?.let { sender ->
|
||||
if (draftEvent.tags.mapNotNull(PTag::parseKey).contains(sender.pubkeyHex)) {
|
||||
notifying = (notifying ?: emptyList()) + sender
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (forwardZapTo.value.items.isNotEmpty()) {
|
||||
wantsForwardZapTo = true
|
||||
}
|
||||
@@ -529,6 +560,15 @@ open class CommentPostViewModel :
|
||||
null
|
||||
}
|
||||
}
|
||||
} else if (replyingToEvent is LnZapEvent) {
|
||||
val sender = zapSenderToNotify(replyingToEvent)
|
||||
// notifying starts with the sender; a missing entry means the
|
||||
// user removed the chip, so respect that and don't tag them.
|
||||
if (sender != null && notifying?.contains(sender) != false) {
|
||||
listOf(sender.toPTag())
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* 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.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
|
||||
|
||||
/**
|
||||
* Heart color of the Liked icon vector; tints reaction activity cards the way
|
||||
* bitcoinColor tints the zap ones.
|
||||
*/
|
||||
val LikeTint = Color(0xFFCA395F)
|
||||
|
||||
/**
|
||||
* Gradient card frame shared by the reaction / zap / nutzap / onchain-zap
|
||||
* renderings, mirroring the onchain card's look: rounded corners and a soft
|
||||
* top-to-bottom wash of the kind's tint.
|
||||
*/
|
||||
@Composable
|
||||
fun ActivityCardFrame(
|
||||
tint: Color,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
colors =
|
||||
listOf(
|
||||
tint.copy(alpha = 0.16f),
|
||||
tint.copy(alpha = 0.04f),
|
||||
),
|
||||
),
|
||||
).padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/** Circular kind badge: a small filled circle of the tint with a white glyph. */
|
||||
@Composable
|
||||
fun ActivityBadge(
|
||||
tint: Color,
|
||||
icon: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
.background(tint),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
icon()
|
||||
}
|
||||
}
|
||||
|
||||
/** Right-aligned kind label, the static cousin of the onchain card's dashed pill. */
|
||||
@Composable
|
||||
fun ActivityPill(
|
||||
label: String,
|
||||
tint: Color,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.border(1.4.dp, tint.copy(alpha = 0.7f), RoundedCornerShape(10.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = tint,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** First line of the card: badge, sender → recipient avatars, and the kind pill. */
|
||||
@Composable
|
||||
fun ActivityHeaderRow(
|
||||
tint: Color,
|
||||
pillLabel: String?,
|
||||
badge: @Composable () -> Unit,
|
||||
senderAvatar: @Composable () -> Unit,
|
||||
recipientAvatar: (@Composable () -> Unit)?,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
badge()
|
||||
senderAvatar()
|
||||
recipientAvatar?.let {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.ArrowForwardIos,
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Size16Modifier,
|
||||
)
|
||||
it()
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
pillLabel?.let { ActivityPill(it, tint) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Big amount line, matching the onchain card's typography. */
|
||||
@Composable
|
||||
fun ActivityAmountRow(
|
||||
amount: String,
|
||||
tint: Color,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = amount,
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = tint,
|
||||
)
|
||||
Text(
|
||||
text = "sats",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = tint,
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* 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.ui.note.types
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
|
||||
/**
|
||||
* Design-time fixtures for the activity cards (reaction / lightning zap /
|
||||
* cashu nutzap / onchain zap). The notes are built by hand and never consumed
|
||||
* into [LocalCache], except for the users behind the avatars.
|
||||
*/
|
||||
private object ActivityCardPreviewData {
|
||||
val senderHex = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
val authorHex = "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b"
|
||||
const val SIG = "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||||
const val TIME = 1_700_000_000L
|
||||
|
||||
val targetId = "a".repeat(64)
|
||||
|
||||
fun targetNote(): Note =
|
||||
Note(targetId).apply {
|
||||
event =
|
||||
TextNoteEvent(
|
||||
targetId,
|
||||
authorHex,
|
||||
TIME,
|
||||
emptyArray(),
|
||||
"Just finished the new release — zaps, nests, and a faster feed. What should we build next?",
|
||||
SIG,
|
||||
)
|
||||
author = LocalCache.getOrCreateUser(authorHex)
|
||||
replyTo = emptyList()
|
||||
}
|
||||
|
||||
fun reactionNote(reaction: String): Note {
|
||||
val id = "b".repeat(64)
|
||||
return Note(id).apply {
|
||||
event =
|
||||
ReactionEvent(
|
||||
id,
|
||||
senderHex,
|
||||
TIME,
|
||||
arrayOf(arrayOf("e", targetId), arrayOf("p", authorHex)),
|
||||
reaction,
|
||||
SIG,
|
||||
)
|
||||
author = LocalCache.getOrCreateUser(senderHex)
|
||||
replyTo = listOf(targetNote())
|
||||
}
|
||||
}
|
||||
|
||||
fun nutzapNote(): Note {
|
||||
val id = "c".repeat(64)
|
||||
return Note(id).apply {
|
||||
event =
|
||||
NutzapEvent(
|
||||
id,
|
||||
senderHex,
|
||||
TIME,
|
||||
arrayOf(
|
||||
arrayOf("proof", """{"amount":2100,"id":"00ad268c4d1f5826","secret":"s","C":"c"}"""),
|
||||
arrayOf("u", "https://mint.example.com"),
|
||||
arrayOf("e", targetId),
|
||||
arrayOf("p", authorHex),
|
||||
),
|
||||
"Have some cashu, great post!",
|
||||
SIG,
|
||||
)
|
||||
author = LocalCache.getOrCreateUser(senderHex)
|
||||
replyTo = listOf(targetNote())
|
||||
}
|
||||
}
|
||||
|
||||
fun lnZapNote(): Note {
|
||||
val id = "d".repeat(64)
|
||||
// Receipts are signed by the lightning provider; the preview skips the
|
||||
// embedded request and feeds the decrypted card to RenderLnZapCard directly.
|
||||
return Note(id).apply {
|
||||
event = null
|
||||
author = null
|
||||
replyTo = listOf(targetNote())
|
||||
}
|
||||
}
|
||||
|
||||
fun onchainZapNote(): Note {
|
||||
val id = "e".repeat(64)
|
||||
return Note(id).apply {
|
||||
event =
|
||||
OnchainZapEvent(
|
||||
id,
|
||||
senderHex,
|
||||
TIME,
|
||||
arrayOf(
|
||||
arrayOf("i", "bitcoin:tx:" + "ab".repeat(32)),
|
||||
arrayOf("amount", "150000"),
|
||||
arrayOf("e", targetId),
|
||||
arrayOf("p", authorHex),
|
||||
),
|
||||
"Welcome to the timechain!",
|
||||
SIG,
|
||||
)
|
||||
author = LocalCache.getOrCreateUser(senderHex)
|
||||
replyTo = listOf(targetNote())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun LikeActivityCardPreview() {
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
|
||||
ThemeComparisonColumn {
|
||||
RenderReaction(
|
||||
note = ActivityCardPreviewData.reactionNote("+"),
|
||||
quotesLeft = 1,
|
||||
backgroundColor = remember { mutableStateOf(Color.Transparent) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun EmojiReactionActivityCardPreview() {
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
|
||||
ThemeComparisonColumn {
|
||||
RenderReaction(
|
||||
note = ActivityCardPreviewData.reactionNote("🤙"),
|
||||
quotesLeft = 1,
|
||||
backgroundColor = remember { mutableStateOf(Color.Transparent) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun LnZapActivityCardPreview() {
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
|
||||
ThemeComparisonColumn {
|
||||
RenderLnZapCard(
|
||||
note = ActivityCardPreviewData.lnZapNote(),
|
||||
card =
|
||||
ZapAmountCommentNotification(
|
||||
user = LocalCache.getOrCreateUser(ActivityCardPreviewData.senderHex),
|
||||
comment = "Great post! ⚡",
|
||||
amount = "100k",
|
||||
),
|
||||
recipientKey = ActivityCardPreviewData.authorHex,
|
||||
quotesLeft = 1,
|
||||
backgroundColor = remember { mutableStateOf(Color.Transparent) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun NutzapActivityCardPreview() {
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
|
||||
ThemeComparisonColumn {
|
||||
RenderNutzap(
|
||||
note = ActivityCardPreviewData.nutzapNote(),
|
||||
quotesLeft = 1,
|
||||
backgroundColor = remember { mutableStateOf(Color.Transparent) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OnchainZapActivityCardPreview() {
|
||||
val accountViewModel = mockAccountViewModel()
|
||||
|
||||
ThemeComparisonColumn {
|
||||
RenderOnchainZap(
|
||||
note = ActivityCardPreviewData.onchainZapNote(),
|
||||
quotesLeft = 1,
|
||||
backgroundColor = remember { mutableStateOf(Color.Transparent) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = EmptyNav(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* 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.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.Cashu
|
||||
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.claimedSatsTotal
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* Renders a NIP-61 nutzap (kind 9321) as an activity card, like lightning zaps.
|
||||
* Unlike kind 9735 receipts, the nutzap is signed by the sender, so [Note.author]
|
||||
* is already the right person to attribute.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderNutzap(
|
||||
note: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val nutzapEvent = note.event as? NutzapEvent ?: return
|
||||
|
||||
val recipientKey = nutzapEvent.linkedPubKeys().firstOrNull()
|
||||
val orange = MaterialTheme.colorScheme.bitcoinColor
|
||||
|
||||
ActivityCardFrame(orange) {
|
||||
ActivityHeaderRow(
|
||||
tint = orange,
|
||||
pillLabel = "CASHU",
|
||||
badge = {
|
||||
ActivityBadge(orange) {
|
||||
Icon(
|
||||
imageVector = CustomHashTagIcons.Cashu,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
senderAvatar = {
|
||||
val sender = note.author
|
||||
if (sender != null) {
|
||||
UserPicture(sender, Size25dp, Modifier, accountViewModel, nav)
|
||||
} else {
|
||||
DisplayBlankAuthor(Size25dp, accountViewModel = accountViewModel)
|
||||
}
|
||||
},
|
||||
recipientAvatar =
|
||||
recipientKey?.let {
|
||||
{ UserPicture(it, Size25dp, Modifier, accountViewModel, nav) }
|
||||
},
|
||||
)
|
||||
|
||||
RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
|
||||
ActivityAmountRow(showAmount(BigDecimal(nutzapEvent.claimedSatsTotal())), orange)
|
||||
|
||||
nutzapEvent.content.ifBlank { null }?.let {
|
||||
CrossfadeToDisplayComment(it, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@ private const val MEMPOOL_TX_URL = "https://mempool.space/tx/"
|
||||
@Composable
|
||||
fun RenderOnchainZap(
|
||||
note: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
@@ -97,6 +98,7 @@ fun RenderOnchainZap(
|
||||
val event = note.event as? OnchainZapEvent ?: return
|
||||
val sender = note.author?.pubkeyHex ?: event.pubKey
|
||||
val recipient = event.recipient() ?: return
|
||||
|
||||
val sats = event.claimedAmountInSats() ?: 0L
|
||||
val txid = event.txid()
|
||||
val message = event.content.takeIf { it.isNotBlank() }
|
||||
@@ -141,6 +143,8 @@ fun RenderOnchainZap(
|
||||
nav = nav,
|
||||
)
|
||||
|
||||
RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
|
||||
AmountRow(sats = sats, orange = orange)
|
||||
|
||||
ConfirmationPill(tx = tx, hasTxid = txid != null, orange = orange)
|
||||
|
||||
@@ -20,16 +20,26 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note.types
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
|
||||
import com.vitorpamplona.amethyst.ui.note.LikedIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size25dp
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderReaction as RenderReactionEmoji
|
||||
|
||||
/**
|
||||
* Renders a kind 7 reaction as an activity card, like the zap kinds, tinted
|
||||
* with the Liked heart color: badge + reactor → author on the first line and
|
||||
* the reacted-to post quoted inside the card.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderReaction(
|
||||
note: Note,
|
||||
@@ -38,24 +48,35 @@ fun RenderReaction(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
note.replyTo?.lastOrNull()?.let {
|
||||
NoteCompose(
|
||||
it,
|
||||
modifier = Modifier,
|
||||
isBoostedNote = true,
|
||||
unPackReply = ReplyRenderType.NONE,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
val reactionType = note.event?.content ?: ""
|
||||
|
||||
ActivityCardFrame(LikeTint) {
|
||||
ActivityHeaderRow(
|
||||
tint = LikeTint,
|
||||
pillLabel = "REACTION",
|
||||
badge = {
|
||||
if (reactionType == "+" || reactionType.isBlank()) {
|
||||
ActivityBadge(LikeTint) {
|
||||
LikedIcon(Modifier.size(16.dp), Color.White)
|
||||
}
|
||||
} else {
|
||||
RenderReactionEmoji(reactionType)
|
||||
}
|
||||
},
|
||||
senderAvatar = {
|
||||
val sender = note.author
|
||||
if (sender != null) {
|
||||
UserPicture(sender, Size25dp, Modifier, accountViewModel, nav)
|
||||
} else {
|
||||
DisplayBlankAuthor(Size25dp, accountViewModel = accountViewModel)
|
||||
}
|
||||
},
|
||||
recipientAvatar =
|
||||
note.replyTo?.lastOrNull()?.author?.let {
|
||||
{ UserPicture(it, Size25dp, Modifier, accountViewModel, nav) }
|
||||
},
|
||||
)
|
||||
|
||||
RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
}
|
||||
|
||||
// Reposts have trash in their contents.
|
||||
val refactorReactionText = if (note.event?.content == "+") "❤" else note.event?.content ?: ""
|
||||
|
||||
Text(
|
||||
text = refactorReactionText,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip14Subject.subject
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.scope
|
||||
|
||||
@@ -115,7 +116,15 @@ fun RenderTextEvent(
|
||||
}
|
||||
|
||||
ReplyRenderType.LINE -> {
|
||||
val parentAuthor = replyingDirectlyTo.author
|
||||
// Zap receipts are signed by the recipient's lightning provider;
|
||||
// label the reply with the zap sender instead of the service key.
|
||||
val zapSender =
|
||||
if (replyingDirectlyTo.event is LnZapEvent) {
|
||||
observeZapSender(replyingDirectlyTo, accountViewModel).value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val parentAuthor = zapSender ?: replyingDirectlyTo.author
|
||||
if (parentAuthor != null) {
|
||||
ReplyToLabel(
|
||||
parentAuthorDisplay = parentAuthor.toBestDisplayName(),
|
||||
|
||||
@@ -23,10 +23,12 @@ package com.vitorpamplona.amethyst.ui.note.types
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
@@ -35,6 +37,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -43,6 +46,8 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayBlankAuthor
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapIcon
|
||||
@@ -60,9 +65,37 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Shows the post a zap targets above the transfer card, mirroring how
|
||||
* reactions and reposts embed their target.
|
||||
*/
|
||||
@Composable
|
||||
fun RenderZappedPost(
|
||||
zapNote: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
zapNote.replyTo?.lastOrNull()?.let {
|
||||
NoteCompose(
|
||||
it,
|
||||
modifier = Modifier,
|
||||
isBoostedNote = true,
|
||||
makeItShort = true,
|
||||
unPackReply = ReplyRenderType.NONE,
|
||||
quotesLeft = quotesLeft - 1,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderLnZap(
|
||||
note: Note,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
@@ -71,18 +104,76 @@ fun RenderLnZap(
|
||||
|
||||
val card by parseAuthorCommentAndAmount(note, accountViewModel)
|
||||
|
||||
val destinationKey = zapEvent.zappedAuthor().firstOrNull() ?: return
|
||||
|
||||
TransferCard(
|
||||
card,
|
||||
destinationKey,
|
||||
backgroundColor,
|
||||
Modifier,
|
||||
accountViewModel,
|
||||
nav,
|
||||
RenderLnZapCard(
|
||||
note = note,
|
||||
card = card,
|
||||
recipientKey = zapEvent.zappedAuthor().firstOrNull(),
|
||||
quotesLeft = quotesLeft,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderLnZapCard(
|
||||
note: Note,
|
||||
card: ZapAmountCommentNotification,
|
||||
recipientKey: String?,
|
||||
quotesLeft: Int,
|
||||
backgroundColor: MutableState<Color>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val orange = MaterialTheme.colorScheme.bitcoinColor
|
||||
|
||||
ActivityCardFrame(orange) {
|
||||
ActivityHeaderRow(
|
||||
tint = orange,
|
||||
pillLabel = "LIGHTNING",
|
||||
badge = {
|
||||
ActivityBadge(orange) {
|
||||
ZapIcon(Modifier.size(18.dp), Color.White)
|
||||
}
|
||||
},
|
||||
senderAvatar = {
|
||||
val sender = card.user
|
||||
if (sender != null) {
|
||||
UserPicture(sender, Size25dp, Modifier, accountViewModel, nav)
|
||||
} else {
|
||||
DisplayBlankAuthor(Size25dp, accountViewModel = accountViewModel)
|
||||
}
|
||||
},
|
||||
recipientAvatar =
|
||||
recipientKey?.let {
|
||||
{ UserPicture(it, Size25dp, Modifier, accountViewModel, nav) }
|
||||
},
|
||||
)
|
||||
|
||||
RenderZappedPost(note, quotesLeft, backgroundColor, accountViewModel, nav)
|
||||
|
||||
card.amount?.let { ActivityAmountRow(it, orange) }
|
||||
|
||||
card.comment?.let {
|
||||
CrossfadeToDisplayComment(it, backgroundColor, nav, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the sender of a zap receipt — the author of the embedded kind 9734
|
||||
* request, decrypted when it is a private zap — since the receipt itself is
|
||||
* signed by the recipient's lightning provider, not by the sender.
|
||||
*/
|
||||
@Composable
|
||||
fun observeZapSender(
|
||||
zapNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
): State<User?> =
|
||||
produceState<User?>(initialValue = null, key1 = zapNote) {
|
||||
value = accountViewModel.innerDecryptAmountMessage(zapNote)?.user
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun parseAuthorCommentAndAmount(
|
||||
zapEventNote: Note,
|
||||
|
||||
+12
-4
@@ -718,6 +718,7 @@ class AccountViewModel(
|
||||
?.content
|
||||
?.ifBlank { null },
|
||||
showAmountInteger((it.response.event as? LnZapEvent)?.amount),
|
||||
it.response,
|
||||
)
|
||||
}.toMutableMap()
|
||||
|
||||
@@ -733,7 +734,7 @@ class AccountViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
results.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info }
|
||||
results.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info.copy(zapNote = decrypted.zapEvent) }
|
||||
|
||||
onNewState(initialResults.values.toImmutableList())
|
||||
}
|
||||
@@ -750,6 +751,7 @@ class AccountViewModel(
|
||||
LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.request.author,
|
||||
cachedPrivateRequest.content.ifBlank { null },
|
||||
showAmountInteger((it.response.event as? LnZapEvent)?.amount),
|
||||
it.response,
|
||||
)
|
||||
} else {
|
||||
ZapAmountCommentNotification(
|
||||
@@ -758,6 +760,7 @@ class AccountViewModel(
|
||||
?.content
|
||||
?.ifBlank { null },
|
||||
showAmountInteger((it.response.event as? LnZapEvent)?.amount),
|
||||
it.response,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -767,6 +770,7 @@ class AccountViewModel(
|
||||
?.content
|
||||
?.ifBlank { null },
|
||||
showAmountInteger((it.response.event as? LnZapEvent)?.amount),
|
||||
it.response,
|
||||
)
|
||||
}
|
||||
}.toImmutableList()
|
||||
@@ -784,6 +788,7 @@ class AccountViewModel(
|
||||
LocalCache.getUserIfExists(cachedPrivateRequest.pubKey) ?: it.first.author,
|
||||
cachedPrivateRequest.content.ifBlank { null },
|
||||
showAmountInteger((it.second?.event as? LnZapEvent)?.amount),
|
||||
it.second,
|
||||
)
|
||||
} else {
|
||||
ZapAmountCommentNotification(
|
||||
@@ -792,6 +797,7 @@ class AccountViewModel(
|
||||
?.content
|
||||
?.ifBlank { null },
|
||||
showAmountInteger((it.second?.event as? LnZapEvent)?.amount),
|
||||
it.second,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -801,6 +807,7 @@ class AccountViewModel(
|
||||
?.content
|
||||
?.ifBlank { null },
|
||||
showAmountInteger((it.second?.event as? LnZapEvent)?.amount),
|
||||
it.second,
|
||||
)
|
||||
}
|
||||
}.toImmutableList()
|
||||
@@ -823,6 +830,7 @@ class AccountViewModel(
|
||||
?.content
|
||||
?.ifBlank { null },
|
||||
showAmountInteger((it.second?.event as? LnZapEvent)?.amount),
|
||||
it.second,
|
||||
)
|
||||
}.toMutableMap()
|
||||
|
||||
@@ -841,7 +849,7 @@ class AccountViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
decryptedInfo.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info }
|
||||
decryptedInfo.forEach { decrypted -> initialResults[decrypted.zapRequest] = decrypted.info.copy(zapNote = decrypted.zapEvent) }
|
||||
|
||||
onNewState(initialResults.values.toImmutableList())
|
||||
}
|
||||
@@ -853,7 +861,7 @@ class AccountViewModel(
|
||||
onNewState: (ZapAmountCommentNotification?) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
onNewState(innerDecryptAmountMessage(zapRequest, zapEvent))
|
||||
onNewState(innerDecryptAmountMessage(zapRequest, zapEvent)?.copy(zapNote = zapEvent))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -861,7 +869,7 @@ class AccountViewModel(
|
||||
val zapEvent = zapNote.event as? LnZapEvent ?: return null
|
||||
val zapRequest = zapEvent.zapRequest ?: return null
|
||||
|
||||
return innerDecryptAmountMessage(zapRequest, zapEvent)
|
||||
return innerDecryptAmountMessage(zapRequest, zapEvent)?.copy(zapNote = zapNote)
|
||||
}
|
||||
|
||||
suspend fun innerDecryptAmountMessage(
|
||||
|
||||
+23
@@ -168,6 +168,29 @@ class NotificationFeedFilter(
|
||||
return true
|
||||
}
|
||||
|
||||
if (event is CommentEvent) {
|
||||
// NIP-22 comments carry their root/parent authors as tags, so a
|
||||
// reply to the user's reaction stays detectable even when the
|
||||
// reaction event is not in the local cache (the replyTo-author
|
||||
// check above needs it loaded).
|
||||
if (event.rootAuthorKeys().contains(authorHex) || event.replyAuthorKeys().contains(authorHex)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Replies to the user's zaps: the receipt — and therefore the
|
||||
// author tags above — is signed by the recipient's lightning
|
||||
// provider, not the zapper. The `k` tag (or the cached parent)
|
||||
// proves the comment targets a zap; the reply's explicit p tag
|
||||
// on the user marks it as theirs.
|
||||
val targetsZapReceipt =
|
||||
event.hasScopeKind(LnZapEvent.KIND.toString()) ||
|
||||
note.replyTo?.any { it.event is LnZapEvent } == true
|
||||
|
||||
if (targetsZapReceipt && event.isTaggedUser(authorHex)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if ((event is TextNoteEvent || event is CommentEvent)) {
|
||||
val community =
|
||||
event
|
||||
|
||||
+43
-10
@@ -106,6 +106,8 @@ import com.vitorpamplona.amethyst.ui.note.ReactionsRow
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderApproveButton
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderDraft
|
||||
import com.vitorpamplona.amethyst.ui.note.RenderRepost
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.calculateBackgroundColor
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits
|
||||
@@ -174,6 +176,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderMusicPlaylist
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderMusicTrack
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderNamedSiteEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderNutzap
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderOnchainZap
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderPodcastEpisode
|
||||
@@ -182,6 +185,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderPoll
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderPublicMessage
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderReaction
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayAddMember
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayDiscovery
|
||||
import com.vitorpamplona.amethyst.ui.note.types.RenderRelayJoinRequest
|
||||
@@ -200,6 +204,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll
|
||||
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
|
||||
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.types.VoiceHeader
|
||||
import com.vitorpamplona.amethyst.ui.note.types.observeZapSender
|
||||
import com.vitorpamplona.amethyst.ui.painterRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.RenderFeedState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -256,6 +261,7 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
|
||||
@@ -294,6 +300,7 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup
|
||||
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
@@ -513,22 +520,44 @@ private fun FullBleedNoteCompose(
|
||||
) {
|
||||
val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel)
|
||||
|
||||
// Zap receipts are signed by the recipient's lightning provider; show the
|
||||
// sender from the embedded zap request instead of the service key.
|
||||
val zapSender =
|
||||
if (noteEvent is LnZapEvent) {
|
||||
observeZapSender(baseNote, accountViewModel).value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 12.dp, end = 12.dp)
|
||||
.clickable(onClick = { baseNote.author?.let { nav.nav(routeFor(it)) } }),
|
||||
.clickable(onClick = { (zapSender ?: baseNote.author)?.let { nav.nav(routeFor(it)) } }),
|
||||
) {
|
||||
NoteAuthorPicture(
|
||||
baseNote = baseNote,
|
||||
size = Size55dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
if (zapSender != null) {
|
||||
UserPicture(
|
||||
user = zapSender,
|
||||
size = Size55dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
} else {
|
||||
NoteAuthorPicture(
|
||||
baseNote = baseNote,
|
||||
size = Size55dp,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
NoteUsernameDisplay(baseNote, Modifier.weight(1f), accountViewModel = accountViewModel)
|
||||
if (zapSender != null) {
|
||||
UsernameDisplay(zapSender, Modifier.weight(1f), accountViewModel = accountViewModel)
|
||||
} else {
|
||||
NoteUsernameDisplay(baseNote, Modifier.weight(1f), accountViewModel = accountViewModel)
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
ObserveDraftEvent(baseNote, accountViewModel) { draftNote ->
|
||||
@@ -716,9 +745,13 @@ private fun FullBleedNoteCompose(
|
||||
} else if (noteEvent is AdvertisedRelayListEvent) {
|
||||
DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav)
|
||||
} else if (noteEvent is LnZapEvent) {
|
||||
RenderLnZap(baseNote, backgroundColor, accountViewModel, nav)
|
||||
RenderLnZap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav)
|
||||
} else if (noteEvent is NutzapEvent) {
|
||||
RenderNutzap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav)
|
||||
} else if (noteEvent is OnchainZapEvent) {
|
||||
RenderOnchainZap(baseNote, backgroundColor, accountViewModel, nav)
|
||||
RenderOnchainZap(baseNote, quotesLeft = 3, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav)
|
||||
} else if (noteEvent is ReactionEvent) {
|
||||
RenderReaction(baseNote, quotesLeft = 3, backgroundColor, accountViewModel, nav)
|
||||
} else if (noteEvent is SearchRelayListEvent) {
|
||||
DisplaySearchRelayList(baseNote, backgroundColor, accountViewModel, nav)
|
||||
} else if (noteEvent is BlockedRelayListEvent) {
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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.ui.screen.loggedIn.notifications.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The "Curated" (Selected) notification mode gates every event through
|
||||
* [NotificationFeedFilter.tagsAnEventByUser]. Replies to the user's likes and
|
||||
* zaps are kind:1111 comments whose relevance must be detectable without the
|
||||
* parent event in the local cache: reaction parents via the NIP-22 root/reply
|
||||
* author tags, zap parents via the `k` tag plus the explicit p tag (the
|
||||
* receipt itself is signed by the lightning provider, not the zapper).
|
||||
*/
|
||||
class NotificationTagsAnEventByUserTest {
|
||||
private val me = "1".repeat(64)
|
||||
private val wallet = "2".repeat(64)
|
||||
private val replier = "3".repeat(64)
|
||||
private val reactionId = "a".repeat(64)
|
||||
private val zapId = "b".repeat(64)
|
||||
private val replyId = "c".repeat(64)
|
||||
private val sig = "f".repeat(128)
|
||||
|
||||
@Test
|
||||
fun `reply to my reaction is relevant via nip22 author tags without the parent in cache`() {
|
||||
val reply =
|
||||
CommentEvent(
|
||||
replyId,
|
||||
replier,
|
||||
1000,
|
||||
arrayOf(
|
||||
arrayOf("E", reactionId, "", me),
|
||||
arrayOf("K", "7"),
|
||||
arrayOf("P", me),
|
||||
arrayOf("e", reactionId, "", me),
|
||||
arrayOf("k", "7"),
|
||||
arrayOf("p", me),
|
||||
),
|
||||
"lol same",
|
||||
sig,
|
||||
)
|
||||
// Parent reaction NOT in cache: replyTo resolves to an empty note (no author).
|
||||
val note =
|
||||
Note(replyId).apply {
|
||||
event = reply
|
||||
replyTo = listOf(Note(reactionId))
|
||||
}
|
||||
|
||||
assertTrue(NotificationFeedFilter.tagsAnEventByUser(note, me))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reply to my zap is relevant via k tag and explicit p tag without the receipt in cache`() {
|
||||
val reply =
|
||||
CommentEvent(
|
||||
replyId,
|
||||
replier,
|
||||
1000,
|
||||
arrayOf(
|
||||
arrayOf("E", zapId, "", wallet),
|
||||
arrayOf("K", LnZapEvent.KIND.toString()),
|
||||
arrayOf("P", wallet),
|
||||
arrayOf("e", zapId, "", wallet),
|
||||
arrayOf("k", LnZapEvent.KIND.toString()),
|
||||
arrayOf("p", wallet),
|
||||
arrayOf("p", me),
|
||||
),
|
||||
"thanks for the zap!",
|
||||
sig,
|
||||
)
|
||||
val note =
|
||||
Note(replyId).apply {
|
||||
event = reply
|
||||
replyTo = listOf(Note(zapId))
|
||||
}
|
||||
|
||||
assertTrue(NotificationFeedFilter.tagsAnEventByUser(note, me))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reply to someone else's zap that does not tag me stays irrelevant`() {
|
||||
val reply =
|
||||
CommentEvent(
|
||||
replyId,
|
||||
replier,
|
||||
1000,
|
||||
arrayOf(
|
||||
arrayOf("E", zapId, "", wallet),
|
||||
arrayOf("K", LnZapEvent.KIND.toString()),
|
||||
arrayOf("P", wallet),
|
||||
arrayOf("e", zapId, "", wallet),
|
||||
arrayOf("k", LnZapEvent.KIND.toString()),
|
||||
arrayOf("p", wallet),
|
||||
),
|
||||
"nice zap",
|
||||
sig,
|
||||
)
|
||||
val note =
|
||||
Note(replyId).apply {
|
||||
event = reply
|
||||
replyTo = listOf(Note(zapId))
|
||||
}
|
||||
|
||||
assertFalse(NotificationFeedFilter.tagsAnEventByUser(note, me))
|
||||
}
|
||||
}
|
||||
+45
-7
@@ -25,8 +25,13 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
|
||||
@@ -43,6 +48,11 @@ class ThreadAssembler(
|
||||
|
||||
if (noteEvent is RepostEvent || noteEvent is GenericRepostEvent) return note
|
||||
|
||||
// Reactions and zaps anchor their own thread: their replyTo points at the
|
||||
// post they reacted to, which lives in a different conversation. Stop here
|
||||
// instead of climbing across that bridge into the targeted post's thread.
|
||||
if (noteEvent.anchorsItsOwnThread()) return note
|
||||
|
||||
testedNotes.add(note)
|
||||
|
||||
val markedAsRoot =
|
||||
@@ -107,14 +117,23 @@ class ThreadAssembler(
|
||||
return if (note.event != null) {
|
||||
val thread = OnlyLatestVersionSet()
|
||||
|
||||
val threadRoot = searchRoot(note, thread) ?: note
|
||||
if (note.event.anchorsItsOwnThread()) {
|
||||
// Reactions and zaps anchor their own conversation: the event is
|
||||
// the root and only its reply subtree is loaded — not the thread
|
||||
// of the post it targeted (that post is embedded in the event's
|
||||
// card). Replies and comments (kind 1 / 1111) keep loading the
|
||||
// full parent thread below.
|
||||
loadDown(note, thread)
|
||||
} else {
|
||||
val threadRoot = searchRoot(note, thread) ?: note
|
||||
|
||||
loadUp(note, thread)
|
||||
loadUp(note, thread)
|
||||
|
||||
loadDown(threadRoot, thread)
|
||||
// adds the replies of the note in case the search for Root
|
||||
// did not added them.
|
||||
note.replies.forEach { loadDown(it, thread) }
|
||||
loadDown(threadRoot, thread)
|
||||
// adds the replies of the note in case the search for Root
|
||||
// did not added them.
|
||||
note.replies.forEach { loadDown(it, thread) }
|
||||
}
|
||||
|
||||
ThreadInfo(
|
||||
root = note,
|
||||
@@ -135,7 +154,12 @@ class ThreadAssembler(
|
||||
if (note !in thread) {
|
||||
thread.add(note)
|
||||
|
||||
note.replyTo?.forEach { loadUp(it, thread) }
|
||||
// A reaction/zap is a thread boundary: include the node itself, but do
|
||||
// not climb its replyTo — that edge bridges into the targeted post's
|
||||
// separate conversation (which must not be pulled into this thread).
|
||||
if (!note.event.anchorsItsOwnThread()) {
|
||||
note.replyTo?.forEach { loadUp(it, thread) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +175,20 @@ class ThreadAssembler(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactions and zaps keep the post they target in [Note.replyTo] so the card can
|
||||
* embed it, but that edge is a cross-reference, not a thread parent: it points
|
||||
* into a different conversation. For thread traversal they behave as roots — their
|
||||
* thread is the event plus its own reply subtree, never the thread of the post
|
||||
* they reacted to. Used by [ThreadAssembler] (root search, upward load) and
|
||||
* [ThreadLevelCalculator] (reply level), mirroring how reposts are treated.
|
||||
*/
|
||||
fun Event?.anchorsItsOwnThread(): Boolean =
|
||||
when (this) {
|
||||
is ReactionEvent, is LnZapEvent, is NutzapEvent, is OnchainZapEvent -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
class OnlyLatestVersionSet : MutableSet<Note> {
|
||||
val map = hashMapOf<Address, Long>()
|
||||
val set = hashSetOf<Note>()
|
||||
|
||||
+10
-2
@@ -61,7 +61,11 @@ object ThreadLevelCalculator {
|
||||
val noteAuthor = note.author
|
||||
|
||||
if (
|
||||
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
|
||||
note.event is RepostEvent ||
|
||||
note.event is GenericRepostEvent ||
|
||||
note.event.anchorsItsOwnThread() ||
|
||||
replyTo == null ||
|
||||
replyTo.isEmpty()
|
||||
) {
|
||||
return LevelSignature(
|
||||
signature = "/" + formattedDateTime(createdAt) + note.idHex.substring(0, 8) + ";",
|
||||
@@ -120,7 +124,11 @@ object ThreadLevelCalculator {
|
||||
): Int {
|
||||
val replyTo = note.replyTo
|
||||
if (
|
||||
note.event is RepostEvent || note.event is GenericRepostEvent || replyTo == null || replyTo.isEmpty()
|
||||
note.event is RepostEvent ||
|
||||
note.event is GenericRepostEvent ||
|
||||
note.event.anchorsItsOwnThread() ||
|
||||
replyTo == null ||
|
||||
replyTo.isEmpty()
|
||||
) {
|
||||
cachedLevels[note] = 0
|
||||
return 0
|
||||
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* 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.model.cache.ICacheEventStream
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Thread anchoring rules: replies and comments (kind 1 / 1111) load the full
|
||||
* parent thread; reactions and zaps anchor their own thread — the event is the
|
||||
* root and only its reply subtree is included, not the conversation of the
|
||||
* post they target.
|
||||
*/
|
||||
class ThreadAssemblerTest {
|
||||
private val threadRootId = "9".repeat(64)
|
||||
private val originalId = "a".repeat(64)
|
||||
private val zapId = "b".repeat(64)
|
||||
private val reactionId = "c".repeat(64)
|
||||
private val replyToZapId = "d".repeat(64)
|
||||
private val replyToReactionId = "8".repeat(64)
|
||||
private val authorKey = "e".repeat(64)
|
||||
private val sig = "f".repeat(128)
|
||||
|
||||
// Thread C: root <- original. `original` is a reply nested inside a thread,
|
||||
// standing in for "a reply on another thread" that gets liked/zapped.
|
||||
private val threadRoot =
|
||||
Note(threadRootId).apply {
|
||||
event = TextNoteEvent(threadRootId, authorKey, 999, arrayOf(arrayOf("t", "test")), "thread root", sig)
|
||||
replyTo = emptyList()
|
||||
}
|
||||
|
||||
private val original =
|
||||
Note(originalId).apply {
|
||||
event =
|
||||
TextNoteEvent(
|
||||
originalId,
|
||||
authorKey,
|
||||
1000,
|
||||
arrayOf(arrayOf("e", threadRootId, "", "root")),
|
||||
"the zapped post",
|
||||
sig,
|
||||
)
|
||||
replyTo = listOf(threadRoot)
|
||||
}
|
||||
|
||||
private val zap =
|
||||
Note(zapId).apply {
|
||||
event = LnZapEvent(zapId, authorKey, 1001, arrayOf(arrayOf("e", originalId), arrayOf("p", authorKey)), "", sig)
|
||||
replyTo = listOf(original)
|
||||
}
|
||||
|
||||
private val reaction =
|
||||
Note(reactionId).apply {
|
||||
event = ReactionEvent(reactionId, authorKey, 1001, arrayOf(arrayOf("e", originalId), arrayOf("p", authorKey)), "+", sig)
|
||||
replyTo = listOf(original)
|
||||
}
|
||||
|
||||
private val replyToZap =
|
||||
Note(replyToZapId).apply {
|
||||
event = CommentEvent(replyToZapId, authorKey, 1002, arrayOf(arrayOf("E", zapId), arrayOf("e", zapId)), "nice zap!", sig)
|
||||
replyTo = listOf(zap)
|
||||
}
|
||||
|
||||
private val replyToReaction =
|
||||
Note(replyToReactionId).apply {
|
||||
event = CommentEvent(replyToReactionId, authorKey, 1002, arrayOf(arrayOf("E", reactionId), arrayOf("e", reactionId)), "lol same", sig)
|
||||
replyTo = listOf(reaction)
|
||||
}
|
||||
|
||||
private val cache =
|
||||
StubCache(
|
||||
mapOf(
|
||||
threadRootId to threadRoot,
|
||||
originalId to original,
|
||||
zapId to zap,
|
||||
reactionId to reaction,
|
||||
replyToZapId to replyToZap,
|
||||
replyToReactionId to replyToReaction,
|
||||
),
|
||||
)
|
||||
|
||||
init {
|
||||
// Mirrors LocalCache: replies link bidirectionally, but zaps and
|
||||
// reactions are credited via addZap/addReaction, not addReply, so the
|
||||
// original note's `replies` does not contain them.
|
||||
threadRoot.addReply(original)
|
||||
zap.addReply(replyToZap)
|
||||
reaction.addReply(replyToReaction)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zapAnchorsItsOwnThread() {
|
||||
val info = ThreadAssembler(cache).findThreadFor(zapId)!!
|
||||
|
||||
assertEquals(zap, info.root)
|
||||
assertTrue(info.allNotes.contains(zap))
|
||||
assertTrue(info.allNotes.contains(replyToZap), "the zap's replies belong to its thread")
|
||||
assertFalse(info.allNotes.contains(original), "the zapped post's thread must not be loaded")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reactionAnchorsItsOwnThread() {
|
||||
val info = ThreadAssembler(cache).findThreadFor(reactionId)!!
|
||||
|
||||
assertEquals(reaction, info.root)
|
||||
assertTrue(info.allNotes.contains(reaction))
|
||||
assertFalse(info.allNotes.contains(original), "the liked post's thread must not be loaded")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commentReplyingToAZapAnchorsAtTheZapNotTheZappedThread() {
|
||||
val info = ThreadAssembler(cache).findThreadFor(replyToZapId)!!
|
||||
|
||||
assertTrue(info.allNotes.contains(replyToZap))
|
||||
assertTrue(info.allNotes.contains(zap), "the zap is the root of this thread")
|
||||
assertFalse(info.allNotes.contains(original), "the zapped post's conversation must not load")
|
||||
assertFalse(info.allNotes.contains(threadRoot), "...nor its thread root")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commentReplyingToALikeAnchorsAtTheLikeNotTheLikedThread() {
|
||||
// The user's scenario: reply A to like L, where L liked a reply (original)
|
||||
// nested in thread C. Opening A's thread must show {L, A}, never thread C.
|
||||
val info = ThreadAssembler(cache).findThreadFor(replyToReactionId)!!
|
||||
|
||||
assertTrue(info.allNotes.contains(replyToReaction))
|
||||
assertTrue(info.allNotes.contains(reaction), "the like is the root of this thread")
|
||||
assertFalse(info.allNotes.contains(original), "the liked post's conversation must not load")
|
||||
assertFalse(info.allNotes.contains(threadRoot), "...nor its thread root")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replyLevelStopsAtTheReactionBoundary() {
|
||||
// Without the boundary the level would climb reaction -> original -> root,
|
||||
// burying the reply several levels deep; it must be a direct child of the like.
|
||||
assertEquals(0, ThreadLevelCalculator.replyLevel(reaction))
|
||||
assertEquals(1, ThreadLevelCalculator.replyLevel(replyToReaction))
|
||||
assertEquals(0, ThreadLevelCalculator.replyLevel(zap))
|
||||
assertEquals(1, ThreadLevelCalculator.replyLevel(replyToZap))
|
||||
}
|
||||
|
||||
private class StubCache(
|
||||
private val notesById: Map<HexKey, Note>,
|
||||
) : ICacheProvider {
|
||||
override fun getAnyChannel(note: Note): Channel? = null
|
||||
|
||||
override fun getUserIfExists(pubkey: HexKey): User? = null
|
||||
|
||||
override fun countUsers(predicate: (String, User) -> Boolean): Int = 0
|
||||
|
||||
override fun getNoteIfExists(hexKey: HexKey): Note? = notesById[hexKey]
|
||||
|
||||
override fun checkGetOrCreateNote(hexKey: HexKey): Note? = notesById[hexKey]
|
||||
|
||||
override fun getOrCreateAddressableNote(key: Address): AddressableNote = error("not used by ThreadAssembler in this test")
|
||||
|
||||
override fun getEventStream(): ICacheEventStream = error("not used by ThreadAssembler in this test")
|
||||
|
||||
override fun hasBeenDeleted(event: Any): Boolean = false
|
||||
|
||||
override fun getOrCreateUser(pubkey: HexKey): User? = null
|
||||
|
||||
override fun justConsumeMyOwnEvent(event: Event): Boolean = false
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -69,6 +69,13 @@ class LnZapRequestEvent(
|
||||
|
||||
fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() }
|
||||
|
||||
/**
|
||||
* True when the request carries an `anon` tag — a private zap (encrypted payload)
|
||||
* or an anonymous zap (blank value). In both cases [pubKey] is an ephemeral
|
||||
* throwaway key, not the sender's real identity.
|
||||
*/
|
||||
fun hasAnonTag() = tags.any { t -> t.isNotEmpty() && t[0] == "anon" }
|
||||
|
||||
fun getAnonTag(): String {
|
||||
val anonTag = tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
|
||||
if (anonTag != null) {
|
||||
@@ -169,7 +176,10 @@ class LnZapRequestEvent(
|
||||
}
|
||||
|
||||
LnZapEvent.ZapType.ANONYMOUS -> {
|
||||
tags += arrayOf(arrayOf("anon", ""))
|
||||
// Valueless `anon` tag: a blank-valued one (`["anon", ""]`) is the
|
||||
// marker for an *unsigned private* zap and would make the throwaway
|
||||
// signer encrypt the message instead of keeping it public.
|
||||
tags += arrayOf(arrayOf("anon"))
|
||||
NostrSignerInternal(KeyPair()).sign(createdAt, KIND, tags, message)
|
||||
}
|
||||
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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.nip57Zaps
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.utils.nsecToKeyPair
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* `hasAnonTag()` is the privacy gate used when replying to a zap: only requests
|
||||
* without an `anon` tag are signed by the sender's real key, so only those may
|
||||
* be p-tagged in a public reply. Anonymous (valueless `anon`) and private
|
||||
* (encrypted `anon`) requests use throwaway keys — tagging anything derived
|
||||
* from them is either useless or, for the decrypted sender, a doxxing risk.
|
||||
*/
|
||||
class LnZapRequestAnonTagTest {
|
||||
private val signer =
|
||||
NostrSignerInternal(
|
||||
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
|
||||
)
|
||||
|
||||
private val receiverPubKey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
|
||||
private val relays = setOf(NormalizedRelayUrl("wss://relay.example.com/"))
|
||||
|
||||
private val zappedEvent =
|
||||
Event(
|
||||
id = "a".repeat(64),
|
||||
pubKey = receiverPubKey,
|
||||
createdAt = 1000L,
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "Hello world",
|
||||
sig = "b".repeat(128),
|
||||
)
|
||||
|
||||
private suspend fun request(zapType: LnZapEvent.ZapType) =
|
||||
LnZapRequestEvent.create(
|
||||
zappedEvent = zappedEvent,
|
||||
relays = relays,
|
||||
signer = signer,
|
||||
pollOption = null,
|
||||
message = "",
|
||||
zapType = zapType,
|
||||
toUserPubHex = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `public zap request has no anon tag and is signed by the sender`() =
|
||||
runTest {
|
||||
val zapRequest = request(LnZapEvent.ZapType.PUBLIC)
|
||||
|
||||
assertFalse(zapRequest.hasAnonTag())
|
||||
assertFalse(zapRequest.isPrivateZap())
|
||||
assertEquals(signer.pubKey, zapRequest.pubKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `anonymous zap request has anon tag but is not private`() =
|
||||
runTest {
|
||||
val zapRequest = request(LnZapEvent.ZapType.ANONYMOUS)
|
||||
|
||||
assertTrue(zapRequest.hasAnonTag())
|
||||
assertFalse(zapRequest.isPrivateZap())
|
||||
assertFalse(zapRequest.pubKey == signer.pubKey, "anonymous zaps must be signed by a throwaway key")
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression test for the user-only (profile zap) overload: its ANONYMOUS
|
||||
* branch used a blank-valued `anon` tag, which [NostrSignerInternal] treats
|
||||
* as an unsigned *private* zap and encrypts — silently turning a public
|
||||
* anonymous comment into an encrypted one nobody but the recipient can read.
|
||||
*/
|
||||
@Test
|
||||
fun `anonymous profile zap request keeps the message public`() =
|
||||
runTest {
|
||||
val zapRequest =
|
||||
LnZapRequestEvent.create(
|
||||
userHex = receiverPubKey,
|
||||
relays = relays,
|
||||
signer = signer,
|
||||
message = "great work",
|
||||
zapType = LnZapEvent.ZapType.ANONYMOUS,
|
||||
)
|
||||
|
||||
assertTrue(zapRequest.hasAnonTag())
|
||||
assertFalse(zapRequest.isPrivateZap(), "anonymous zaps must not be encrypted as private zaps")
|
||||
assertEquals("great work", zapRequest.content)
|
||||
assertFalse(zapRequest.pubKey == signer.pubKey, "anonymous zaps must be signed by a throwaway key")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `private zap request has anon tag, is private, and hides the sender key`() =
|
||||
runTest {
|
||||
val zapRequest = request(LnZapEvent.ZapType.PRIVATE)
|
||||
|
||||
assertTrue(zapRequest.hasAnonTag())
|
||||
assertTrue(zapRequest.isPrivateZap())
|
||||
assertFalse(zapRequest.pubKey == signer.pubKey, "private zaps must be signed by an ephemeral key")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user