feat: add NIP-A3 payment targets (kind 10133)

Users can publish payment addresses for other cryptocurrencies and
payment apps (payto tags per draft NIP-A3) and pay other users
through them.

- NipA3.kt: parse/build payto tags, type validation, payto:// and
  native wallet URIs, recognized-type stylization
- PaymentTargetRepository: LRU + SharedPrefs cache per pubkey;
  stores empty lists so cleared targets propagate
- ZapDialog: "Other ways to pay" chip section; targets-only dialog
  when lightning zapping isn't possible but targets exist
- PaymentTargetSheet: QR, copy, open-in-wallet bottom sheet
- Wallet settings: manage and publish own targets with network
  read-back before editing to avoid clobbering the replaceable event
- Profile screen: targets shown under the lightning address
- On-demand fetch only (zap dialog / profile open), ingested via
  EventRouter with created_at freshness guard
This commit is contained in:
Barry Deen
2026-06-10 13:41:36 -04:00
parent 28cb2dad42
commit edbb5ab549
14 changed files with 1070 additions and 45 deletions
@@ -175,6 +175,13 @@ object Routes {
const val LIVE_STREAM = "live_stream/{hostPubkey}/{dTag}?relayHint={relayHint}"
}
/** Unknown profile counts as zappable — the zap send path surfaces the error. */
private fun zapRecipientHasLud16(
feedViewModel: com.darkwisp.app.viewmodel.FeedViewModel,
pubkey: String
): Boolean =
feedViewModel.eventRepo.getProfileData(pubkey)?.let { !it.lud16.isNullOrBlank() } ?: true
/**
* Map a decoded NIP-19 entity to a navigation route, or null if there is no
* route for it (e.g. an addressable event whose kind we don't render).
@@ -217,7 +224,9 @@ fun WispNavHost(
feedViewModel.eventRepo,
feedViewModel.relayPool,
feedViewModel.keyRepo,
appContext.contentResolver
appContext.contentResolver,
paymentTargetRepo = feedViewModel.paymentTargetRepo,
getSigner = { feedViewModel.signer }
) as T
}
}
@@ -1058,7 +1067,8 @@ fun WispNavHost(
subManager = feedViewModel.subManager,
topRelayUrls = feedViewModel.getScoredRelays().take(5).map { it.url },
relayHintStore = feedViewModel.relayHintStore,
extendedNetworkRepo = feedViewModel.extendedNetworkRepo
extendedNetworkRepo = feedViewModel.extendedNetworkRepo,
paymentTargetRepo = feedViewModel.paymentTargetRepo
)
}
val isBlockedState by feedViewModel.muteRepo.blockedPubkeys.collectAsState()
@@ -1116,6 +1126,7 @@ fun WispNavHost(
zapInProgressIds = profileZapInProgress,
canPrivateZap = feedViewModel.hasLocalKeypair && feedViewModel.relayPool.hasDmRelays() && feedViewModel.relayListRepo.hasDmRelays(pubkey),
fetchDmRelays = { pk -> feedViewModel.fetchDmRelaysIfMissing(pk) && feedViewModel.relayPool.hasDmRelays() },
fetchPaymentTargets = feedViewModel::fetchPaymentTargets,
ownLists = feedViewModel.listRepo.ownLists.collectAsState().value,
onAddToList = { dTag, pk -> feedViewModel.addToList(dTag, pk) },
onRemoveFromList = { dTag, pk -> feedViewModel.removeFromList(dTag, pk) },
@@ -1224,7 +1235,10 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays
canPrivateZap = feedViewModel.hasLocalKeypair && userHasDmRelays && recipientHasDmRelays,
recipientPubkey = zapRecipient,
recipientHasLud16 = zapRecipientHasLud16(feedViewModel, zapRecipient),
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
)
}
SearchScreen(
@@ -1382,6 +1396,7 @@ fun WispNavHost(
peerPubkey = pubkey,
signer = activeSigner,
socialActionManager = feedViewModel.socialActions,
fetchPaymentTargets = feedViewModel::fetchPaymentTargets,
isWalletConnected = feedViewModel.activeWalletProvider.hasConnection(),
onGoToWallet = { navController.navigate(Routes.WALLET) },
noteActions = remember {
@@ -1469,6 +1484,7 @@ fun WispNavHost(
participants = participantList,
signer = activeSigner,
socialActionManager = feedViewModel.socialActions,
fetchPaymentTargets = feedViewModel::fetchPaymentTargets,
isWalletConnected = feedViewModel.activeWalletProvider.hasConnection(),
onGoToWallet = { navController.navigate(Routes.WALLET) },
noteActions = remember {
@@ -1593,7 +1609,10 @@ fun WispNavHost(
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.hasLocalKeypair && feedViewModel.relayPool.hasDmRelays() && recipientHasDmRelays,
initialSatsHint = groupRoomZapInitialSats
initialSatsHint = groupRoomZapInitialSats,
recipientPubkey = zapRecipient,
recipientHasLud16 = zapRecipientHasLud16(feedViewModel, zapRecipient),
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
)
}
val groupRoomMediaLauncher = rememberLauncherForActivityResult(
@@ -1870,7 +1889,10 @@ fun WispNavHost(
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.hasLocalKeypair && threadUserHasDmRelays && threadRecipientHasDmRelays,
forcePrivate = threadZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true
forcePrivate = threadZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true,
recipientPubkey = threadZapRecipient,
recipientHasLud16 = zapRecipientHasLud16(feedViewModel, threadZapRecipient),
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
)
}
val threadSetListedIds by feedViewModel.bookmarkSetRepo.allListedEventIds.collectAsState()
@@ -2039,7 +2061,10 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.hasLocalKeypair && hashtagUserHasDmRelays && hashtagRecipientHasDmRelays
canPrivateZap = feedViewModel.hasLocalKeypair && hashtagUserHasDmRelays && hashtagRecipientHasDmRelays,
recipientPubkey = hashtagZapRecipient,
recipientHasLud16 = zapRecipientHasLud16(feedViewModel, hashtagZapRecipient),
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
)
}
@@ -2190,7 +2215,10 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.hasLocalKeypair && setFeedUserHasDmRelays && setFeedRecipientHasDmRelays
canPrivateZap = feedViewModel.hasLocalKeypair && setFeedUserHasDmRelays && setFeedRecipientHasDmRelays,
recipientPubkey = setFeedZapRecipient,
recipientHasLud16 = zapRecipientHasLud16(feedViewModel, setFeedZapRecipient),
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
)
}
@@ -2354,7 +2382,10 @@ fun WispNavHost(
feedViewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays
canPrivateZap = feedViewModel.hasLocalKeypair && articleUserHasDmRelays && articleRecipientHasDmRelays,
recipientPubkey = zapRecipient,
recipientHasLud16 = zapRecipientHasLud16(feedViewModel, zapRecipient),
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
)
}
@@ -2561,7 +2592,10 @@ fun WispNavHost(
// DIP-03 needs a concrete note id for the ephemeral key
// derivation; live-stream zaps target an addressable event
// (a-tag) instead, so private zaps don't apply here.
canPrivateZap = false
canPrivateZap = false,
recipientPubkey = zapRecipient,
recipientHasLud16 = zapRecipientHasLud16(feedViewModel, zapRecipient),
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
)
}
val streamActivityEventId = remember(hostPubkey, dTag) {
@@ -3119,7 +3153,10 @@ fun WispNavHost(
},
onGoToWallet = { navController.navigate(Routes.WALLET) },
canPrivateZap = feedViewModel.hasLocalKeypair && notifUserHasDmRelays && notifRecipientHasDmRelays,
forcePrivate = notifZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true
forcePrivate = notifZapTarget?.id?.let { feedViewModel.eventRepo.isPrivate(it) } == true,
recipientPubkey = notifZapRecipient,
recipientHasLud16 = zapRecipientHasLud16(feedViewModel, notifZapRecipient),
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
)
}
@@ -3137,7 +3174,11 @@ fun WispNavHost(
rumorId = target.rumorId.ifEmpty { null }
)
},
onGoToWallet = { navController.navigate(Routes.WALLET) }
onGoToWallet = { navController.navigate(Routes.WALLET) },
recipientPubkey = notifDmZapTarget?.senderPubkey,
recipientHasLud16 = notifDmZapTarget?.senderPubkey
?.let { zapRecipientHasLud16(feedViewModel, it) } ?: true,
fetchPaymentTargets = feedViewModel::fetchPaymentTargets
)
}
@@ -0,0 +1,85 @@
package com.darkwisp.app.nostr
import java.net.URLEncoder
/**
* NIP-A3: payto: Payment Targets (RFC-8905).
*
* Kind 10133 is a replaceable event whose ["payto", "<type>", "<authority>"] tags
* declare payment addresses (bitcoin, monero, venmo, ...) for the author.
* Clients assemble payto://<type>/<authority> URIs from each tag.
*/
object NipA3 {
const val KIND = 10133
const val TAG_NAME = "payto"
data class PaymentTarget(val type: String, val authority: String)
data class TargetStyle(val displayName: String, val symbol: String?, val ticker: String?)
/** Recognized types from the NIP-A3 stylization table. */
val RECOGNIZED: Map<String, TargetStyle> = mapOf(
"bitcoin" to TargetStyle("Bitcoin", "", "BTC"),
"cashme" to TargetStyle("Cash App", "$", null),
"ethereum" to TargetStyle("Ethereum", "Ξ", "ETH"),
"lightning" to TargetStyle("Lightning", "", "LBTC"),
"monero" to TargetStyle("Monero", "ɱ", "XMR"),
"nano" to TargetStyle("Nano", "Ӿ", "XNO"),
"revolut" to TargetStyle("Revolut", null, null),
"venmo" to TargetStyle("Venmo", "$", null)
)
private val TYPE_REGEX = Regex("^[a-z0-9-]+$")
/** Types with a widely supported native Android URI scheme; preferred over payto://. */
private val NATIVE_SCHEMES = mapOf(
"bitcoin" to "bitcoin:",
"ethereum" to "ethereum:",
"monero" to "monero:",
"nano" to "nano:",
"lightning" to "lightning:"
)
/** Lowercased, trimmed type, or null if it isn't a valid payto type. */
fun normalizeType(raw: String): String? {
val type = raw.trim().lowercase()
return if (TYPE_REGEX.matches(type)) type else null
}
fun isValidAuthority(authority: String): Boolean =
authority.isNotBlank() && authority.none { it.isWhitespace() || it.isISOControl() }
fun parse(event: NostrEvent): List<PaymentTarget> {
if (event.kind != KIND) return emptyList()
return event.tags.mapNotNull { tag ->
// Elements past index 2 are reserved for future RFC-8905 features; ignore them.
if (tag.size < 3 || tag[0] != TAG_NAME) return@mapNotNull null
val type = normalizeType(tag[1]) ?: return@mapNotNull null
val authority = tag[2]
if (!isValidAuthority(authority)) return@mapNotNull null
PaymentTarget(type, authority)
}.distinct()
}
fun buildTags(targets: List<PaymentTarget>): List<List<String>> =
targets.map { listOf(TAG_NAME, it.type, it.authority) }
fun assemblePaytoUri(target: PaymentTarget): String {
val encoded = URLEncoder.encode(target.authority, "UTF-8").replace("+", "%20")
return "payto://${target.type}/$encoded"
}
/**
* URI for launching a wallet app: native scheme (bitcoin:, monero:, ...) for
* recognized types since almost no Android wallet handles payto://, else payto://.
*/
fun nativeUri(target: PaymentTarget): String =
NATIVE_SCHEMES[target.type]?.let { it + target.authority } ?: assemblePaytoUri(target)
fun displayName(type: String): String =
RECOGNIZED[type]?.displayName ?: type.replaceFirstChar { it.uppercase() }
fun symbol(type: String): String? = RECOGNIZED[type]?.symbol
fun ticker(type: String): String? = RECOGNIZED[type]?.ticker
}
@@ -0,0 +1,85 @@
package com.darkwisp.app.repo
import android.content.Context
import android.content.SharedPreferences
import android.util.LruCache
import com.darkwisp.app.nostr.NipA3
import com.darkwisp.app.nostr.NostrEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/** Cache of NIP-A3 payment target lists (kind 10133) keyed by pubkey. */
class PaymentTargetRepository(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("wisp_payment_targets", Context.MODE_PRIVATE)
private val json = Json { ignoreUnknownKeys = true }
// pubkey -> parsed payment targets
private val cache = LruCache<String, List<NipA3.PaymentTarget>>(2000)
// pubkey -> event timestamp
private val timestamps = LruCache<String, Long>(2000)
private val _version = MutableStateFlow(0)
/** Bumped on every cache update so Compose can react to late-arriving events. */
val version: StateFlow<Int> = _version
init {
loadFromPrefs()
}
fun updateFromEvent(event: NostrEvent) {
if (event.kind != NipA3.KIND) return
val existing = timestamps.get(event.pubkey)
if (existing != null && event.created_at <= existing) return
// Unlike relay lists, an empty result must be stored: an empty kind 10133
// means the user cleared their targets, and dropping it would pin stale ones.
val targets = NipA3.parse(event)
cache.put(event.pubkey, targets)
timestamps.put(event.pubkey, event.created_at)
saveToPrefs(event.pubkey, targets, event.created_at)
_version.value++
}
/** null = never fetched; empty list = fetched and known to have none. */
fun getTargets(pubkey: String): List<NipA3.PaymentTarget>? = cache.get(pubkey)
fun hasEntry(pubkey: String): Boolean = cache.get(pubkey) != null
fun clear() {
cache.evictAll()
timestamps.evictAll()
prefs.edit().clear().apply()
_version.value++
}
private fun saveToPrefs(pubkey: String, targets: List<NipA3.PaymentTarget>, timestamp: Long) {
val serializable = targets.map { SerializableTarget(it.type, it.authority) }
prefs.edit()
.putString("pt_$pubkey", json.encodeToString(serializable))
.putLong("pt_ts_$pubkey", timestamp)
.apply()
}
private fun loadFromPrefs() {
val pubkeys = prefs.all.keys
.filter { it.startsWith("pt_") && !it.startsWith("pt_ts_") }
.map { it.removePrefix("pt_") }
for (pubkey in pubkeys) {
try {
val str = prefs.getString("pt_$pubkey", null) ?: continue
val ts = prefs.getLong("pt_ts_$pubkey", 0)
val serializable = json.decodeFromString<List<SerializableTarget>>(str)
cache.put(pubkey, serializable.map { NipA3.PaymentTarget(it.type, it.authority) })
timestamps.put(pubkey, ts)
} catch (_: Exception) {}
}
}
@Serializable
private data class SerializableTarget(val type: String, val authority: String)
}
@@ -0,0 +1,160 @@
package com.darkwisp.app.ui.component
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.outlined.AccountBalanceWallet
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.darkwisp.app.nostr.NipA3
/**
* Bottom sheet for a single NIP-A3 payment target: QR code of the address,
* copy button, and a button launching a wallet app for the target's URI.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaymentTargetSheet(target: NipA3.PaymentTarget, onDismiss: () -> Unit) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
val qrBitmap = remember(target) { generateQrBitmap(target.authority) }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
NipA3.symbol(target.type)?.let { symbol ->
Text(
text = symbol,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(end = 8.dp)
)
}
Text(
text = NipA3.displayName(target.type),
style = MaterialTheme.typography.titleLarge
)
NipA3.ticker(target.type)?.let { ticker ->
Text(
text = ticker,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp)
)
}
}
Spacer(Modifier.height(20.dp))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(256.dp)
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(8.dp)
) {
Image(
bitmap = qrBitmap.asImageBitmap(),
contentDescription = "${NipA3.displayName(target.type)} address QR code",
modifier = Modifier.matchParentSize()
)
}
Spacer(Modifier.height(20.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = target.authority,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
IconButton(onClick = {
clipboardManager.setText(AnnotatedString(target.authority))
Toast.makeText(context, "Address copied", Toast.LENGTH_SHORT).show()
}) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy address",
modifier = Modifier.size(18.dp)
)
}
}
Spacer(Modifier.height(12.dp))
Button(
onClick = {
try {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(NipA3.nativeUri(target)))
)
} catch (_: ActivityNotFoundException) {
Toast.makeText(
context,
"No wallet app found for ${NipA3.displayName(target.type)}",
Toast.LENGTH_SHORT
).show()
}
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Outlined.AccountBalanceWallet,
contentDescription = null,
modifier = Modifier
.padding(end = 8.dp)
.size(18.dp)
)
Text("Open in wallet")
}
}
}
}
@@ -82,6 +82,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.darkwisp.app.R
import com.darkwisp.app.nostr.NipA3
import com.darkwisp.app.repo.FiatPreferences
import com.darkwisp.app.repo.ZapPreferences
import com.darkwisp.app.repo.ZapPreset
@@ -115,26 +116,63 @@ fun ZapDialog(
*/
forcePrivate: Boolean = false,
/** When opening from a quick preset (e.g. chat actions sheet), pre-select that amount in sats. */
initialSatsHint: Int? = null
initialSatsHint: Int? = null,
/** Note author / zap recipient; enables the NIP-A3 "Other ways to pay" section. */
recipientPubkey: String? = null,
/** False when the recipient's profile has no lightning address. */
recipientHasLud16: Boolean = true,
/** Loads the recipient's NIP-A3 payment targets (FeedViewModel::fetchPaymentTargets). */
fetchPaymentTargets: (suspend (String) -> List<NipA3.PaymentTarget>)? = null
) {
if (!isWalletConnected) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.zap_wallet_not_connected)) },
text = { Text(stringResource(R.string.zap_connect_wallet)) },
confirmButton = {
TextButton(onClick = {
onDismiss()
onGoToWallet()
}) {
Text(stringResource(R.string.btn_go_to_wallet))
var paymentTargets by remember { mutableStateOf<List<NipA3.PaymentTarget>>(emptyList()) }
var selectedTarget by remember { mutableStateOf<NipA3.PaymentTarget?>(null) }
LaunchedEffect(recipientPubkey) {
val pk = recipientPubkey ?: return@LaunchedEffect
val fetch = fetchPaymentTargets ?: return@LaunchedEffect
paymentTargets = fetch(pk)
}
selectedTarget?.let { target ->
PaymentTargetSheet(target = target) { selectedTarget = null }
}
// Lightning zapping needs a connected wallet and a recipient lightning address.
// When either is missing but the author published NIP-A3 payment targets, show
// those instead of dead-ending.
if (!isWalletConnected || !recipientHasLud16) {
if (paymentTargets.isNotEmpty()) {
PaymentTargetsOnlyDialog(
targets = paymentTargets,
showGoToWallet = !isWalletConnected,
onTargetClick = { selectedTarget = it },
onGoToWallet = onGoToWallet,
onDismiss = onDismiss
)
return
}
if (!isWalletConnected) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.zap_wallet_not_connected)) },
text = { Text(stringResource(R.string.zap_connect_wallet)) },
confirmButton = {
TextButton(onClick = {
onDismiss()
onGoToWallet()
}) {
Text(stringResource(R.string.btn_go_to_wallet))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) }
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_cancel)) }
}
)
return
)
return
}
// Wallet connected but no lud16 and no payment targets: fall through to the
// regular dialog — the zap send surfaces the missing-lightning-address error,
// matching pre-NIP-A3 behavior.
}
val context = LocalContext.current
@@ -499,6 +537,27 @@ fun ZapDialog(
}
} // end !forcePrivate
// NIP-A3 payment targets
if (paymentTargets.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(R.string.zap_other_ways_to_pay),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
paymentTargets.forEach { target ->
PaymentTargetChip(target) { selectedTarget = target }
}
}
}
Spacer(Modifier.height(16.dp))
// Action buttons
@@ -551,6 +610,86 @@ fun ZapDialog(
}
/**
* Shown instead of the zap dialog when lightning zapping isn't possible
* (no wallet or no lud16) but the author published NIP-A3 payment targets.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun PaymentTargetsOnlyDialog(
targets: List<NipA3.PaymentTarget>,
showGoToWallet: Boolean,
onTargetClick: (NipA3.PaymentTarget) -> Unit,
onGoToWallet: () -> Unit,
onDismiss: () -> Unit
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
shape = RoundedCornerShape(28.dp),
color = WispThemeColors.backgroundColor,
tonalElevation = 8.dp
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.zap_other_ways_to_pay),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(16.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
targets.forEach { target ->
PaymentTargetChip(target) { onTargetClick(target) }
}
}
Spacer(Modifier.height(16.dp))
if (showGoToWallet) {
TextButton(onClick = {
onDismiss()
onGoToWallet()
}) {
Text(stringResource(R.string.zap_connect_wallet_to_zap))
}
}
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.btn_cancel))
}
}
}
}
}
@Composable
private fun PaymentTargetChip(target: NipA3.PaymentTarget, onClick: () -> Unit) {
val label = buildString {
NipA3.symbol(target.type)?.let { append(it).append(' ') }
append(NipA3.displayName(target.type))
}
ZapChipButton(
label = label,
isSelected = false,
onClick = onClick
)
}
@Composable
private fun AnimatedBoltHeader() {
val infiniteTransition = rememberInfiniteTransition(label = "bolt")
@@ -127,7 +127,8 @@ fun DmConversationScreen(
resolvedEmojis: Map<String, String> = emptyMap(),
unicodeEmojis: List<String> = emptyList(),
onOpenEmojiLibrary: (() -> Unit)? = null,
onEmojiUsed: ((String) -> Unit)? = null
onEmojiUsed: ((String) -> Unit)? = null,
fetchPaymentTargets: (suspend (String) -> List<com.darkwisp.app.nostr.NipA3.PaymentTarget>)? = null
) {
val messages by viewModel.messages.collectAsState()
val messageText by viewModel.messageText.collectAsState()
@@ -633,7 +634,11 @@ fun DmConversationScreen(
onGoToWallet = {
zapTargetMessage = null
onGoToWallet()
}
},
recipientPubkey = zapTargetMessage?.senderPubkey,
recipientHasLud16 = zapTargetMessage?.senderPubkey
?.let { pk -> eventRepo?.getProfileData(pk)?.let { !it.lud16.isNullOrBlank() } } ?: true,
fetchPaymentTargets = fetchPaymentTargets
)
}
}
@@ -547,7 +547,12 @@ fun FeedScreen(
viewModel.sendZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = onWallet,
canPrivateZap = userHasDmRelays && recipientHasDmRelays
canPrivateZap = userHasDmRelays && recipientHasDmRelays,
recipientPubkey = zapRecipient,
// Unknown profile -> assume zappable; the send path surfaces the error.
recipientHasLud16 = viewModel.eventRepo.getProfileData(zapRecipient)
?.let { !it.lud16.isNullOrBlank() } ?: true,
fetchPaymentTargets = viewModel::fetchPaymentTargets
)
}
@@ -42,6 +42,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.CurrencyBitcoin
import com.darkwisp.app.nostr.Nip30
import com.darkwisp.app.ui.component.Nip05Badge
import com.darkwisp.app.ui.component.PaymentTargetSheet
import com.darkwisp.app.ui.component.RichContent
import com.darkwisp.app.ui.component.parseImetaTags
import androidx.compose.material3.CircularProgressIndicator
@@ -86,6 +87,7 @@ import com.darkwisp.app.R
import com.darkwisp.app.nostr.FollowSet
import com.darkwisp.app.nostr.Nip02
import com.darkwisp.app.nostr.Nip69
import com.darkwisp.app.nostr.NipA3
import com.darkwisp.app.nostr.NostrEvent
import com.darkwisp.app.nostr.ProfileData
import com.darkwisp.app.relay.RelayConfig
@@ -157,6 +159,7 @@ fun UserProfileScreen(
zapInProgressIds: Set<String> = emptySet(),
canPrivateZap: Boolean = false,
fetchDmRelays: (suspend (String) -> Boolean)? = null,
fetchPaymentTargets: (suspend (String) -> List<NipA3.PaymentTarget>)? = null,
ownLists: List<FollowSet> = emptyList(),
onAddToList: ((String, String) -> Unit)? = null,
onRemoveFromList: ((String, String) -> Unit)? = null,
@@ -232,6 +235,12 @@ fun UserProfileScreen(
var zapAnimatingIds by remember { mutableStateOf(emptySet<String>()) }
var zapErrorMessage by remember { mutableStateOf<String?>(null) }
val paymentTargets by viewModel.paymentTargets.collectAsState()
var paymentTargetSheetTarget by remember { mutableStateOf<NipA3.PaymentTarget?>(null) }
paymentTargetSheetTarget?.let { target ->
PaymentTargetSheet(target = target) { paymentTargetSheetTarget = null }
}
var showProfileZapDialog by remember { mutableStateOf(false) }
var profileZapStatus by remember { mutableStateOf<ProfileZapStatus>(ProfileZapStatus.Idle) }
@@ -276,7 +285,11 @@ fun UserProfileScreen(
onZap(event, amountMsats, message, isAnonymous, isPrivate)
},
onGoToWallet = onWallet,
canPrivateZap = resolvedCanPrivateZap
canPrivateZap = resolvedCanPrivateZap,
recipientPubkey = zapRecipient,
recipientHasLud16 = eventRepo?.getProfileData(zapRecipient)
?.let { !it.lud16.isNullOrBlank() } ?: true,
fetchPaymentTargets = fetchPaymentTargets
)
}
@@ -290,7 +303,10 @@ fun UserProfileScreen(
onZapProfile?.invoke(amountMsats, message, isAnonymous)
},
onGoToWallet = onWallet,
canPrivateZap = false
canPrivateZap = false,
recipientPubkey = profilePubkey.ifEmpty { null },
recipientHasLud16 = profile?.let { !it.lud16.isNullOrBlank() } ?: true,
fetchPaymentTargets = fetchPaymentTargets
)
}
@@ -555,7 +571,9 @@ fun UserProfileScreen(
followingCount = followList.size,
followedBy = followedBy,
followsYou = !isOwnProfile && userPubkey != null && followList.any { it.pubkey == userPubkey },
isBlocked = isBlocked
isBlocked = isBlocked,
paymentTargets = paymentTargets,
onPaymentTargetClick = { paymentTargetSheetTarget = it }
)
}
@@ -1130,7 +1148,9 @@ private fun ProfileHeader(
followingCount: Int = 0,
followedBy: List<String> = emptyList(),
followsYou: Boolean = false,
isBlocked: Boolean = false
isBlocked: Boolean = false,
paymentTargets: List<NipA3.PaymentTarget> = emptyList(),
onPaymentTargetClick: (NipA3.PaymentTarget) -> Unit = {}
) {
var fullScreenImageUrl by remember { mutableStateOf<String?>(null) }
@@ -1305,6 +1325,36 @@ private fun ProfileHeader(
}
}
// NIP-A3 payment targets
paymentTargets.forEach { target ->
Spacer(Modifier.height(6.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { onPaymentTargetClick(target) }
) {
Text(
text = NipA3.symbol(target.type) ?: "¤",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFFFFC107)
)
Spacer(Modifier.width(4.dp))
Text(
text = NipA3.displayName(target.type),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.width(6.dp))
Text(
text = target.authority,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
}
}
// Following / Followers in your network counts
if (followingCount > 0 || followedBy.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
@@ -55,7 +55,10 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccountBalanceWallet
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Surface
@@ -129,6 +132,7 @@ import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import com.darkwisp.app.BuildConfig
import com.darkwisp.app.R
import com.darkwisp.app.nostr.NipA3
import com.darkwisp.app.repo.BalanceUnit
import com.darkwisp.app.repo.FiatPreferences
import com.darkwisp.app.repo.WalletMode
@@ -180,6 +184,21 @@ fun WalletScreen(
)
}
) { padding ->
// Payment targets are published to Nostr, not tied to a Lightning wallet,
// so this page is reachable in every wallet state.
if (currentPage is WalletPage.PaymentTargets) {
PaymentTargetsContent(
targets = viewModel.paymentTargets.collectAsState().value,
isLoading = viewModel.paymentTargetsLoading.collectAsState().value,
error = viewModel.paymentTargetsError.collectAsState().value,
isDirty = viewModel.paymentTargetsDirty.collectAsState().value,
onAdd = { type, authority -> viewModel.addPaymentTarget(type, authority) },
onRemove = { viewModel.removePaymentTarget(it) },
onSave = { viewModel.publishPaymentTargets() },
modifier = Modifier.padding(padding)
)
return@Scaffold
}
when (walletState) {
is WalletState.NotConnected,
is WalletState.Connecting,
@@ -240,11 +259,32 @@ fun WalletScreen(
onConfirm = { viewModel.confirmSparkBackup() }
)
}
else -> WalletModeSelectionContent(
onSelectNwc = { viewModel.selectNwcMode() },
onSelectSpark = { viewModel.selectSparkMode() },
onRestoreSpark = { viewModel.selectSparkMode() }
)
else -> {
WalletModeSelectionContent(
onSelectNwc = { viewModel.selectNwcMode() },
onSelectSpark = { viewModel.selectSparkMode() },
onRestoreSpark = { viewModel.selectSparkMode() }
)
// Publishing payment targets doesn't need a connected wallet
if (viewModel.keyRepo.isLoggedIn()) {
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = {
viewModel.loadPaymentTargets()
viewModel.navigateTo(WalletPage.PaymentTargets)
},
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Outlined.AccountBalanceWallet,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Payment Targets")
}
}
}
}
}
}
@@ -420,6 +460,10 @@ fun WalletScreen(
viewModel.navigateTo(WalletPage.BackupToRelay)
},
onDeleteWallet = { viewModel.navigateTo(WalletPage.DeleteWalletConfirm) },
onPaymentTargets = {
viewModel.loadPaymentTargets()
viewModel.navigateTo(WalletPage.PaymentTargets)
},
relayBackupStatuses = viewModel.relayBackupStatuses.collectAsState().value,
relayBackupCheckLoading = viewModel.relayBackupCheckLoading.collectAsState().value,
deleteBackupStatus = viewModel.deleteBackupStatus.collectAsState().value,
@@ -2643,6 +2687,7 @@ private fun WalletSettingsContent(
onBackupMnemonic: () -> Unit,
onBackupToRelay: () -> Unit = {},
onDeleteWallet: () -> Unit,
onPaymentTargets: () -> Unit = {},
relayBackupStatuses: List<RelayBackupInfo> = emptyList(),
relayBackupCheckLoading: Boolean = false,
deleteBackupStatus: DeleteBackupStatus = DeleteBackupStatus.Idle,
@@ -2834,6 +2879,30 @@ private fun WalletSettingsContent(
}
}
// Payments section
Spacer(Modifier.height(24.dp))
Text(
"Payments",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onPaymentTargets,
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Outlined.AccountBalanceWallet,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Payment Targets")
}
// Security section
Spacer(Modifier.height(24.dp))
@@ -3822,6 +3891,207 @@ private fun RestoreFromRelayContent(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun PaymentTargetsContent(
targets: List<NipA3.PaymentTarget>,
isLoading: Boolean,
error: String?,
isDirty: Boolean,
onAdd: (type: String, authority: String) -> Boolean,
onRemove: (NipA3.PaymentTarget) -> Unit,
onSave: () -> Unit,
modifier: Modifier = Modifier
) {
var typeInput by remember { mutableStateOf("") }
var authorityInput by remember { mutableStateOf("") }
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState())
) {
Spacer(Modifier.height(16.dp))
Text(
"Payment Targets",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(8.dp))
Text(
"Publish addresses for other cryptocurrencies and payment apps so people can pay you beyond Lightning zaps.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (isLoading) {
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
Spacer(Modifier.height(16.dp))
if (targets.isEmpty() && !isLoading) {
Text(
"No payment targets yet. Add one below.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
targets.forEach { target ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
NipA3.symbol(target.type)?.let {
Text(
it,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(end = 6.dp)
)
}
Text(
NipA3.displayName(target.type),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
}
Text(
target.authority,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
IconButton(onClick = { onRemove(target) }) {
Icon(
Icons.Default.Close,
contentDescription = "Remove payment target",
modifier = Modifier.size(18.dp)
)
}
}
}
}
Spacer(Modifier.height(24.dp))
Text(
"Add target",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = typeInput,
onValueChange = { typeInput = it },
label = { Text("Network type") },
placeholder = { Text("bitcoin") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
NipA3.RECOGNIZED.keys.forEach { type ->
val selected = typeInput.trim().lowercase() == type
OutlinedButton(
onClick = { typeInput = type },
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outline
)
) {
Text(
NipA3.displayName(type),
style = MaterialTheme.typography.labelMedium,
color = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = authorityInput,
onValueChange = { authorityInput = it },
label = { Text("Address") },
placeholder = { Text("bc1q…") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = {
if (onAdd(typeInput, authorityInput)) {
typeInput = ""
authorityInput = ""
}
},
enabled = typeInput.trim().isNotEmpty() && authorityInput.trim().isNotEmpty(),
modifier = Modifier.fillMaxWidth()
) {
Text("Add")
}
Spacer(Modifier.height(24.dp))
Button(
onClick = onSave,
enabled = isDirty,
modifier = Modifier.fillMaxWidth()
) {
Text("Save & Publish")
}
Spacer(Modifier.height(32.dp))
}
}
private fun formatRelativeTime(timestamp: Long): String {
val now = System.currentTimeMillis() / 1000
val diff = now - timestamp
@@ -15,6 +15,7 @@ import com.darkwisp.app.nostr.Nip51
import com.darkwisp.app.nostr.Nip57
import com.darkwisp.app.nostr.Nip88
import com.darkwisp.app.nostr.Nip65
import com.darkwisp.app.nostr.NipA3
import com.darkwisp.app.nostr.NostrEvent
import com.darkwisp.app.nostr.NostrSigner
import com.darkwisp.app.nostr.toHex
@@ -35,6 +36,7 @@ import com.darkwisp.app.repo.ListRepository
import com.darkwisp.app.repo.MetadataFetcher
import com.darkwisp.app.repo.MuteRepository
import com.darkwisp.app.repo.NotificationRepository
import com.darkwisp.app.repo.PaymentTargetRepository
import com.darkwisp.app.repo.PinRepository
import com.darkwisp.app.repo.DiagnosticLogger
import com.darkwisp.app.repo.GroupRepository
@@ -62,6 +64,7 @@ class EventRouter(
private val blossomRepo: BlossomRepository,
private val customEmojiRepo: CustomEmojiRepository,
private val relayListRepo: RelayListRepository,
private val paymentTargetRepo: PaymentTargetRepository,
private val interestRepo: InterestRepository,
private val relaySetRepo: RelaySetRepository,
private val relayScoreBoard: RelayScoreBoard,
@@ -391,6 +394,9 @@ class EventRouter(
relayPool.updateDmRelays(urls)
}
}
if (event.kind == NipA3.KIND) {
paymentTargetRepo.updateFromEvent(event)
}
if (event.kind == Nip51.KIND_SEARCH_RELAYS) {
val myPubkey = getUserPubkey()
if (myPubkey != null && event.pubkey == myPubkey && isNewestSelfData(event)) {
@@ -51,12 +51,14 @@ import com.darkwisp.app.repo.ZapPreferences
import com.darkwisp.app.repo.RelayHintStore
import com.darkwisp.app.repo.RelayInfoRepository
import com.darkwisp.app.repo.TranslationRepository
import com.darkwisp.app.repo.PaymentTargetRepository
import com.darkwisp.app.repo.RelayListRepository
import com.darkwisp.app.repo.RelaySetRepository
import com.darkwisp.app.repo.ZapSender
import kotlinx.coroutines.Dispatchers
import com.darkwisp.app.nostr.ClientMessage
import com.darkwisp.app.nostr.Nip51
import com.darkwisp.app.nostr.NipA3
import com.darkwisp.app.nostr.RelaySet
import android.content.Context
import com.darkwisp.app.nostr.Filter
@@ -197,6 +199,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
val liveStreamRepo = LiveStreamRepository()
val notifRepo = NotificationRepository(app, pubkeyHex, muteRepo, eventRepo)
val relayListRepo = RelayListRepository(app)
val paymentTargetRepo = PaymentTargetRepository(app)
val bookmarkRepo = BookmarkRepository(app, pubkeyHex)
val bookmarkSetRepo = BookmarkSetRepository(app, pubkeyHex)
val relaySetRepo = RelaySetRepository(app, pubkeyHex)
@@ -293,7 +296,7 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
val eventRouter: EventRouter = EventRouter(
relayPool, eventRepo, contactRepo, muteRepo, notifRepo, listRepo, bookmarkRepo,
bookmarkSetRepo, pinRepo, blossomRepo, customEmojiRepo, relayListRepo, interestRepo, relaySetRepo,
bookmarkSetRepo, pinRepo, blossomRepo, customEmojiRepo, relayListRepo, paymentTargetRepo, interestRepo, relaySetRepo,
relayScoreBoard, relayHintStore, keyRepo, dmRepo, extendedNetworkRepo, groupRepo, liveStreamRepo, metadataFetcher,
getUserPubkey = { getUserPubkey() },
getSigner = { signer },
@@ -766,6 +769,47 @@ class FeedViewModel(app: Application) : AndroidViewModel(app) {
return relayListRepo.hasDmRelays(pubkey)
}
private val paymentTargetFetchAttempted =
java.util.Collections.synchronizedSet(mutableSetOf<String>())
/**
* Returns the author's NIP-A3 payment targets, fetching their kind 10133 event
* on demand (write relays + indexers) the first time it's needed this session.
*/
suspend fun fetchPaymentTargets(pubkey: String): List<NipA3.PaymentTarget> {
paymentTargetRepo.getTargets(pubkey)?.let { return it }
if (!paymentTargetFetchAttempted.add(pubkey)) return emptyList()
val subId = "paytgt_${pubkey.take(8)}"
val filter = Filter(
kinds = listOf(NipA3.KIND),
authors = listOf(pubkey),
limit = 1
)
outboxRouter.subscribeToUserWriteRelays(subId, pubkey, filter)
val reqMsg = ClientMessage.req(subId, filter)
for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) {
relayPool.sendToRelayOrEphemeral(url, reqMsg, skipBadCheck = true)
}
val result = withTimeoutOrNull(4000L) {
relayPool.relayEvents.first {
it.subscriptionId == subId && it.event.kind == NipA3.KIND && it.event.pubkey == pubkey
}
}
val closeMsg = ClientMessage.close(subId)
for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) {
relayPool.sendToRelay(url, closeMsg)
}
relayPool.sendToAll(closeMsg)
if (result != null) {
paymentTargetRepo.updateFromEvent(result.event)
}
return paymentTargetRepo.getTargets(pubkey) ?: emptyList()
}
override fun onCleared() {
super.onCleared()
nwcRepo.disconnect()
@@ -10,6 +10,7 @@ import com.darkwisp.app.nostr.Nip02
import com.darkwisp.app.nostr.Nip10
import com.darkwisp.app.nostr.Nip51
import com.darkwisp.app.nostr.Nip65
import com.darkwisp.app.nostr.NipA3
import com.darkwisp.app.nostr.SimpleGroupEntry
import com.darkwisp.app.nostr.LocalSigner
import com.darkwisp.app.nostr.NostrEvent
@@ -23,6 +24,7 @@ import com.darkwisp.app.repo.EventRepository
import com.darkwisp.app.repo.DiscoveryState
import com.darkwisp.app.repo.ExtendedNetworkRepository
import com.darkwisp.app.repo.KeyRepository
import com.darkwisp.app.repo.PaymentTargetRepository
import com.darkwisp.app.repo.RelayHintStore
import com.darkwisp.app.repo.RelayListRepository
import com.darkwisp.app.relay.SubscriptionManager
@@ -78,6 +80,9 @@ class UserProfileViewModel(app: Application) : AndroidViewModel(app) {
private val _relayList = MutableStateFlow<List<RelayConfig>>(emptyList())
val relayList: StateFlow<List<RelayConfig>> = _relayList
private val _paymentTargets = MutableStateFlow<List<NipA3.PaymentTarget>>(emptyList())
val paymentTargets: StateFlow<List<NipA3.PaymentTarget>> = _paymentTargets
private val _pinnedNoteIds = MutableStateFlow<Set<String>>(emptySet())
val pinnedNoteIds: StateFlow<Set<String>> = _pinnedNoteIds
@@ -129,6 +134,7 @@ class UserProfileViewModel(app: Application) : AndroidViewModel(app) {
private var outboxRouterRef: OutboxRouter? = null
private var subManagerRef: SubscriptionManager? = null
private var relayHintStoreRef: RelayHintStore? = null
private var paymentTargetRepoRef: PaymentTargetRepository? = null
private val activeEngagementSubIds = mutableListOf<String>()
private val activeFollowProfileSubIds = mutableListOf<String>()
private var topRelayUrls: List<String> = emptyList()
@@ -152,7 +158,7 @@ class UserProfileViewModel(app: Application) : AndroidViewModel(app) {
private var latestRelayListTimestamp: Long = 0
companion object {
private val SUB_IDS = setOf("userprofile", "userposts", "usergallery", "userfollows", "userrelays", "userpins", "usergroups", "followprofiles")
private val SUB_IDS = setOf("userprofile", "userposts", "usergallery", "userfollows", "userrelays", "userpins", "usergroups", "userpaytargets", "followprofiles")
}
fun loadProfile(
@@ -165,7 +171,8 @@ class UserProfileViewModel(app: Application) : AndroidViewModel(app) {
subManager: SubscriptionManager? = null,
topRelayUrls: List<String> = emptyList(),
relayHintStore: RelayHintStore? = null,
extendedNetworkRepo: ExtendedNetworkRepository? = null
extendedNetworkRepo: ExtendedNetworkRepository? = null,
paymentTargetRepo: PaymentTargetRepository? = null
) {
targetPubkey = pubkey
eventRepoRef = eventRepo
@@ -191,6 +198,8 @@ class UserProfileViewModel(app: Application) : AndroidViewModel(app) {
_groups.value = emptyList()
_groupsLoading.value = false
_profile.value = eventRepo.getProfileData(pubkey)
paymentTargetRepoRef = paymentTargetRepo
_paymentTargets.value = paymentTargetRepo?.getTargets(pubkey) ?: emptyList()
_relayHints.value = relayHintStore?.getHints(pubkey) ?: emptySet()
_isFollowing.value = contactRepo.isFollowing(pubkey)
extendedNetworkRepoRef = extendedNetworkRepo
@@ -222,6 +231,7 @@ class UserProfileViewModel(app: Application) : AndroidViewModel(app) {
val relayFilter = Filter(kinds = listOf(10002), authors = listOf(pubkey), limit = 1)
val pinFilter = Filter(kinds = listOf(10001), authors = listOf(pubkey), limit = 1)
val groupsFilter = Filter(kinds = listOf(Nip51.KIND_SIMPLE_GROUPS), authors = listOf(pubkey), limit = 1)
val payTargetsFilter = Filter(kinds = listOf(NipA3.KIND), authors = listOf(pubkey), limit = 1)
if (outboxRouter != null) {
outboxRouter.subscribeToUserWriteRelays("userprofile", pubkey, profileFilter)
@@ -231,6 +241,7 @@ class UserProfileViewModel(app: Application) : AndroidViewModel(app) {
outboxRouter.subscribeToUserWriteRelays("userrelays", pubkey, relayFilter)
outboxRouter.subscribeToUserWriteRelays("userpins", pubkey, pinFilter)
outboxRouter.subscribeToUserWriteRelays("usergroups", pubkey, groupsFilter)
outboxRouter.subscribeToUserWriteRelays("userpaytargets", pubkey, payTargetsFilter)
} else {
relayPool.sendToAll(ClientMessage.req("userprofile", profileFilter))
relayPool.sendToAll(ClientMessage.req("userposts", postsFilter))
@@ -239,6 +250,7 @@ class UserProfileViewModel(app: Application) : AndroidViewModel(app) {
relayPool.sendToAll(ClientMessage.req("userrelays", relayFilter))
relayPool.sendToAll(ClientMessage.req("userpins", pinFilter))
relayPool.sendToAll(ClientMessage.req("usergroups", groupsFilter))
relayPool.sendToAll(ClientMessage.req("userpaytargets", payTargetsFilter))
}
// Also query top scored relays as safety net
for (url in topRelayUrls) {
@@ -299,6 +311,11 @@ class UserProfileViewModel(app: Application) : AndroidViewModel(app) {
if (event.kind == 10002 && event.pubkey == pubkey) {
relayListRepo?.updateFromEvent(event)
}
if (event.kind == NipA3.KIND && event.pubkey == pubkey) {
paymentTargetRepoRef?.updateFromEvent(event)
_paymentTargets.value =
paymentTargetRepoRef?.getTargets(pubkey) ?: NipA3.parse(event)
}
if (event.pubkey == pubkey) {
when (event.kind) {
0 -> {
@@ -8,12 +8,15 @@ import com.darkwisp.app.nostr.Filter
import com.darkwisp.app.nostr.LocalSigner
import com.darkwisp.app.nostr.Nip57
import com.darkwisp.app.nostr.Nip78
import com.darkwisp.app.nostr.NipA3
import com.darkwisp.app.nostr.NostrEvent
import com.darkwisp.app.nostr.NostrSigner
import com.darkwisp.app.nostr.RemoteSigner
import com.darkwisp.app.nostr.toHex
import android.content.ContentResolver
import com.darkwisp.app.relay.RelayConfig
import com.darkwisp.app.relay.RelayEvent
import com.darkwisp.app.repo.PaymentTargetRepository
import com.darkwisp.app.repo.SigningMode
import com.darkwisp.app.repo.EventRepository
import com.darkwisp.app.repo.KeyRepository
@@ -112,6 +115,7 @@ sealed class WalletPage {
data class ReceiveSuccess(val amountSats: Long) : WalletPage()
object Transactions : WalletPage()
object Settings : WalletPage()
object PaymentTargets : WalletPage()
object LightningAddressSetup : WalletPage()
object LightningAddressQR : WalletPage()
object DeleteWalletConfirm : WalletPage()
@@ -126,7 +130,9 @@ class WalletViewModel(
val eventRepo: EventRepository,
val relayPool: RelayPool,
val keyRepo: KeyRepository,
private val contentResolver: ContentResolver? = null
private val contentResolver: ContentResolver? = null,
private val paymentTargetRepo: PaymentTargetRepository? = null,
private val getSigner: () -> NostrSigner? = { null }
) : ViewModel() {
private val _walletMode = MutableStateFlow(walletModeRepo.getMode())
@@ -330,6 +336,116 @@ class WalletViewModel(
}
}
// --- NIP-A3 payment targets ---
private val _paymentTargets = MutableStateFlow<List<NipA3.PaymentTarget>>(emptyList())
val paymentTargets: StateFlow<List<NipA3.PaymentTarget>> = _paymentTargets
private val _paymentTargetsLoading = MutableStateFlow(false)
val paymentTargetsLoading: StateFlow<Boolean> = _paymentTargetsLoading
private val _paymentTargetsError = MutableStateFlow<String?>(null)
val paymentTargetsError: StateFlow<String?> = _paymentTargetsError
private val _paymentTargetsDirty = MutableStateFlow(false)
val paymentTargetsDirty: StateFlow<Boolean> = _paymentTargetsDirty
/**
* Seed from the local cache, then read back the newest kind 10133 from the
* network before editing it's a replaceable event, so saving on top of a
* stale copy would clobber targets added from another client.
*/
fun loadPaymentTargets() {
val repo = paymentTargetRepo ?: return
val me = keyRepo.getPubkeyHex() ?: return
_paymentTargetsError.value = null
_paymentTargetsDirty.value = false
_paymentTargets.value = repo.getTargets(me) ?: emptyList()
_paymentTargetsLoading.value = true
viewModelScope.launch {
try {
val subId = "own-paytgt"
val filter = Filter(kinds = listOf(NipA3.KIND), authors = listOf(me), limit = 1)
val reqMsg = ClientMessage.req(subId, filter)
for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) {
relayPool.sendToRelayOrEphemeral(url, reqMsg, skipBadCheck = true)
}
relayPool.sendToAll(reqMsg)
val result = withTimeoutOrNull(4000L) {
relayPool.relayEvents.first {
it.subscriptionId == subId && it.event.kind == NipA3.KIND && it.event.pubkey == me
}
}
val closeMsg = ClientMessage.close(subId)
for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) {
relayPool.sendToRelay(url, closeMsg)
}
relayPool.sendToAll(closeMsg)
if (result != null) {
repo.updateFromEvent(result.event)
if (!_paymentTargetsDirty.value) {
_paymentTargets.value = repo.getTargets(me) ?: emptyList()
}
}
} finally {
_paymentTargetsLoading.value = false
}
}
}
fun addPaymentTarget(typeRaw: String, authority: String): Boolean {
val type = NipA3.normalizeType(typeRaw)
if (type == null) {
_paymentTargetsError.value = "Type may only contain a-z, 0-9 and hyphens"
return false
}
val trimmedAuthority = authority.trim()
if (!NipA3.isValidAuthority(trimmedAuthority)) {
_paymentTargetsError.value = "Enter a valid address"
return false
}
val target = NipA3.PaymentTarget(type, trimmedAuthority)
if (target in _paymentTargets.value) {
_paymentTargetsError.value = "That payment target is already in the list"
return false
}
_paymentTargetsError.value = null
_paymentTargets.value = _paymentTargets.value + target
_paymentTargetsDirty.value = true
return true
}
fun removePaymentTarget(target: NipA3.PaymentTarget) {
_paymentTargets.value = _paymentTargets.value - target
_paymentTargetsDirty.value = true
}
fun publishPaymentTargets(): Boolean {
val s = getSigner() ?: keyRepo.getKeypair()?.let { LocalSigner(it.privkey, it.pubkey) } ?: return false
return try {
viewModelScope.launch {
val event = s.signEvent(
kind = NipA3.KIND,
content = "",
tags = NipA3.buildTags(_paymentTargets.value)
)
val msg = ClientMessage.event(event)
relayPool.sendToWriteRelays(msg)
for (url in RelayConfig.DEFAULT_INDEXER_RELAYS) {
relayPool.sendToRelayOrEphemeral(url, msg)
}
paymentTargetRepo?.updateFromEvent(event)
_paymentTargetsDirty.value = false
}
true
} catch (_: Exception) {
false
}
}
// --- Navigation ---
fun navigateTo(page: WalletPage) {
+2
View File
@@ -451,6 +451,8 @@
<string name="zap_quick_amounts">Quick Amounts</string>
<string name="zap_wallet_not_connected">Wallet Not Connected</string>
<string name="zap_connect_wallet">Connect a Lightning wallet to send zaps.</string>
<string name="zap_other_ways_to_pay">Other ways to pay</string>
<string name="zap_connect_wallet_to_zap">Connect a wallet to zap instead</string>
<string name="zap_both_parties_need_dm_relays">Both parties need DM relays</string>
<string name="zap_private_locked_for_private_reply">Private — replying to a private thread</string>
<string name="zap_private_requires_dm_relays">Private zap requires DM relays on both sides</string>