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 =