Merge pull request #2114 from greenart7c3/claude/implement-payment-targets-VzxXc
Add payment targets feature with NIP-A3 support
This commit is contained in:
@@ -122,6 +122,7 @@ import com.vitorpamplona.quartz.experimental.nip95.header.dimension
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.fileSize
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.hash
|
||||
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.blurhash
|
||||
import com.vitorpamplona.quartz.experimental.profileGallery.dimension
|
||||
@@ -2127,6 +2128,8 @@ class Account(
|
||||
|
||||
suspend fun sendBlossomServersList(servers: List<String>) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers))
|
||||
|
||||
suspend fun savePaymentTargets(targets: List<PaymentTarget>) = sendMyPublicAndPrivateOutbox(paymentTargetsState.savePaymentTargets(targets))
|
||||
|
||||
fun markAsRead(
|
||||
route: String,
|
||||
timestampInSecs: Long,
|
||||
|
||||
+2
@@ -46,6 +46,7 @@ enum class ReactionRowAction {
|
||||
Like,
|
||||
Zap,
|
||||
Share,
|
||||
Pay,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -62,6 +63,7 @@ val DefaultReactionRowItems =
|
||||
ReactionRowItem(ReactionRowAction.Like),
|
||||
ReactionRowItem(ReactionRowAction.Zap),
|
||||
ReactionRowItem(ReactionRowAction.Share, showCounter = false),
|
||||
ReactionRowItem(ReactionRowAction.Pay, showCounter = false),
|
||||
)
|
||||
|
||||
fun getLanguagesSpokenByUser(): Set<String> {
|
||||
|
||||
+22
@@ -23,13 +23,18 @@ package com.vitorpamplona.amethyst.model.nipA3PaymentTargets
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NipA3PaymentTargetsState(
|
||||
@@ -44,6 +49,23 @@ class NipA3PaymentTargetsState(
|
||||
|
||||
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 {
|
||||
settings.backupNipA3PaymentTargets?.let {
|
||||
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.ui.actions.NewUserMetadataScreen
|
||||
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.call.ActiveCallHolder
|
||||
import com.vitorpamplona.amethyst.ui.call.CallActivity
|
||||
@@ -262,6 +263,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.RequestToVanish> { RequestToVanishScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.EditPaymentTargets> { PaymentTargetsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
|
||||
|
||||
composableFromEndArgs<Route.ContentDiscovery> { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) }
|
||||
|
||||
@@ -156,6 +156,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object EditMediaServers : Route()
|
||||
|
||||
@Serializable object EditPaymentTargets : Route()
|
||||
|
||||
@Serializable object UpdateReactionType : Route()
|
||||
|
||||
@Serializable data class Nip47NWCSetup(
|
||||
|
||||
@@ -50,11 +50,14 @@ 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.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AccountBalanceWallet
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
@@ -95,6 +98,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -121,10 +125,15 @@ import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo
|
||||
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.note.types.EditState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -161,6 +170,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.reactionBox
|
||||
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
|
||||
@@ -279,6 +289,14 @@ private fun InnerReactionRow(
|
||||
grayTint = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
|
||||
ReactionRowAction.Pay -> {
|
||||
PayReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.placeholderText,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -321,6 +339,81 @@ fun ShareReaction(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PayReaction(
|
||||
baseNote: Note,
|
||||
grayTint: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
iconSizeModifier: Modifier = Size20Modifier,
|
||||
) {
|
||||
val authorPubkey = baseNote.author?.pubkeyHex ?: return
|
||||
val address = remember(authorPubkey) { PaymentTargetsEvent.createAddress(authorPubkey) }
|
||||
val context = LocalContext.current
|
||||
|
||||
LoadAddressableNote(address, accountViewModel) { note ->
|
||||
val targets = remember(note) { (note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() }
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
ClickableBox(
|
||||
modifier = iconSizeModifier,
|
||||
onClick = { expanded = true },
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.AccountBalanceWallet,
|
||||
contentDescription = stringRes(R.string.payment_targets),
|
||||
tint = grayTint,
|
||||
modifier = iconSizeModifier,
|
||||
)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
M3ActionDialog(
|
||||
title = stringRes(R.string.payment_targets),
|
||||
onDismiss = { expanded = false },
|
||||
) {
|
||||
M3ActionSection {
|
||||
if (targets.isEmpty()) {
|
||||
M3ActionRow(
|
||||
icon = Icons.Outlined.AccountBalanceWallet,
|
||||
text = stringRes(R.string.no_payment_targets_message),
|
||||
enabled = false,
|
||||
onClick = {},
|
||||
)
|
||||
} else {
|
||||
targets.forEach { target ->
|
||||
M3ActionRow(
|
||||
icon = Icons.Outlined.AccountBalanceWallet,
|
||||
text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}",
|
||||
onClick = {
|
||||
expanded = false
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, "payto://${target.type}/${target.authority}".toUri())
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
errorMessage = stringRes(context, R.string.no_payment_app_found)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errorMessage?.let { msg ->
|
||||
ErrorMessageDialog(
|
||||
title = stringRes(R.string.error_dialog_payment_error),
|
||||
textContent = msg,
|
||||
onDismiss = { errorMessage = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenericInnerReactionRow(
|
||||
showReactionDetail: Boolean,
|
||||
|
||||
+2
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource
|
||||
|
||||
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.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -41,6 +42,7 @@ val UserProfileListKinds =
|
||||
FollowListEvent.KIND,
|
||||
HashtagListEvent.KIND,
|
||||
AppRecommendationEvent.KIND,
|
||||
PaymentTargetsEvent.KIND,
|
||||
)
|
||||
|
||||
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.routes.Route
|
||||
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.timeAgo
|
||||
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.StdHorzSpacer
|
||||
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.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity
|
||||
@@ -217,6 +220,8 @@ fun DrawAdditionalInfo(
|
||||
}
|
||||
DisplayLNAddress(lud16, baseUser, accountViewModel, nav)
|
||||
|
||||
DisplayPaymentTargets(baseUser, accountViewModel)
|
||||
|
||||
val website = user.info.website
|
||||
if (!website.isNullOrEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -369,3 +374,46 @@ fun getIdentityClaimDescription(identity: IdentityClaimTag): Int =
|
||||
is GitHubIdentity -> 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* 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.screen.loggedIn.profile.header
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AccountBalanceWallet
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
|
||||
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
|
||||
|
||||
@Composable
|
||||
fun PaymentButton(
|
||||
user: User,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val address =
|
||||
remember(user.pubkeyHex) {
|
||||
PaymentTargetsEvent.createAddress(user.pubkeyHex)
|
||||
}
|
||||
|
||||
LoadAddressableNote(address, accountViewModel) { note ->
|
||||
val targets =
|
||||
remember(note) {
|
||||
(note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList()
|
||||
}
|
||||
PaymentButtonWithTargets(targets)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PaymentButtonWithTargets(targets: List<PaymentTarget>) {
|
||||
val context = LocalContext.current
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
FilledTonalButton(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
.width(50.dp),
|
||||
onClick = { expanded = true },
|
||||
contentPadding = ZeroPadding,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.AccountBalanceWallet,
|
||||
contentDescription = stringRes(R.string.payment_targets),
|
||||
)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
M3ActionDialog(
|
||||
title = stringRes(R.string.payment_targets),
|
||||
onDismiss = { expanded = false },
|
||||
) {
|
||||
M3ActionSection {
|
||||
if (targets.isEmpty()) {
|
||||
M3ActionRow(
|
||||
icon = Icons.Outlined.AccountBalanceWallet,
|
||||
text = stringRes(R.string.no_payment_targets_message),
|
||||
enabled = false,
|
||||
onClick = {},
|
||||
)
|
||||
} else {
|
||||
targets.forEach { target ->
|
||||
M3ActionRow(
|
||||
icon = Icons.Outlined.AccountBalanceWallet,
|
||||
text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}",
|
||||
onClick = {
|
||||
expanded = false
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, "payto://${target.type}/${target.authority}".toUri())
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
if (e is kotlinx.coroutines.CancellationException) throw e
|
||||
errorMessage = stringRes(context, R.string.no_payment_app_found)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errorMessage?.let { msg ->
|
||||
ErrorMessageDialog(
|
||||
title = stringRes(R.string.error_dialog_payment_error),
|
||||
textContent = msg,
|
||||
onDismiss = { errorMessage = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
+2
@@ -40,6 +40,8 @@ fun ProfileActions(
|
||||
) {
|
||||
MessageButton(baseUser, accountViewModel, nav)
|
||||
|
||||
PaymentButton(baseUser, accountViewModel)
|
||||
|
||||
val isMe by
|
||||
remember(accountViewModel) { derivedStateOf { accountViewModel.userProfile() == baseUser } }
|
||||
|
||||
|
||||
+8
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.CloudUpload
|
||||
import androidx.compose.material.icons.outlined.DeleteForever
|
||||
@@ -105,6 +106,13 @@ fun AllSettingsScreen(
|
||||
onClick = { nav.nav(Route.EditMediaServers) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
SettingsNavigationRow(
|
||||
title = R.string.payment_targets,
|
||||
icon = Icons.Outlined.AccountBalanceWallet,
|
||||
tint = tint,
|
||||
onClick = { nav.nav(Route.EditPaymentTargets) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
SettingsNavigationRow(
|
||||
title = R.string.reactions,
|
||||
icon = Icons.Outlined.FavoriteBorder,
|
||||
|
||||
+3
-1
@@ -330,7 +330,7 @@ private fun ReactionRowItemCard(
|
||||
)
|
||||
}
|
||||
|
||||
if (item.action != ReactionRowAction.Share) {
|
||||
if (item.action != ReactionRowAction.Share && item.action != ReactionRowAction.Pay) {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -362,6 +362,7 @@ fun reactionActionName(action: ReactionRowAction): String =
|
||||
ReactionRowAction.Like -> stringRes(R.string.reactions_settings_like)
|
||||
ReactionRowAction.Zap -> stringRes(R.string.reactions_settings_zap)
|
||||
ReactionRowAction.Share -> stringRes(R.string.reactions_settings_share)
|
||||
ReactionRowAction.Pay -> stringRes(R.string.reactions_settings_pay)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -372,4 +373,5 @@ fun reactionActionDescription(action: ReactionRowAction): String =
|
||||
ReactionRowAction.Like -> stringRes(R.string.reactions_settings_like_description)
|
||||
ReactionRowAction.Zap -> stringRes(R.string.reactions_settings_zap_description)
|
||||
ReactionRowAction.Share -> stringRes(R.string.reactions_settings_share_description)
|
||||
ReactionRowAction.Pay -> stringRes(R.string.reactions_settings_pay_description)
|
||||
}
|
||||
|
||||
@@ -576,6 +576,15 @@
|
||||
<string name="use_default_servers">Use Default List</string>
|
||||
<string name="add_media_server">Add 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="no_payment_app_found">No app found to handle this payment. Please install a compatible wallet.</string>
|
||||
<string name="error_dialog_payment_error">Unable to open payment</string>
|
||||
|
||||
<string name="uploading_state_ready">Not Started</string>
|
||||
<string name="uploading_state_compressing">Compressing</string>
|
||||
@@ -1424,6 +1433,8 @@
|
||||
<string name="reactions_settings_zap_description">Send a lightning payment to the author</string>
|
||||
<string name="reactions_settings_share">Share</string>
|
||||
<string name="reactions_settings_share_description">Share this note externally</string>
|
||||
<string name="reactions_settings_pay">Pay</string>
|
||||
<string name="reactions_settings_pay_description">Send a payment to the author via their payment targets</string>
|
||||
|
||||
<string name="profile_image_of_user">Profile Picture of %1$s</string>
|
||||
<string name="relay_info">Relay %1$s</string>
|
||||
|
||||
+2
-3
@@ -35,11 +35,10 @@ class PaymentTargetTag {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
ensure(tag.has(2)) { return null }
|
||||
ensure(tag[2].isNotEmpty()) { return null }
|
||||
|
||||
val paymentTarget = PaymentTarget(tag[1], tag[2])
|
||||
|
||||
return paymentTarget
|
||||
return PaymentTarget(tag[1], tag[2])
|
||||
}
|
||||
|
||||
fun assemble(paymentTarget: PaymentTarget) = arrayOf(TAG_NAME, paymentTarget.type, paymentTarget.authority)
|
||||
|
||||
+2
@@ -34,6 +34,8 @@ class PaymentTargetsEvent(
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
fun paymentTargets(): List<PaymentTarget> = tags.mapNotNull { PaymentTargetTag.parse(it) }
|
||||
|
||||
companion object {
|
||||
const val KIND = 10133
|
||||
|
||||
|
||||
Reference in New Issue
Block a user