Merge branch 'vitorpamplona:main' into profiles-list-management

This commit is contained in:
KotlinGeekDev
2025-08-16 01:34:30 +00:00
committed by GitHub
45 changed files with 888 additions and 594 deletions
+76 -2
View File
@@ -251,10 +251,84 @@ dependencyResolutionManagement {
} }
``` ```
Add the dependency Add the dependency by using one of the versions [here](https://jitpack.io/#vitorpamplona/amethyst/)
```gradle ```gradle
implementation('com.github.vitorpamplona.amethyst:quartz:v0.85.1') implementation('com.github.vitorpamplona.amethyst:quartz:<tag, commit, -SNAPSHOT>')
```
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("<mnemonic>"))
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 ## Contributing
@@ -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.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator 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.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.tor.TorManager import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
@@ -111,7 +112,7 @@ class Amethyst : Application() {
val relayProxyClientConnector = RelayProxyClientConnector(torProxySettingsAnchor, okHttpClients, connManager, client, applicationIOScope) val relayProxyClientConnector = RelayProxyClientConnector(torProxySettingsAnchor, okHttpClients, connManager, client, applicationIOScope)
// Verifies and inserts in the cache from all relays, all subscriptions // 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 // Show messages from the Relay and controls their dismissal
val notifyCoordinator = NotifyCoordinator(client) val notifyCoordinator = NotifyCoordinator(client)
@@ -119,7 +120,7 @@ class Amethyst : Application() {
// Authenticates with relays. // Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationIOScope) 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 // Coordinates all subscriptions for the Nostr Client
val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, applicationIOScope) val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, applicationIOScope)
@@ -216,6 +216,7 @@ import kotlinx.coroutines.withContext
import java.math.BigDecimal import java.math.BigDecimal
import java.util.Locale import java.util.Locale
import kotlin.collections.forEach import kotlin.collections.forEach
import kotlin.collections.ifEmpty
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
@Stable @Stable
@@ -305,7 +306,7 @@ class Account(
// Follows Relays // Follows Relays
val followOutboxesOrProxy = FollowListOutboxOrProxyRelays(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope) 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 // keeps a cache of the outbox relays for each author
val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope).flow val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope).flow
@@ -538,7 +539,7 @@ class Account(
val zapRequest = val zapRequest =
LnZapRequestEvent.create( LnZapRequestEvent.create(
userHex = user.pubkeyHex, userHex = user.pubkeyHex,
relays = nip65RelayList.inboxFlow.value + user.inboxRelays(), relays = nip65RelayList.inboxFlow.value + (user.inboxRelays() ?: emptyList()),
signer = signer, signer = signer,
message = message, message = message,
zapType = zapType, zapType = zapType,
@@ -659,7 +660,8 @@ class Account(
if (replyToAuthor == userProfile()) { if (replyToAuthor == userProfile()) {
outboxRelays.flow.value outboxRelays.flow.value
} else { } else {
replyToAuthor.outboxRelays().ifEmpty { null }?.toSet() replyToAuthor.inboxRelays()?.ifEmpty { null }?.toSet()
?: replyToAuthor.relaysBeingUsed.keys.ifEmpty { null }
?: cache.relayHints ?: cache.relayHints
.hintsForKey(replyToAuthor.pubkeyHex) .hintsForKey(replyToAuthor.pubkeyHex)
.ifEmpty { null } .ifEmpty { null }
@@ -685,7 +687,7 @@ class Account(
if (user == userProfile()) { if (user == userProfile()) {
notificationRelays.flow.value notificationRelays.flow.value
} else { } else {
user.inboxRelays().ifEmpty { null }?.toSet() user.inboxRelays()?.ifEmpty { null }?.toSet()
?: (cache.relayHints.hintsForKey(user.pubkeyHex).toSet() + user.relaysBeingUsed.keys) ?: (cache.relayHints.hintsForKey(user.pubkeyHex).toSet() + user.relaysBeingUsed.keys)
} }
@@ -738,10 +740,12 @@ class Account(
if (author == userProfile()) { if (author == userProfile()) {
relayList.addAll(outboxRelays.flow.value) relayList.addAll(outboxRelays.flow.value)
} else { } else {
relayList.addAll( val relays =
author.outboxRelays().ifEmpty { null } author.outboxRelays()?.ifEmpty { null }
?: cache.relayHints.hintsForKey(author.pubkeyHex), ?: author.relaysBeingUsed.keys.ifEmpty { null }
) ?: cache.relayHints.hintsForKey(author.pubkeyHex)
relayList.addAll(relays)
} }
} else { } else {
relayList.addAll(cache.relayHints.hintsForKey(event.pubKey)) relayList.addAll(cache.relayHints.hintsForKey(event.pubKey))
@@ -1566,11 +1570,8 @@ class Account(
val request = NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey.pubkeyHex, signer.pubKey, relays, signer) val request = NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey.pubkeyHex, signer.pubKey, relays, signer)
val relayList = val relayList =
dvmPublicKey dvmPublicKey.inboxRelays()?.toSet()?.ifEmpty { null }
.inboxRelays() ?: (dvmPublicKey.relaysBeingUsed.keys + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex))
.ifEmpty {
cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex) + dvmPublicKey.relaysBeingUsed.keys
}.toSet()
cache.justConsumeMyOwnEvent(request) cache.justConsumeMyOwnEvent(request)
onReady(request) onReady(request)
@@ -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.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference import com.vitorpamplona.quartz.nip01Core.tags.events.EventReference
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.anyHashTag 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.nip02FollowList.ImmutableListOfLists
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag 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.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent 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.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent
import com.vitorpamplona.quartz.nip71Video.VideoEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.anyAsync import com.vitorpamplona.quartz.utils.anyAsync
import com.vitorpamplona.quartz.utils.containsAny import com.vitorpamplona.quartz.utils.containsAny
@@ -95,19 +92,9 @@ class AddressableNote(
override fun createdAt(): Long? { override fun createdAt(): Long? {
val currentEvent = event val currentEvent = event
if (currentEvent == null) return null if (currentEvent == null) return null
if (currentEvent is PublishedAtProvider) return currentEvent.publishedAt() ?: currentEvent.createdAt
val publishedAt = return currentEvent.createdAt
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)
} }
fun dTag(): String = address.dTag fun dTag(): String = address.dTag
@@ -202,15 +189,15 @@ open class Note(
} }
fun relayUrls(): List<NormalizedRelayUrl> { fun relayUrls(): List<NormalizedRelayUrl> {
val authorRelay = author?.relayHints()?.ifEmpty { null } val authorRelay = author?.relayHints() ?: emptyList()
return authorRelay ?: relays return authorRelay + relays
} }
fun relayUrlsForReactions(): List<NormalizedRelayUrl> { fun relayUrlsForReactions(): List<NormalizedRelayUrl> {
val authorRelay = author?.inboxRelays()?.ifEmpty { null } val authorRelay = author?.inboxRelays() ?: emptyList()
return authorRelay ?: relays return authorRelay + relays
} }
fun relayHintUrl(): NormalizedRelayUrl? { fun relayHintUrl(): NormalizedRelayUrl? {
@@ -78,11 +78,11 @@ class User(
fun toNProfile() = NProfile.create(pubkeyHex, relayHints()) 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 relayHints() = authorRelayList()?.writeRelaysNorm()?.take(3) ?: listOfNotNull(latestMetadataRelay)
fun inboxRelays() = authorRelayList()?.readRelaysNorm() ?: listOfNotNull(latestMetadataRelay) fun inboxRelays() = authorRelayList()?.readRelaysNorm()
fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays() fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays()
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.model.serverList
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxOrProxyRelays 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.nip51Lists.indexerRelays.IndexerRelayListState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -41,7 +40,6 @@ class MergedFollowPlusMineRelayListsState(
val nip65RelayList: Nip65RelayListState, val nip65RelayList: Nip65RelayListState,
val privateOutboxRelayList: PrivateStorageRelayListState, val privateOutboxRelayList: PrivateStorageRelayListState,
val localRelayList: LocalRelayListState, val localRelayList: LocalRelayListState,
val broadcastRelayList: BroadcastRelayListState,
val indexerRelayList: IndexerRelayListState, val indexerRelayList: IndexerRelayListState,
val scope: CoroutineScope, val scope: CoroutineScope,
) { ) {
@@ -55,7 +53,6 @@ class MergedFollowPlusMineRelayListsState(
nip65RelayList.inboxFlow, nip65RelayList.inboxFlow,
privateOutboxRelayList.flow, privateOutboxRelayList.flow,
localRelayList.flow, localRelayList.flow,
broadcastRelayList.flow,
indexerRelayList.flow, indexerRelayList.flow,
), ),
::mergeLists, ::mergeLists,
@@ -68,7 +65,6 @@ class MergedFollowPlusMineRelayListsState(
nip65RelayList.inboxFlow.value, nip65RelayList.inboxFlow.value,
privateOutboxRelayList.flow.value, privateOutboxRelayList.flow.value,
localRelayList.flow.value, localRelayList.flow.value,
broadcastRelayList.flow.value,
indexerRelayList.flow.value, indexerRelayList.flow.value,
), ),
), ),
@@ -84,7 +80,6 @@ class MergedFollowPlusMineRelayListsState(
nip65RelayList.inboxFlow.value, nip65RelayList.inboxFlow.value,
privateOutboxRelayList.flow.value, privateOutboxRelayList.flow.value,
localRelayList.flow.value, localRelayList.flow.value,
broadcastRelayList.flow.value,
indexerRelayList.flow.value, indexerRelayList.flow.value,
), ),
), ),
@@ -40,6 +40,8 @@ class OkHttpWebSocket(
fun buildRequest() = Request.Builder().url(url.url).build() fun buildRequest() = Request.Builder().url(url.url).build()
override fun needsReconnect(): Boolean { override fun needsReconnect(): Boolean {
if (socket == null) return true
val myUsingOkHttp = usingOkHttp val myUsingOkHttp = usingOkHttp
if (myUsingOkHttp == null) return true if (myUsingOkHttp == null) return true
@@ -89,13 +91,19 @@ class OkHttpWebSocket(
webSocket: okhttp3.WebSocket, webSocket: okhttp3.WebSocket,
code: Int, code: Int,
reason: String, reason: String,
) = out.onClosed(code, reason) ) {
socket = null
out.onClosed(code, reason)
}
override fun onFailure( override fun onFailure(
webSocket: okhttp3.WebSocket, webSocket: okhttp3.WebSocket,
t: Throwable, t: Throwable,
response: Response?, response: Response?,
) = out.onFailure(t, response?.code, response?.message) ) {
socket = null
out.onFailure(t, response?.code, response?.message)
}
} }
class Builder( class Builder(
@@ -111,6 +119,7 @@ class OkHttpWebSocket(
override fun disconnect() { override fun disconnect() {
// uses cancel to kill the SEND stack that might be waiting // uses cancel to kill the SEND stack that might be waiting
socket?.cancel() socket?.cancel()
socket = null
} }
override fun send(msg: String): Boolean = socket?.send(msg) ?: false override fun send(msg: String): Boolean = socket?.send(msg) ?: false
@@ -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.EventCollector
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayInsertConfirmationCollector import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayInsertConfirmationCollector
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
class CacheClientConnector( class CacheClientConnector(
val client: NostrClient, val client: NostrClient,
val cache: LocalCache, val cache: LocalCache,
val scope: CoroutineScope,
) { ) {
val receiver = val receiver =
EventCollector(client) { event, relay -> EventCollector(client) { event, relay ->
@@ -77,7 +77,7 @@ class AccountNotificationsEoseFromInboxRelaysManager(
} }
}, },
key.account.scope.launch(Dispatchers.Default) { key.account.scope.launch(Dispatchers.Default) {
key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest { key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters() invalidateFilters()
} }
}, },
@@ -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.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class AccountNotificationsEoseFromRandomRelaysManager( class AccountNotificationsEoseFromRandomRelaysManager(
@@ -49,11 +49,18 @@ class AccountNotificationsEoseFromRandomRelaysManager(
override fun updateFilter( override fun updateFilter(
key: AccountQueryState, key: AccountQueryState,
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter>? = ): List<RelayBasedFilter>? {
(key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap { // only loads this after the feed is built
val since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo() val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled()
filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since) 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<User, List<Job>>() val userJobMap = mutableMapOf<User, List<Job>>()
@@ -69,6 +76,11 @@ class AccountNotificationsEoseFromRandomRelaysManager(
invalidateFilters() invalidateFilters()
} }
}, },
key.account.scope.launch(Dispatchers.Default) {
key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
) )
return super.newSub(key) return super.newSub(key)
@@ -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<Long>? = 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<ImmutableList<Long>?>(null) }
var wantsToPay by remember(baseNote) {
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>(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<Long>?,
onZapStarts: () -> Unit,
onZappingProgress: (Float) -> Unit,
onMultipleChoices: (List<Long>) -> Unit,
onError: (String, String, User?) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> 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))
}
}
}
@@ -50,7 +50,7 @@ class FeedContentState(
// Simple counter that changes when it needs to invalidate everything // Simple counter that changes when it needs to invalidate everything
private val _scrollToTop = MutableStateFlow<Int>(0) private val _scrollToTop = MutableStateFlow<Int>(0)
val scrollToTop = _scrollToTop.asStateFlow() val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false var scrollToTopPending = false
private var lastFeedKey: Any? = null private var lastFeedKey: Any? = null
@@ -59,14 +59,14 @@ class FeedContentState(
val lastNoteCreatedAtWhenFullyLoaded = MutableStateFlow<Long?>(null) val lastNoteCreatedAtWhenFullyLoaded = MutableStateFlow<Long?>(null)
fun sendToTop() { fun sendToTop() {
if (scrolltoTopPending) return if (scrollToTopPending) return
scrolltoTopPending = true scrollToTopPending = true
viewModelScope.launch(Dispatchers.IO) { _scrollToTop.emit(_scrollToTop.value + 1) } viewModelScope.launch(Dispatchers.IO) { _scrollToTop.emit(_scrollToTop.value + 1) }
} }
suspend fun sentToTop() { suspend fun sentToTop() {
scrolltoTopPending = false scrollToTopPending = false
} }
private fun refresh() { private fun refresh() {
@@ -107,9 +107,10 @@ class FeedContentState(
private fun updateFeed(notes: ImmutableList<Note>) { private fun updateFeed(notes: ImmutableList<Note>) {
if (notes.size >= localFilter.limit()) { if (notes.size >= localFilter.limit()) {
val lastNomeTime = notes.lastOrNull { it.event != null }?.createdAt() // feeds might not be sorted by created at, so full search
if (lastNomeTime != lastNoteCreatedAtWhenFullyLoaded.value) { val lastNoteTime = notes.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE }
lastNoteCreatedAtWhenFullyLoaded.tryEmit(lastNomeTime) if (lastNoteTime != lastNoteCreatedAtWhenFullyLoaded.value) {
lastNoteCreatedAtWhenFullyLoaded.tryEmit(lastNoteTime)
} }
} }
@@ -128,7 +129,8 @@ class FeedContentState(
fun deleteFromFeed(deletedNotes: Set<Note>) { fun deleteFromFeed(deletedNotes: Set<Note>) {
val feed = _feedContent.value val feed = _feedContent.value
if (feed is FeedState.Loaded) { if (feed is FeedState.Loaded) {
updateFeed((feed.feed.value.list - deletedNotes).toImmutableList()) val notes = (feed.feed.value.list - deletedNotes).toImmutableList()
updateFeed(notes)
} }
} }
@@ -37,7 +37,7 @@ fun WatchScrollToTop(
val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle() val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle()
LaunchedEffect(scrollToTop) { LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && feedContentState.scrolltoTopPending) { if (scrollToTop > 0 && feedContentState.scrollToTopPending) {
listState.scrollToItem(index = 0) listState.scrollToItem(index = 0)
feedContentState.sentToTop() feedContentState.sentToTop()
} }
@@ -52,7 +52,7 @@ fun WatchScrollToTop(
val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle() val scrollToTop by feedContentState.scrollToTop.collectAsStateWithLifecycle()
LaunchedEffect(scrollToTop) { LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && feedContentState.scrolltoTopPending) { if (scrollToTop > 0 && feedContentState.scrollToTopPending) {
listState.scrollToItem(index = 0) listState.scrollToItem(index = 0)
feedContentState.sentToTop() feedContentState.sentToTop()
} }
@@ -82,7 +82,7 @@ fun WatchScrollToTop(
val scrollToTop by videoFeedContentState.scrollToTop.collectAsStateWithLifecycle() val scrollToTop by videoFeedContentState.scrollToTop.collectAsStateWithLifecycle()
LaunchedEffect(scrollToTop) { LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && videoFeedContentState.scrolltoTopPending) { if (scrollToTop > 0 && videoFeedContentState.scrollToTopPending) {
pagerState.scrollToPage(page = 0) pagerState.scrollToPage(page = 0)
videoFeedContentState.sentToTop() videoFeedContentState.sentToTop()
} }
@@ -20,84 +20,54 @@
*/ */
package com.vitorpamplona.amethyst.ui.note.elements 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.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.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withLink
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note 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.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.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.components.appendLink
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.CloseIcon 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.note.creators.zapsplits.DisplayZapSplits
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes 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.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size14Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier 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.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.nip01Core.core.Event 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.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@Preview @Preview
@@ -286,214 +256,17 @@ fun ZapDonationButton(
baseNote: Note, baseNote: Note,
grayTint: Color, grayTint: Color,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
iconSize: Dp = Size35dp,
iconSizeModifier: Modifier = Size20Modifier,
animationModifier: Modifier = Size14Modifier,
nav: INav, nav: INav,
) { ) {
var wantsToZap by remember { mutableStateOf<ImmutableList<Long>?>(null) } val config =
var wantsToPay by ZapButtonConfig(
remember(baseNote) { grayTint = grayTint,
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>( )
persistentListOf(),
)
}
val context = LocalContext.current ReusableZapButton(
val scope = rememberCoroutineScope() baseNote = baseNote,
accountViewModel = accountViewModel,
var zappingProgress by remember { mutableFloatStateOf(0f) } nav = nav,
var zapStartingTime by remember { mutableLongStateOf(0L) } config = config,
)
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<Long>) -> Unit,
onError: (String, String, User?) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> 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))
}
}
} }
@@ -91,16 +91,16 @@ fun RenderAppDefinition(
withContext(Dispatchers.Default) { metadata = noteEvent.appMetaData() } withContext(Dispatchers.Default) { metadata = noteEvent.appMetaData() }
} }
metadata?.let { metadata?.let { theAppMetadata ->
Box { Box {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val uri = LocalUriHandler.current val uri = LocalUriHandler.current
if (!it.banner.isNullOrBlank()) { if (!theAppMetadata.banner.isNullOrBlank()) {
var zoomImageDialogOpen by remember { mutableStateOf(false) } var zoomImageDialogOpen by remember { mutableStateOf(false) }
AsyncImage( AsyncImage(
model = it.banner, model = theAppMetadata.banner,
contentDescription = stringRes(id = R.string.profile_image), contentDescription = stringRes(id = R.string.profile_image),
contentScale = ContentScale.FillWidth, contentScale = ContentScale.FillWidth,
modifier = modifier =
@@ -109,13 +109,13 @@ fun RenderAppDefinition(
.height(125.dp) .height(125.dp)
.combinedClickable( .combinedClickable(
onClick = {}, onClick = {},
onLongClick = { clipboardManager.setText(AnnotatedString(it.banner!!)) }, onLongClick = { clipboardManager.setText(AnnotatedString(theAppMetadata.banner!!)) },
), ),
) )
if (zoomImageDialogOpen) { if (zoomImageDialogOpen) {
ZoomableImageDialog( ZoomableImageDialog(
imageUrl = RichTextParser.parseImageOrVideo(it.banner!!), imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.banner!!),
onDismiss = { zoomImageDialogOpen = false }, onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
@@ -144,12 +144,11 @@ fun RenderAppDefinition(
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
) { ) {
var zoomImageDialogOpen by remember { mutableStateOf(false) } var zoomImageDialogOpen by remember { mutableStateOf(false) }
Box(Modifier.size(100.dp)) { Box(Modifier.size(100.dp)) {
it.picture?.let { picture -> theAppMetadata.picture?.let { picture ->
AsyncImage( AsyncImage(
model = picture, model = picture,
contentDescription = it.name, contentDescription = theAppMetadata.name,
contentScale = ContentScale.FillWidth, contentScale = ContentScale.FillWidth,
modifier = modifier =
Modifier Modifier
@@ -165,15 +164,15 @@ fun RenderAppDefinition(
onLongClick = { clipboardManager.setText(AnnotatedString(picture)) }, onLongClick = { clipboardManager.setText(AnnotatedString(picture)) },
), ),
) )
}
}
if (zoomImageDialogOpen) { if (zoomImageDialogOpen) {
ZoomableImageDialog( ZoomableImageDialog(
imageUrl = RichTextParser.parseImageOrVideo(it.banner!!), imageUrl = RichTextParser.parseImageOrVideo(theAppMetadata.picture!!),
onDismiss = { zoomImageDialogOpen = false }, onDismiss = { zoomImageDialogOpen = false },
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
}
}
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
@@ -186,7 +185,7 @@ fun RenderAppDefinition(
) {} ) {}
} }
val name = remember(it) { it.anyName() } val name = remember(theAppMetadata) { theAppMetadata.anyName() }
name?.let { name?.let {
Row( Row(
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
@@ -204,7 +203,7 @@ fun RenderAppDefinition(
} }
} }
val website = remember(it) { it.website } val website = remember(theAppMetadata) { theAppMetadata.website }
if (!website.isNullOrEmpty()) { if (!website.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText) LinkIcon(Size16Modifier, MaterialTheme.colorScheme.placeholderText)
@@ -217,7 +216,7 @@ fun RenderAppDefinition(
} }
} }
it.about?.let { theAppMetadata.about?.let {
Row( Row(
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp), modifier = Modifier.padding(top = 5.dp, bottom = 5.dp),
) { ) {
@@ -86,7 +86,7 @@ class DiscoveryFollowsSetsAndLiveStreamsSubAssembler2(
key.feedStates.discoverLive.lastNoteCreatedAtWhenFullyLoaded, key.feedStates.discoverLive.lastNoteCreatedAtWhenFullyLoaded,
) { ) {
Any() Any()
}.sample(1000).collectLatest { }.sample(5000).collectLatest {
invalidateFilters() invalidateFilters()
} }
}, },
@@ -89,7 +89,7 @@ class DiscoveryLongFormClassifiedsAndDVMSubAssembler1(
key.feedStates.discoverMarketplace.lastNoteCreatedAtWhenFullyLoaded, key.feedStates.discoverMarketplace.lastNoteCreatedAtWhenFullyLoaded,
) { ) {
Any() Any()
}.sample(1000).collectLatest { }.sample(5000).collectLatest {
invalidateFilters() invalidateFilters()
} }
}, },
@@ -85,7 +85,7 @@ class DiscoveryPublicChatsAndCommunitiesSubAssembler3(
key.feedStates.discoverCommunities.lastNoteCreatedAtWhenFullyLoaded, key.feedStates.discoverCommunities.lastNoteCreatedAtWhenFullyLoaded,
) { ) {
Any() Any()
}.sample(1000).collectLatest { }.sample(5000).collectLatest {
invalidateFilters() invalidateFilters()
} }
}, },
@@ -20,30 +20,21 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.BottomStart 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.R
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User 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.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap 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.LoadNote
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage 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.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.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.BannerImage
import com.vitorpamplona.amethyst.ui.note.elements.customZapClick
import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.screen.RenderFeedState import com.vitorpamplona.amethyst.ui.screen.RenderFeedState
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState 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.screen.loggedIn.dvms.dal.NIP90ContentDiscoveryFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer 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.SimpleImage75Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse 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.nip89AppHandlers.definition.AppMetadata
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent 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 @Composable
fun DvmContentDiscoveryScreen( fun DvmContentDiscoveryScreen(
@@ -453,160 +429,23 @@ fun ZapDVMButton(
grayTint: Color, grayTint: Color,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
iconSize: Dp = Size35dp, iconSize: Dp = Size35dp,
iconSizeModifier: Modifier = Size20Modifier,
animationSize: Dp = 14.dp,
nav: INav, 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<List<Long>?>(null) } ReusableZapButton(
var wantsToPay by baseNote = baseNote,
remember(baseNote) { accountViewModel = accountViewModel,
mutableStateOf<ImmutableList<ZapPaymentHandler.Payable>>( nav = nav,
persistentListOf(), config = config,
) )
}
// 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))
}
}
} }
@Composable @Composable
@@ -102,12 +102,12 @@ class HomeOutboxEventsEoseManager(
} }
}, },
key.account.scope.launch(Dispatchers.Default) { key.account.scope.launch(Dispatchers.Default) {
key.feedState.homeNewThreads.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest { key.feedState.homeNewThreads.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters() invalidateFilters()
} }
}, },
key.account.scope.launch(Dispatchers.Default) { key.account.scope.launch(Dispatchers.Default) {
key.feedState.homeReplies.lastNoteCreatedAtWhenFullyLoaded.sample(1000).collectLatest { key.feedState.homeReplies.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters() invalidateFilters()
} }
}, },
@@ -78,6 +78,9 @@ class CardFeedContentState(
val lastNoteCreatedAtWhenFullyLoaded = MutableStateFlow<Long?>(null) val lastNoteCreatedAtWhenFullyLoaded = MutableStateFlow<Long?>(null)
private var lastAccount: Account? = null
private var lastNotes: Set<Note>? = null
fun sendToTop() { fun sendToTop() {
if (scrolltoTopPending) return if (scrolltoTopPending) return
@@ -89,8 +92,14 @@ class CardFeedContentState(
scrolltoTopPending = false scrolltoTopPending = false
} }
private var lastAccount: Account? = null fun visibleNotes(): List<Card> {
private var lastNotes: Set<Note>? = null val currentState = _feedContent.value
return if (currentState is CardFeedState.Loaded) {
currentState.feed.value.list
} else {
emptyList()
}
}
fun lastNoteCreatedAtIfFilled() = lastNoteCreatedAtWhenFullyLoaded.value fun lastNoteCreatedAtIfFilled() = lastNoteCreatedAtWhenFullyLoaded.value
@@ -305,8 +314,16 @@ class CardFeedContentState(
private fun updateFeed(notes: ImmutableList<Card>) { private fun updateFeed(notes: ImmutableList<Card>) {
if (notes.size >= localFilter.limit()) { if (notes.size >= localFilter.limit()) {
val lastNomeTime = notes.lastOrNull()?.createdAt() val lastNoteTime =
if (lastNomeTime != lastNoteCreatedAtWhenFullyLoaded.value) { notes.minOfOrNull {
val createdAt = it.createdAt()
if (createdAt > 0L) {
createdAt
} else {
Long.MAX_VALUE
}
}
if (lastNoteTime != lastNoteCreatedAtWhenFullyLoaded.value) {
lastNoteCreatedAtWhenFullyLoaded.tryEmit(notes.lastOrNull()?.createdAt()) lastNoteCreatedAtWhenFullyLoaded.tryEmit(notes.lastOrNull()?.createdAt())
} }
} }
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource 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.model.User
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -31,8 +32,13 @@ val UserProfileFollowersKinds = listOf(ContactListEvent.KIND)
fun filterUserProfileFollowers( fun filterUserProfileFollowers(
user: User, user: User,
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter> = ): List<RelayBasedFilter> {
user.inboxRelays().map { val relays =
user.inboxRelays()?.ifEmpty { null }
?: user.relaysBeingUsed.keys.ifEmpty { null }
?: LocalCache.relayHints.hintsForKey(user.pubkeyHex)
return relays.map {
RelayBasedFilter( RelayBasedFilter(
relay = it, relay = it,
filter = filter =
@@ -43,3 +49,4 @@ fun filterUserProfileFollowers(
), ),
) )
} }
}
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource 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.model.User
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
@@ -40,8 +41,13 @@ val UserProfileMediaKinds =
fun filterUserProfileMedia( fun filterUserProfileMedia(
user: User, user: User,
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter> = ): List<RelayBasedFilter> {
user.outboxRelays().map { relay -> val relays =
user.outboxRelays()?.ifEmpty { null }
?: user.relaysBeingUsed.keys.ifEmpty { null }
?: LocalCache.relayHints.hintsForKey(user.pubkeyHex)
return relays.map { relay ->
RelayBasedFilter( RelayBasedFilter(
relay = relay, relay = relay,
filter = filter =
@@ -53,3 +59,4 @@ fun filterUserProfileMedia(
), ),
) )
} }
}
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource 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.model.User
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
@@ -66,9 +67,13 @@ val UserProfilePostKinds2 =
fun filterUserProfilePosts( fun filterUserProfilePosts(
user: User, user: User,
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter> = ): List<RelayBasedFilter> {
user val relays =
.outboxRelays() user.outboxRelays()?.ifEmpty { null }
?: user.relaysBeingUsed.keys.ifEmpty { null }
?: LocalCache.relayHints.hintsForKey(user.pubkeyHex)
return relays
.map { relay -> .map { relay ->
listOf( listOf(
RelayBasedFilter( RelayBasedFilter(
@@ -93,3 +98,4 @@ fun filterUserProfilePosts(
), ),
) )
}.flatten() }.flatten()
}
@@ -20,19 +20,26 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource 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.model.User
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlin.collections.ifEmpty
val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND) val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND)
fun filterUserProfileZapsReceived( fun filterUserProfileZapsReceived(
user: User, user: User,
since: SincePerRelayMap?, since: SincePerRelayMap?,
): List<RelayBasedFilter> = ): List<RelayBasedFilter> {
user.inboxRelays().map { relay -> val relays =
user.inboxRelays()?.ifEmpty { null }
?: user.relaysBeingUsed.keys.ifEmpty { null }
?: LocalCache.relayHints.hintsForKey(user.pubkeyHex)
return relays.map { relay ->
RelayBasedFilter( RelayBasedFilter(
relay = relay, relay = relay,
filter = filter =
@@ -44,3 +51,4 @@ fun filterUserProfileZapsReceived(
), ),
) )
} }
}
@@ -20,11 +20,13 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource 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.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.utils.mapOfSet import com.vitorpamplona.quartz.utils.mapOfSet
import kotlin.collections.ifEmpty
class UserProfileMetadataFilterSubAssembler( class UserProfileMetadataFilterSubAssembler(
client: NostrClient, client: NostrClient,
@@ -37,7 +39,12 @@ class UserProfileMetadataFilterSubAssembler(
val userPerRelay = val userPerRelay =
mapOfSet { mapOfSet {
keys.mapTo(mutableSetOf()) { key -> key.user }.forEach { user -> 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) add(relay, user.pubkeyHex)
} }
} }
@@ -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.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -63,8 +64,8 @@ class VideoOutboxEventsFilterSubAssembler(
is AllCommunitiesTopNavPerRelayFilterSet -> filterPictureAndVideoByAllCommunities(feedSettings, since, defaultSince) is AllCommunitiesTopNavPerRelayFilterSet -> filterPictureAndVideoByAllCommunities(feedSettings, since, defaultSince)
is AllFollowsTopNavPerRelayFilterSet -> filterPictureAndVideoByFollows(feedSettings, since, defaultSince) is AllFollowsTopNavPerRelayFilterSet -> filterPictureAndVideoByFollows(feedSettings, since, defaultSince)
is AuthorsTopNavPerRelayFilterSet -> filterPictureAndVideoByAuthors(feedSettings, since, defaultSince) is AuthorsTopNavPerRelayFilterSet -> filterPictureAndVideoByAuthors(feedSettings, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterPictureAndVideoGlobal(feedSettings, since, defaultSince) is GlobalTopNavPerRelayFilterSet -> filterPictureAndVideoGlobal(feedSettings, since, defaultSince ?: TimeUtils.oneWeekAgo())
is HashtagTopNavPerRelayFilterSet -> filterPictureAndVideoByHashtag(feedSettings, since, defaultSince) is HashtagTopNavPerRelayFilterSet -> filterPictureAndVideoByHashtag(feedSettings, since, defaultSince ?: TimeUtils.oneMonthAgo())
is LocationTopNavPerRelayFilterSet -> filterPictureAndVideoByGeohash(feedSettings, since, defaultSince) is LocationTopNavPerRelayFilterSet -> filterPictureAndVideoByGeohash(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterPictureAndVideoByAuthors(feedSettings, since, defaultSince) is MutedAuthorsTopNavPerRelayFilterSet -> filterPictureAndVideoByAuthors(feedSettings, since, defaultSince)
is SingleCommunityTopNavPerRelayFilterSet -> filterPictureAndVideoByCommunity(feedSettings, since, defaultSince) is SingleCommunityTopNavPerRelayFilterSet -> filterPictureAndVideoByCommunity(feedSettings, since, defaultSince)
+3 -3
View File
@@ -13,12 +13,12 @@ benchmark = "1.4.0"
benchmarkJunit4 = "1.4.0" benchmarkJunit4 = "1.4.0"
biometricKtx = "1.2.0-alpha05" biometricKtx = "1.2.0-alpha05"
coil = "3.3.0" coil = "3.3.0"
composeBom = "2025.07.00" composeBom = "2025.08.00"
coreKtx = "1.16.0" coreKtx = "1.17.0"
datastore = "1.1.7" datastore = "1.1.7"
espressoCore = "3.7.0" espressoCore = "3.7.0"
firebaseBom = "34.1.0" firebaseBom = "34.1.0"
fragmentKtx = "1.8.8" fragmentKtx = "1.8.9"
gms = "4.4.3" gms = "4.4.3"
jacksonModuleKotlin = "2.19.2" jacksonModuleKotlin = "2.19.2"
jna = "5.17.0" jna = "5.17.0"
+3
View File
@@ -76,6 +76,9 @@ dependencies {
// Normalizes URLs // Normalizes URLs
api libs.rfc3986.normalizer api libs.rfc3986.normalizer
// Websockets API
implementation libs.okhttp
testImplementation libs.junit testImplementation libs.junit
testImplementation libs.secp256k1.kmp.jni.jvm testImplementation libs.secp256k1.kmp.jni.jvm
androidTestImplementation platform(libs.androidx.compose.bom) androidTestImplementation platform(libs.androidx.compose.bom)
@@ -122,8 +122,6 @@ class NostrClient(
} }
} }
fun allAvailableRelays() = relayPool.getAll()
// Reconnects all relays that may have disconnected // Reconnects all relays that may have disconnected
fun connect() { fun connect() {
isActive = true isActive = true
@@ -138,10 +136,7 @@ class NostrClient(
@Synchronized @Synchronized
fun reconnect(onlyIfChanged: Boolean = false) { fun reconnect(onlyIfChanged: Boolean = false) {
if (onlyIfChanged) { if (onlyIfChanged) {
relayPool.getAllNeedsToReconnect().forEach { relayPool.reconnectIfNeedsToORIfItIsTime()
it.disconnect()
}
relayPool.connect()
} else { } else {
relayPool.disconnect() relayPool.disconnect()
relayPool.connect() relayPool.connect()
@@ -236,6 +231,9 @@ class NostrClient(
relayPool.connectIfDisconnected(relay) relayPool.connectIfDisconnected(relay)
} }
} }
// wakes up all the other relays
relayPool.reconnectIfNeedsToORIfItIsTime()
} }
} }
@@ -269,6 +267,9 @@ class NostrClient(
relayPool.connectIfDisconnected(relay) relayPool.connectIfDisconnected(relay)
} }
} }
// wakes up all the other relays
relayPool.reconnectIfNeedsToORIfItIsTime()
} }
} }
@@ -278,6 +279,9 @@ class NostrClient(
) { ) {
if (isActive) { if (isActive) {
relayPool.getRelay(connectedRelay)?.send(event) relayPool.getRelay(connectedRelay)?.send(event)
// wakes up all the other relays
relayPool.reconnectIfNeedsToORIfItIsTime()
} }
} }
@@ -288,6 +292,9 @@ class NostrClient(
eventOutbox.markAsSending(event, relayList) eventOutbox.markAsSending(event, relayList)
if (isActive) { if (isActive) {
relayPool.send(event, relayList) relayPool.send(event, relayList)
// wakes up all the other relays
relayPool.reconnectIfNeedsToORIfItIsTime()
} }
} }
@@ -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<NormalizedRelayUrl, List<Filter>> = { 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)
}
@@ -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.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.LargeCache import com.vitorpamplona.quartz.utils.LargeCache
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -70,16 +71,24 @@ class RelayPool(
fun getRelay(url: NormalizedRelayUrl): IRelayClient? = relays.get(url) 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 reconnectIfNeedsToORIfItIsTime() {
if (lastReconnectCall < TimeUtils.tenSecondsAgo()) {
fun reconnectsRelaysThatNeedTo() { relays.forEach { url, relay ->
relays.forEach { url, relay -> if (relay.isConnected()) {
if (relay.needsToReconnect()) { if (relay.needsToReconnect()) {
relay.disconnect() // network has changed, force reconnect
relay.connect() relay.disconnect()
relay.connect()
}
} else {
// relay is not connected. Connect if it is time
relay.connectAndSyncFiltersIfDisconnected()
}
} }
lastReconnectCall = TimeUtils.now()
} }
} }
@@ -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.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayState 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.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.client.stats.RelayStat
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
@@ -84,7 +83,7 @@ open class BasicRelayClient(
) : IRelayClient { ) : IRelayClient {
companion object { companion object {
// waits 3 minutes to reconnect once things fail // 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}\"" const val EVENT_MESSAGE_PREFIX = "[\"${EventMessage.LABEL}\""
} }
@@ -94,8 +93,8 @@ open class BasicRelayClient(
private var isReady: Boolean = false private var isReady: Boolean = false
private var usingCompression: Boolean = false private var usingCompression: Boolean = false
private var lastConnectTentative: Long = 0L // the beginning of time. private var lastConnectTentativeInSeconds: Long = 0L // the beginning of time.
private var delayToConnect = DELAY_TO_RECONNECT_IN_MSECS private var delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS
private var afterEOSEPerSubscription = mutableMapOf<String, Boolean>() private var afterEOSEPerSubscription = mutableMapOf<String, Boolean>()
@@ -138,7 +137,7 @@ open class BasicRelayClient(
Log.d(logTag, "Connecting...") Log.d(logTag, "Connecting...")
lastConnectTentative = TimeUtils.now() lastConnectTentativeInSeconds = TimeUtils.now()
socket = socketBuilder.build(url, MyWebsocketListener(onConnected)) socket = socketBuilder.build(url, MyWebsocketListener(onConnected))
socket?.connect() socket?.connect()
@@ -207,22 +206,30 @@ open class BasicRelayClient(
code: Int?, code: Int?,
response: String?, 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) {
if (!(socket == null && (t.message == "Socket is closed" || t.message == "Socket closed"))) { // comes from the disconnect action.
stats.newError(response ?: t.message ?: "onFailure event from server: ${t.javaClass.simpleName}") 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 this.usingCompression = usingCompression
// resets any extra delays added during on offline state // 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 stats.pingInMs = pingInMs
} }
@@ -320,7 +327,7 @@ open class BasicRelayClient(
} }
private fun processAuth(msg: AuthMessage) { private fun processAuth(msg: AuthMessage) {
// Log.d(logTag, "Auth $newMessage") Log.d(logTag, "Auth ${msg.challenge}")
listener.onAuth(this@BasicRelayClient, msg.challenge) listener.onAuth(this@BasicRelayClient, msg.challenge)
} }
@@ -344,8 +351,8 @@ open class BasicRelayClient(
override fun disconnect() { override fun disconnect() {
Log.d(logTag, "Disconnecting...") Log.d(logTag, "Disconnecting...")
lastConnectTentative = 0L // this is not an error, so prepare to reconnect as soon as requested. lastConnectTentativeInSeconds = 0L // this is not an error, so prepare to reconnect as soon as requested.
delayToConnect = DELAY_TO_RECONNECT_IN_MSECS delayToConnectInSeconds = DELAY_TO_RECONNECT_IN_SECS
socket?.disconnect() socket?.disconnect()
socket = null socket = null
isReady = false isReady = false
@@ -372,12 +379,7 @@ open class BasicRelayClient(
} }
} }
} else { } else {
// waits 60 seconds to reconnect after disconnected. connectAndSyncFiltersIfDisconnected()
if (TimeUtils.now() > lastConnectTentative + delayToConnect) {
delayToConnect = delayToConnect * 2
// sends all filters after connection is successful.
connect()
}
} }
} }
@@ -393,25 +395,30 @@ open class BasicRelayClient(
} }
} }
} else { } else {
connectAndSyncFiltersIfDisconnected()
}
}
override fun connectAndSyncFiltersIfDisconnected() {
if (!isConnectionStarted() && !connectingMutex.get()) {
// waits 60 seconds to reconnect after disconnected. // waits 60 seconds to reconnect after disconnected.
if (TimeUtils.now() > lastConnectTentative + delayToConnect) { if (TimeUtils.now() > lastConnectTentativeInSeconds + delayToConnectInSeconds) {
// sends all filters after connection is successful. upRelayDelayToConnect()
delayToConnect = delayToConnect * 2
connect() connect()
} }
} }
} }
override fun connectAndSyncFiltersIfDisconnected() { fun upRelayDelayToConnect() {
if (socket == null) { if (delayToConnectInSeconds < TimeUtils.FIVE_MINUTES) {
// waits 60 seconds to reconnect after disconnected. delayToConnectInSeconds = delayToConnectInSeconds * 2
if (TimeUtils.now() > lastConnectTentative + delayToConnect) {
delayToConnect = delayToConnect * 2
connect()
}
} }
} }
fun dontTryAgainForALongTime() {
delayToConnectInSeconds = TimeUtils.ONE_DAY
}
override fun send(event: Event) { override fun send(event: Event) {
listener.onBeforeSend(this@BasicRelayClient, event) listener.onBeforeSend(this@BasicRelayClient, event)
@@ -439,8 +446,7 @@ open class BasicRelayClient(
writeToSocket(EventCmd.Companion.toJson(event)) writeToSocket(EventCmd.Companion.toJson(event))
} }
} else { } else {
// automatically sends all filters after connection is successful. connectAndSyncFiltersIfDisconnected()
connect()
} }
} }
@@ -29,9 +29,6 @@ class NoticeMessage(
const val LABEL = "NOTICE" const val LABEL = "NOTICE"
@JvmStatic @JvmStatic
fun parse(msgArray: JsonNode): NoticeMessage = fun parse(msgArray: JsonNode) = NoticeMessage(msgArray.get(1).asText())
NoticeMessage(
msgArray.get(1).asText(),
)
} }
} }
@@ -36,7 +36,7 @@ fun NormalizedRelayUrl.toHttp() =
if (url.startsWith("wss://")) { if (url.startsWith("wss://")) {
"https${url.drop(3)}" "https${url.drop(3)}"
} else if (url.startsWith("ws://")) { } else if (url.startsWith("ws://")) {
"https${url.drop(2)}" "http${url.drop(2)}"
} else { } else {
"https://$url" "https://$url"
} }
@@ -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)
}
}
@@ -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?
}
@@ -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.dTags.dTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag 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.BaseThreadedEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag
import com.vitorpamplona.quartz.nip19Bech32.addressHints import com.vitorpamplona.quartz.nip19Bech32.addressHints
@@ -68,6 +69,7 @@ class LongTextNoteEvent(
EventHintProvider, EventHintProvider,
PubKeyHintProvider, PubKeyHintProvider,
AddressHintProvider, AddressHintProvider,
PublishedAtProvider,
RootScope, RootScope,
SearchableEvent { SearchableEvent {
override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content
@@ -130,7 +132,7 @@ class LongTextNoteEvent(
fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse)
fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
companion object { companion object {
const val KIND = 30023 const val KIND = 30023
@@ -32,7 +32,16 @@ class PublishedAtTag {
ensure(tag.has(1)) { return null } ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null } ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { 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 @JvmStatic
@@ -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.dTags.dTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag 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.BaseThreadedEvent
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag
@@ -62,6 +63,7 @@ class WikiNoteEvent(
EventHintProvider, EventHintProvider,
AddressHintProvider, AddressHintProvider,
PubKeyHintProvider, PubKeyHintProvider,
PublishedAtProvider,
SearchableEvent { SearchableEvent {
override fun indexableContent() = "title: " + title() + "\nsummary: " + summary() + "\n" + content 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 image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
fun publishedAt() = override fun publishedAt() =
try { try {
tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull() tags.firstOrNull { it.size > 1 && it[0] == "published_at" }?.get(1)?.toLongOrNull()
} catch (_: Exception) { } catch (_: Exception) {
@@ -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.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag 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.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
@@ -45,12 +46,13 @@ abstract class VideoEvent(
content: String, content: String,
sig: HexKey, sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig), ) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig),
PublishedAtProvider,
RootScope { RootScope {
@Transient var iMetas: List<VideoMeta>? = null @Transient var iMetas: List<VideoMeta>? = null
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) 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) fun duration() = tags.firstNotNullOfOrNull(DurationTag::parse)
@@ -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.dTags.dTag
import com.vitorpamplona.quartz.nip01Core.tags.kinds.isTaggedKind import com.vitorpamplona.quartz.nip01Core.tags.kinds.isTaggedKind
import com.vitorpamplona.quartz.nip01Core.tags.kinds.kinds 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.nip21UriScheme.toNostrUri
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip31Alts.alt
@@ -46,7 +47,8 @@ class AppDefinitionEvent(
tags: Array<Array<String>>, tags: Array<Array<String>>,
content: String, content: String,
sig: HexKey, 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) override fun countMemory(): Long = super.countMemory() + (cachedMetadata?.countMemory() ?: 8L)
@Transient private var cachedMetadata: AppMetadata? = null @Transient private var cachedMetadata: AppMetadata? = null
@@ -77,7 +79,7 @@ class AppDefinitionEvent(
fun includeKind(kind: Int) = tags.isTaggedKind(kind) fun includeKind(kind: Int) = tags.isTaggedKind(kind)
fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
companion object { companion object {
const val KIND = 31990 const val KIND = 31990
@@ -113,8 +113,8 @@ class AppMetadata {
fun toJson() = assemble(this) fun toJson() = assemble(this)
companion object { 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)
} }
} }
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.containsAllTagNamesWithValues
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags 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.ImageTag
import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag import com.vitorpamplona.quartz.nip23LongContent.tags.PublishedAtTag
import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag
@@ -49,7 +50,8 @@ class ClassifiedsEvent(
tags: Array<Array<String>>, tags: Array<Array<String>>,
content: String, content: String,
sig: HexKey, 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 title() = tags.firstNotNullOfOrNull(TitleTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
@@ -68,7 +70,7 @@ class ClassifiedsEvent(
fun location() = tags.firstNotNullOfOrNull(LocationTag::parse) fun location() = tags.firstNotNullOfOrNull(LocationTag::parse)
fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse) override fun publishedAt() = tags.firstNotNullOfOrNull(PublishedAtTag::parse)
fun categories() = tags.hashtags() fun categories() = tags.hashtags()
@@ -36,6 +36,8 @@ object TimeUtils {
fun tenSecondsFromNow() = now() + TEN_SECONDS fun tenSecondsFromNow() = now() + TEN_SECONDS
fun tenSecondsAgo() = now() - TEN_SECONDS
fun oneMinuteFromNow() = now() + ONE_MINUTE fun oneMinuteFromNow() = now() + ONE_MINUTE
fun oneMinuteAgo() = now() - ONE_MINUTE fun oneMinuteAgo() = now() - ONE_MINUTE