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:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user