Merge pull request #4126 from davotoula/fix/nwc-zapped-note-in-history

feat(wallet): open the zapped note from NWC wallet history
This commit is contained in:
Vitor Pamplona
2026-09-16 08:33:45 -04:00
committed by GitHub
6 changed files with 135 additions and 11 deletions
@@ -34,6 +34,8 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransactionType
data class TransactionRowLabels(
val title: Title,
val subtitle: String?,
/** The note a zap was for (event id or `a` address), or null for a profile zap or a plain payment. */
val zappedNoteId: String?,
) {
sealed interface Title {
/** Render the counterparty's profile for this pubkey, falling back to [name]. */
@@ -83,6 +85,7 @@ data class TransactionRowLabels(
return TransactionRowLabels(
title = named ?: Title.Literal(fallback),
subtitle = comment ?: fallback.takeIf { named != null },
zappedNoteId = parsed?.zappedNoteId(),
)
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -65,11 +66,13 @@ import com.vitorpamplona.amethyst.commons.resources.wallet_filter_zaps
import com.vitorpamplona.amethyst.commons.resources.wallet_incoming
import com.vitorpamplona.amethyst.commons.resources.wallet_loading
import com.vitorpamplona.amethyst.commons.resources.wallet_no_transactions
import com.vitorpamplona.amethyst.commons.resources.wallet_open_zapped_note
import com.vitorpamplona.amethyst.commons.resources.wallet_outgoing
import com.vitorpamplona.amethyst.commons.resources.wallet_refresh
import com.vitorpamplona.amethyst.commons.resources.wallet_transactions
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.formatMonthDayTime
@@ -289,12 +292,23 @@ private fun TransactionItem(
TransactionRowLabels.resolve(tx, directionLabel)
}
val counterpartyPubkeyHex = (labels.title as? TransactionRowLabels.Title.User)?.pubkeyHex
val zappedNoteId = labels.zappedNoteId
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
.then(
// Before the padding, so the whole row height is the target. The
// picture keeps its own tap (profile): picture -> profile, row -> note.
if (zappedNoteId != null) {
Modifier.clickable(onClickLabel = stringRes(Res.string.wallet_open_zapped_note)) {
nav.nav(Route.Note(zappedNoteId))
}
} else {
Modifier
},
).padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (counterpartyPubkeyHex != null) {
@@ -368,6 +382,17 @@ private fun TransactionItem(
MaterialTheme.colorScheme.onBackground
},
)
if (zappedNoteId != null) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
symbol = MaterialSymbols.ChevronRight,
// The row's onClickLabel already announces it.
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -116,4 +116,29 @@ class TransactionRowLabelsTest {
// The comment merely repeats the description, so it is not shown twice.
assertEquals("Test", labels.subtitle)
}
/** A note zap's `e` tag reaches the row, so it can open the zapped note. */
@Test
fun aNoteZapCarriesTheNoteId() {
val noteHex = "d".repeat(64)
val labels =
TransactionRowLabels.resolve(
NwcTransaction(
type = "outgoing",
metadata = mapOf("nostr" to mapOf("tags" to listOf(listOf("e", noteHex), listOf("p", recipientHex)))),
),
"Sent",
)
assertEquals(noteHex, labels.zappedNoteId)
}
@Test
fun aProfileZapCarriesNoNoteId() {
val labels =
TransactionRowLabels.resolve(
NwcTransaction(type = "outgoing", metadata = mapOf("nostr" to mapOf("tags" to listOf(listOf("p", recipientHex))))),
"Sent",
)
assertNull(labels.zappedNoteId)
}
}
@@ -1662,6 +1662,7 @@
<string name="wallet_no_transactions_for_filter">No transactions match this filter</string>
<string name="wallet_loading">Loading…</string>
<string name="wallet_incoming">Received</string>
<string name="wallet_open_zapped_note">Open zapped note</string>
<string name="wallet_outgoing">Sent</string>
<string name="wallet_refresh">Refresh</string>
<string name="wallet_filter_all">All</string>
@@ -20,8 +20,10 @@
*/
package com.vitorpamplona.quartz.nip47WalletConnect.rpc
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.RawJson
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
class NwcTransactionMetadata(
@@ -44,6 +46,8 @@ class NwcTransactionMetadata(
val pubkeyHex: String?,
val recipientPubkeyHex: String?,
val content: String?,
val zappedEventId: String?,
val zappedAddress: String?,
)
fun senderPubkeyHex(): String? = nostr?.pubkeyHex ?: payerData?.pubkey?.let { decodePublicKeyAsHexOrNull(it) }
@@ -54,6 +58,13 @@ class NwcTransactionMetadata(
fun recipientPubkeyHex(): String? = nostr?.recipientPubkeyHex
/**
* The note this zap was for: the `a` address for an addressable target (so the
* living version opens, not the one that was zapped), else the `e` id. Null for
* a profile zap, which legitimately carries neither.
*/
fun zappedNoteId(): String? = nostr?.zappedAddress ?: nostr?.zappedEventId
/**
* The message to show for this transaction.
*
@@ -90,21 +101,26 @@ class NwcTransactionMetadata(
val rawPubkey = n["pubkey"] as? String
val pubkeyHex = rawPubkey?.let { decodePublicKeyAsHexOrNull(it) }
val tags = n["tags"] as? List<*>
val recipientHex =
tags?.firstNotNullOfOrNull { tag ->
val tagList = tag as? List<*>
if (tagList != null && tagList.size >= 2 && tagList[0] == "p") {
tagList[1] as? String
} else {
null
}
var recipientHex: String? = null
var zappedEventId: String? = null
var zappedAddress: String? = null
(n["tags"] as? List<*>)?.forEach { tag ->
val tagList = tag as? List<*> ?: return@forEach
if (tagList.size < 2) return@forEach
val value = tagList[1] as? String ?: return@forEach
when (tagList[0]) {
"p" -> if (recipientHex == null) recipientHex = value
"e" -> if (zappedEventId == null && value.isValid()) zappedEventId = value
"a" -> if (zappedAddress == null) zappedAddress = Address.parse(value)?.toValue()
}
}
NostrZapData(
pubkeyHex = pubkeyHex,
recipientPubkeyHex = recipientHex,
content = n["content"] as? String,
zappedEventId = zappedEventId,
zappedAddress = zappedAddress,
)
}
@@ -44,15 +44,23 @@ class NwcOutgoingMetadataTest {
private val recipientHex = "ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b"
private val payerHex = "f512822a89d2369a386bfeb1e687ccd26ceb6bb33e73b98417499bb9054bff1f"
private val noteHex = "d".repeat(64)
private fun zapRequest(
content: String = "great post",
relays: List<String> = listOf("wss://relay.damus.io"),
zappedNoteId: String? = null,
) = Event(
id = "a".repeat(64),
pubKey = payerHex,
createdAt = 1756000000L,
kind = 9734,
tags = arrayOf(arrayOf("p", recipientHex), arrayOf("relays", *relays.toTypedArray())),
tags =
listOfNotNull(
zappedNoteId?.let { arrayOf("e", it) },
arrayOf("p", recipientHex),
arrayOf("relays", *relays.toTypedArray()),
).toTypedArray(),
content = content,
sig = "b".repeat(128),
)
@@ -233,6 +241,52 @@ class NwcOutgoingMetadataTest {
assertEquals("for the article", parsed.displayComment())
}
@Test
fun theZappedNoteSurvivesTheRoundTrip() {
val wire =
OptimizedJsonMapper.toJson(
PayInvoiceMethod.create("lnbc1", NwcTransactionMetadata.build(zapRequest(zappedNoteId = noteHex), "user@domain.com", "")),
)
val back = OptimizedJsonMapper.fromJsonTo<Request>(wire) as PayInvoiceMethod
val parsed = assertNotNull(NwcTransactionMetadata.parse(back.params?.metadata))
assertEquals(noteHex, parsed.zappedNoteId())
assertEquals(recipientHex, parsed.recipientPubkeyHex(), "the p tag is still read with the e tag in front of it")
}
private fun parseTags(vararg tags: List<String>) = assertNotNull(NwcTransactionMetadata.parse(mapOf("nostr" to mapOf("tags" to tags.toList()))))
@Test
fun aProfileZapHasNoNote() {
assertNull(parseTags(listOf("p", recipientHex)).zappedNoteId())
}
@Test
fun tagOrderDoesNotMatter() {
val parsed = parseTags(listOf("e", noteHex), listOf("p", recipientHex))
assertEquals(recipientHex, parsed.recipientPubkeyHex())
assertEquals(noteHex, parsed.zappedNoteId())
}
@Test
fun anAddressableZapPrefersTheAddress() {
val address = "30023:$recipientHex:my-article"
assertEquals(address, parseTags(listOf("e", noteHex), listOf("a", address), listOf("p", recipientHex)).zappedNoteId())
}
@Test
fun aMalformedAddressFallsBackToTheEventId() {
assertEquals(noteHex, parseTags(listOf("e", noteHex), listOf("a", "not-an-address"), listOf("p", recipientHex)).zappedNoteId())
}
@Test
fun aMalformedEventIdIsIgnored() {
// "noteHex + ff": the id check must bound the length, not just scan 64 hex chars.
listOf("not-a-note", noteHex + "ff").forEach { bad ->
assertNull(parseTags(listOf("e", bad), listOf("p", recipientHex)).zappedNoteId(), bad)
}
}
@Test
fun blankFieldsReadAsAbsent() {
val parsed =