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 d80373eaf..9279654c3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import nostr.postr.Persona +import java.math.BigDecimal import java.util.Locale val DefaultChannels = setOf( @@ -170,7 +171,26 @@ class Account( return zapPaymentRequest != null } - fun sendZapPaymentRequestFor(bolt11: String, onResponse: (Response?) -> Unit) { + fun decryptZapPaymentResponseEvent(zapResponseEvent: LnZapPaymentResponseEvent): Response? { + val myNip47 = zapPaymentRequest ?: return null + return zapResponseEvent.response( + myNip47.secret?.toByteArray() ?: loggedIn.privKey!!, + myNip47.pubKeyHex.toByteArray() + ) + } + + fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean { + return zappedNote.isZappedBy(userProfile(), this) == true + } + + fun calculateZappedAmount(zappedNote: Note?): BigDecimal { + return zappedNote?.zappedAmount( + zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!!, + zapPaymentRequest?.pubKeyHex?.toByteArray() + ) ?: BigDecimal.ZERO + } + + fun sendZapPaymentRequestFor(bolt11: String, zappedNote: Note?, onResponse: (Response?) -> Unit) { if (!isWriteable()) return zapPaymentRequest?.let { nip47 -> @@ -179,7 +199,7 @@ class Account( val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id) wcListener.start() - LocalCache.consume(event) { + LocalCache.consume(event, zappedNote) { // After the response is received. val privKey = nip47.secret?.toByteArray() if (privKey != null) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index a368fa75b..78c58dac6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -4,7 +4,6 @@ import android.util.Log import androidx.lifecycle.LiveData import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.vitorpamplona.amethyst.service.NostrAccountDataSource.account import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.BundledInsert @@ -31,7 +30,8 @@ object LocalCache { val channels = ConcurrentHashMap() val addressables = ConcurrentHashMap(100) - val awaitingPaymentRequests = ConcurrentHashMap Unit>(10) + val awaitingPaymentRequests = + ConcurrentHashMap Unit>>(10) fun checkGetOrCreateUser(key: String): User? { if (isValidHexNpub(key)) { @@ -379,6 +379,7 @@ object LocalCache { masterNote.removeBoost(deleteNote) masterNote.removeReaction(deleteNote) masterNote.removeZap(deleteNote) + masterNote.removeZapPayment(deleteNote) masterNote.removeReport(deleteNote) } @@ -728,12 +729,41 @@ object LocalCache { // Does nothing without a response callback. } - fun consume(event: LnZapPaymentRequestEvent, onResponse: (LnZapPaymentResponseEvent) -> Unit) { - awaitingPaymentRequests.put(event.id, onResponse) + fun consume(event: LnZapPaymentRequestEvent, zappedNote: Note?, onResponse: (LnZapPaymentResponseEvent) -> Unit) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + zappedNote?.addZapPayment(note, null) + + awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse)) + + refreshObservers(note) } fun consume(event: LnZapPaymentResponseEvent) { - val responseCallback = awaitingPaymentRequests[event.requestId()] + val requestId = event.requestId() + val pair = awaitingPaymentRequests[requestId] ?: return + + val (zappedNote, responseCallback) = pair + + val requestNote = requestId?.let { checkGetOrCreateNote(requestId) } + + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + requestNote?.let { request -> + zappedNote?.addZapPayment(request, note) + } if (responseCallback != null) { responseCallback(event) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 4c4959398..c1afee79b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource +import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.relays.EOSETime @@ -45,6 +46,8 @@ open class Note(val idHex: String) { private set var zaps = mapOf() private set + var zapPayments = mapOf() + private set var relays = setOf() private set @@ -156,6 +159,17 @@ open class Note(val idHex: String) { } } + fun removeZapPayment(note: Note) { + if (zapPayments[note] != null) { + zapPayments = zapPayments.minus(note) + liveSet?.zaps?.invalidateData() + } else if (zapPayments.containsValue(note)) { + val toRemove = zapPayments.filterValues { it == note } + zapPayments = zapPayments.minus(toRemove.keys) + liveSet?.zaps?.invalidateData() + } + } + fun addBoost(note: Note) { if (note !in boosts) { boosts = boosts + note @@ -174,6 +188,17 @@ open class Note(val idHex: String) { } } + @Synchronized + fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) { + if (zapPaymentRequest !in zapPayments.keys) { + zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment) + liveSet?.zaps?.invalidateData() + } else if (zapPaymentRequest in zapPayments.keys && zapPayments[zapPaymentRequest] == null) { + zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment) + liveSet?.zaps?.invalidateData() + } + } + fun addReaction(note: Note) { if (note !in reactions) { reactions = reactions + note @@ -204,6 +229,14 @@ open class Note(val idHex: String) { // Zaps who the requester was the user return zaps.any { it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex + } || zapPayments.any { + val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent + val response = if (zapResponseEvent != null) { + account.decryptZapPaymentResponseEvent(zapResponseEvent) + } else { + null + } + response is PayInvoiceSuccessResponse && zapResponseEvent?.requestAuthor() == user.pubkeyHex } } @@ -233,12 +266,44 @@ open class Note(val idHex: String) { }.flatten() } - fun zappedAmount(): BigDecimal { - return zaps.mapNotNull { it.value?.event } + fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal { + // Regular Zap Receipts + val completedZaps = zaps.asSequence() + .mapNotNull { it.value?.event } .filterIsInstance() - .mapNotNull { - it.amount - }.sumOf { it } + .filter { it.amount != null } + .associate { + it.lnInvoice() to it.amount + } + .toMap() + + val completedPayments = if (privKey != null && walletServicePubkey != null) { + // Payments confirmed by the User's Wallet + zapPayments + .asSequence() + .filter { + val response = (it.value?.event as? LnZapPaymentResponseEvent)?.response(privKey, walletServicePubkey) + response is PayInvoiceSuccessResponse + } + .associate { + val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey) + val amount = try { + if (lnInvoice == null) { + null + } else { + LnInvoiceUtil.getAmountInSats(lnInvoice) + } + } catch (e: java.lang.Exception) { + null + } + lnInvoice to amount + } + .toMap() + } else { + emptyMap() + } + + return (completedZaps + completedPayments).values.filterNotNull().sumOf { it } } fun hasPledgeBy(user: User): Boolean { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index 4c21971e3..f5dd55142 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -19,9 +19,14 @@ class FileHeader( ) { companion object { fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { - val imageData = URL(fileUrl).readBytes() + try { + val imageData = URL(fileUrl).readBytes() - prepare(imageData, fileUrl, mimeType, description, onReady, onError) + prepare(imageData, fileUrl, mimeType, description, onReady, onError) + } catch (e: Exception) { + Log.e("ImageDownload", "Couldn't download image from server: ${e.message}") + onError() + } } fun prepare(data: ByteArray, fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt index 92384c92b..a326f5795 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt @@ -57,7 +57,7 @@ class LnZapEvent( return content } - private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1) + fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1) private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 2d7f76f8c..b5a4a91f2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -56,8 +56,7 @@ fun PollNote( val account = accountState?.account ?: return val pollViewModel = PollNoteViewModel() - pollViewModel.account = account - pollViewModel.load(zappedNote) + pollViewModel.load(account, zappedNote) pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op -> OptionNote( @@ -212,7 +211,7 @@ fun ZapVote( clickablePrepend: @Composable () -> Unit ) { val zapsState by baseNote.live().zaps.observeAsState() - val zappedNote = zapsState?.note + val zappedNote = zapsState?.note ?: return var wantsToZap by remember { mutableStateOf(false) } @@ -253,7 +252,7 @@ fun ZapVote( ) .show() } - } else if (accountViewModel.isLoggedUser(zappedNote?.author)) { + } else if (accountViewModel.isLoggedUser(zappedNote.author)) { scope.launch { Toast .makeText( @@ -376,7 +375,7 @@ fun ZapVote( LaunchedEffect(key1 = zapsState) { withContext(Dispatchers.IO) { if (!wasZappedByLoggedInUser) { - wasZappedByLoggedInUser = zappedNote?.isZappedBy(accountViewModel.userProfile(), account) == true + wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index 3ad8ac90f..73c273806 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -20,8 +20,10 @@ class PollNoteViewModel { var consensusThreshold: BigDecimal? = null var totalZapped: BigDecimal = BigDecimal.ZERO + var wasZappedByAuthor: Boolean = false - fun load(note: Note?) { + fun load(acc: Account, note: Note?) { + account = acc pollNote = note pollEvent = pollNote?.event as PollNoteEvent pollOptions = pollEvent?.pollOptions() @@ -31,13 +33,14 @@ class PollNoteViewModel { closedAt = pollEvent?.getTagInt(CLOSED_AT) totalZapped = totalZapped() + wasZappedByAuthor = note?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false } fun canZap(): Boolean { val account = account ?: return false val user = account.userProfile() ?: return false val note = pollNote ?: return false - return user != note.author && !note.isZappedBy(user, account) + return user != note.author && !wasZappedByAuthor } fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 27c377036..310c2c7df 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -309,7 +309,7 @@ fun ZapReaction( val account = accountState?.account ?: return val zapsState by baseNote.live().zaps.observeAsState() - val zappedNote = zapsState?.note + val zappedNote = zapsState?.note ?: return val zapMessage = "" var wantsToZap by remember { mutableStateOf(false) } @@ -326,7 +326,7 @@ fun ZapReaction( LaunchedEffect(key1 = zapsState) { withContext(Dispatchers.IO) { if (!wasZappedByLoggedInUser) { - wasZappedByLoggedInUser = zappedNote?.isZappedBy(account.userProfile(), account) == true + wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote) } } } @@ -459,7 +459,7 @@ fun ZapReaction( LaunchedEffect(key1 = zapsState) { withContext(Dispatchers.IO) { - zapAmount = zappedNote?.zappedAmount() + zapAmount = account.calculateZappedAmount(zappedNote) } } 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 cad01018d..05e5c7fa6 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 @@ -59,6 +59,10 @@ class AccountViewModel(private val account: Account) : ViewModel() { zap(note, amount, pollOption, message, context, onError, onProgress, account.defaultZapType) } + fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean { + return account.calculateIfNoteWasZappedByAccount(zappedNote) + } + fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) { val lud16 = note.event?.zapAddress() ?: note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() @@ -88,6 +92,7 @@ class AccountViewModel(private val account: Account) : ViewModel() { if (account.hasWalletConnectSetup()) { account.sendZapPaymentRequestFor( bolt11 = it, + note, onResponse = { response -> if (response is PayInvoiceErrorResponse) { onProgress(0.0f) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 26a563099..b23bb3623 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -565,7 +565,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode onSuccess = { // pay directly if (account.hasWalletConnectSetup()) { - account.sendZapPaymentRequestFor(it) { response -> + account.sendZapPaymentRequestFor(it, null) { response -> if (response is PayInvoiceSuccessResponse) { scope.launch { Toast.makeText(