From 074a7d41fdc627daa4b1eace8263762e68de0657 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 26 Nov 2023 18:00:08 -0500 Subject: [PATCH] Adding payment requests from nostr.wine --- .../vitorpamplona/amethyst/model/Account.kt | 24 ++++ .../service/NostrAccountDataSource.kt | 8 ++ .../amethyst/service/NostrDataSource.kt | 10 ++ .../NostrSearchEventOrUserDataSource.kt | 16 ++- .../amethyst/service/relays/Client.kt | 10 ++ .../amethyst/service/relays/Relay.kt | 11 +- .../amethyst/service/relays/RelayPool.kt | 6 + .../amethyst/ui/actions/NewPostViewModel.kt | 4 - .../amethyst/ui/actions/PayRequestDialog.kt | 107 ++++++++++++++++++ .../amethyst/ui/components/InvoicePreview.kt | 33 ++++-- .../ui/screen/loggedIn/AccountViewModel.kt | 6 + .../amethyst/ui/screen/loggedIn/MainScreen.kt | 23 ++++ app/src/main/res/values/strings.xml | 7 ++ 13 files changed, 243 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/actions/PayRequestDialog.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 3b7ffa580..d91b65b47 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -150,6 +150,15 @@ class Account( var transientHiddenUsers: ImmutableSet = persistentSetOf() + data class PaymentRequest( + val relayUrl: String, + val lnInvoice: String?, + val description: String?, + val otherOptionsUrl: String? + ) + var transientPaymentRequestDismissals: Set = emptySet() + val transientPaymentRequests: MutableStateFlow> = MutableStateFlow(emptySet()) + // Observers line up here. val live: AccountLiveData = AccountLiveData(this) val liveLanguages: AccountLiveData = AccountLiveData(this) @@ -383,6 +392,21 @@ class Account( } } + fun addPaymentRequestIfNew(paymentRequest: PaymentRequest) { + if (!this.transientPaymentRequests.value.contains(paymentRequest) && + !this.transientPaymentRequestDismissals.contains(paymentRequest) + ) { + this.transientPaymentRequests.value = transientPaymentRequests.value + paymentRequest + } + } + + fun dismissPaymentRequest(request: PaymentRequest) { + if (this.transientPaymentRequests.value.contains(request)) { + this.transientPaymentRequests.value = transientPaymentRequests.value - request + this.transientPaymentRequestDismissals = transientPaymentRequestDismissals + request + } + } + var userProfileCache: User? = null fun updateOptOutOptions(warnReports: Boolean, filterSpam: Boolean) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index b1adad63e..edd2ee124 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -244,4 +244,12 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { } } } + + override fun pay(relay: Relay, lnInvoice: String?, description: String?, otherOptionsUrl: String?) { + super.pay(relay, lnInvoice, description, otherOptionsUrl) + + if (this::account.isInitialized) { + account.addPaymentRequestIfNew(Account.PaymentRequest(relay.url, lnInvoice, description, otherOptionsUrl)) + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 284bbe185..5941293a8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -76,6 +76,15 @@ abstract class NostrDataSource(val debugName: String) { override fun onAuth(relay: Relay, challenge: String) { auth(relay, challenge) } + + override fun onPaymentRequired( + relay: Relay, + lnInvoice: String?, + description: String?, + otherOptionsUrl: String? + ) { + pay(relay, lnInvoice, description, otherOptionsUrl) + } } init { @@ -190,4 +199,5 @@ abstract class NostrDataSource(val debugName: String) { abstract fun updateChannelFilters() open fun auth(relay: Relay, challenge: String) = Unit + open fun pay(relay: Relay, lnInvoice: String?, description: String?, otherOptionsUrl: String?) = Unit } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt index fa9dec7f8..9bfaebdf2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -115,14 +115,18 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") { } fun search(searchString: String) { - println("DataSource: ${this.javaClass.simpleName} Search for $searchString") - this.searchString = searchString - invalidateFilters() + if (this.searchString != searchString) { + println("DataSource: ${this.javaClass.simpleName} Search for $searchString") + this.searchString = searchString + invalidateFilters() + } } fun clear() { - println("DataSource: ${this.javaClass.simpleName} Clear") - searchString = null - invalidateFilters() + if (searchString != null) { + println("DataSource: ${this.javaClass.simpleName} Clear") + searchString = null + invalidateFilters() + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt index 9fa1804d6..c33a5c79b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt @@ -177,6 +177,14 @@ object Client : RelayPool.Listener { } } + override fun onPaymentRequired(relay: Relay, lnInvoice: String?, description: String?, otherOptionsUrl: String?) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onPaymentRequired(relay, lnInvoice, description, otherOptionsUrl) } + } + } + fun subscribe(listener: Listener) { listeners = listeners.plus(listener) } @@ -215,5 +223,7 @@ object Client : RelayPool.Listener { open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit open fun onAuth(relay: Relay, challenge: String) = Unit + + open fun onPaymentRequired(relay: Relay, lnInvoice: String?, description: String?, otherOptionsUrl: String?) = Unit } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index 426884cd8..8acf2cdc4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -244,8 +244,12 @@ class Relay( // Log.w("Relay", "Relay$url, ${msg[1].asString}") it.onAuth(this@Relay, msgArray[1].asText()) } + "PAY" -> listeners.forEach { + // Log.w("Relay", "Relay$url, ${msg[1].asString}") + it.onPaymentRequired(this@Relay, msgArray[1].asText(), msgArray[2].asText(), msgArray[3].asText()) + } else -> listeners.forEach { - // Log.w("Relay", "Relay something else $url, $channel") + Log.w("Relay", "Unsupported message: $newMessage") it.onError( this@Relay, channel, @@ -393,5 +397,10 @@ class Relay( * @param type is 0 for disconnect and 1 for connect */ fun onRelayStateChange(relay: Relay, type: StateType, channel: String?) + + /** + * Relay sent an invoice + */ + fun onPaymentRequired(relay: Relay, lnInvoice: String?, description: String?, otherOptionsUrl: String?) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt index 4c0d8e039..dcc08bc83 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt @@ -112,6 +112,8 @@ object RelayPool : Relay.Listener { fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) fun onAuth(relay: Relay, challenge: String) + + fun onPaymentRequired(relay: Relay, lnInvoice: String?, description: String?, otherOptionsUrl: String?) } override fun onEvent(relay: Relay, subscriptionId: String, event: Event) { @@ -138,6 +140,10 @@ object RelayPool : Relay.Listener { listeners.forEach { it.onAuth(relay, challenge) } } + override fun onPaymentRequired(relay: Relay, lnInvoice: String?, description: String?, otherOptionsUrl: String?) { + listeners.forEach { it.onPaymentRequired(relay, lnInvoice, description, otherOptionsUrl) } + } + private fun updateStatus() { val connected = connectedRelays() val available = availableRelays() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 5e7caa981..7671cc838 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -98,10 +98,6 @@ open class NewPostViewModel() : ViewModel() { var canAddInvoice by mutableStateOf(false) var wantsInvoice by mutableStateOf(false) - data class ForwardZapSetup(val user: User) { - var percentage by mutableStateOf(100) - } - // Forward Zap to var wantsForwardZapTo by mutableStateOf(false) var forwardZapTo by mutableStateOf>(Split()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/PayRequestDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/PayRequestDialog.kt new file mode 100644 index 000000000..cb0fc32a5 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/PayRequestDialog.kt @@ -0,0 +1,107 @@ +package com.vitorpamplona.amethyst.ui.actions + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.OpenInNew +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDirection +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.InvoicePreview +import com.vitorpamplona.amethyst.ui.components.LoadValueFromInvoice +import com.vitorpamplona.amethyst.ui.theme.Size16dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun PayRequestDialog( + title: String, + textContent: String, + lnInvoice: String?, + textContent2: String, + otherOptions: String?, + buttonColors: ButtonColors = ButtonDefaults.buttonColors(), + onDismiss: () -> Unit +) { + val uri = LocalUriHandler.current + + val uriOpener: @Composable (() -> Unit) = otherOptions?.let { + { + Button( + onClick = { + runCatching { + uri.openUri(it) + } + }, + colors = buttonColors, + contentPadding = PaddingValues(horizontal = Size16dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Outlined.OpenInNew, + contentDescription = null + ) + Spacer(StdHorzSpacer) + Text(stringResource(R.string.other_options)) + } + } + } + } ?: { + Row() {} + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(title) + }, + text = { + Column { + Text(textContent) + Spacer(modifier = StdVertSpacer) + if (lnInvoice != null) { + LoadValueFromInvoice(lnbcWord = lnInvoice) { invoiceAmount -> + Crossfade(targetState = invoiceAmount, label = "PayRequestDialog") { + if (it != null) { + InvoicePreview(it.invoice, it.amount) + } else { + Text( + text = lnInvoice, + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } + } + } + } + Spacer(modifier = StdVertSpacer) + Text(textContent2) + } + }, + confirmButton = uriOpener, + dismissButton = { + TextButton( + onClick = { + onDismiss() + } + ) { + Text(text = stringResource(R.string.dismiss)) + } + } + ) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt index 4f090867b..a72f1568b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt @@ -16,6 +16,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -41,9 +42,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.text.NumberFormat +@Stable +data class InvoiceAmount(val invoice: String, val amount: String?) + @Composable -fun MayBeInvoicePreview(lnbcWord: String) { - var lnInvoice by remember { mutableStateOf?>(null) } +fun LoadValueFromInvoice(lnbcWord: String, inner: @Composable (invoiceAmount: InvoiceAmount?) -> Unit) { + var lnInvoice by remember { mutableStateOf(null) } LaunchedEffect(key1 = lnbcWord) { launch(Dispatchers.IO) { @@ -56,19 +60,26 @@ fun MayBeInvoicePreview(lnbcWord: String) { null } - lnInvoice = Pair(myInvoice, myInvoiceAmount) + lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount) } } } - Crossfade(targetState = lnInvoice) { - if (it != null) { - InvoicePreview(it.first, it.second) - } else { - Text( - text = lnbcWord, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) + inner(lnInvoice) +} + +@Composable +fun MayBeInvoicePreview(lnbcWord: String) { + LoadValueFromInvoice(lnbcWord = lnbcWord) { invoiceAmount -> + Crossfade(targetState = invoiceAmount, label = "MayBeInvoicePreview") { + if (it != null) { + InvoicePreview(it.invoice, it.amount) + } else { + Text( + text = lnbcWord, + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index c295d30fe..bcf1cb2a2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -952,6 +952,12 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View ) } } + + fun dismissPaymentRequest(request: Account.PaymentRequest) { + viewModelScope.launch(Dispatchers.IO) { + account.dismissPaymentRequest(request) + } + } } class HasNotificationDot(bottomNavigationItems: ImmutableList) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 41e6cba07..8ca25403d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -52,8 +53,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavBackStackEntry import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.ui.actions.InformationDialog +import com.vitorpamplona.amethyst.ui.actions.PayRequestDialog import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn import com.vitorpamplona.amethyst.ui.buttons.NewCommunityNoteButton import com.vitorpamplona.amethyst.ui.buttons.NewImageButton @@ -133,6 +136,7 @@ fun MainScreen( } DisplayErrorMessages(accountViewModel) + DisplayPayMessages(accountViewModel) val navPopBack = remember(navController) { { @@ -416,6 +420,25 @@ private fun DisplayErrorMessages(accountViewModel: AccountViewModel) { } } +@Composable +private fun DisplayPayMessages(accountViewModel: AccountViewModel) { + val openDialogMsg = accountViewModel.account.transientPaymentRequests.collectAsStateWithLifecycle(null) + + openDialogMsg.value?.firstOrNull()?.let { request -> + PayRequestDialog( + stringResource(id = R.string.payment_required_title, request.relayUrl.removePrefix("wss://").removeSuffix("/")), + request.description?.let { + stringResource(id = R.string.payment_required_explain, it) + } ?: stringResource(id = R.string.payment_required_explain_null_description), + request.lnInvoice, + stringResource(id = R.string.payment_required_explain2), + request.otherOptionsUrl + ) { + accountViewModel.dismissPaymentRequest(request) + } + } +} + @Composable fun WatchNavStateToUpdateBarVisibility(navState: State, onReset: () -> Unit) { LaunchedEffect(key1 = navState.value) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6e373593b..150003030 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -661,4 +661,11 @@ To receive push notifications, install any app that supports [Unified Push](https://unifiedpush.org/), such as [Nfty](https://ntfy.sh/). After installing, select the app you want to use in the Settings. + + Payment Required for %1$s + Relay has requested a payment of the invoice below for the %1$s. + Relay has requested a payment of the invoice below + If you do not intent to use this relay anymore, please remove it from your relay list + Dismiss + See other options