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