mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+3
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
@@ -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>
|
||||
|
||||
+25
-9
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+55
-1
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user