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