From 909eb62db649ab9bebbfafbeeebc7541353c1ae5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 14 Jul 2025 11:18:56 -0400 Subject: [PATCH] Moves private zap decryption to async, all at once. --- .../com/vitorpamplona/amethyst/model/Note.kt | 32 ++++++---- .../quartz/nip57Zaps/LnZapRequestEvent.kt | 4 +- .../quartz/utils/ParallelUtils.kt | 62 +++++++++++++++++++ 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 372381c54..93820b9f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import coil3.util.CoilUtils.result import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji @@ -68,6 +69,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.anyAsync import com.vitorpamplona.quartz.utils.containsAny import com.vitorpamplona.quartz.utils.launchAndWaitAll import com.vitorpamplona.quartz.utils.tryAndWait @@ -496,6 +498,8 @@ open class Note( return } + val parallelDecrypt = mutableListOf>() + zapEvents.forEach { next -> val zapRequest = next.key.event as LnZapRequestEvent val zapEvent = next.value?.event as? LnZapEvent @@ -518,21 +522,27 @@ open class Note( } } else { if (account.isWriteable()) { - val result = - tryAndWait { continuation -> - zapRequest.decryptPrivateZap(account.signer) { - continuation.resume(it) - } - } - - if (result?.pubKey == user.pubkeyHex && (option == null || option == zapEvent?.zappedPollOption())) { - onWasZappedByAuthor() - return - } + parallelDecrypt.add(Pair(zapRequest, zapEvent)) } } } } + + val result = + anyAsync(parallelDecrypt) { pair -> + val result = + tryAndWait { continuation -> + pair.first.decryptPrivateZap(account.signer) { + continuation.resume(it) + } + } + + result?.pubKey == user.pubkeyHex && (option == null || option == pair.second?.zappedPollOption()) + } + + if (result) { + onWasZappedByAuthor() + } } suspend fun isZappedBy( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt index 851869d76..f2e00897e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt @@ -64,7 +64,9 @@ class LnZapRequestEvent( override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId) - @Transient private var privateZapEvent: LnZapPrivateEvent? = null + // TODO: Create a per key map with resulting options to account for rejections and avoiding reasking. + @Transient + private var privateZapEvent: LnZapPrivateEvent? = null override fun countMemory(): Long = super.countMemory() + pointerSizeInBytes + (privateZapEvent?.countMemory() ?: 0) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt index 1d4158487..4e32f16a4 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/ParallelUtils.kt @@ -25,9 +25,11 @@ import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.joinAll +import kotlinx.coroutines.selects.select import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull import kotlin.coroutines.Continuation +import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.resume /** @@ -153,3 +155,63 @@ suspend fun mapNotNullAsync( } } } + +/** + * Executes a mapping function asynchronously on each input in the list. + * Returns true as soon as the first mapping returns true, cancelling all other ongoing operations. + * + * @param inputs A list of input objects to process. + * @param mappingFunction A suspend function that takes an input object and returns a Boolean. + * @return True if any mapping function returns true, false otherwise. + */ +suspend fun anyAsync( + inputs: List, + timeoutMillis: Long = 30000, + mappingFunction: suspend (T) -> Boolean, +): Boolean = + coroutineScope { + // Create a list to hold all our deferred results + val deferredResults = + inputs.map { input -> + async { + // Each async block will execute the mapping function. + // If this coroutine gets cancelled, CancellationException will be thrown, + // and we'll catch it to ensure it doesn't propagate further. + try { + mappingFunction(input) + } catch (e: CancellationException) { + // When cancelled, we treat it as if it didn't return true + false + } + } + } + + // Use select to wait for the first deferred to complete with 'true' + val foundTrue = + withTimeoutOrNull(timeoutMillis) { + select { + deferredResults.forEach { deferred -> + // For each deferred, if it completes and its result is 'true', + // this branch of the select expression will be chosen. + deferred.onAwait { result -> + if (result) { + true // Return true from the select expression + } else { + // If a deferred completes with false, we don't want to + // immediately end the select, so we return false, which + // lets select continue waiting for other branches. + false + } + } + } + } + } + + // Once select returns (either with true or after all deferreds complete/are cancelled), + // cancel any remaining ongoing operations. + // If foundTrue is true, all other deferreds are implicitly cancelled by the select winning. + // If foundTrue is false, it means all completed with false or were cancelled. + deferredResults.forEach { it.cancel() } // Ensure all are cancelled. + + return@coroutineScope foundTrue == true + }