diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TransactionRowLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TransactionRowLabels.kt
index f3254b872a..6f157fd4c8 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TransactionRowLabels.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/TransactionRowLabels.kt
@@ -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(),
)
}
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt
index dcb39e2879..ecfa6e6065 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt
@@ -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,
+ )
+ }
}
}
diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/wallet/TransactionRowLabelsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/wallet/TransactionRowLabelsTest.kt
index 81c39d94be..6e34dfe90e 100644
--- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/wallet/TransactionRowLabelsTest.kt
+++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/wallet/TransactionRowLabelsTest.kt
@@ -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)
+ }
}
diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml
index cdb9318316..d38864bbfa 100644
--- a/commonsUI/src/commonMain/composeResources/values/strings.xml
+++ b/commonsUI/src/commonMain/composeResources/values/strings.xml
@@ -1662,6 +1662,7 @@
No transactions match this filter
Loading…
Received
+ Open zapped note
Sent
Refresh
All
diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt
index c95e636604..7d90f3b256 100644
--- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt
+++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt
@@ -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,
)
}
diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt
index 1a20c1987c..8bddfc1446 100644
--- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt
+++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt
@@ -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 = 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(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) = 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 =