feat: implement NIP-A3 payment targets (kind 10133)
- Fix PaymentTargetTag.parse() missing tag.has(2) bounds check - Add paymentTargets() method to PaymentTargetsEvent to read tags - Add savePaymentTargets() and flow StateFlow to NipA3PaymentTargetsState - Add savePaymentTargets() to Account for publishing kind 10133 events - Create PaymentTargetsViewModel and PaymentTargetsScreen for settings UI - Add EditPaymentTargets route and navigation entry - Add Payment Targets row in AllSettingsScreen (wallet icon) - Display other users' payment targets on their profile page - Fetch kind 10133 when loading user profiles via UserProfileListKinds - Add string resources for payment targets UI https://claude.ai/code/session_018G4g1cChqjeNEM5TztD3FE
This commit is contained in:
@@ -121,6 +121,7 @@ import com.vitorpamplona.quartz.experimental.nip95.header.dimension
|
|||||||
import com.vitorpamplona.quartz.experimental.nip95.header.fileSize
|
import com.vitorpamplona.quartz.experimental.nip95.header.fileSize
|
||||||
import com.vitorpamplona.quartz.experimental.nip95.header.hash
|
import com.vitorpamplona.quartz.experimental.nip95.header.hash
|
||||||
import com.vitorpamplona.quartz.experimental.nip95.header.mimeType
|
import com.vitorpamplona.quartz.experimental.nip95.header.mimeType
|
||||||
|
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||||
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
|
||||||
import com.vitorpamplona.quartz.experimental.profileGallery.blurhash
|
import com.vitorpamplona.quartz.experimental.profileGallery.blurhash
|
||||||
import com.vitorpamplona.quartz.experimental.profileGallery.dimension
|
import com.vitorpamplona.quartz.experimental.profileGallery.dimension
|
||||||
@@ -2103,6 +2104,8 @@ class Account(
|
|||||||
|
|
||||||
suspend fun sendBlossomServersList(servers: List<String>) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers))
|
suspend fun sendBlossomServersList(servers: List<String>) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers))
|
||||||
|
|
||||||
|
suspend fun savePaymentTargets(targets: List<PaymentTarget>) = sendMyPublicAndPrivateOutbox(paymentTargetsState.savePaymentTargets(targets))
|
||||||
|
|
||||||
fun markAsRead(
|
fun markAsRead(
|
||||||
route: String,
|
route: String,
|
||||||
timestampInSecs: Long,
|
timestampInSecs: Long,
|
||||||
|
|||||||
+22
@@ -23,13 +23,18 @@ package com.vitorpamplona.amethyst.model.nipA3PaymentTargets
|
|||||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.NoteState
|
import com.vitorpamplona.amethyst.model.NoteState
|
||||||
|
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class NipA3PaymentTargetsState(
|
class NipA3PaymentTargetsState(
|
||||||
@@ -44,6 +49,23 @@ class NipA3PaymentTargetsState(
|
|||||||
|
|
||||||
fun getNipA3PaymentTargetsState() = PaymentTargetsEvent.createAddress(signer.pubKey)
|
fun getNipA3PaymentTargetsState() = PaymentTargetsEvent.createAddress(signer.pubKey)
|
||||||
|
|
||||||
|
fun getPaymentTargetsEvent(): PaymentTargetsEvent? = nipA3PaymentTargetsNote.event as? PaymentTargetsEvent
|
||||||
|
|
||||||
|
val flow: StateFlow<List<PaymentTarget>> =
|
||||||
|
getNipA3PaymentTargetsFlow()
|
||||||
|
.map { (it.note.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() }
|
||||||
|
.flowOn(Dispatchers.IO)
|
||||||
|
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
|
suspend fun savePaymentTargets(targets: List<PaymentTarget>): PaymentTargetsEvent {
|
||||||
|
val existing = getPaymentTargetsEvent()
|
||||||
|
return if (existing != null && existing.tags.isNotEmpty()) {
|
||||||
|
PaymentTargetsEvent.updatePaymentTargets(existing, targets, signer)
|
||||||
|
} else {
|
||||||
|
PaymentTargetsEvent.create(targets, signer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
settings.backupNipA3PaymentTargets?.let {
|
settings.backupNipA3PaymentTargets?.let {
|
||||||
Log.d("AccountRegisterObservers") { "Loading saved nipA3 Payment targets ${it.toJson()}" }
|
Log.d("AccountRegisterObservers") { "Loading saved nipA3 Payment targets ${it.toJson()}" }
|
||||||
|
|||||||
+272
@@ -0,0 +1,272 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.actions.paymentTargets
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Delete
|
||||||
|
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.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
|
||||||
|
import com.vitorpamplona.amethyst.ui.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||||
|
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PaymentTargetsScreen(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
val viewModel: PaymentTargetsViewModel = viewModel()
|
||||||
|
viewModel.init(accountViewModel)
|
||||||
|
|
||||||
|
LaunchedEffect(key1 = accountViewModel) {
|
||||||
|
viewModel.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
PaymentTargetsScaffold(viewModel) {
|
||||||
|
nav.popBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PaymentTargetsScaffold(
|
||||||
|
viewModel: PaymentTargetsViewModel,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
SavingTopBar(
|
||||||
|
titleRes = R.string.payment_targets,
|
||||||
|
onCancel = {
|
||||||
|
viewModel.refresh()
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
onPost = {
|
||||||
|
viewModel.savePaymentTargets()
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(
|
||||||
|
start = 16.dp,
|
||||||
|
top = padding.calculateTopPadding(),
|
||||||
|
end = 16.dp,
|
||||||
|
bottom = padding.calculateBottomPadding(),
|
||||||
|
).consumeWindowInsets(padding)
|
||||||
|
.imePadding(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringRes(id = R.string.payment_targets_explainer),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(top = 10.dp),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.grayText,
|
||||||
|
)
|
||||||
|
|
||||||
|
PaymentTargetsBody(viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PaymentTargetsBody(viewModel: PaymentTargetsViewModel) {
|
||||||
|
val targets by viewModel.paymentTargets.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.SpaceAround,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
contentPadding = FeedPadding,
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
SettingsCategory(
|
||||||
|
R.string.payment_targets,
|
||||||
|
R.string.payment_targets_section_explainer,
|
||||||
|
SettingsCategoryFirstModifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targets.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = stringRes(id = R.string.no_payment_targets_message),
|
||||||
|
modifier = Modifier.padding(vertical = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
itemsIndexed(
|
||||||
|
targets,
|
||||||
|
key = { _: Int, target: PaymentTarget -> target.type + ":" + target.authority },
|
||||||
|
) { _, target ->
|
||||||
|
PaymentTargetEntry(target = target, onDelete = { viewModel.removeTarget(target) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(modifier = StdVertSpacer)
|
||||||
|
PaymentTargetAddField { type, authority ->
|
||||||
|
viewModel.addTarget(type, authority)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PaymentTargetEntry(
|
||||||
|
target: PaymentTarget,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceAround,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = target.type.replaceFirstChar(Char::titlecase),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Spacer(modifier = StdVertSpacer)
|
||||||
|
Text(
|
||||||
|
text = target.authority,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.grayText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Delete,
|
||||||
|
contentDescription = stringRes(id = R.string.delete_payment_target),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PaymentTargetAddField(onAdd: (type: String, authority: String) -> Unit) {
|
||||||
|
var type by remember { mutableStateOf("") }
|
||||||
|
var authority by remember { mutableStateOf("") }
|
||||||
|
val isValid = type.trim().isNotEmpty() && authority.trim().isNotEmpty()
|
||||||
|
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(Size10dp)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Size10dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
label = { Text(text = stringRes(R.string.payment_target_type)) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
value = type,
|
||||||
|
onValueChange = { type = it },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "bitcoin",
|
||||||
|
color = MaterialTheme.colorScheme.placeholderText,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(Size10dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
label = { Text(text = stringRes(R.string.payment_target_authority)) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
value = authority,
|
||||||
|
onValueChange = { authority = it },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = "bc1q...",
|
||||||
|
color = MaterialTheme.colorScheme.placeholderText,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (isValid) {
|
||||||
|
onAdd(type, authority)
|
||||||
|
type = ""
|
||||||
|
authority = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = ButtonBorder,
|
||||||
|
enabled = isValid,
|
||||||
|
) {
|
||||||
|
Text(text = stringRes(id = R.string.add), color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.actions.paymentTargets
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
class PaymentTargetsViewModel : ViewModel() {
|
||||||
|
private lateinit var accountViewModel: AccountViewModel
|
||||||
|
private lateinit var account: Account
|
||||||
|
|
||||||
|
private val _paymentTargets = MutableStateFlow<List<PaymentTarget>>(emptyList())
|
||||||
|
val paymentTargets = _paymentTargets.asStateFlow()
|
||||||
|
private var isModified = false
|
||||||
|
|
||||||
|
fun init(accountViewModel: AccountViewModel) {
|
||||||
|
this.accountViewModel = accountViewModel
|
||||||
|
this.account = accountViewModel.account
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
isModified = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
_paymentTargets.update {
|
||||||
|
account.paymentTargetsState.flow.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addTarget(
|
||||||
|
type: String,
|
||||||
|
authority: String,
|
||||||
|
) {
|
||||||
|
val trimmedType = type.trim().lowercase()
|
||||||
|
val trimmedAuthority = authority.trim()
|
||||||
|
if (trimmedType.isEmpty() || trimmedAuthority.isEmpty()) return
|
||||||
|
val target = PaymentTarget(trimmedType, trimmedAuthority)
|
||||||
|
if (_paymentTargets.value.any { it.type == target.type && it.authority == target.authority }) return
|
||||||
|
_paymentTargets.update { it.plus(target) }
|
||||||
|
isModified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeTarget(target: PaymentTarget) {
|
||||||
|
_paymentTargets.update { it.minus(target) }
|
||||||
|
isModified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun savePaymentTargets() {
|
||||||
|
if (isModified) {
|
||||||
|
accountViewModel.launchSigner {
|
||||||
|
account.savePaymentTargets(_paymentTargets.value)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
|
|||||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
|
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
|
||||||
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
|
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
|
||||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen
|
||||||
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
|
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
|
||||||
import com.vitorpamplona.amethyst.ui.call.CallScreen
|
import com.vitorpamplona.amethyst.ui.call.CallScreen
|
||||||
import com.vitorpamplona.amethyst.ui.components.getActivity
|
import com.vitorpamplona.amethyst.ui.components.getActivity
|
||||||
@@ -263,6 +264,7 @@ fun BuildNavigation(
|
|||||||
composableFromEnd<Route.RequestToVanish> { RequestToVanishScreen(accountViewModel, nav) }
|
composableFromEnd<Route.RequestToVanish> { RequestToVanishScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
|
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
|
||||||
|
composableFromEnd<Route.EditPaymentTargets> { PaymentTargetsScreen(accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
|
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
|
||||||
|
|
||||||
composableFromEndArgs<Route.ContentDiscovery> { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) }
|
composableFromEndArgs<Route.ContentDiscovery> { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) }
|
||||||
|
|||||||
@@ -156,6 +156,8 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object EditMediaServers : Route()
|
@Serializable object EditMediaServers : Route()
|
||||||
|
|
||||||
|
@Serializable object EditPaymentTargets : Route()
|
||||||
|
|
||||||
@Serializable object UpdateReactionType : Route()
|
@Serializable object UpdateReactionType : Route()
|
||||||
|
|
||||||
@Serializable data class Nip47NWCSetup(
|
@Serializable data class Nip47NWCSetup(
|
||||||
|
|||||||
+2
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource
|
||||||
|
|
||||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||||
|
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
@@ -41,6 +42,7 @@ val UserProfileListKinds =
|
|||||||
FollowListEvent.KIND,
|
FollowListEvent.KIND,
|
||||||
HashtagListEvent.KIND,
|
HashtagListEvent.KIND,
|
||||||
AppRecommendationEvent.KIND,
|
AppRecommendationEvent.KIND,
|
||||||
|
PaymentTargetsEvent.KIND,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun filterUserProfileLists(
|
fun filterUserProfileLists(
|
||||||
|
|||||||
+48
@@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.components.appendLink
|
|||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||||
import com.vitorpamplona.amethyst.ui.note.DrawPlayName
|
import com.vitorpamplona.amethyst.ui.note.DrawPlayName
|
||||||
|
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||||
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol
|
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol
|
||||||
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
import com.vitorpamplona.amethyst.ui.note.timeAgo
|
||||||
import com.vitorpamplona.amethyst.ui.painterRes
|
import com.vitorpamplona.amethyst.ui.painterRes
|
||||||
@@ -79,6 +80,8 @@ import com.vitorpamplona.amethyst.ui.theme.SpacedBy3dp
|
|||||||
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
|
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
|
||||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||||
|
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||||
|
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity
|
import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity
|
||||||
@@ -217,6 +220,8 @@ fun DrawAdditionalInfo(
|
|||||||
}
|
}
|
||||||
DisplayLNAddress(lud16, baseUser, accountViewModel, nav)
|
DisplayLNAddress(lud16, baseUser, accountViewModel, nav)
|
||||||
|
|
||||||
|
DisplayPaymentTargets(baseUser, accountViewModel)
|
||||||
|
|
||||||
val website = user.info.website
|
val website = user.info.website
|
||||||
if (!website.isNullOrEmpty()) {
|
if (!website.isNullOrEmpty()) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -369,3 +374,46 @@ fun getIdentityClaimDescription(identity: IdentityClaimTag): Int =
|
|||||||
is GitHubIdentity -> R.string.github
|
is GitHubIdentity -> R.string.github
|
||||||
else -> R.string.github
|
else -> R.string.github
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DisplayPaymentTargets(
|
||||||
|
baseUser: User,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
) {
|
||||||
|
val address =
|
||||||
|
remember(baseUser.pubkeyHex) {
|
||||||
|
PaymentTargetsEvent.createAddress(baseUser.pubkeyHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadAddressableNote(address, accountViewModel) { note ->
|
||||||
|
val targets =
|
||||||
|
remember(note) {
|
||||||
|
(note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList()
|
||||||
|
}
|
||||||
|
targets.forEach { target ->
|
||||||
|
PaymentTargetRow(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PaymentTargetRow(target: PaymentTarget) {
|
||||||
|
val uri = LocalUriHandler.current
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = target.type.replaceFirstChar(Char::titlecase),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(end = 4.dp),
|
||||||
|
)
|
||||||
|
ClickableTextPrimary(
|
||||||
|
text = target.authority,
|
||||||
|
onClick = {
|
||||||
|
runCatching {
|
||||||
|
uri.openUri("payto://${target.type}/${target.authority}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+8
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.AccountBalanceWallet
|
||||||
import androidx.compose.material.icons.outlined.Bolt
|
import androidx.compose.material.icons.outlined.Bolt
|
||||||
import androidx.compose.material.icons.outlined.CloudUpload
|
import androidx.compose.material.icons.outlined.CloudUpload
|
||||||
import androidx.compose.material.icons.outlined.DeleteForever
|
import androidx.compose.material.icons.outlined.DeleteForever
|
||||||
@@ -105,6 +106,13 @@ fun AllSettingsScreen(
|
|||||||
onClick = { nav.nav(Route.EditMediaServers) },
|
onClick = { nav.nav(Route.EditMediaServers) },
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
SettingsNavigationRow(
|
||||||
|
title = R.string.payment_targets,
|
||||||
|
icon = Icons.Outlined.AccountBalanceWallet,
|
||||||
|
tint = tint,
|
||||||
|
onClick = { nav.nav(Route.EditPaymentTargets) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
title = R.string.reactions,
|
title = R.string.reactions,
|
||||||
icon = Icons.Outlined.FavoriteBorder,
|
icon = Icons.Outlined.FavoriteBorder,
|
||||||
|
|||||||
@@ -576,6 +576,13 @@
|
|||||||
<string name="use_default_servers">Use Default List</string>
|
<string name="use_default_servers">Use Default List</string>
|
||||||
<string name="add_media_server">Add media server</string>
|
<string name="add_media_server">Add media server</string>
|
||||||
<string name="delete_media_server">Delete media server</string>
|
<string name="delete_media_server">Delete media server</string>
|
||||||
|
<string name="payment_targets">Payment Targets</string>
|
||||||
|
<string name="payment_targets_explainer">Publish your payment addresses so others can send you funds directly.</string>
|
||||||
|
<string name="payment_targets_section_explainer">Add payment addresses for different networks (e.g. bitcoin, lightning, ethereum).</string>
|
||||||
|
<string name="no_payment_targets_message">No payment targets set. Add one below ↓</string>
|
||||||
|
<string name="payment_target_type">Network type (e.g. bitcoin)</string>
|
||||||
|
<string name="payment_target_authority">Address</string>
|
||||||
|
<string name="delete_payment_target">Delete payment target</string>
|
||||||
|
|
||||||
<string name="uploading_state_ready">Not Started</string>
|
<string name="uploading_state_ready">Not Started</string>
|
||||||
<string name="uploading_state_compressing">Compressing</string>
|
<string name="uploading_state_compressing">Compressing</string>
|
||||||
|
|||||||
+2
-3
@@ -35,11 +35,10 @@ class PaymentTargetTag {
|
|||||||
ensure(tag.has(1)) { return null }
|
ensure(tag.has(1)) { return null }
|
||||||
ensure(tag[0] == TAG_NAME) { return null }
|
ensure(tag[0] == TAG_NAME) { return null }
|
||||||
ensure(tag[1].isNotEmpty()) { return null }
|
ensure(tag[1].isNotEmpty()) { return null }
|
||||||
|
ensure(tag.has(2)) { return null }
|
||||||
ensure(tag[2].isNotEmpty()) { return null }
|
ensure(tag[2].isNotEmpty()) { return null }
|
||||||
|
|
||||||
val paymentTarget = PaymentTarget(tag[1], tag[2])
|
return PaymentTarget(tag[1], tag[2])
|
||||||
|
|
||||||
return paymentTarget
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun assemble(paymentTarget: PaymentTarget) = arrayOf(TAG_NAME, paymentTarget.type, paymentTarget.authority)
|
fun assemble(paymentTarget: PaymentTarget) = arrayOf(TAG_NAME, paymentTarget.type, paymentTarget.authority)
|
||||||
|
|||||||
+2
@@ -34,6 +34,8 @@ class PaymentTargetsEvent(
|
|||||||
content: String,
|
content: String,
|
||||||
sig: HexKey,
|
sig: HexKey,
|
||||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
fun paymentTargets(): List<PaymentTarget> = tags.mapNotNull { PaymentTargetTag.parse(it) }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val KIND = 10133
|
const val KIND = 10133
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user