diff --git a/README.md b/README.md index 27e371412..f32278427 100644 --- a/README.md +++ b/README.md @@ -251,10 +251,84 @@ dependencyResolutionManagement { } ``` -Add the dependency +Add the dependency by using one of the versions [here](https://jitpack.io/#vitorpamplona/amethyst/) ```gradle -implementation('com.github.vitorpamplona.amethyst:quartz:v0.85.1') +implementation('com.github.vitorpamplona.amethyst:quartz:') +``` + +Manage logged in users with the `KeyPair` class + +```kt +val keys = KeyPair() // creates a random key +val keys = KeyPair("hex...".hexToByteArray()) +val keys = KeyPair("nsec1...".bechToBytes()) +val keys = KeyPair(Nip06().privateKeyFromMnemonic("")) +val readOnly = KeyPair(pubKey = "hex...".hexToByteArray()) +val readOnly = KeyPair(pubKey = "npub1...".bechToBytes()) +``` + +Create signers that can be internal, when you have the private key or when it is a read-only user +or external, when it is controlled by Amber in NIP-55 + +the `NostrSignerInternal` and `NostrSignerExternal` classes. + +```kt +val signer = NostrSignerInternal(keyPair) +val amberSigner = NostrSignerExternal( + pubKey = keyPair.pubKey.toHexKey(), + packageName = signerPackageName, + contentResolver = appContext.contentResolver, +) +``` + +Create a single NostrClient for the entire application and control which relays it will access by +registering subscriptions and sending events. The pool will automatically changed based on filters + +outbox events. + +You will need a coroutine scope to process events and if you are using OKHttp, we offer a basic +wrapper to create the socket connections themselves. + +```kt +val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) +val rootClient = OkHttpClient.Builder().build() +val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient } + +val client = NostrClient(socketBuilder, appScope) +``` + +If you want to auth, given a logged-in `signer`: + +```kt +val authCoordinator = RelayAuthenticator(client, applicationIOScope) { challenge, relay -> + val authedEvent = RelayAuthEvent.create(relayUrl, challenge, signer) + client.sendIfExists(authedEvent, relay.url) +} +``` + +To manage subscriptions, the suggested approach is to use subscriptions in the Application class. + +```kt +val metadataSub = RelayClientSubscription( + client = client, + filter = { + val filters = listOf( + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = listOf(signer.pubkey) + ) + ) + + val signerOutboxRelays = listOfNotNull( + RelayUrlNormalizer.normalizeOrNull("wss://relay1.com"), + RelayUrlNormalizer.normalizeOrNull("wss://relay2.com") + ) + + signerOutboxRelays.associateWith { filters } + } +) { event -> + /* consume event */ +} ``` ## Contributing diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index cf4c3bf3d..1129c7773 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory import com.vitorpamplona.amethyst.ui.tor.TorManager import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient @@ -111,7 +112,7 @@ class Amethyst : Application() { val relayProxyClientConnector = RelayProxyClientConnector(torProxySettingsAnchor, okHttpClients, connManager, client, applicationIOScope) // Verifies and inserts in the cache from all relays, all subscriptions - val cacheClientConnector = CacheClientConnector(client, cache, applicationIOScope) + val cacheClientConnector = CacheClientConnector(client, cache) // Show messages from the Relay and controls their dismissal val notifyCoordinator = NotifyCoordinator(client) @@ -119,7 +120,7 @@ class Amethyst : Application() { // Authenticates with relays. val authCoordinator = AuthCoordinator(client, applicationIOScope) - // val logger = if (isDebug) RelaySpeedLogger(client) else null + val logger = if (isDebug) RelaySpeedLogger(client) else null // Coordinates all subscriptions for the Nostr Client val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, applicationIOScope) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 35e970430..359666aff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -216,6 +216,7 @@ import kotlinx.coroutines.withContext import java.math.BigDecimal import java.util.Locale import kotlin.collections.forEach +import kotlin.collections.ifEmpty @OptIn(DelicateCoroutinesApi::class) @Stable @@ -305,7 +306,7 @@ class Account( // Follows Relays val followOutboxesOrProxy = FollowListOutboxOrProxyRelays(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope) - val followPlusAllMine = MergedFollowPlusMineRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, broadcastRelayList, indexerRelayList, scope) + val followPlusAllMine = MergedFollowPlusMineRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, indexerRelayList, scope) // keeps a cache of the outbox relays for each author val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope).flow @@ -538,7 +539,7 @@ class Account( val zapRequest = LnZapRequestEvent.create( userHex = user.pubkeyHex, - relays = nip65RelayList.inboxFlow.value + user.inboxRelays(), + relays = nip65RelayList.inboxFlow.value + (user.inboxRelays() ?: emptyList()), signer = signer, message = message, zapType = zapType, @@ -659,7 +660,8 @@ class Account( if (replyToAuthor == userProfile()) { outboxRelays.flow.value } else { - replyToAuthor.outboxRelays().ifEmpty { null }?.toSet() + replyToAuthor.inboxRelays()?.ifEmpty { null }?.toSet() + ?: replyToAuthor.relaysBeingUsed.keys.ifEmpty { null } ?: cache.relayHints .hintsForKey(replyToAuthor.pubkeyHex) .ifEmpty { null } @@ -685,7 +687,7 @@ class Account( if (user == userProfile()) { notificationRelays.flow.value } else { - user.inboxRelays().ifEmpty { null }?.toSet() + user.inboxRelays()?.ifEmpty { null }?.toSet() ?: (cache.relayHints.hintsForKey(user.pubkeyHex).toSet() + user.relaysBeingUsed.keys) } @@ -738,10 +740,12 @@ class Account( if (author == userProfile()) { relayList.addAll(outboxRelays.flow.value) } else { - relayList.addAll( - author.outboxRelays().ifEmpty { null } - ?: cache.relayHints.hintsForKey(author.pubkeyHex), - ) + val relays = + author.outboxRelays()?.ifEmpty { null } + ?: author.relaysBeingUsed.keys.ifEmpty { null } + ?: cache.relayHints.hintsForKey(author.pubkeyHex) + + relayList.addAll(relays) } } else { relayList.addAll(cache.relayHints.hintsForKey(event.pubKey)) @@ -1566,11 +1570,8 @@ class Account( val request = NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey.pubkeyHex, signer.pubKey, relays, signer) val relayList = - dvmPublicKey - .inboxRelays() - .ifEmpty { - cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex) + dvmPublicKey.relaysBeingUsed.keys - }.toSet() + dvmPublicKey.inboxRelays()?.toSet()?.ifEmpty { null } + ?: (dvmPublicKey.relaysBeingUsed.keys + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex)) cache.justConsumeMyOwnEvent(request) onReady(request) 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 e23666235..883dcb8ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference import com.vitorpamplona.quartz.nip01Core.tags.hashtags.anyHashTag +import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag @@ -49,7 +50,6 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -58,15 +58,12 @@ import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent -import com.vitorpamplona.quartz.nip71Video.VideoEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.anyAsync import com.vitorpamplona.quartz.utils.containsAny @@ -95,19 +92,9 @@ class AddressableNote( override fun createdAt(): Long? { val currentEvent = event - if (currentEvent == null) return null - - val publishedAt = - when (currentEvent) { - is LongTextNoteEvent -> currentEvent.publishedAt() ?: Long.MAX_VALUE - is WikiNoteEvent -> currentEvent.publishedAt() ?: Long.MAX_VALUE - is VideoEvent -> currentEvent.publishedAt() ?: Long.MAX_VALUE - is ClassifiedsEvent -> currentEvent.publishedAt() ?: Long.MAX_VALUE - else -> Long.MAX_VALUE - } - - return minOf(publishedAt, currentEvent.createdAt) + if (currentEvent is PublishedAtProvider) return currentEvent.publishedAt() ?: currentEvent.createdAt + return currentEvent.createdAt } fun dTag(): String = address.dTag @@ -202,15 +189,15 @@ open class Note( } fun relayUrls(): List { - val authorRelay = author?.relayHints()?.ifEmpty { null } + val authorRelay = author?.relayHints() ?: emptyList() - return authorRelay ?: relays + return authorRelay + relays } fun relayUrlsForReactions(): List { - val authorRelay = author?.inboxRelays()?.ifEmpty { null } + val authorRelay = author?.inboxRelays() ?: emptyList() - return authorRelay ?: relays + return authorRelay + relays } fun relayHintUrl(): NormalizedRelayUrl? { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 237a1ff7a..4f068b536 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -78,11 +78,11 @@ class User( fun toNProfile() = NProfile.create(pubkeyHex, relayHints()) - fun outboxRelays() = authorRelayList()?.writeRelaysNorm() ?: listOfNotNull(latestMetadataRelay) + fun outboxRelays() = authorRelayList()?.writeRelaysNorm() fun relayHints() = authorRelayList()?.writeRelaysNorm()?.take(3) ?: listOfNotNull(latestMetadataRelay) - fun inboxRelays() = authorRelayList()?.readRelaysNorm() ?: listOfNotNull(latestMetadataRelay) + fun inboxRelays() = authorRelayList()?.readRelaysNorm() fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineRelayListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineRelayListsState.kt index 2b0fa4a56..35474e98a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineRelayListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineRelayListsState.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.model.serverList import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxOrProxyRelays -import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -41,7 +40,6 @@ class MergedFollowPlusMineRelayListsState( val nip65RelayList: Nip65RelayListState, val privateOutboxRelayList: PrivateStorageRelayListState, val localRelayList: LocalRelayListState, - val broadcastRelayList: BroadcastRelayListState, val indexerRelayList: IndexerRelayListState, val scope: CoroutineScope, ) { @@ -55,7 +53,6 @@ class MergedFollowPlusMineRelayListsState( nip65RelayList.inboxFlow, privateOutboxRelayList.flow, localRelayList.flow, - broadcastRelayList.flow, indexerRelayList.flow, ), ::mergeLists, @@ -68,7 +65,6 @@ class MergedFollowPlusMineRelayListsState( nip65RelayList.inboxFlow.value, privateOutboxRelayList.flow.value, localRelayList.flow.value, - broadcastRelayList.flow.value, indexerRelayList.flow.value, ), ), @@ -84,7 +80,6 @@ class MergedFollowPlusMineRelayListsState( nip65RelayList.inboxFlow.value, privateOutboxRelayList.flow.value, localRelayList.flow.value, - broadcastRelayList.flow.value, indexerRelayList.flow.value, ), ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt index 476f17d12..e139631dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpWebSocket.kt @@ -40,6 +40,8 @@ class OkHttpWebSocket( fun buildRequest() = Request.Builder().url(url.url).build() override fun needsReconnect(): Boolean { + if (socket == null) return true + val myUsingOkHttp = usingOkHttp if (myUsingOkHttp == null) return true @@ -89,13 +91,19 @@ class OkHttpWebSocket( webSocket: okhttp3.WebSocket, code: Int, reason: String, - ) = out.onClosed(code, reason) + ) { + socket = null + out.onClosed(code, reason) + } override fun onFailure( webSocket: okhttp3.WebSocket, t: Throwable, response: Response?, - ) = out.onFailure(t, response?.code, response?.message) + ) { + socket = null + out.onFailure(t, response?.code, response?.message) + } } class Builder( @@ -111,6 +119,7 @@ class OkHttpWebSocket( override fun disconnect() { // uses cancel to kill the SEND stack that might be waiting socket?.cancel() + socket = null } override fun send(msg: String): Boolean = socket?.send(msg) ?: false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt index 0371fc4fe..24d576b60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/CacheClientConnector.kt @@ -27,12 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.EventCollector import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayInsertConfirmationCollector import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import kotlinx.coroutines.CoroutineScope class CacheClientConnector( val client: NostrClient, val cache: LocalCache, - val scope: CoroutineScope, ) { val receiver = EventCollector(client) { event, relay -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt index 0f64a2668..b443d6a89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromInboxRelaysManager.kt @@ -77,7 +77,7 @@ class AccountNotificationsEoseFromInboxRelaysManager( } }, key.account.scope.launch(Dispatchers.Default) { - key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest { + key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { invalidateFilters() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt index 9f59c18bc..dc21ec483 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsEoseFromRandomRelaysManager.kt @@ -27,12 +27,12 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription -import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch class AccountNotificationsEoseFromRandomRelaysManager( @@ -49,11 +49,18 @@ class AccountNotificationsEoseFromRandomRelaysManager( override fun updateFilter( key: AccountQueryState, since: SincePerRelayMap?, - ): List? = - (key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap { - val since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo() - filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since) + ): List? { + // only loads this after the feed is built + val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() + return if (defaultSince != null) { + (key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap { + val since = since?.get(it)?.time ?: defaultSince + filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since) + } + } else { + emptyList() } + } val userJobMap = mutableMapOf>() @@ -69,6 +76,11 @@ class AccountNotificationsEoseFromRandomRelaysManager( invalidateFilters() } }, + key.account.scope.launch(Dispatchers.Default) { + key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { + invalidateFilters() + } + }, ) return super.newSub(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt new file mode 100644 index 000000000..dc9d10831 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ReusableZapButton.kt @@ -0,0 +1,327 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.components + +import android.content.Context +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.ZapPaymentHandler +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon +import com.vitorpamplona.amethyst.ui.note.PayViaIntentDialog +import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup +import com.vitorpamplona.amethyst.ui.note.ZapIcon +import com.vitorpamplona.amethyst.ui.note.ZappedIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp +import com.vitorpamplona.amethyst.ui.theme.Size14Modifier +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Configuration for zap button behavior and appearance + */ +data class ZapButtonConfig( + val grayTint: Color = Color.Gray, + val iconSize: Dp = Size35dp, + val iconSizeModifier: Modifier = Size20Modifier, + val animationModifier: Modifier = Size14Modifier, + val showUserFinderSubscription: Boolean = false, + val zapAmountChoices: List? = null, + val thankYouText: String? = null, + val buttonText: String? = null, +) + +/** + * Callbacks for zap button events + */ +data class ZapButtonCallbacks( + val onZapComplete: ((Boolean) -> Unit)? = null, +) + +@Composable +fun ReusableZapButton( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, + config: ZapButtonConfig = ZapButtonConfig(), + callbacks: ZapButtonCallbacks = ZapButtonCallbacks(), +) { + var wantsToZap by remember { mutableStateOf?>(null) } + var wantsToPay by remember(baseNote) { + mutableStateOf>(persistentListOf()) + } + + // Makes sure the user is loaded to get his ln address ahead of time (for DVM buttons) + if (config.showUserFinderSubscription) { + baseNote.author?.let { author -> + UserFinderFilterAssemblerSubscription(author, accountViewModel) + } + } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var zappingProgress by remember { mutableFloatStateOf(0f) } + var zapStartingTime by remember { mutableLongStateOf(0L) } + var hasZapped by remember { mutableStateOf(false) } + + Button( + onClick = { + handleZapClick( + baseNote = baseNote, + accountViewModel = accountViewModel, + context = context, + zapAmountChoices = config.zapAmountChoices, + onZapStarts = { zapStartingTime = TimeUtils.now() }, + onZappingProgress = { progress -> + scope.launch { zappingProgress = progress } + }, + onMultipleChoices = { options -> + wantsToZap = options.toImmutableList() + }, + onError = { _, message, toUser -> + scope.launch { + zappingProgress = 0f + accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, toUser) + } + }, + onPayViaIntent = { wantsToPay = it }, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + wantsToZap?.let { zapAmountChoices -> + ZapAmountChoicePopup( + baseNote = baseNote, + zapAmountChoices = zapAmountChoices, + popupYOffset = config.iconSize, + accountViewModel = accountViewModel, + onZapStarts = { zapStartingTime = TimeUtils.now() }, + onDismiss = { + wantsToZap = null + zappingProgress = 0f + }, + onChangeAmount = { + wantsToZap = null + }, + onError = { _, message, user -> + scope.launch { + zappingProgress = 0f + accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user) + } + }, + onProgress = { + scope.launch(Dispatchers.Main) { zappingProgress = it } + }, + onPayViaIntent = { wantsToPay = it }, + ) + } + + if (wantsToPay.isNotEmpty()) { + PayViaIntentDialog( + payingInvoices = wantsToPay, + accountViewModel = accountViewModel, + onClose = { wantsToPay = persistentListOf() }, + onError = { + wantsToPay = persistentListOf() + scope.launch { + zappingProgress = 0f + accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it) + } + }, + justShowError = { + scope.launch { + accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it) + } + }, + ) + } + + // Zap Icon and Progress + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = config.iconSizeModifier, + ) { + if (zappingProgress > 0.00001 && zappingProgress < 0.99999) { + Spacer(ModifierWidth3dp) + + val animatedProgress by animateFloatAsState( + targetValue = zappingProgress, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + label = "ZapIconIndicator", + ) + + ObserveZapIcon( + baseNote, + accountViewModel, + zapStartingTime, + ) { wasZappedByLoggedInUser -> + CrossfadeIfEnabled( + targetState = wasZappedByLoggedInUser.value, + label = "ZapIcon", + accountViewModel = accountViewModel, + ) { + if (it) { + ZappedIcon(config.iconSizeModifier) + } else { + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = config.animationModifier, + strokeWidth = 2.dp, + color = config.grayTint, + ) + } + } + } + } else { + ObserveZapIcon( + baseNote, + accountViewModel, + ) { wasZappedByLoggedInUser -> + LaunchedEffect(wasZappedByLoggedInUser.value) { + hasZapped = wasZappedByLoggedInUser.value + callbacks.onZapComplete?.invoke(wasZappedByLoggedInUser.value) + + if (wasZappedByLoggedInUser.value && !accountViewModel.account.hasDonatedInThisVersion()) { + delay(1000) + accountViewModel.markDonatedInThisVersion() + } + } + + CrossfadeIfEnabled( + targetState = wasZappedByLoggedInUser.value, + label = "ZapIcon", + accountViewModel = accountViewModel, + ) { + if (it) { + ZappedIcon(config.iconSizeModifier) + } else { + ZapIcon(config.iconSizeModifier, config.grayTint) + } + } + } + } + } + + val displayText = + when { + hasZapped -> config.thankYouText ?: stringRes(id = R.string.thank_you) + config.buttonText != null -> config.buttonText + else -> stringRes(id = R.string.donate_now) + } + + Text(text = displayText) + } +} + +private fun handleZapClick( + baseNote: Note, + accountViewModel: AccountViewModel, + context: Context, + zapAmountChoices: List?, + onZapStarts: () -> Unit, + onZappingProgress: (Float) -> Unit, + onMultipleChoices: (List) -> Unit, + onError: (String, String, User?) -> Unit, + onPayViaIntent: (ImmutableList) -> Unit, +) { + if (baseNote.isDraft()) { + accountViewModel.toastManager.toast( + R.string.draft_note, + R.string.it_s_not_possible_to_zap_to_a_draft_note, + ) + return + } + + val choices = zapAmountChoices ?: accountViewModel.zapAmountChoices() + + if (choices.isEmpty()) { + accountViewModel.toastManager.toast( + stringRes(context, R.string.error_dialog_zap_error), + stringRes(context, R.string.no_zap_amount_setup_long_press_to_change), + ) + } else if (!accountViewModel.isWriteable()) { + accountViewModel.toastManager.toast( + stringRes(context, R.string.error_dialog_zap_error), + stringRes(context, R.string.login_with_a_private_key_to_be_able_to_send_zaps), + ) + } else if (choices.size == 1) { + val amount = choices.first() + + if (amount > 1100 || zapAmountChoices != null) { + onZapStarts() + accountViewModel.zap( + baseNote, + amount * 1000, + null, + "", + context, + showErrorIfNoLnAddress = false, + onError = onError, + onProgress = { onZappingProgress(it) }, + onPayViaIntent = onPayViaIntent, + ) + } else { + onMultipleChoices(listOf(1000L, 5_000L, 10_000L)) + } + } else { + if (choices.any { it > 1100 } || zapAmountChoices != null) { + onMultipleChoices(choices) + } else { + onMultipleChoices(listOf(1000L, 5_000L, 10_000L)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentState.kt index f84a96c83..8796d527b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/FeedContentState.kt @@ -50,7 +50,7 @@ class FeedContentState( // Simple counter that changes when it needs to invalidate everything private val _scrollToTop = MutableStateFlow(0) val scrollToTop = _scrollToTop.asStateFlow() - var scrolltoTopPending = false + var scrollToTopPending = false private var lastFeedKey: Any? = null @@ -59,14 +59,14 @@ class FeedContentState( val lastNoteCreatedAtWhenFullyLoaded = MutableStateFlow(null) fun sendToTop() { - if (scrolltoTopPending) return + if (scrollToTopPending) return - scrolltoTopPending = true + scrollToTopPending = true viewModelScope.launch(Dispatchers.IO) { _scrollToTop.emit(_scrollToTop.value + 1) } } suspend fun sentToTop() { - scrolltoTopPending = false + scrollToTopPending = false } private fun refresh() { @@ -107,9 +107,10 @@ class FeedContentState( private fun updateFeed(notes: ImmutableList) { if (notes.size >= localFilter.limit()) { - val lastNomeTime = notes.lastOrNull { it.event != null }?.createdAt() - if (lastNomeTime != lastNoteCreatedAtWhenFullyLoaded.value) { - lastNoteCreatedAtWhenFullyLoaded.tryEmit(lastNomeTime) + // feeds might not be sorted by created at, so full search + val lastNoteTime = notes.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } + if (lastNoteTime != lastNoteCreatedAtWhenFullyLoaded.value) { + lastNoteCreatedAtWhenFullyLoaded.tryEmit(lastNoteTime) } } @@ -128,7 +129,8 @@ class FeedContentState( fun deleteFromFeed(deletedNotes: Set) { val feed = _feedContent.value if (feed is FeedState.Loaded) { - updateFeed((feed.feed.value.list - deletedNotes).toImmutableList()) + val notes = (feed.feed.value.list - deletedNotes).toImmutableList() + updateFeed(notes) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt index a35b9d88e..ebb6964e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt @@ -37,7 +37,7 @@ fun WatchScrollToTop( val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle() LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && feedContentState.scrolltoTopPending) { + if (scrollToTop > 0 && feedContentState.scrollToTopPending) { listState.scrollToItem(index = 0) feedContentState.sentToTop() } @@ -52,7 +52,7 @@ fun WatchScrollToTop( val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle() LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && feedContentState.scrolltoTopPending) { + if (scrollToTop > 0 && feedContentState.scrollToTopPending) { listState.scrollToItem(index = 0) feedContentState.sentToTop() } @@ -82,7 +82,7 @@ fun WatchScrollToTop( val scrollToTop by videoFeedContentState.scrollToTop.collectAsStateWithLifecycle() LaunchedEffect(scrollToTop) { - if (scrollToTop > 0 && videoFeedContentState.scrolltoTopPending) { + if (scrollToTop > 0 && videoFeedContentState.scrollToTopPending) { pagerState.scrollToPage(page = 0) videoFeedContentState.sentToTop() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt index 7fd70dcc1..70c5afa7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ZapTheDevsCard.kt @@ -20,84 +20,54 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import android.content.Context -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withLink import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote -import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.ReusableZapButton +import com.vitorpamplona.amethyst.ui.components.ZapButtonConfig import com.vitorpamplona.amethyst.ui.components.appendLink import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.CloseIcon -import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon -import com.vitorpamplona.amethyst.ui.note.PayViaIntentDialog -import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup -import com.vitorpamplona.amethyst.ui.note.ZapIcon -import com.vitorpamplona.amethyst.ui.note.ZappedIcon import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size14Modifier import com.vitorpamplona.amethyst.ui.theme.Size20Modifier -import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @Preview @@ -286,214 +256,17 @@ fun ZapDonationButton( baseNote: Note, grayTint: Color, accountViewModel: AccountViewModel, - iconSize: Dp = Size35dp, - iconSizeModifier: Modifier = Size20Modifier, - animationModifier: Modifier = Size14Modifier, nav: INav, ) { - var wantsToZap by remember { mutableStateOf?>(null) } - var wantsToPay by - remember(baseNote) { - mutableStateOf>( - persistentListOf(), - ) - } + val config = + ZapButtonConfig( + grayTint = grayTint, + ) - val context = LocalContext.current - val scope = rememberCoroutineScope() - - var zappingProgress by remember { mutableFloatStateOf(0f) } - var zapStartingTime by remember { mutableLongStateOf(0L) } - - var hasZapped by remember { mutableStateOf(false) } - - Button( - onClick = { - customZapClick( - baseNote, - accountViewModel, - context, - onZapStarts = { zapStartingTime = TimeUtils.now() }, - onZappingProgress = { progress: Float -> - scope.launch { zappingProgress = progress } - }, - onMultipleChoices = { options -> wantsToZap = options.toImmutableList() }, - onError = { _, message, toUser -> - scope.launch { - zappingProgress = 0f - accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, toUser) - } - }, - onPayViaIntent = { wantsToPay = it }, - ) - }, - modifier = Modifier.fillMaxWidth(), - ) { - wantsToZap?.let { - ZapAmountChoicePopup( - baseNote = baseNote, - zapAmountChoices = it, - popupYOffset = iconSize, - accountViewModel = accountViewModel, - onZapStarts = { zapStartingTime = TimeUtils.now() }, - onDismiss = { - wantsToZap = null - zappingProgress = 0f - }, - onChangeAmount = { - wantsToZap = null - }, - onError = { _, message, user -> - scope.launch { - zappingProgress = 0f - accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user) - } - }, - onProgress = { - scope.launch(Dispatchers.Main) { zappingProgress = it } - }, - onPayViaIntent = { wantsToPay = it }, - ) - } - - if (wantsToPay.isNotEmpty()) { - PayViaIntentDialog( - payingInvoices = wantsToPay, - accountViewModel = accountViewModel, - onClose = { wantsToPay = persistentListOf() }, - onError = { - wantsToPay = persistentListOf() - scope.launch { - zappingProgress = 0f - accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it) - } - }, - justShowError = { - scope.launch { - accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it) - } - }, - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = iconSizeModifier, - ) { - if (zappingProgress > 0.00001 && zappingProgress < 0.99999) { - Spacer(ModifierWidth3dp) - - val animatedProgress by animateFloatAsState( - targetValue = zappingProgress, - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - label = "ZapIconIndicator", - ) - - ObserveZapIcon( - baseNote, - accountViewModel, - zapStartingTime, - ) { wasZappedByLoggedInUser -> - CrossfadeIfEnabled(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon", accountViewModel = accountViewModel) { - if (it) { - ZappedIcon(iconSizeModifier) - } else { - CircularProgressIndicator( - progress = { animatedProgress }, - modifier = animationModifier, - strokeWidth = 2.dp, - color = grayTint, - ) - } - } - } - } else { - ObserveZapIcon( - baseNote, - accountViewModel, - ) { wasZappedByLoggedInUser -> - LaunchedEffect(wasZappedByLoggedInUser.value) { - hasZapped = wasZappedByLoggedInUser.value - if (wasZappedByLoggedInUser.value && !accountViewModel.account.hasDonatedInThisVersion()) { - delay(1000) - accountViewModel.markDonatedInThisVersion() - } - } - - CrossfadeIfEnabled(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon", accountViewModel = accountViewModel) { - if (it) { - ZappedIcon(iconSizeModifier) - } else { - ZapIcon(iconSizeModifier, grayTint) - } - } - } - } - } - - if (hasZapped) { - Text(text = stringRes(id = R.string.thank_you)) - } else { - Text(text = stringRes(id = R.string.donate_now)) - } - } -} - -fun customZapClick( - baseNote: Note, - accountViewModel: AccountViewModel, - context: Context, - onZapStarts: () -> Unit, - onZappingProgress: (Float) -> Unit, - onMultipleChoices: (List) -> Unit, - onError: (String, String, User?) -> Unit, - onPayViaIntent: (ImmutableList) -> Unit, -) { - if (baseNote.isDraft()) { - accountViewModel.toastManager.toast( - R.string.draft_note, - R.string.it_s_not_possible_to_zap_to_a_draft_note, - ) - return - } - - val choices = accountViewModel.zapAmountChoices() - - if (choices.isEmpty()) { - accountViewModel.toastManager.toast( - stringRes(context, R.string.error_dialog_zap_error), - stringRes(context, R.string.no_zap_amount_setup_long_press_to_change), - ) - } else if (!accountViewModel.isWriteable()) { - accountViewModel.toastManager.toast( - stringRes(context, R.string.error_dialog_zap_error), - stringRes(context, R.string.login_with_a_private_key_to_be_able_to_send_zaps), - ) - } else if (choices.size == 1) { - val amount = choices.first() - - if (amount > 1100) { - onZapStarts() - accountViewModel.zap( - baseNote, - amount * 1000, - null, - "", - context, - showErrorIfNoLnAddress = false, - onError = onError, - onProgress = { onZappingProgress(it) }, - onPayViaIntent = onPayViaIntent, - ) - } else { - onMultipleChoices(listOf(1000L, 5_000L, 10_000L)) - // recommends amounts for a monthly release. - } - } else if (choices.size > 1) { - if (choices.any { it > 1100 }) { - onMultipleChoices(choices) - } else { - onMultipleChoices(listOf(1000L, 5_000L, 10_000L)) - } - } + ReusableZapButton( + baseNote = baseNote, + accountViewModel = accountViewModel, + nav = nav, + config = config, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt index f690866fd..71e3ab8bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/AppDefinition.kt @@ -91,16 +91,16 @@ fun RenderAppDefinition( withContext(Dispatchers.Default) { metadata = noteEvent.appMetaData() } } - metadata?.let { + metadata?.let { theAppMetadata -> Box { val clipboardManager = LocalClipboardManager.current val uri = LocalUriHandler.current - if (!it.banner.isNullOrBlank()) { + if (!theAppMetadata.banner.isNullOrBlank()) { var zoomImageDialogOpen by remember { mutableStateOf(false) } AsyncImage( - model = it.banner, + model = theAppMetadata.banner, contentDescription = stringRes(id = R.string.profile_image), contentScale = ContentScale.FillWidth, modifier = @@ -109,13 +109,13 @@ fun RenderAppDefinition( .height(125.dp) .combinedClickable( onClick = {}, - onLongClick = { clipboardManager.setText(AnnotatedString(it.banner!!)) }, + onLongClick = { clipboardManager.setText(AnnotatedString(theAppMetadata.banner!!)) }, ), ) if (zoomImageDialogOpen) { ZoomableImageDialog( - imageUrl = RichTextParser.parseImageOrVideo(it.banner!!), + imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.banner!!), onDismiss = { zoomImageDialogOpen = false }, accountViewModel = accountViewModel, ) @@ -144,12 +144,11 @@ fun RenderAppDefinition( verticalAlignment = Alignment.Bottom, ) { var zoomImageDialogOpen by remember { mutableStateOf(false) } - Box(Modifier.size(100.dp)) { - it.picture?.let { picture -> + theAppMetadata.picture?.let { picture -> AsyncImage( model = picture, - contentDescription = it.name, + contentDescription = theAppMetadata.name, contentScale = ContentScale.FillWidth, modifier = Modifier @@ -165,15 +164,15 @@ fun RenderAppDefinition( onLongClick = { clipboardManager.setText(AnnotatedString(picture)) }, ), ) - } - } - if (zoomImageDialogOpen) { - ZoomableImageDialog( - imageUrl = RichTextParser.parseImageOrVideo(it.banner!!), - onDismiss = { zoomImageDialogOpen = false }, - accountViewModel = accountViewModel, - ) + if (zoomImageDialogOpen) { + ZoomableImageDialog( + imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.picture!!), + onDismiss = { zoomImageDialogOpen = false }, + accountViewModel = accountViewModel, + ) + } + } } Spacer(Modifier.weight(1f)) @@ -186,7 +185,7 @@ fun RenderAppDefinition( ) {} } - val name = remember(it) { it.anyName() } + val name = remember(theAppMetadata) { theAppMetadata.anyName() } name?.let { Row( verticalAlignment = Alignment.Bottom, @@ -204,7 +203,7 @@ fun RenderAppDefinition( } } - val website = remember(it) { it.website } + val website = remember(theAppMetadata) { theAppMetadata.website } if (!website.isNullOrEmpty()) { Row(verticalAlignment = Alignment.CenterVertically) { LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText) @@ -217,7 +216,7 @@ fun RenderAppDefinition( } } - it.about?.let { + theAppMetadata.about?.let { Row( modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt index 37c224244..dcb7e3e69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt @@ -86,7 +86,7 @@ class DiscoveryFollowsSetsAndLiveStreamsSubAssembler2( key.feedStates.discoverLive.lastNoteCreatedAtWhenFullyLoaded, ) { Any() - }.sample(1000).collectLatest { + }.sample(5000).collectLatest { invalidateFilters() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryLongFormClassifiedsAndDVMSubAssembler1.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryLongFormClassifiedsAndDVMSubAssembler1.kt index 00d94583e..9cb50a1d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryLongFormClassifiedsAndDVMSubAssembler1.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryLongFormClassifiedsAndDVMSubAssembler1.kt @@ -89,7 +89,7 @@ class DiscoveryLongFormClassifiedsAndDVMSubAssembler1( key.feedStates.discoverMarketplace.lastNoteCreatedAtWhenFullyLoaded, ) { Any() - }.sample(1000).collectLatest { + }.sample(5000).collectLatest { invalidateFilters() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryPublicChatsAndCommunitiesSubAssembler3.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryPublicChatsAndCommunitiesSubAssembler3.kt index ef4fbb011..18feac606 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryPublicChatsAndCommunitiesSubAssembler3.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryPublicChatsAndCommunitiesSubAssembler3.kt @@ -85,7 +85,7 @@ class DiscoveryPublicChatsAndCommunitiesSubAssembler3( key.feedStates.discoverCommunities.lastNoteCreatedAtWhenFullyLoaded, ) { Any() - }.sample(1000).collectLatest { + }.sample(5000).collectLatest { invalidateFilters() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index 50d538d3d..80826edec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -20,30 +20,21 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.BottomStart @@ -61,25 +52,18 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription -import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.MyAsyncImage +import com.vitorpamplona.amethyst.ui.components.ReusableZapButton +import com.vitorpamplona.amethyst.ui.components.ZapButtonConfig import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.ObserveZapIcon -import com.vitorpamplona.amethyst.ui.note.PayViaIntentDialog import com.vitorpamplona.amethyst.ui.note.WatchNoteEvent -import com.vitorpamplona.amethyst.ui.note.ZapAmountChoicePopup -import com.vitorpamplona.amethyst.ui.note.ZapIcon -import com.vitorpamplona.amethyst.ui.note.ZappedIcon import com.vitorpamplona.amethyst.ui.note.elements.BannerImage -import com.vitorpamplona.amethyst.ui.note.elements.customZapClick import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.screen.RenderFeedState import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState @@ -88,9 +72,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.DVMCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.dal.NIP90ContentDiscoveryFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp import com.vitorpamplona.amethyst.ui.theme.SimpleImage75Modifier -import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse @@ -98,12 +80,6 @@ import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent -import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch @Composable fun DvmContentDiscoveryScreen( @@ -453,160 +429,23 @@ fun ZapDVMButton( grayTint: Color, accountViewModel: AccountViewModel, iconSize: Dp = Size35dp, - iconSizeModifier: Modifier = Size20Modifier, - animationSize: Dp = 14.dp, nav: INav, ) { - val noteAuthor = baseNote.author ?: return + val config = + ZapButtonConfig( + grayTint = grayTint, + iconSize = iconSize, + showUserFinderSubscription = true, + zapAmountChoices = listOf(amount / 1000), + buttonText = "Zap ${(amount / 1000)} sats to the DVM", + ) - var wantsToZap by remember { mutableStateOf?>(null) } - var wantsToPay by - remember(baseNote) { - mutableStateOf>( - persistentListOf(), - ) - } - - // Makes sure the user is loaded to get his ln address ahead of time. - UserFinderFilterAssemblerSubscription(noteAuthor, accountViewModel) - - val context = LocalContext.current - val scope = rememberCoroutineScope() - - var zappingProgress by remember { mutableFloatStateOf(0f) } - var zapStartingTime by remember { mutableLongStateOf(0L) } - var hasZapped by remember { mutableStateOf(false) } - - Button( - onClick = { - customZapClick( - baseNote, - accountViewModel, - context, - onZapStarts = { zapStartingTime = TimeUtils.now() }, - onZappingProgress = { progress: Float -> - scope.launch { zappingProgress = progress } - }, - onMultipleChoices = { options -> wantsToZap = options }, - onError = { _, message, toUser -> - scope.launch { - zappingProgress = 0f - accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, toUser) - } - }, - onPayViaIntent = { wantsToPay = it }, - ) - }, - modifier = Modifier.fillMaxWidth(), - ) { - if (wantsToZap != null) { - ZapAmountChoicePopup( - baseNote = baseNote, - zapAmountChoices = persistentListOf(amount / 1000), - popupYOffset = iconSize, - accountViewModel = accountViewModel, - onZapStarts = { zapStartingTime = TimeUtils.now() }, - onDismiss = { - wantsToZap = null - zappingProgress = 0f - }, - onChangeAmount = { - wantsToZap = null - }, - onError = { _, message, user -> - scope.launch { - zappingProgress = 0f - accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, message, user) - } - }, - onProgress = { - scope.launch(Dispatchers.Main) { zappingProgress = it } - }, - onPayViaIntent = { wantsToPay = it }, - ) - } - - if (wantsToPay.isNotEmpty()) { - PayViaIntentDialog( - payingInvoices = wantsToPay, - accountViewModel = accountViewModel, - onClose = { wantsToPay = persistentListOf() }, - onError = { - wantsToPay = persistentListOf() - scope.launch { - zappingProgress = 0f - accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it) - } - }, - justShowError = { - scope.launch { - accountViewModel.toastManager.toast(R.string.error_dialog_zap_error, it) - } - }, - ) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = iconSizeModifier, - ) { - if (zappingProgress > 0.00001 && zappingProgress < 0.99999) { - Spacer(ModifierWidth3dp) - - val animatedProgress by animateFloatAsState( - targetValue = zappingProgress, - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - label = "ZapIconIndicator", - ) - - ObserveZapIcon( - baseNote, - accountViewModel, - zapStartingTime, - ) { wasZappedByLoggedInUser -> - CrossfadeIfEnabled(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon", accountViewModel = accountViewModel) { - if (it) { - ZappedIcon(iconSizeModifier) - } else { - CircularProgressIndicator( - progress = { animatedProgress }, - modifier = remember { Modifier.size(animationSize) }, - strokeWidth = 2.dp, - color = grayTint, - ) - } - } - } - } else { - ObserveZapIcon( - baseNote, - accountViewModel, - ) { wasZappedByLoggedInUser -> - LaunchedEffect(wasZappedByLoggedInUser.value) { - hasZapped = wasZappedByLoggedInUser.value - if (wasZappedByLoggedInUser.value && !accountViewModel.account.hasDonatedInThisVersion()) { - delay(1000) - accountViewModel.markDonatedInThisVersion() - } - } - - CrossfadeIfEnabled(targetState = wasZappedByLoggedInUser.value, label = "ZapIcon", accountViewModel = accountViewModel) { - if (it) { - ZappedIcon(iconSizeModifier) - } else { - ZapIcon(iconSizeModifier, grayTint) - } - } - } - } - } - - if (hasZapped) { - Text(text = stringRes(id = R.string.thank_you)) - } else { - Text(text = "Zap " + (amount / 1000).toString() + " sats to the DVM") // stringRes(id = R.string.donate_now)) - } - } + ReusableZapButton( + baseNote = baseNote, + accountViewModel = accountViewModel, + nav = nav, + config = config, + ) } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt index 10803e6f1..f3e8ae122 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -102,12 +102,12 @@ class HomeOutboxEventsEoseManager( } }, key.account.scope.launch(Dispatchers.Default) { - key.feedState.homeNewThreads.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest { + key.feedState.homeNewThreads.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { invalidateFilters() } }, key.account.scope.launch(Dispatchers.Default) { - key.feedState.homeReplies.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest { + key.feedState.homeReplies.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { invalidateFilters() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index 59e3e7801..a22766245 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -78,6 +78,9 @@ class CardFeedContentState( val lastNoteCreatedAtWhenFullyLoaded = MutableStateFlow(null) + private var lastAccount: Account? = null + private var lastNotes: Set? = null + fun sendToTop() { if (scrolltoTopPending) return @@ -89,8 +92,14 @@ class CardFeedContentState( scrolltoTopPending = false } - private var lastAccount: Account? = null - private var lastNotes: Set? = null + fun visibleNotes(): List { + val currentState = _feedContent.value + return if (currentState is CardFeedState.Loaded) { + currentState.feed.value.list + } else { + emptyList() + } + } fun lastNoteCreatedAtIfFilled() = lastNoteCreatedAtWhenFullyLoaded.value @@ -305,8 +314,16 @@ class CardFeedContentState( private fun updateFeed(notes: ImmutableList) { if (notes.size >= localFilter.limit()) { - val lastNomeTime = notes.lastOrNull()?.createdAt() - if (lastNomeTime != lastNoteCreatedAtWhenFullyLoaded.value) { + val lastNoteTime = + notes.minOfOrNull { + val createdAt = it.createdAt() + if (createdAt > 0L) { + createdAt + } else { + Long.MAX_VALUE + } + } + if (lastNoteTime != lastNoteCreatedAtWhenFullyLoaded.value) { lastNoteCreatedAtWhenFullyLoaded.tryEmit(notes.lastOrNull()?.createdAt()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt index 274fc334b..42d758ae4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileFollowers.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -31,8 +32,13 @@ val UserProfileFollowersKinds = listOf(ContactListEvent.KIND) fun filterUserProfileFollowers( user: User, since: SincePerRelayMap?, -): List = - user.inboxRelays().map { +): List { + val relays = + user.inboxRelays()?.ifEmpty { null } + ?: user.relaysBeingUsed.keys.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(user.pubkeyHex) + + return relays.map { RelayBasedFilter( relay = it, filter = @@ -43,3 +49,4 @@ fun filterUserProfileFollowers( ), ) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt index a1a97d332..869640bda 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileMedia.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent @@ -40,8 +41,13 @@ val UserProfileMediaKinds = fun filterUserProfileMedia( user: User, since: SincePerRelayMap?, -): List = - user.outboxRelays().map { relay -> +): List { + val relays = + user.outboxRelays()?.ifEmpty { null } + ?: user.relaysBeingUsed.keys.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(user.pubkeyHex) + + return relays.map { relay -> RelayBasedFilter( relay = relay, filter = @@ -53,3 +59,4 @@ fun filterUserProfileMedia( ), ) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt index 57b533a36..ffaea839a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent @@ -66,9 +67,13 @@ val UserProfilePostKinds2 = fun filterUserProfilePosts( user: User, since: SincePerRelayMap?, -): List = - user - .outboxRelays() +): List { + val relays = + user.outboxRelays()?.ifEmpty { null } + ?: user.relaysBeingUsed.keys.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(user.pubkeyHex) + + return relays .map { relay -> listOf( RelayBasedFilter( @@ -93,3 +98,4 @@ fun filterUserProfilePosts( ), ) }.flatten() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt index ffd963542..11db8cf3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt @@ -20,19 +20,26 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import kotlin.collections.ifEmpty val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND) fun filterUserProfileZapsReceived( user: User, since: SincePerRelayMap?, -): List = - user.inboxRelays().map { relay -> +): List { + val relays = + user.inboxRelays()?.ifEmpty { null } + ?: user.relaysBeingUsed.keys.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(user.pubkeyHex) + + return relays.map { relay -> RelayBasedFilter( relay = relay, filter = @@ -44,3 +51,4 @@ fun filterUserProfileZapsReceived( ), ) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt index 0b028e741..e7e291bd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/UserProfileMetadataFilterSubAssembler.kt @@ -20,11 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.utils.mapOfSet +import kotlin.collections.ifEmpty class UserProfileMetadataFilterSubAssembler( client: NostrClient, @@ -37,7 +39,12 @@ class UserProfileMetadataFilterSubAssembler( val userPerRelay = mapOfSet { keys.mapTo(mutableSetOf()) { key -> key.user }.forEach { user -> - user.outboxRelays().forEach { relay -> + val relays = + user.outboxRelays()?.ifEmpty { null } + ?: user.relaysBeingUsed.keys.ifEmpty { null } + ?: LocalCache.relayHints.hintsForKey(user.pubkeyHex) + + relays.forEach { relay -> add(relay, user.pubkeyHex) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt index 99714ded7..ec18f4503 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassembl import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job @@ -63,8 +64,8 @@ class VideoOutboxEventsFilterSubAssembler( is AllCommunitiesTopNavPerRelayFilterSet -> filterPictureAndVideoByAllCommunities(feedSettings, since, defaultSince) is AllFollowsTopNavPerRelayFilterSet -> filterPictureAndVideoByFollows(feedSettings, since, defaultSince) is AuthorsTopNavPerRelayFilterSet -> filterPictureAndVideoByAuthors(feedSettings, since, defaultSince) - is GlobalTopNavPerRelayFilterSet -> filterPictureAndVideoGlobal(feedSettings, since, defaultSince) - is HashtagTopNavPerRelayFilterSet -> filterPictureAndVideoByHashtag(feedSettings, since, defaultSince) + is GlobalTopNavPerRelayFilterSet -> filterPictureAndVideoGlobal(feedSettings, since, defaultSince ?: TimeUtils.oneWeekAgo()) + is HashtagTopNavPerRelayFilterSet -> filterPictureAndVideoByHashtag(feedSettings, since, defaultSince ?: TimeUtils.oneMonthAgo()) is LocationTopNavPerRelayFilterSet -> filterPictureAndVideoByGeohash(feedSettings, since, defaultSince) is MutedAuthorsTopNavPerRelayFilterSet -> filterPictureAndVideoByAuthors(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterPictureAndVideoByCommunity(feedSettings, since, defaultSince) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9987bbbc9..95221f910 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,12 +13,12 @@ benchmark = "1.4.0" benchmarkJunit4 = "1.4.0" biometricKtx = "1.2.0-alpha05" coil = "3.3.0" -composeBom = "2025.07.00" -coreKtx = "1.16.0" +composeBom = "2025.08.00" +coreKtx = "1.17.0" datastore = "1.1.7" espressoCore = "3.7.0" firebaseBom = "34.1.0" -fragmentKtx = "1.8.8" +fragmentKtx = "1.8.9" gms = "4.4.3" jacksonModuleKotlin = "2.19.2" jna = "5.17.0" diff --git a/quartz/build.gradle b/quartz/build.gradle index 59399786a..60c5ab93c 100644 --- a/quartz/build.gradle +++ b/quartz/build.gradle @@ -76,6 +76,9 @@ dependencies { // Normalizes URLs api libs.rfc3986.normalizer + // Websockets API + implementation libs.okhttp + testImplementation libs.junit testImplementation libs.secp256k1.kmp.jni.jvm androidTestImplementation platform(libs.androidx.compose.bom) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 4dd823d66..ac17b0764 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -122,8 +122,6 @@ class NostrClient( } } - fun allAvailableRelays() = relayPool.getAll() - // Reconnects all relays that may have disconnected fun connect() { isActive = true @@ -138,10 +136,7 @@ class NostrClient( @Synchronized fun reconnect(onlyIfChanged: Boolean = false) { if (onlyIfChanged) { - relayPool.getAllNeedsToReconnect().forEach { - it.disconnect() - } - relayPool.connect() + relayPool.reconnectIfNeedsToORIfItIsTime() } else { relayPool.disconnect() relayPool.connect() @@ -236,6 +231,9 @@ class NostrClient( relayPool.connectIfDisconnected(relay) } } + + // wakes up all the other relays + relayPool.reconnectIfNeedsToORIfItIsTime() } } @@ -269,6 +267,9 @@ class NostrClient( relayPool.connectIfDisconnected(relay) } } + + // wakes up all the other relays + relayPool.reconnectIfNeedsToORIfItIsTime() } } @@ -278,6 +279,9 @@ class NostrClient( ) { if (isActive) { relayPool.getRelay(connectedRelay)?.send(event) + + // wakes up all the other relays + relayPool.reconnectIfNeedsToORIfItIsTime() } } @@ -288,6 +292,9 @@ class NostrClient( eventOutbox.markAsSending(event, relayList) if (isActive) { relayPool.send(event, relayList) + + // wakes up all the other relays + relayPool.reconnectIfNeedsToORIfItIsTime() } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt new file mode 100644 index 000000000..e06da52a9 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientSubscription.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.client + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.RandomInstance + +class NostrClientSubscription( + val client: NostrClient, + val filter: () -> Map> = { emptyMap() }, + val onEvent: (event: Event) -> Unit = {}, +) : IRelayClientListener { + private val subId = RandomInstance.randomChars(10) + + init { + client.subscribe(this) + } + + override fun onEvent( + relay: IRelayClient, + subId: String, + event: Event, + arrivalTime: Long, + afterEOSE: Boolean, + ) { + if (this.subId == subId) { + onEvent(event) + } + } + + /** + * Creates or Updates the filter with relays. This method should be called + * everytime the filter changes. + */ + fun updateFilter() = client.openReqSubscription(subId, filter()) + + fun closeSubscription() = client.close(subId) +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt index f745e96b5..47605b1c6 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/pool/RelayPool.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.LargeCache +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -70,16 +71,24 @@ class RelayPool( fun getRelay(url: NormalizedRelayUrl): IRelayClient? = relays.get(url) - fun getAll() = statusFlow.value.connected + var lastReconnectCall = TimeUtils.now() - fun getAllNeedsToReconnect() = relays.filter { url, relay -> relay.needsToReconnect() } - - fun reconnectsRelaysThatNeedTo() { - relays.forEach { url, relay -> - if (relay.needsToReconnect()) { - relay.disconnect() - relay.connect() + fun reconnectIfNeedsToORIfItIsTime() { + if (lastReconnectCall < TimeUtils.tenSecondsAgo()) { + relays.forEach { url, relay -> + if (relay.isConnected()) { + if (relay.needsToReconnect()) { + // network has changed, force reconnect + relay.disconnect() + relay.connect() + } + } else { + // relay is not connected. Connect if it is time + relay.connectAndSyncFiltersIfDisconnected() + } } + + lastReconnectCall = TimeUtils.now() } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index 7555915d9..54c1f7e5e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -26,7 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient -import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient.Companion.DELAY_TO_RECONNECT_IN_MSECS import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage @@ -84,7 +83,7 @@ open class BasicRelayClient( ) : IRelayClient { companion object { // waits 3 minutes to reconnect once things fail - const val DELAY_TO_RECONNECT_IN_MSECS = 500L + const val DELAY_TO_RECONNECT_IN_SECS = 1 const val EVENT_MESSAGE_PREFIX = "[\"${EventMessage.LABEL}\"" } @@ -94,8 +93,8 @@ open class BasicRelayClient( private var isReady: Boolean = false private var usingCompression: Boolean = false - private var lastConnectTentative: Long = 0L // the beginning of time. - private var delayToConnect = DELAY_TO_RECONNECT_IN_MSECS + private var lastConnectTentativeInSeconds: Long = 0L // the beginning of time. + private var delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS private var afterEOSEPerSubscription = mutableMapOf() @@ -138,7 +137,7 @@ open class BasicRelayClient( Log.d(logTag, "Connecting...") - lastConnectTentative = TimeUtils.now() + lastConnectTentativeInSeconds = TimeUtils.now() socket = socketBuilder.build(url, MyWebsocketListener(onConnected)) socket?.connect() @@ -207,22 +206,30 @@ open class BasicRelayClient( code: Int?, response: String?, ) { - socket?.disconnect() // 1000, "Normal close" + // socket is already closed + // socket?.disconnect() - // checks if this is an actual failure. Closing the socket generates an onFailure as well. - if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) { - stats.newError(response ?: t.message ?: "onFailure event from server: ${t.javaClass.simpleName}") + if (socket == null) { + // comes from the disconnect action. + listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED) + } else { + // checks if this is an actual failure. Closing the socket generates an onFailure as well. + if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) { + stats.newError(response ?: t.message ?: "onFailure event from server: ${t.javaClass.simpleName}") + } + + // Failures disconnect the relay. + markConnectionAsClosed() + + Log.w(logTag, "OnFailure $code $response ${t.message}") + + if (code == 403 || code == 502 || code == 503 || code == 402 || code == 410 || t.message == "SOCKS: Host unreachable") { + dontTryAgainForALongTime() + } + + listener.onRelayStateChange(this@BasicRelayClient, RelayState.DISCONNECTED) + listener.onError(this@BasicRelayClient, "", Error("WebSocket Failure. Response: $code $response. Exception: ${t.message}", t)) } - - // Failures disconnect the relay. - markConnectionAsClosed() - - Log.w(logTag, "OnFailure $code $response ${t.message} $socket") - listener.onError( - this@BasicRelayClient, - "", - Error("WebSocket Failure. Response: $code $response. Exception: ${t.message}", t), - ) } } @@ -261,7 +268,7 @@ open class BasicRelayClient( this.usingCompression = usingCompression // resets any extra delays added during on offline state - this.delayToConnect = DELAY_TO_RECONNECT_IN_MSECS + this.delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS stats.pingInMs = pingInMs } @@ -320,7 +327,7 @@ open class BasicRelayClient( } private fun processAuth(msg: AuthMessage) { - // Log.d(logTag, "Auth $newMessage") + Log.d(logTag, "Auth ${msg.challenge}") listener.onAuth(this@BasicRelayClient, msg.challenge) } @@ -344,8 +351,8 @@ open class BasicRelayClient( override fun disconnect() { Log.d(logTag, "Disconnecting...") - lastConnectTentative = 0L // this is not an error, so prepare to reconnect as soon as requested. - delayToConnect = DELAY_TO_RECONNECT_IN_MSECS + lastConnectTentativeInSeconds = 0L // this is not an error, so prepare to reconnect as soon as requested. + delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS socket?.disconnect() socket = null isReady = false @@ -372,12 +379,7 @@ open class BasicRelayClient( } } } else { - // waits 60 seconds to reconnect after disconnected. - if (TimeUtils.now() > lastConnectTentative + delayToConnect) { - delayToConnect = delayToConnect * 2 - // sends all filters after connection is successful. - connect() - } + connectAndSyncFiltersIfDisconnected() } } @@ -393,25 +395,30 @@ open class BasicRelayClient( } } } else { + connectAndSyncFiltersIfDisconnected() + } + } + + override fun connectAndSyncFiltersIfDisconnected() { + if (!isConnectionStarted() && !connectingMutex.get()) { // waits 60 seconds to reconnect after disconnected. - if (TimeUtils.now() > lastConnectTentative + delayToConnect) { - // sends all filters after connection is successful. - delayToConnect = delayToConnect * 2 + if (TimeUtils.now() > lastConnectTentativeInSeconds + delayToConnectInSeconds) { + upRelayDelayToConnect() connect() } } } - override fun connectAndSyncFiltersIfDisconnected() { - if (socket == null) { - // waits 60 seconds to reconnect after disconnected. - if (TimeUtils.now() > lastConnectTentative + delayToConnect) { - delayToConnect = delayToConnect * 2 - connect() - } + fun upRelayDelayToConnect() { + if (delayToConnectInSeconds < TimeUtils.FIVE_MINUTES) { + delayToConnectInSeconds = delayToConnectInSeconds * 2 } } + fun dontTryAgainForALongTime() { + delayToConnectInSeconds = TimeUtils.ONE_DAY + } + override fun send(event: Event) { listener.onBeforeSend(this@BasicRelayClient, event) @@ -439,8 +446,7 @@ open class BasicRelayClient( writeToSocket(EventCmd.Companion.toJson(event)) } } else { - // automatically sends all filters after connection is successful. - connect() + connectAndSyncFiltersIfDisconnected() } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NoticeMessage.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NoticeMessage.kt index 86e2993cf..ba1ca1499 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NoticeMessage.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/NoticeMessage.kt @@ -29,9 +29,6 @@ class NoticeMessage( const val LABEL = "NOTICE" @JvmStatic - fun parse(msgArray: JsonNode): NoticeMessage = - NoticeMessage( - msgArray.get(1).asText(), - ) + fun parse(msgArray: JsonNode) = NoticeMessage(msgArray.get(1).asText()) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt index d9e80aefa..dd9ec6f7c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt @@ -36,7 +36,7 @@ fun NormalizedRelayUrl.toHttp() = if (url.startsWith("wss://")) { "https${url.drop(3)}" } else if (url.startsWith("ws://")) { - "https${url.drop(2)}" + "http${url.drop(2)}" } else { "https://$url" } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocket.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocket.kt new file mode 100644 index 000000000..434abb021 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/sockets/okhttp/BasicOkHttpWebSocket.kt @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response + +class BasicOkHttpWebSocket( + val url: NormalizedRelayUrl, + val httpClient: OkHttpClient, + val out: WebSocketListener, +) : WebSocket { + private var socket: okhttp3.WebSocket? = null + + override fun needsReconnect() = socket == null + + override fun connect() { + val request = Request.Builder().url(url.url).build() + + val listener = + object : okhttp3.WebSocketListener() { + override fun onOpen( + webSocket: okhttp3.WebSocket, + response: Response, + ) = out.onOpen( + response.receivedResponseAtMillis - response.sentRequestAtMillis, + response.headers["Sec-WebSocket-Extensions"]?.contains("permessage-deflate") ?: false, + ) + + override fun onMessage( + webSocket: okhttp3.WebSocket, + text: String, + ) = out.onMessage(text) + + override fun onClosing( + webSocket: okhttp3.WebSocket, + code: Int, + reason: String, + ) = out.onClosing(code, reason) + + override fun onClosed( + webSocket: okhttp3.WebSocket, + code: Int, + reason: String, + ) = out.onClosed(code, reason) + + override fun onFailure( + webSocket: okhttp3.WebSocket, + t: Throwable, + r: Response?, + ) = out.onFailure(t, r?.code, r?.message) + } + + socket = httpClient.newWebSocket(request, listener) + } + + override fun disconnect() { + socket?.cancel() + socket = null + } + + override fun send(msg: String): Boolean = socket?.send(msg) ?: false + + class Builder( + val httpClient: OkHttpClient, + ) : WebsocketBuilder { + // Called when connecting. + override fun build( + url: NormalizedRelayUrl, + out: WebSocketListener, + ) = BasicOkHttpWebSocket(url, httpClient, out) + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/publishedAt/PublishedAtProvider.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/publishedAt/PublishedAtProvider.kt new file mode 100644 index 000000000..d16102f72 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/publishedAt/PublishedAtProvider.kt @@ -0,0 +1,25 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.tags.publishedAt + +interface PublishedAtProvider { + fun publishedAt(): Long? +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt index 23b131b0b..f561afb53 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag import com.vitorpamplona.quartz.nip19Bech32.addressHints @@ -68,6 +69,7 @@ class LongTextNoteEvent( EventHintProvider, PubKeyHintProvider, AddressHintProvider, + PublishedAtProvider, RootScope, SearchableEvent { override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content @@ -130,7 +132,7 @@ class LongTextNoteEvent( fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) - fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) + override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) companion object { const val KIND = 30023 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/PublishedAtTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/PublishedAtTag.kt index 466b82140..3b7818473 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/PublishedAtTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip23LongContent/tags/PublishedAtTag.kt @@ -32,7 +32,16 @@ class PublishedAtTag { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - return tag[1].toLongOrNull() + val timestamp = tag[1].toLongOrNull() + + if (timestamp == null) return null + + if (timestamp > 3_000_000_000) { + // like in milliseconds + return timestamp / 1000 + } else { + return timestamp + } } @JvmStatic diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt index d0996a1fc..e35cbafef 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag @@ -62,6 +63,7 @@ class WikiNoteEvent( EventHintProvider, AddressHintProvider, PubKeyHintProvider, + PublishedAtProvider, SearchableEvent { override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content @@ -127,7 +129,7 @@ class WikiNoteEvent( fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1) - fun publishedAt() = + override fun publishedAt() = try { tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull() } catch (_: Exception) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt index 2d2b61cc9..71ec35c8c 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip71Video/VideoEvent.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag @@ -45,12 +46,13 @@ abstract class VideoEvent( content: String, sig: HexKey, ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig), + PublishedAtProvider, RootScope { @Transient var iMetas: List? = null fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) - fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) + override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) fun duration() = tags.firstNotNullOfOrNull(DurationTag::parse) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt index f2d77522e..76db75a2a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppDefinitionEvent.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip01Core.tags.kinds.isTaggedKind import com.vitorpamplona.quartz.nip01Core.tags.kinds.kinds +import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip31Alts.alt @@ -46,7 +47,8 @@ class AppDefinitionEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PublishedAtProvider { override fun countMemory(): Long = super.countMemory() + (cachedMetadata?.countMemory() ?: 8L) @Transient private var cachedMetadata: AppMetadata? = null @@ -77,7 +79,7 @@ class AppDefinitionEvent( fun includeKind(kind: Int) = tags.isTaggedKind(kind) - fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) + override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) companion object { const val KIND = 31990 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt index 2857593eb..9ee0072ce 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt @@ -113,8 +113,8 @@ class AppMetadata { fun toJson() = assemble(this) companion object { - fun assemble(data: AppMetadata) = JsonMapper.mapper.writeValueAsString(data) + fun assemble(data: AppMetadata): String = JsonMapper.mapper.writeValueAsString(data) - fun parse(content: String) = JsonMapper.mapper.readValue(content, AppMetadata::class.java) + fun parse(content: String): AppMetadata = JsonMapper.mapper.readValue(content, AppMetadata::class.java) } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt index 0de755531..b0222fca9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip99Classifieds/ClassifiedsEvent.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.containsAllTagNamesWithValues import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.publishedAt.PublishedAtProvider import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag @@ -49,7 +50,8 @@ class ClassifiedsEvent( tags: Array>, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PublishedAtProvider { fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) @@ -68,7 +70,7 @@ class ClassifiedsEvent( fun location() = tags.firstNotNullOfOrNull(LocationTag::parse) - fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) + override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) fun categories() = tags.hashtags() diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt index 45d83bfc9..6f46cb0d9 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -36,6 +36,8 @@ object TimeUtils { fun tenSecondsFromNow() = now() + TEN_SECONDS + fun tenSecondsAgo() = now() - TEN_SECONDS + fun oneMinuteFromNow() = now() + ONE_MINUTE fun oneMinuteAgo() = now() - ONE_MINUTE