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 0a1377559..457f669da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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) = 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/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index 104149a2c..8bd329898 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, showCounter = false), ) fun getLanguagesSpokenByUser(): Set { 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 512b74535..87525c32b 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.ActiveCallHolder import com.vitorpamplona.amethyst.ui.call.CallActivity @@ -262,6 +263,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 0d6e614c3..9fa265f54 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/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 76b943fa7..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 @@ -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(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, 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/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..84e3a2cf4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/PaymentButton.kt @@ -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) { + val context = LocalContext.current + var expanded by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(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 }, + ) + } +} 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 } } 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/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 2b1101cc3..d78a6a2e9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -576,6 +576,15 @@ 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 + No app found to handle this payment. Please install a compatible wallet. + Unable to open payment Not Started Compressing @@ -1424,6 +1433,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 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