From 480542d46d2cb80e99b977918a23ec9a18a731cf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 10:17:16 +0000 Subject: [PATCH 1/5] 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 --- .../vitorpamplona/amethyst/model/Account.kt | 3 + .../NipA3PaymentTargetsState.kt | 22 ++ .../paymentTargets/PaymentTargetsScreen.kt | 272 ++++++++++++++++++ .../paymentTargets/PaymentTargetsViewModel.kt | 87 ++++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../datasource/FilterUserProfileLists.kt | 2 + .../profile/header/DrawAdditionalInfo.kt | 48 ++++ .../loggedIn/settings/AllSettingsScreen.kt | 8 + amethyst/src/main/res/values/strings.xml | 7 + .../experimental/nipA3/PaymentTargetTag.kt | 5 +- .../experimental/nipA3/PaymentTargetsEvent.kt | 2 + 12 files changed, 457 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 0f30f4259..7c9187ae3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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.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 @@ -2103,6 +2104,8 @@ class Account( suspend fun sendBlossomServersList(servers: List) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers)) + suspend fun savePaymentTargets(targets: List) = sendMyPublicAndPrivateOutbox(paymentTargetsState.savePaymentTargets(targets)) + fun markAsRead( route: String, timestampInSecs: Long, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt index aa7481521..ad5ad6a15 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipA3PaymentTargets/NipA3PaymentTargetsState.kt @@ -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> = + getNipA3PaymentTargetsFlow() + .map { (it.note.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + suspend fun savePaymentTargets(targets: List): 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()}" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsScreen.kt new file mode 100644 index 000000000..25905f7a1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsScreen.kt @@ -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) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsViewModel.kt new file mode 100644 index 000000000..37c63c9ec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/paymentTargets/PaymentTargetsViewModel.kt @@ -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>(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() + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index aafcb4a21..52234b52f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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.CallScreen import com.vitorpamplona.amethyst.ui.components.getActivity @@ -263,6 +264,7 @@ fun BuildNavigation( composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd { VanishEventsScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } + composableFromEnd { PaymentTargetsScreen(accountViewModel, nav) } composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 16883691d..bd0116809 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -156,6 +156,8 @@ sealed class Route { @Serializable object EditMediaServers : Route() + @Serializable object EditPaymentTargets : Route() + @Serializable object UpdateReactionType : Route() @Serializable data class Nip47NWCSetup( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt index 9e078f7e7..2292153d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 58cb3829b..cc062c25c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -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), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index a75accba5..545ba8f8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -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, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b2c1dba13..5de217c4a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -576,6 +576,13 @@ Use Default List Add media server Delete media server + Payment Targets + Publish your payment addresses so others can send you funds directly. + Add payment addresses for different networks (e.g. bitcoin, lightning, ethereum). + No payment targets set. Add one below ↓ + Network type (e.g. bitcoin) + Address + Delete payment target Not Started Compressing diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.kt index 108047f8d..5be6ef492 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt index 4d4f2aae0..937aa351c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt @@ -34,6 +34,8 @@ class PaymentTargetsEvent( content: String, sig: HexKey, ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun paymentTargets(): List = tags.mapNotNull { PaymentTargetTag.parse(it) } + companion object { const val KIND = 10133 From c9a741ad91ba2447ea7eb54daef720f616a87593 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 10:26:20 +0000 Subject: [PATCH 2/5] feat: add payment targets button to profile icon row Shows a wallet icon button in the profile action row when a user has published payment targets (kind 10133). Tapping it opens a dropdown listing each target; selecting one opens the payto:// deep link. https://claude.ai/code/session_018G4g1cChqjeNEM5TztD3FE --- .../loggedIn/profile/header/PaymentButton.kt | 110 ++++++++++++++++++ .../loggedIn/profile/header/ProfileActions.kt | 2 + 2 files changed, 112 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt new file mode 100644 index 000000000..d5bdcf5fa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt @@ -0,0 +1,110 @@ +/* + * 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 androidx.compose.foundation.layout.Box +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.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +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.LocalUriHandler +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +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() + } + if (targets.isNotEmpty()) { + PaymentButtonWithTargets(targets) + } + } +} + +@Composable +fun PaymentButtonWithTargets(targets: List) { + val uri = LocalUriHandler.current + var expanded by remember { mutableStateOf(false) } + + Box { + 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), + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + targets.forEach { target -> + DropdownMenuItem( + text = { + Text("${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}") + }, + onClick = { + expanded = false + runCatching { + uri.openUri("payto://${target.type}/${target.authority}") + } + }, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt index 048629efb..595a64d83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt @@ -40,6 +40,8 @@ fun ProfileActions( ) { MessageButton(baseUser, accountViewModel, nav) + PaymentButton(baseUser, accountViewModel) + val isMe by remember(accountViewModel) { derivedStateOf { accountViewModel.userProfile() == baseUser } } From 151794367fb553853a08f9cb71d6a67edd1bccd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 10:35:07 +0000 Subject: [PATCH 3/5] feat: add Pay action to note reactions row for payment targets Adds a Pay (wallet) button to the note reactions icon row that shows when the note author has published payment targets (kind 10133). Tapping the button opens a dropdown listing each target; selecting one opens a payto:// deep link. The action is off by default and can be enabled, reordered, and managed alongside Reply/Boost/Like/Zap/Share in the Reactions Settings screen. No counter toggle is shown (same as Share). https://claude.ai/code/session_018G4g1cChqjeNEM5TztD3FE --- .../model/AccountSyncedSettingsInternal.kt | 2 + .../amethyst/ui/note/ReactionsRow.kt | 74 +++++++++++++++++++ .../settings/ReactionsSettingsScreen.kt | 4 +- amethyst/src/main/res/values/strings.xml | 2 + 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index 5dbb8727b..cfc3a0ffa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -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, enabled = false, showCounter = false), ) fun getLanguagesSpokenByUser(): Set { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 76b943fa7..6949a509d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -50,11 +50,16 @@ 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.DropdownMenu +import androidx.compose.material3.DropdownMenuItem 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 @@ -84,6 +89,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.style.TextAlign @@ -125,6 +131,7 @@ import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorM 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.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 +168,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 +287,14 @@ private fun InnerReactionRow( grayTint = MaterialTheme.colorScheme.placeholderText, ) } + + ReactionRowAction.Pay -> { + PayReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.placeholderText, + accountViewModel = accountViewModel, + ) + } } }, ) @@ -321,6 +337,64 @@ 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) + } + + LoadAddressableNote(address, accountViewModel) { note -> + val targets = + remember(note) { + (note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() + } + if (targets.isEmpty()) return@LoadAddressableNote + + val uri = LocalUriHandler.current + var expanded by remember { mutableStateOf(false) } + + Box { + ClickableBox( + modifier = iconSizeModifier, + onClick = { expanded = true }, + ) { + Icon( + imageVector = Icons.Outlined.AccountBalanceWallet, + contentDescription = stringRes(R.string.payment_targets), + tint = grayTint, + modifier = iconSizeModifier, + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + targets.forEach { target -> + DropdownMenuItem( + text = { + Text("${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}") + }, + onClick = { + expanded = false + runCatching { + uri.openUri("payto://${target.type}/${target.authority}") + } + }, + ) + } + } + } + } +} + @Composable private fun GenericInnerReactionRow( showReactionDetail: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt index e995b1377..6ebec3891 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt @@ -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) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5de217c4a..20601bae8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1426,6 +1426,8 @@ Send a lightning payment to the author Share Share this note externally + Pay + Send a payment to the author via their payment targets Profile Picture of %1$s Relay %1$s From ef9b1b468f20cdfd257835bf3be10a6440eb9f8a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 11:25:02 +0000 Subject: [PATCH 4/5] fix: enable Pay by default and use M3ActionDialog for payment popups - Enable Pay reaction in DefaultReactionRowItems (was disabled) - Replace DropdownMenu with M3ActionDialog + M3ActionSection + M3ActionRow in both PayReaction (reactions row) and PaymentButtonWithTargets (profile), matching the style of other note/profile action dialogs https://claude.ai/code/session_018G4g1cChqjeNEM5TztD3FE --- .../model/AccountSyncedSettingsInternal.kt | 2 +- .../amethyst/ui/note/ReactionsRow.kt | 58 ++++++++-------- .../loggedIn/profile/header/PaymentButton.kt | 66 +++++++++---------- 3 files changed, 64 insertions(+), 62 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index cfc3a0ffa..ce4d4f8aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -63,7 +63,7 @@ val DefaultReactionRowItems = ReactionRowItem(ReactionRowAction.Like), ReactionRowItem(ReactionRowAction.Zap), ReactionRowItem(ReactionRowAction.Share, showCounter = false), - ReactionRowItem(ReactionRowAction.Pay, enabled = false, showCounter = false), + ReactionRowItem(ReactionRowAction.Pay, showCounter = false), ) fun getLanguagesSpokenByUser(): Set { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 6949a509d..01b0d5a98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -56,8 +56,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator @@ -127,6 +125,9 @@ 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 @@ -360,35 +361,36 @@ fun PayReaction( val uri = LocalUriHandler.current var expanded by remember { mutableStateOf(false) } - Box { - ClickableBox( + ClickableBox( + modifier = iconSizeModifier, + onClick = { expanded = true }, + ) { + Icon( + imageVector = Icons.Outlined.AccountBalanceWallet, + contentDescription = stringRes(R.string.payment_targets), + tint = grayTint, modifier = iconSizeModifier, - onClick = { expanded = true }, - ) { - Icon( - imageVector = Icons.Outlined.AccountBalanceWallet, - contentDescription = stringRes(R.string.payment_targets), - tint = grayTint, - modifier = iconSizeModifier, - ) - } + ) + } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, + if (expanded) { + M3ActionDialog( + title = stringRes(R.string.payment_targets), + onDismiss = { expanded = false }, ) { - targets.forEach { target -> - DropdownMenuItem( - text = { - Text("${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}") - }, - onClick = { - expanded = false - runCatching { - uri.openUri("payto://${target.type}/${target.authority}") - } - }, - ) + M3ActionSection { + targets.forEach { target -> + M3ActionRow( + icon = Icons.Outlined.AccountBalanceWallet, + text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}", + onClick = { + expanded = false + runCatching { + uri.openUri("payto://${target.type}/${target.authority}") + } + }, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt index d5bdcf5fa..d01d76beb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt @@ -20,16 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header -import androidx.compose.foundation.layout.Box 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.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -40,6 +36,9 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp 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.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -73,37 +72,38 @@ fun PaymentButtonWithTargets(targets: List) { val uri = LocalUriHandler.current var expanded by remember { mutableStateOf(false) } - Box { - 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), - ) - } + 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), + ) + } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, + if (expanded) { + M3ActionDialog( + title = stringRes(R.string.payment_targets), + onDismiss = { expanded = false }, ) { - targets.forEach { target -> - DropdownMenuItem( - text = { - Text("${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}") - }, - onClick = { - expanded = false - runCatching { - uri.openUri("payto://${target.type}/${target.authority}") - } - }, - ) + M3ActionSection { + targets.forEach { target -> + M3ActionRow( + icon = Icons.Outlined.AccountBalanceWallet, + text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}", + onClick = { + expanded = false + runCatching { + uri.openUri("payto://${target.type}/${target.authority}") + } + }, + ) + } } } } From c803c06607a6ccd96299e4504b464736f4111a6e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 19:54:29 +0000 Subject: [PATCH 5/5] feat: show error dialog when payto:// URI fails to open - Use Intent(ACTION_VIEW) + startActivity instead of LocalUriHandler so ActivityNotFoundException is catchable - Show ErrorMessageDialog with title/message when no app handles the URI, matching the pattern used by zap payment errors - Always show the wallet icon (profile button + reactions row), showing a disabled "no targets" row in the dialog when the author has none set - Add no_payment_app_found and error_dialog_payment_error string resources https://claude.ai/code/session_018G4g1cChqjeNEM5TztD3FE --- .../amethyst/ui/note/ReactionsRow.kt | 55 ++++++++++++------- .../loggedIn/profile/header/PaymentButton.kt | 50 ++++++++++++----- amethyst/src/main/res/values/strings.xml | 2 + 3 files changed, 75 insertions(+), 32 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 01b0d5a98..a332565d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -87,7 +87,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.style.TextAlign @@ -99,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 @@ -132,6 +132,7 @@ import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorM 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 @@ -346,20 +347,14 @@ fun PayReaction( iconSizeModifier: Modifier = Size20Modifier, ) { val authorPubkey = baseNote.author?.pubkeyHex ?: return - val address = - remember(authorPubkey) { - PaymentTargetsEvent.createAddress(authorPubkey) - } + 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() - } - if (targets.isEmpty()) return@LoadAddressableNote + val targets = remember(note) { (note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() } - val uri = LocalUriHandler.current var expanded by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } ClickableBox( modifier = iconSizeModifier, @@ -379,21 +374,43 @@ fun PayReaction( onDismiss = { expanded = false }, ) { M3ActionSection { - targets.forEach { target -> + if (targets.isEmpty()) { M3ActionRow( icon = Icons.Outlined.AccountBalanceWallet, - text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}", - onClick = { - expanded = false - runCatching { - uri.openUri("payto://${target.type}/${target.authority}") - } - }, + 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 }, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt index d01d76beb..84e3a2cf4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt @@ -20,6 +20,7 @@ */ 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 @@ -32,13 +33,15 @@ 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.LocalUriHandler +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 @@ -61,16 +64,15 @@ fun PaymentButton( remember(note) { (note?.event as? PaymentTargetsEvent)?.paymentTargets() ?: emptyList() } - if (targets.isNotEmpty()) { - PaymentButtonWithTargets(targets) - } + PaymentButtonWithTargets(targets) } } @Composable fun PaymentButtonWithTargets(targets: List) { - val uri = LocalUriHandler.current + val context = LocalContext.current var expanded by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } FilledTonalButton( modifier = @@ -92,19 +94,41 @@ fun PaymentButtonWithTargets(targets: List) { onDismiss = { expanded = false }, ) { M3ActionSection { - targets.forEach { target -> + if (targets.isEmpty()) { M3ActionRow( icon = Icons.Outlined.AccountBalanceWallet, - text = "${target.type.replaceFirstChar(Char::titlecase)}: ${target.authority}", - onClick = { - expanded = false - runCatching { - uri.openUri("payto://${target.type}/${target.authority}") - } - }, + 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 }, + ) + } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 20601bae8..3c28d66d2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -583,6 +583,8 @@ Network type (e.g. bitcoin) Address Delete payment target + No app found to handle this payment. Please install a compatible wallet. + Unable to open payment Not Started Compressing