Adding payment requests from nostr.wine

This commit is contained in:
Vitor Pamplona
2023-11-26 18:00:08 -05:00
parent 7d4fbc3e04
commit 074a7d41fd
13 changed files with 243 additions and 22 deletions
@@ -150,6 +150,15 @@ class Account(
var transientHiddenUsers: ImmutableSet<String> = persistentSetOf() var transientHiddenUsers: ImmutableSet<String> = persistentSetOf()
data class PaymentRequest(
val relayUrl: String,
val lnInvoice: String?,
val description: String?,
val otherOptionsUrl: String?
)
var transientPaymentRequestDismissals: Set<PaymentRequest> = emptySet()
val transientPaymentRequests: MutableStateFlow<Set<PaymentRequest>> = MutableStateFlow(emptySet())
// Observers line up here. // Observers line up here.
val live: AccountLiveData = AccountLiveData(this) val live: AccountLiveData = AccountLiveData(this)
val liveLanguages: 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 var userProfileCache: User? = null
fun updateOptOutOptions(warnReports: Boolean, filterSpam: Boolean) { fun updateOptOutOptions(warnReports: Boolean, filterSpam: Boolean) {
@@ -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))
}
}
} }
@@ -76,6 +76,15 @@ abstract class NostrDataSource(val debugName: String) {
override fun onAuth(relay: Relay, challenge: String) { override fun onAuth(relay: Relay, challenge: String) {
auth(relay, challenge) auth(relay, challenge)
} }
override fun onPaymentRequired(
relay: Relay,
lnInvoice: String?,
description: String?,
otherOptionsUrl: String?
) {
pay(relay, lnInvoice, description, otherOptionsUrl)
}
} }
init { init {
@@ -190,4 +199,5 @@ abstract class NostrDataSource(val debugName: String) {
abstract fun updateChannelFilters() abstract fun updateChannelFilters()
open fun auth(relay: Relay, challenge: String) = Unit open fun auth(relay: Relay, challenge: String) = Unit
open fun pay(relay: Relay, lnInvoice: String?, description: String?, otherOptionsUrl: String?) = Unit
} }
@@ -115,14 +115,18 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
} }
fun search(searchString: String) { fun search(searchString: String) {
println("DataSource: ${this.javaClass.simpleName} Search for $searchString") if (this.searchString != searchString) {
this.searchString = searchString println("DataSource: ${this.javaClass.simpleName} Search for $searchString")
invalidateFilters() this.searchString = searchString
invalidateFilters()
}
} }
fun clear() { fun clear() {
println("DataSource: ${this.javaClass.simpleName} Clear") if (searchString != null) {
searchString = null println("DataSource: ${this.javaClass.simpleName} Clear")
invalidateFilters() searchString = null
invalidateFilters()
}
} }
} }
@@ -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) { fun subscribe(listener: Listener) {
listeners = listeners.plus(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 onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit
open fun onAuth(relay: Relay, challenge: String) = Unit open fun onAuth(relay: Relay, challenge: String) = Unit
open fun onPaymentRequired(relay: Relay, lnInvoice: String?, description: String?, otherOptionsUrl: String?) = Unit
} }
} }
@@ -244,8 +244,12 @@ class Relay(
// Log.w("Relay", "Relay$url, ${msg[1].asString}") // Log.w("Relay", "Relay$url, ${msg[1].asString}")
it.onAuth(this@Relay, msgArray[1].asText()) 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 { else -> listeners.forEach {
// Log.w("Relay", "Relay something else $url, $channel") Log.w("Relay", "Unsupported message: $newMessage")
it.onError( it.onError(
this@Relay, this@Relay,
channel, channel,
@@ -393,5 +397,10 @@ class Relay(
* @param type is 0 for disconnect and 1 for connect * @param type is 0 for disconnect and 1 for connect
*/ */
fun onRelayStateChange(relay: Relay, type: StateType, channel: String?) fun onRelayStateChange(relay: Relay, type: StateType, channel: String?)
/**
* Relay sent an invoice
*/
fun onPaymentRequired(relay: Relay, lnInvoice: String?, description: String?, otherOptionsUrl: String?)
} }
} }
@@ -112,6 +112,8 @@ object RelayPool : Relay.Listener {
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
fun onAuth(relay: Relay, challenge: String) 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) { override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
@@ -138,6 +140,10 @@ object RelayPool : Relay.Listener {
listeners.forEach { it.onAuth(relay, challenge) } 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() { private fun updateStatus() {
val connected = connectedRelays() val connected = connectedRelays()
val available = availableRelays() val available = availableRelays()
@@ -98,10 +98,6 @@ open class NewPostViewModel() : ViewModel() {
var canAddInvoice by mutableStateOf(false) var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false) var wantsInvoice by mutableStateOf(false)
data class ForwardZapSetup(val user: User) {
var percentage by mutableStateOf(100)
}
// Forward Zap to // Forward Zap to
var wantsForwardZapTo by mutableStateOf(false) var wantsForwardZapTo by mutableStateOf(false)
var forwardZapTo by mutableStateOf<Split<User>>(Split()) var forwardZapTo by mutableStateOf<Split<User>>(Split())
@@ -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))
}
}
)
}
@@ -16,6 +16,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -41,9 +42,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.NumberFormat import java.text.NumberFormat
@Stable
data class InvoiceAmount(val invoice: String, val amount: String?)
@Composable @Composable
fun MayBeInvoicePreview(lnbcWord: String) { fun LoadValueFromInvoice(lnbcWord: String, inner: @Composable (invoiceAmount: InvoiceAmount?) -> Unit) {
var lnInvoice by remember { mutableStateOf<Pair<String, String?>?>(null) } var lnInvoice by remember { mutableStateOf<InvoiceAmount?>(null) }
LaunchedEffect(key1 = lnbcWord) { LaunchedEffect(key1 = lnbcWord) {
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
@@ -56,19 +60,26 @@ fun MayBeInvoicePreview(lnbcWord: String) {
null null
} }
lnInvoice = Pair(myInvoice, myInvoiceAmount) lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount)
} }
} }
} }
Crossfade(targetState = lnInvoice) { inner(lnInvoice)
if (it != null) { }
InvoicePreview(it.first, it.second)
} else { @Composable
Text( fun MayBeInvoicePreview(lnbcWord: String) {
text = lnbcWord, LoadValueFromInvoice(lnbcWord = lnbcWord) { invoiceAmount ->
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) Crossfade(targetState = invoiceAmount, label = "MayBeInvoicePreview") {
) if (it != null) {
InvoicePreview(it.invoice, it.amount)
} else {
Text(
text = lnbcWord,
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} }
} }
} }
@@ -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<Route>) { class HasNotificationDot(bottomNavigationItems: ImmutableList<Route>) {
@@ -44,6 +44,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
@@ -52,8 +53,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.ui.actions.InformationDialog 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.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.buttons.NewCommunityNoteButton import com.vitorpamplona.amethyst.ui.buttons.NewCommunityNoteButton
import com.vitorpamplona.amethyst.ui.buttons.NewImageButton import com.vitorpamplona.amethyst.ui.buttons.NewImageButton
@@ -133,6 +136,7 @@ fun MainScreen(
} }
DisplayErrorMessages(accountViewModel) DisplayErrorMessages(accountViewModel)
DisplayPayMessages(accountViewModel)
val navPopBack = remember(navController) { 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 @Composable
fun WatchNavStateToUpdateBarVisibility(navState: State<NavBackStackEntry?>, onReset: () -> Unit) { fun WatchNavStateToUpdateBarVisibility(navState: State<NavBackStackEntry?>, onReset: () -> Unit) {
LaunchedEffect(key1 = navState.value) { LaunchedEffect(key1 = navState.value) {
+7
View File
@@ -661,4 +661,11 @@
To receive push notifications, install any app that supports [Unified Push](https://unifiedpush.org/), such as [Nfty](https://ntfy.sh/). 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. After installing, select the app you want to use in the Settings.
</string> </string>
<string name="payment_required_title">Payment Required for %1$s</string>
<string name="payment_required_explain">Relay has requested a payment of the invoice below for the %1$s.</string>
<string name="payment_required_explain_null_description">Relay has requested a payment of the invoice below</string>
<string name="payment_required_explain2">If you do not intent to use this relay anymore, please remove it from your relay list</string>
<string name="dismiss">Dismiss</string>
<string name="other_options">See other options</string>
</resources> </resources>