Refactoring the DVM payment code.

This commit is contained in:
Vitor Pamplona 2024-05-20 12:37:55 -04:00
parent c88b21b547
commit 9ceb8866ed
6 changed files with 177 additions and 100 deletions

View File

@ -143,17 +143,17 @@ object LocalCache {
val deletionIndex = DeletionIndex()
val observablesByKindAndETag = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindWithETag>>(10)
val observablesByKindAndETag = ConcurrentHashMap<Int, ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>>(10)
fun observeETag(
fun <T : Event> observeETag(
kind: Int,
eventId: HexKey,
onCreate: () -> LatestByKindWithETag,
): LatestByKindWithETag {
onCreate: () -> LatestByKindWithETag<T>,
): LatestByKindWithETag<T> {
var eTagList = observablesByKindAndETag.get(kind)
if (eTagList == null) {
eTagList = ConcurrentHashMap<HexKey, LatestByKindWithETag>(1)
eTagList = ConcurrentHashMap<HexKey, LatestByKindWithETag<T>>(1) as ConcurrentHashMap<HexKey, LatestByKindWithETag<Event>>
observablesByKindAndETag.put(kind, eTagList)
}
@ -162,10 +162,10 @@ object LocalCache {
return if (value != null) {
value
} else {
val newObject = onCreate()
val newObject = onCreate() as LatestByKindWithETag<Event>
val obj = eTagList.putIfAbsent(eventId, newObject) ?: newObject
obj
}
} as LatestByKindWithETag<T>
}
fun updateObservables(event: Event) {

View File

@ -26,11 +26,11 @@ import com.vitorpamplona.quartz.events.Event
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class LatestByKindWithETag(private val kind: Int, private val eTag: String) {
private val _latest = MutableStateFlow<Event?>(null)
class LatestByKindWithETag<T : Event>(private val kind: Int, private val eTag: String) {
private val _latest = MutableStateFlow<T?>(null)
val latest = _latest.asStateFlow()
fun updateIfMatches(event: Event) {
fun updateIfMatches(event: T) {
if (event.kind == kind && event.isTaggedEvent(eTag)) {
if (event.createdAt > (_latest.value?.createdAt ?: 0)) {
_latest.tryEmit(event)
@ -65,7 +65,7 @@ class LatestByKindWithETag(private val kind: Int, private val eTag: String) {
firstEvent.createdAt().compareTo(secondEvent.createdAt())
}
},
)?.event as? Event
)?.event as? T
_latest.tryEmit(latestNote)
}

View File

@ -176,16 +176,16 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
account.reactTo(note, reaction)
}
fun observeByETag(
fun <T : Event> observeByETag(
kind: Int,
eTag: HexKey,
): StateFlow<Event?> {
): StateFlow<T?> {
val observable =
LocalCache.observeETag(
LocalCache.observeETag<T>(
kind = kind,
eventId = eTag,
) {
LatestByKindWithETag(kind, eTag)
LatestByKindWithETag<T>(kind, eTag)
}
viewModelScope.launch(Dispatchers.IO) {

View File

@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -71,7 +70,6 @@ import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
import com.vitorpamplona.amethyst.ui.note.DVMCard
import com.vitorpamplona.amethyst.ui.note.ErrorMessageDialog
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon
import com.vitorpamplona.amethyst.ui.note.PayViaIntentDialog
@ -92,10 +90,11 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.quartz.encoders.LnInvoiceUtil
import com.vitorpamplona.quartz.events.AppDefinitionEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.events.NIP90StatusEvent
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
@ -116,7 +115,7 @@ fun NIP90ContentDiscoveryScreen(
NIP90ContentDiscoveryScreen(baseNote, accountViewModel, nav)
},
onBlank = {
FeedDVM(baseNote, null, accountViewModel, nav)
FeedEmptyWithStatus(baseNote, stringResource(R.string.dvm_looking_for_app), accountViewModel, nav)
},
)
}
@ -160,7 +159,8 @@ fun NIP90ContentDiscoveryScreen(
)
} else {
// TODO: Make a good splash screen with loading animation for this DVM.
FeedDVM(appDefinition, null, accountViewModel, nav)
// FeedDVM(appDefinition, null, accountViewModel, nav)
FeedEmptyWithStatus(appDefinition, stringResource(R.string.dvm_requesting_job), accountViewModel, nav)
}
}
}
@ -178,7 +178,7 @@ fun ObserverContentDiscoveryResponse(
val resultFlow =
remember(dvmRequestId) {
accountViewModel.observeByETag(NIP90ContentDiscoveryResponseEvent.KIND, dvmRequestId.idHex)
accountViewModel.observeByETag<NIP90ContentDiscoveryResponseEvent>(NIP90ContentDiscoveryResponseEvent.KIND, dvmRequestId.idHex)
}
val latestResponse by resultFlow.collectAsStateWithLifecycle()
@ -210,12 +210,20 @@ fun ObserverDvmStatusResponse(
) {
val statusFlow =
remember(dvmRequestId) {
accountViewModel.observeByETag(NIP90StatusEvent.KIND, dvmRequestId)
accountViewModel.observeByETag<NIP90StatusEvent>(NIP90StatusEvent.KIND, dvmRequestId)
}
val latestStatus by statusFlow.collectAsStateWithLifecycle()
// TODO: Make a good splash screen with loading animation for this DVM.
FeedDVM(appDefinition, latestStatus, accountViewModel, nav)
if (latestStatus != null) {
// TODO: Make a good splash screen with loading animation for this DVM.
latestStatus?.let {
FeedDVM(appDefinition, it, accountViewModel, nav)
}
} else {
// TODO: Make a good splash screen with loading animation for this DVM.
FeedEmptyWithStatus(appDefinition, stringResource(R.string.dvm_waiting_status), accountViewModel, nav)
}
}
@Composable
@ -265,56 +273,16 @@ fun RenderNostrNIP90ContentDiscoveryScreen(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FeedDVM(
appDefinitionNote: Note,
latestStatus: Event?,
latestStatus: NIP90StatusEvent,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
var status = "waiting"
var content = ""
var lninvoice = ""
var amount: Long = 0
var statusNote: Note? = null
val status = latestStatus.status() ?: return
if (latestStatus == null) {
content = stringResource(R.string.dvm_waiting_status)
} else {
latestStatus.let {
LoadNote(baseNoteHex = it.id, accountViewModel = accountViewModel) { stateNote ->
if (stateNote != null) {
statusNote = stateNote
}
}
content = it.content()
val statusTag =
it.tags().first { it2 ->
it2.size > 1 && (it2[0] == "status")
}
status = statusTag[1]
if (statusTag.size > 2 && content == "") {
// Some DVMs *might* send a the content in the second status tag (even though the NIP says otherwise)
content = statusTag[2]
}
if (status == "payment-required") {
val amountTag =
it.tags().first { it2 ->
it2.size > 1 && (it2[0] == "amount")
}
amount = amountTag[1].toLong()
if (amountTag.size > 2) {
// DVM *might* send a lninvoice in the second tag
lninvoice = amountTag[2]
}
}
}
}
var currentStatus = status.description
Column(
Modifier
@ -347,42 +315,77 @@ fun FeedDVM(
)
Spacer(modifier = DoubleVertSpacer)
Text(content, textAlign = TextAlign.Center)
Text(currentStatus, textAlign = TextAlign.Center)
if (status == "payment-required") {
if (lninvoice != "") {
if (status.code == "payment-required") {
val amountTag = latestStatus.firstAmount()
val amount = amountTag?.amount
val invoice = amountTag?.lnInvoice
val thankYou = stringResource(id = R.string.dvm_waiting_to_confim_payment)
val nwcPaymentRequest = stringResource(id = R.string.nwc_payment_request)
if (invoice != null) {
val context = LocalContext.current
Button(onClick = {
if (accountViewModel.account.hasWalletConnectSetup()) {
// ZapPaymentHandler.payViaNWC(lninvoice, statusNote ...)
// TODO is there a way to use payViaNWC instead of this? It's suspended.
payViaIntent(
lninvoice,
context,
onPaid = { println("paid") },
onError = { println("error") },
accountViewModel.account.sendZapPaymentRequestFor(
bolt11 = invoice,
zappedNote = null,
onSent = {
currentStatus = nwcPaymentRequest
},
onResponse = { response ->
currentStatus =
if (response is PayInvoiceErrorResponse) {
context.getString(
R.string.wallet_connect_pay_invoice_error_error,
response.error?.message
?: response.error?.code?.toString() ?: "Error parsing error message",
)
} else {
thankYou
}
},
)
} else {
payViaIntent(
lninvoice,
invoice,
context,
onPaid = { println("paid") },
onError = { println("error") },
onPaid = {
currentStatus = thankYou
},
onError = {
currentStatus = it
},
)
}
}) {
Text(text = "Pay " + (amount / 1000).toString() + " Sats to the DVM")
val amountInInvoice =
try {
LnInvoiceUtil.getAmountInSats(invoice).toLong()
} catch (e: Exception) {
null
}
if (amountInInvoice != null) {
Text(text = "Pay " + (amountInInvoice / 1000).toString() + " sats to the DVM")
} else {
Text(text = "Pay Invoice from the DVM")
}
}
} else {
statusNote?.let {
ZapDVMButton(
baseNote = it,
amount = amount,
grayTint = MaterialTheme.colorScheme.onPrimary,
accountViewModel = accountViewModel,
nav = nav,
)
} else if (amount != null) {
LoadNote(baseNoteHex = latestStatus.id, accountViewModel = accountViewModel) { stateNote ->
stateNote?.let {
ZapDVMButton(
baseNote = it,
amount = amount,
grayTint = MaterialTheme.colorScheme.onPrimary,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@ -400,6 +403,8 @@ fun ZapDVMButton(
animationSize: Dp = 14.dp,
nav: (String) -> Unit,
) {
val noteAuthor = baseNote.author ?: return
var wantsToZap by remember { mutableStateOf<List<Long>?>(null) }
var showErrorMessageDialog by remember { mutableStateOf<String?>(null) }
var wantsToPay by
@ -408,15 +413,9 @@ fun ZapDVMButton(
persistentListOf(),
)
}
baseNote.author?.let {
LoadUser(baseUserHex = it.pubkeyHex, accountViewModel = accountViewModel) { author ->
if (author != null) {
author.live().metadata.observeAsState()
println(author.info?.lnAddress())
println(author.info?.name)
}
}
}
// Makes sure the user is loaded to get his ln address
val userState = noteAuthor.live().metadata.observeAsState()
val context = LocalContext.current
val scope = rememberCoroutineScope()
@ -553,11 +552,54 @@ fun ZapDVMButton(
if (hasZapped) {
Text(text = stringResource(id = R.string.thank_you))
} else {
Text(text = "Zap " + (amount / 1000).toString() + " Sats to the DVM") // stringResource(id = R.string.donate_now))
Text(text = "Zap " + (amount / 1000).toString() + " sats to the DVM") // stringResource(id = R.string.donate_now))
}
}
}
@Composable
fun FeedEmptyWithStatus(
appDefinitionNote: Note,
status: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
Column(
Modifier
.fillMaxSize()
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
val card = observeAppDefinition(appDefinitionNote)
card.cover?.let {
AsyncImage(
model = it,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier =
Modifier
.size(Size75dp)
.clip(QuoteBorder),
)
} ?: run { NoteAuthorPicture(appDefinitionNote, nav, accountViewModel, Size75dp) }
Spacer(modifier = DoubleVertSpacer)
Text(
text = card.name,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = DoubleVertSpacer)
Text(status)
}
}
@Composable
fun observeAppDefinition(appDefinitionNote: Note): DVMCard {
val noteEvent =

View File

@ -850,4 +850,6 @@
<string name="dvm_looking_for_app">Looking for Application</string>
<string name="dvm_waiting_status">Job Requested, waiting for a reply</string>
<string name="dvm_requesting_job">Requesting Job from DVM</string>
<string name="nwc_payment_request">Payment request sent, waiting for confirmation from your wallet</string>
<string name="dvm_waiting_to_confim_payment">Waiting for DVM to confirm payment or send results</string>
</resources>

View File

@ -34,6 +34,39 @@ class NIP90StatusEvent(
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
class StatusCode(val code: String, val description: String)
class AmountInvoice(val amount: Long?, val lnInvoice: String?)
fun status(): StatusCode? {
return tags.firstOrNull { it.size > 1 && it[0] == "status" }?.let {
if (it.size > 2 && content == "") {
StatusCode(it[1], it[2])
} else {
StatusCode(it[1], content)
}
}
}
fun firstAmount(): AmountInvoice? {
return tags.firstOrNull { it.size > 1 && it[0] == "amount" }?.let {
val amount = it[1].toLongOrNull()
if (it.size > 2) {
if (it[2].isNotBlank()) {
AmountInvoice(amount, it[2])
} else {
null
}
} else {
if (amount != null) {
AmountInvoice(amount, null)
} else {
null
}
}
}
}
companion object {
const val KIND = 7000
const val ALT = "NIP90 Status update"