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

View File

@ -150,6 +150,15 @@ class Account(
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.
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) {

View File

@ -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))
}
}
}

View File

@ -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
}

View File

@ -115,14 +115,18 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SearchEventFeed") {
}
fun search(searchString: String) {
if (this.searchString != searchString) {
println("DataSource: ${this.javaClass.simpleName} Search for $searchString")
this.searchString = searchString
invalidateFilters()
}
}
fun clear() {
if (searchString != null) {
println("DataSource: ${this.javaClass.simpleName} Clear")
searchString = null
invalidateFilters()
}
}
}

View File

@ -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
}
}

View File

@ -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?)
}
}

View File

@ -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()

View File

@ -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<User>>(Split())

View File

@ -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))
}
}
)
}

View File

@ -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<Pair<String, String?>?>(null) }
fun LoadValueFromInvoice(lnbcWord: String, inner: @Composable (invoiceAmount: InvoiceAmount?) -> Unit) {
var lnInvoice by remember { mutableStateOf<InvoiceAmount?>(null) }
LaunchedEffect(key1 = lnbcWord) {
launch(Dispatchers.IO) {
@ -56,14 +60,20 @@ fun MayBeInvoicePreview(lnbcWord: String) {
null
}
lnInvoice = Pair(myInvoice, myInvoiceAmount)
lnInvoice = InvoiceAmount(myInvoice, myInvoiceAmount)
}
}
}
Crossfade(targetState = lnInvoice) {
inner(lnInvoice)
}
@Composable
fun MayBeInvoicePreview(lnbcWord: String) {
LoadValueFromInvoice(lnbcWord = lnbcWord) { invoiceAmount ->
Crossfade(targetState = invoiceAmount, label = "MayBeInvoicePreview") {
if (it != null) {
InvoicePreview(it.first, it.second)
InvoicePreview(it.invoice, it.amount)
} else {
Text(
text = lnbcWord,
@ -72,6 +82,7 @@ fun MayBeInvoicePreview(lnbcWord: String) {
}
}
}
}
@Composable
fun InvoicePreview(lnInvoice: String, amount: String?) {

View File

@ -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>) {

View File

@ -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<NavBackStackEntry?>, onReset: () -> Unit) {
LaunchedEffect(key1 = navState.value) {

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/).
After installing, select the app you want to use in the Settings.
</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>