Merge pull request #2449 from vitorpamplona/claude/add-favorite-dvm-filter-DfveW
Add NIP-90 DVM favorite list support with content discovery
This commit is contained in:
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusActi
|
||||
import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.logTime
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
|
||||
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
|
||||
import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState
|
||||
@@ -66,6 +67,8 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
|
||||
@@ -187,6 +190,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||
@@ -323,6 +327,10 @@ class Account(
|
||||
val hashtagListDecryptionCache = HashtagListDecryptionCache(signer)
|
||||
val hashtagList = HashtagListState(signer, cache, hashtagListDecryptionCache, scope, settings)
|
||||
|
||||
val favoriteAlgoFeedsListDecryptionCache = FavoriteAlgoFeedsListDecryptionCache(signer)
|
||||
val favoriteAlgoFeedsList = FavoriteAlgoFeedsListState(signer, cache, favoriteAlgoFeedsListDecryptionCache, scope, settings)
|
||||
val favoriteAlgoFeedsOrchestrator = FavoriteAlgoFeedsOrchestrator(this, scope)
|
||||
|
||||
val geohashListDecryptionCache = GeohashListDecryptionCache(signer)
|
||||
val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings)
|
||||
|
||||
@@ -418,6 +426,8 @@ class Account(
|
||||
caches = feedDecryptionCaches,
|
||||
signer = signer,
|
||||
scope = scope,
|
||||
favoriteAlgoFeedsOrchestrator = favoriteAlgoFeedsOrchestrator,
|
||||
favoriteAlgoFeedAddresses = favoriteAlgoFeedsList.flow,
|
||||
).flow
|
||||
|
||||
// App-ready Feeds
|
||||
@@ -1013,6 +1023,12 @@ class Account(
|
||||
|
||||
suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag))
|
||||
|
||||
suspend fun followFavoriteAlgoFeed(dvm: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteAlgoFeedsList.follow(dvm))
|
||||
|
||||
suspend fun unfollowFavoriteAlgoFeed(dvm: Address) = sendMyPublicAndPrivateOutbox(favoriteAlgoFeedsList.unfollow(dvm))
|
||||
|
||||
fun isFavoriteAlgoFeed(dvm: Address): Boolean = favoriteAlgoFeedsList.flow.value.contains(dvm)
|
||||
|
||||
suspend fun followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash))
|
||||
|
||||
suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash))
|
||||
@@ -2367,7 +2383,7 @@ class Account(
|
||||
|
||||
suspend fun requestDVMContentDiscovery(
|
||||
dvmPublicKey: User,
|
||||
onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit,
|
||||
onReady: (event: NIP90ContentDiscoveryRequestEvent, relays: Set<NormalizedRelayUrl>) -> Unit,
|
||||
) {
|
||||
val relays = nip65RelayList.inboxFlow.value.toSet()
|
||||
val request = signer.sign<NIP90ContentDiscoveryRequestEvent>(NIP90ContentDiscoveryRequestEvent.build(dvmPublicKey.pubkeyHex, signer.pubKey, relays))
|
||||
@@ -2377,7 +2393,7 @@ class Account(
|
||||
?: (dvmPublicKey.allUsedRelays() + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex))
|
||||
|
||||
cache.justConsumeMyOwnEvent(request)
|
||||
onReady(request)
|
||||
onReady(request, relayList.toSet())
|
||||
delay(100)
|
||||
client.publish(request, relayList)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayList
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
@@ -154,6 +155,13 @@ sealed class TopFilter(
|
||||
class Relay(
|
||||
val url: String,
|
||||
) : TopFilter("Relay/$url")
|
||||
|
||||
@Serializable
|
||||
class FavoriteAlgoFeed(
|
||||
val address: Address,
|
||||
) : TopFilter("FavoriteAlgoFeed/${address.toValue()}")
|
||||
|
||||
@Serializable object AllFavoriteAlgoFeeds : TopFilter(" All Favourite DVMs ")
|
||||
}
|
||||
|
||||
@Stable
|
||||
@@ -195,6 +203,7 @@ class AccountSettings(
|
||||
var backupChannelList: ChannelListEvent? = null,
|
||||
var backupCommunityList: CommunityListEvent? = null,
|
||||
var backupHashtagList: HashtagListEvent? = null,
|
||||
var backupFavoriteAlgoFeedsList: FavoriteAlgoFeedsListEvent? = null,
|
||||
var backupGeohashList: GeohashListEvent? = null,
|
||||
var backupEphemeralChatList: EphemeralChatListEvent? = null,
|
||||
var backupTrustProviderList: TrustProviderListEvent? = null,
|
||||
@@ -718,6 +727,16 @@ class AccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFavoriteAlgoFeedsListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) {
|
||||
if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return
|
||||
|
||||
// Events might be different objects, we have to compare their ids.
|
||||
if (backupFavoriteAlgoFeedsList?.id != newFavoriteDvmList.id) {
|
||||
backupFavoriteAlgoFeedsList = newFavoriteDvmList
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateCommunityListTo(newCommunityList: CommunityListEvent?) {
|
||||
if (newCommunityList == null || newCommunityList.tags.isEmpty()) return
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
@@ -2635,6 +2636,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
is LiveChessGameEndEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is LiveChessDrawOfferEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is HashtagListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is FavoriteAlgoFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is HighlightEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||
is IndexerRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
is InteractiveStoryPrologueEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* 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.model.algoFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
private const val RESPONSE_TIMEOUT_MS = 20_000L
|
||||
|
||||
/**
|
||||
* Immutable snapshot of a favorite algo feed's current request/response state.
|
||||
*
|
||||
* - [requestId] is the id of the most recently published kind-5300 request.
|
||||
* - [responseRelays] is the relay set the kind-5300 was sent to — the same set on
|
||||
* which the DVM will publish its 6300/7000 responses, so the home subscription
|
||||
* manager must listen there (not on the user's own outbox).
|
||||
* - [ids] and [addresses] are the note references returned by the latest kind-6300 response.
|
||||
* - [latestStatus] is the latest kind-7000 status event (processing, payment-required, error, …).
|
||||
* - [errorMessage] captures any client-side failure while publishing the request.
|
||||
*/
|
||||
data class FavoriteAlgoFeedsSnapshot(
|
||||
val requestId: HexKey? = null,
|
||||
val responseRelays: Set<NormalizedRelayUrl> = emptySet(),
|
||||
val ids: Set<HexKey> = emptySet(),
|
||||
val addresses: Set<String> = emptySet(),
|
||||
val latestStatus: NIP90StatusEvent? = null,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Manages the NIP-90 content-discovery RPC cycle for each favorite algo feed the user
|
||||
* pins to the top-nav.
|
||||
*
|
||||
* The orchestrator is lazy: it starts a request/response cycle the first time any
|
||||
* consumer calls [observe] for a given DVM address, and keeps emitting updated
|
||||
* snapshots until [stop] (or account tear-down). Call [refresh] to re-issue the
|
||||
* kind-5300 request (e.g. pull-to-refresh).
|
||||
*
|
||||
* This class does not own the relay subscriptions that fetch DVM responses and
|
||||
* matching notes. Those are issued by `HomeOutboxEventsEoseManager` while the
|
||||
* user has a `TopFilter.FavoriteAlgoFeed` selected. The orchestrator merely observes
|
||||
* what the relays deliver into `LocalCache`.
|
||||
*/
|
||||
class FavoriteAlgoFeedsOrchestrator(
|
||||
val account: Account,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
private val flows = mutableMapOf<Address, MutableStateFlow<FavoriteAlgoFeedsSnapshot>>()
|
||||
private val jobs = mutableMapOf<Address, Job>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
fun observe(feedAddress: Address): StateFlow<FavoriteAlgoFeedsSnapshot> {
|
||||
flows[feedAddress]?.let { return it.asStateFlow() }
|
||||
|
||||
val seed = MutableStateFlow(FavoriteAlgoFeedsSnapshot())
|
||||
flows[feedAddress] = seed
|
||||
scope.launch { startFor(feedAddress, seed) }
|
||||
return seed.asStateFlow()
|
||||
}
|
||||
|
||||
fun refresh(feedAddress: Address) {
|
||||
val seed = flows[feedAddress] ?: return
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
jobs.remove(feedAddress)?.cancel()
|
||||
}
|
||||
startFor(feedAddress, seed)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(feedAddress: Address) {
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
jobs.remove(feedAddress)?.cancel()
|
||||
flows.remove(feedAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startFor(
|
||||
feedAddress: Address,
|
||||
seed: MutableStateFlow<FavoriteAlgoFeedsSnapshot>,
|
||||
) {
|
||||
val user = account.cache.checkGetOrCreateUser(feedAddress.pubKeyHex) ?: return
|
||||
val job =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
account.requestDVMContentDiscovery(user) { request, relays ->
|
||||
seed.update {
|
||||
it.copy(
|
||||
requestId = request.id,
|
||||
responseRelays = relays,
|
||||
ids = emptySet(),
|
||||
addresses = emptySet(),
|
||||
latestStatus = null,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val requestId = seed.value.requestId ?: return@launch
|
||||
|
||||
launch {
|
||||
account.cache
|
||||
.observeLatestEvent<NIP90ContentDiscoveryResponseEvent>(
|
||||
Filter(
|
||||
kinds = listOf(NIP90ContentDiscoveryResponseEvent.KIND),
|
||||
tags = mapOf("e" to listOf(requestId)),
|
||||
limit = 1,
|
||||
),
|
||||
).collectLatest { response ->
|
||||
if (response == null) return@collectLatest
|
||||
val (eventIds, addresses) = splitInnerTags(response.innerTags())
|
||||
seed.update {
|
||||
it.copy(
|
||||
ids = eventIds,
|
||||
addresses = addresses,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
account.cache
|
||||
.observeLatestEvent<NIP90StatusEvent>(
|
||||
Filter(
|
||||
kinds = listOf(NIP90StatusEvent.KIND),
|
||||
tags = mapOf("e" to listOf(requestId)),
|
||||
limit = 1,
|
||||
),
|
||||
).collectLatest { status ->
|
||||
seed.update { it.copy(latestStatus = status) }
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing arrives within RESPONSE_TIMEOUT_MS (neither a 6300
|
||||
// response nor any 7000 status), surface an error so the banner
|
||||
// can show Retry instead of spinning forever.
|
||||
launch {
|
||||
delay(RESPONSE_TIMEOUT_MS)
|
||||
val current = seed.value
|
||||
val stillWaiting =
|
||||
current.requestId == requestId &&
|
||||
current.ids.isEmpty() &&
|
||||
current.addresses.isEmpty() &&
|
||||
current.latestStatus == null &&
|
||||
current.errorMessage == null
|
||||
if (stillWaiting) {
|
||||
seed.update { it.copy(errorMessage = "timeout") }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("FavoriteAlgoFeedsOrchestrator", "Failed to start DVM request: ${e.message}", e)
|
||||
seed.update { it.copy(errorMessage = e.message ?: "Unknown error") }
|
||||
}
|
||||
}
|
||||
|
||||
mutex.withLock { jobs[feedAddress] = job }
|
||||
}
|
||||
|
||||
private fun splitInnerTags(innerTags: List<HexKey>): Pair<Set<HexKey>, Set<String>> {
|
||||
val ids = mutableSetOf<HexKey>()
|
||||
val addresses = mutableSetOf<String>()
|
||||
innerTags.forEach { value ->
|
||||
if (value.contains(':')) {
|
||||
addresses.add(value)
|
||||
} else if (value.length == 64) {
|
||||
ids.add(value)
|
||||
}
|
||||
}
|
||||
return ids to addresses
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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.model.nip51Lists.favoriteAlgoFeedsLists
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.favoriteAlgoFeedsSet
|
||||
|
||||
class FavoriteAlgoFeedsListDecryptionCache(
|
||||
val signer: NostrSigner,
|
||||
) {
|
||||
val cachedPrivateLists = PrivateTagArrayEventCache<FavoriteAlgoFeedsListEvent>(signer)
|
||||
|
||||
fun cachedFavoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteAlgoFeedsSet()
|
||||
|
||||
suspend fun favoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteAlgoFeedsSet()
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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.model.nip51Lists.favoriteAlgoFeedsLists
|
||||
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.NoteState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class FavoriteAlgoFeedsListState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val decryptionCache: FavoriteAlgoFeedsListDecryptionCache,
|
||||
val scope: CoroutineScope,
|
||||
val settings: AccountSettings,
|
||||
) {
|
||||
// Creates a long-term reference for this note so that the GC doesn't collect the note itself
|
||||
val favoriteAlgoFeedsListNote = cache.getOrCreateAddressableNote(getFavoriteAlgoFeedsListAddress())
|
||||
|
||||
fun getFavoriteAlgoFeedsListAddress() = FavoriteAlgoFeedsListEvent.createAddress(signer.pubKey)
|
||||
|
||||
fun getFavoriteAlgoFeedsListFlow(): StateFlow<NoteState> = favoriteAlgoFeedsListNote.flow().metadata.stateFlow
|
||||
|
||||
fun getFavoriteAlgoFeedsList(): FavoriteAlgoFeedsListEvent? = favoriteAlgoFeedsListNote.event as? FavoriteAlgoFeedsListEvent
|
||||
|
||||
suspend fun favoriteAlgoFeedsListWithBackup(note: Note): Set<Address> {
|
||||
val event = note.event as? FavoriteAlgoFeedsListEvent ?: settings.backupFavoriteAlgoFeedsList
|
||||
return event?.let { decryptionCache.favoriteAlgoFeeds(it) } ?: emptySet()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val flow: StateFlow<Set<Address>> =
|
||||
getFavoriteAlgoFeedsListFlow()
|
||||
.transformLatest { noteState ->
|
||||
emit(favoriteAlgoFeedsListWithBackup(noteState.note))
|
||||
}.onStart {
|
||||
emit(favoriteAlgoFeedsListWithBackup(favoriteAlgoFeedsListNote))
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val flowNotes: StateFlow<List<AddressableNote>> =
|
||||
flow
|
||||
.map { addresses ->
|
||||
addresses.map { cache.getOrCreateAddressableNote(it) }
|
||||
}.onStart {
|
||||
emit(flow.value.map { cache.getOrCreateAddressableNote(it) })
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptyList(),
|
||||
)
|
||||
|
||||
suspend fun follow(dvm: AddressBookmark): FavoriteAlgoFeedsListEvent {
|
||||
val list = getFavoriteAlgoFeedsList()
|
||||
return if (list == null) {
|
||||
FavoriteAlgoFeedsListEvent.create(dvm, false, signer)
|
||||
} else {
|
||||
FavoriteAlgoFeedsListEvent.add(list, dvm, false, signer)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun unfollow(dvm: Address): FavoriteAlgoFeedsListEvent? {
|
||||
val list = getFavoriteAlgoFeedsList() ?: return null
|
||||
return FavoriteAlgoFeedsListEvent.remove(list, dvm, signer)
|
||||
}
|
||||
|
||||
init {
|
||||
settings.backupFavoriteAlgoFeedsList?.let { event ->
|
||||
Log.d("AccountRegisterObservers") { "Loading saved Favorite DVM list ${event.toJson()}" }
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
LocalCache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
Log.d("AccountRegisterObservers", "Favorite DVM List Collector Start")
|
||||
getFavoriteAlgoFeedsListFlow().collect {
|
||||
Log.d("AccountRegisterObservers") { "Favorite DVM List for ${signer.pubKey}" }
|
||||
(it.note.event as? FavoriteAlgoFeedsListEvent)?.let {
|
||||
settings.updateFavoriteAlgoFeedsListTo(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
|
||||
import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState
|
||||
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow
|
||||
@@ -30,11 +31,14 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFoll
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.GeohashFeedFlow
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessFeedFlow
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.AllFavoriteAlgoFeedsFlow
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedFlow
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayFeedFlow
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
@@ -62,6 +66,8 @@ class FeedTopNavFilterState(
|
||||
val caches: FeedDecryptionCaches,
|
||||
val signer: NostrSigner,
|
||||
val scope: CoroutineScope,
|
||||
val favoriteAlgoFeedsOrchestrator: FavoriteAlgoFeedsOrchestrator,
|
||||
val favoriteAlgoFeedAddresses: StateFlow<Set<Address>>,
|
||||
) {
|
||||
fun loadFlowsFor(listName: TopFilter): IFeedFlowsType =
|
||||
when (listName) {
|
||||
@@ -142,6 +148,24 @@ class FeedTopNavFilterState(
|
||||
is TopFilter.Relay -> {
|
||||
RelayFeedFlow(listName.url.normalizeRelayUrl())
|
||||
}
|
||||
|
||||
is TopFilter.FavoriteAlgoFeed -> {
|
||||
FavoriteAlgoFeedFlow(
|
||||
feedAddress = listName.address,
|
||||
orchestrator = favoriteAlgoFeedsOrchestrator,
|
||||
outboxRelays = followsRelays,
|
||||
proxyRelays = proxyRelays,
|
||||
)
|
||||
}
|
||||
|
||||
TopFilter.AllFavoriteAlgoFeeds -> {
|
||||
AllFavoriteAlgoFeedsFlow(
|
||||
favoriteAlgoFeedAddresses = favoriteAlgoFeedAddresses,
|
||||
orchestrator = favoriteAlgoFeedsOrchestrator,
|
||||
outboxRelays = followsRelays,
|
||||
proxyRelays = proxyRelays,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
|
||||
/**
|
||||
* Feed flow that merges snapshots from every currently-favorited DVM into a
|
||||
* single [AllFavoriteAlgoFeedsTopNavFilter]. Re-wires subscriptions whenever the
|
||||
* favorite set changes.
|
||||
*/
|
||||
class AllFavoriteAlgoFeedsFlow(
|
||||
val favoriteAlgoFeedAddresses: StateFlow<Set<Address>>,
|
||||
val orchestrator: FavoriteAlgoFeedsOrchestrator,
|
||||
val outboxRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
) : IFeedFlowsType {
|
||||
private fun resolveContentRelays(
|
||||
outbox: Set<NormalizedRelayUrl>,
|
||||
proxy: Set<NormalizedRelayUrl>,
|
||||
): Set<NormalizedRelayUrl> = if (proxy.isNotEmpty()) proxy else outbox
|
||||
|
||||
private fun merge(
|
||||
snapshots: List<FavoriteAlgoFeedsSnapshot>,
|
||||
contentRelays: Set<NormalizedRelayUrl>,
|
||||
): AllFavoriteAlgoFeedsTopNavFilter {
|
||||
val ids = mutableSetOf<String>()
|
||||
val addresses = mutableSetOf<String>()
|
||||
val listen = mutableSetOf<NormalizedRelayUrl>()
|
||||
val requestIds = mutableSetOf<String>()
|
||||
snapshots.forEach { snap ->
|
||||
ids += snap.ids
|
||||
addresses += snap.addresses
|
||||
listen += snap.responseRelays
|
||||
snap.requestId?.let { requestIds += it }
|
||||
}
|
||||
return AllFavoriteAlgoFeedsTopNavFilter(
|
||||
acceptedIds = ids,
|
||||
acceptedAddresses = addresses,
|
||||
contentRelays = contentRelays,
|
||||
listenRelays = listen,
|
||||
requestIds = requestIds,
|
||||
)
|
||||
}
|
||||
|
||||
private fun emptyFilter(contentRelays: Set<NormalizedRelayUrl>): AllFavoriteAlgoFeedsTopNavFilter =
|
||||
AllFavoriteAlgoFeedsTopNavFilter(
|
||||
acceptedIds = emptySet(),
|
||||
acceptedAddresses = emptySet(),
|
||||
contentRelays = contentRelays,
|
||||
listenRelays = emptySet(),
|
||||
requestIds = emptySet(),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun flow(): Flow<IFeedTopNavFilter> =
|
||||
favoriteAlgoFeedAddresses.flatMapLatest { addresses ->
|
||||
if (addresses.isEmpty()) {
|
||||
combine(outboxRelays, proxyRelays) { outbox, proxy ->
|
||||
emptyFilter(resolveContentRelays(outbox, proxy))
|
||||
}
|
||||
} else {
|
||||
val snapshotFlows: List<Flow<FavoriteAlgoFeedsSnapshot>> = addresses.map { orchestrator.observe(it) }
|
||||
combine(snapshotFlows) { it.toList() }
|
||||
.let { merged ->
|
||||
combine(merged, outboxRelays, proxyRelays) { snaps, outbox, proxy ->
|
||||
merge(snaps, resolveContentRelays(outbox, proxy))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun startValue(): AllFavoriteAlgoFeedsTopNavFilter {
|
||||
val contentRelays = resolveContentRelays(outboxRelays.value, proxyRelays.value)
|
||||
val addresses = favoriteAlgoFeedAddresses.value
|
||||
return if (addresses.isEmpty()) {
|
||||
emptyFilter(contentRelays)
|
||||
} else {
|
||||
merge(addresses.map { orchestrator.observe(it).value }, contentRelays)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
|
||||
collector.emit(startValue())
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* Top-nav filter that unions the latest kind-6300 responses from every currently
|
||||
* favorited DVM. Behaves like [FavoriteAlgoFeedTopNavFilter] (pure membership check
|
||||
* against a snapshot), but the accepted set is the union across N DVMs and the
|
||||
* request-id list carries one entry per DVM for the relay-listen subscription.
|
||||
*/
|
||||
@Immutable
|
||||
class AllFavoriteAlgoFeedsTopNavFilter(
|
||||
val acceptedIds: Set<HexKey>,
|
||||
val acceptedAddresses: Set<String>,
|
||||
val contentRelays: Set<NormalizedRelayUrl>,
|
||||
val listenRelays: Set<NormalizedRelayUrl>,
|
||||
val requestIds: Set<HexKey>,
|
||||
) : IFeedTopNavFilter {
|
||||
override fun matchAuthor(pubkey: HexKey): Boolean = true
|
||||
|
||||
override fun match(noteEvent: Event): Boolean =
|
||||
noteEvent.id in acceptedIds ||
|
||||
(noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses)
|
||||
|
||||
override fun toPerRelayFlow(cache: LocalCache): Flow<FavoriteAlgoFeedTopNavPerRelayFilterSet> = MutableStateFlow(startValue(cache))
|
||||
|
||||
override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet =
|
||||
FavoriteAlgoFeedTopNavPerRelayFilterSet(
|
||||
contentFetches =
|
||||
contentRelays.associateWith {
|
||||
FavoriteAlgoFeedTopNavPerRelayFilter(
|
||||
ids = acceptedIds,
|
||||
addresses = acceptedAddresses,
|
||||
)
|
||||
},
|
||||
listenRelays = listenRelays,
|
||||
requestIds = requestIds,
|
||||
)
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
|
||||
class FavoriteAlgoFeedFlow(
|
||||
val feedAddress: Address,
|
||||
val orchestrator: FavoriteAlgoFeedsOrchestrator,
|
||||
val outboxRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
) : IFeedFlowsType {
|
||||
private fun resolveRelays(
|
||||
outbox: Set<NormalizedRelayUrl>,
|
||||
proxy: Set<NormalizedRelayUrl>,
|
||||
): Set<NormalizedRelayUrl> = if (proxy.isNotEmpty()) proxy else outbox
|
||||
|
||||
private fun buildFilter(
|
||||
snapshot: com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot,
|
||||
contentRelays: Set<NormalizedRelayUrl>,
|
||||
) = FavoriteAlgoFeedTopNavFilter(
|
||||
feedAddress = feedAddress,
|
||||
acceptedIds = snapshot.ids,
|
||||
acceptedAddresses = snapshot.addresses,
|
||||
contentRelays = contentRelays,
|
||||
listenRelays = snapshot.responseRelays,
|
||||
requestId = snapshot.requestId,
|
||||
)
|
||||
|
||||
override fun flow(): Flow<IFeedTopNavFilter> =
|
||||
combine(orchestrator.observe(feedAddress), outboxRelays, proxyRelays) { snap, outbox, proxy ->
|
||||
buildFilter(snap, resolveRelays(outbox, proxy))
|
||||
}
|
||||
|
||||
override fun startValue(): FavoriteAlgoFeedTopNavFilter =
|
||||
buildFilter(
|
||||
snapshot = orchestrator.observe(feedAddress).value,
|
||||
contentRelays = resolveRelays(outboxRelays.value, proxyRelays.value),
|
||||
)
|
||||
|
||||
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
|
||||
collector.emit(startValue())
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* Top-nav filter backed by the latest kind-6300 response from a favorite algo feed.
|
||||
*
|
||||
* The filter is a pure immutable membership check: [match] accepts a note only if the
|
||||
* DVM's latest response included it. When a new response arrives, a new instance is
|
||||
* emitted through [FavoriteAlgoFeedFlow] and replaces the active filter.
|
||||
*/
|
||||
@Immutable
|
||||
class FavoriteAlgoFeedTopNavFilter(
|
||||
val feedAddress: Address,
|
||||
val acceptedIds: Set<HexKey>,
|
||||
val acceptedAddresses: Set<String>,
|
||||
val contentRelays: Set<NormalizedRelayUrl>,
|
||||
val listenRelays: Set<NormalizedRelayUrl>,
|
||||
val requestId: HexKey?,
|
||||
) : IFeedTopNavFilter {
|
||||
override fun matchAuthor(pubkey: HexKey): Boolean = true
|
||||
|
||||
override fun match(noteEvent: Event): Boolean =
|
||||
noteEvent.id in acceptedIds ||
|
||||
(noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses)
|
||||
|
||||
override fun toPerRelayFlow(cache: LocalCache): Flow<FavoriteAlgoFeedTopNavPerRelayFilterSet> = MutableStateFlow(startValue(cache))
|
||||
|
||||
override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet =
|
||||
FavoriteAlgoFeedTopNavPerRelayFilterSet(
|
||||
contentFetches =
|
||||
contentRelays.associateWith {
|
||||
FavoriteAlgoFeedTopNavPerRelayFilter(
|
||||
ids = acceptedIds,
|
||||
addresses = acceptedAddresses,
|
||||
)
|
||||
},
|
||||
listenRelays = listenRelays,
|
||||
requestIds = setOfNotNull(requestId),
|
||||
)
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
@Immutable
|
||||
class FavoriteAlgoFeedTopNavPerRelayFilter(
|
||||
val ids: Set<HexKey>,
|
||||
val addresses: Set<String>,
|
||||
) : IFeedTopNavPerRelayFilter
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* Two relay sets, two distinct subscriptions:
|
||||
*
|
||||
* - [contentFetches] — for each user-configured content relay, the ids/addresses
|
||||
* we want to pull (the actual notes the DVM curated).
|
||||
* - [listenRelays] — the union of DVM publish relays across all active DVMs
|
||||
* (where they will deliver future kind 6300 / 7000 events for their requests).
|
||||
* - [requestIds] — the set of currently-active kind-5300 request ids to listen
|
||||
* for. A single feed carries one; the merged "All favorite algo feeds"
|
||||
* filter carries one per favorite algo feed.
|
||||
*/
|
||||
class FavoriteAlgoFeedTopNavPerRelayFilterSet(
|
||||
val contentFetches: Map<NormalizedRelayUrl, FavoriteAlgoFeedTopNavPerRelayFilter>,
|
||||
val listenRelays: Set<NormalizedRelayUrl>,
|
||||
val requestIds: Set<HexKey>,
|
||||
) : IFeedTopNavPerRelayFilterSet
|
||||
@@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.Long
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen
|
||||
@@ -277,6 +278,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.RequestToVanish> { RequestToVanishScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.EditFavoriteAlgoFeeds> { FavoriteAlgoFeedsListScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.EditPaymentTargets> { PaymentTargetsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
|
||||
|
||||
|
||||
@@ -171,6 +171,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object EditMediaServers : Route()
|
||||
|
||||
@Serializable object EditFavoriteAlgoFeeds : Route()
|
||||
|
||||
@Serializable object EditPaymentTargets : Route()
|
||||
|
||||
@Serializable object UpdateReactionType : Route()
|
||||
|
||||
+64
-4
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ViewList
|
||||
import androidx.compose.material.icons.automirrored.outlined.VolumeOff
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import androidx.compose.material.icons.outlined.Groups
|
||||
import androidx.compose.material.icons.outlined.LocationOn
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
@@ -67,6 +68,7 @@ import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@@ -84,6 +86,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.CommunityName
|
||||
import com.vitorpamplona.amethyst.ui.screen.FavoriteAlgoFeedName
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.GeoHashName
|
||||
import com.vitorpamplona.amethyst.ui.screen.HashtagName
|
||||
@@ -100,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@@ -150,23 +154,40 @@ fun FeedFilterSpinner(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Size20Modifier)
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// Bound the Column so long filter names (e.g. DVM titles) get truncated
|
||||
// instead of wrapping to multiple lines and shoving the expand icon out.
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
) {
|
||||
val filter = selected?.code
|
||||
if (filter is TopFilter.Geohash) {
|
||||
LoadCityName(
|
||||
geohashStr = filter.tag,
|
||||
onLoading = {
|
||||
Row {
|
||||
Text(filter.tag)
|
||||
Text(
|
||||
text = filter.tag,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
|
||||
}
|
||||
},
|
||||
) { cityName ->
|
||||
Text(cityName)
|
||||
Text(
|
||||
text = cityName,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(currentText)
|
||||
Text(
|
||||
text = currentText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
if (filter is TopFilter.AroundMe) {
|
||||
@@ -178,6 +199,8 @@ fun FeedFilterSpinner(
|
||||
text = stringRes(R.string.lack_location_permissions),
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
val location by Amethyst.instance.locationManager.geohashStateFlow
|
||||
@@ -193,6 +216,8 @@ fun FeedFilterSpinner(
|
||||
text = "(${myLocation.geoHash})",
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
|
||||
@@ -203,6 +228,8 @@ fun FeedFilterSpinner(
|
||||
text = "($cityName)",
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -212,6 +239,8 @@ fun FeedFilterSpinner(
|
||||
text = stringRes(R.string.lack_location_permissions),
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -220,6 +249,8 @@ fun FeedFilterSpinner(
|
||||
text = stringRes(R.string.loading_location),
|
||||
fontSize = Font12SP,
|
||||
lineHeight = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -329,6 +360,20 @@ fun RenderOption(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
is FavoriteAlgoFeedName -> {
|
||||
val noteState by observeNote(option.note, accountViewModel)
|
||||
val name =
|
||||
(noteState.note.event as? AppDefinitionEvent)
|
||||
?.appMetaData()
|
||||
?.name
|
||||
?.takeIf { it.isNotBlank() } ?: option.note.dTag()
|
||||
Text(
|
||||
text = name,
|
||||
fontSize = Font14SP,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +391,7 @@ private enum class FeedGroup(
|
||||
COMMUNITIES(R.string.feed_group_communities),
|
||||
LOCATIONS(R.string.feed_group_locations),
|
||||
LISTS(R.string.feed_group_lists),
|
||||
DVMS(R.string.feed_group_dvms),
|
||||
RELAYS(R.string.feed_group_relays),
|
||||
}
|
||||
|
||||
@@ -373,10 +419,15 @@ private fun groupFeedDefinitions(options: ImmutableList<FeedDefinition>): Map<Fe
|
||||
FeedGroup.LOCATIONS
|
||||
}
|
||||
|
||||
is FavoriteAlgoFeedName -> {
|
||||
FeedGroup.DVMS
|
||||
}
|
||||
|
||||
is ResourceName -> {
|
||||
when (entry.item.code) {
|
||||
is TopFilter.AroundMe -> FeedGroup.LOCATIONS
|
||||
is TopFilter.Global -> FeedGroup.RELAYS
|
||||
is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS
|
||||
else -> FeedGroup.FEEDS
|
||||
}
|
||||
}
|
||||
@@ -541,12 +592,21 @@ private fun FeedIcon(
|
||||
Icons.AutoMirrored.Outlined.ViewList
|
||||
}
|
||||
|
||||
is TopFilter.FavoriteAlgoFeed -> {
|
||||
Icons.Outlined.AutoAwesome
|
||||
}
|
||||
|
||||
is TopFilter.AllFavoriteAlgoFeeds -> {
|
||||
Icons.Outlined.AutoAwesome
|
||||
}
|
||||
|
||||
else -> {
|
||||
when (item.name) {
|
||||
is GeoHashName -> Icons.Outlined.LocationOn
|
||||
is RelayName -> Icons.Outlined.Storage
|
||||
is CommunityName -> Icons.Outlined.Groups
|
||||
is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList
|
||||
is FavoriteAlgoFeedName -> Icons.Outlined.AutoAwesome
|
||||
else -> Icons.Outlined.Person
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -96,6 +97,12 @@ class TopNavFilterState(
|
||||
name = ResourceName(R.string.follow_list_chess),
|
||||
)
|
||||
|
||||
val allFavoriteAlgoFeedsFollow =
|
||||
FeedDefinition(
|
||||
code = TopFilter.AllFavoriteAlgoFeeds,
|
||||
name = ResourceName(R.string.follow_list_all_favorite_dvms),
|
||||
)
|
||||
|
||||
val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)
|
||||
|
||||
fun mergePeopleLists(
|
||||
@@ -140,6 +147,7 @@ class TopNavFilterState(
|
||||
geotagList: Set<String>,
|
||||
communityList: List<AddressableNote>,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
favoriteAlgoFeedsList: List<AddressableNote>,
|
||||
): List<FeedDefinition> {
|
||||
val hashtags =
|
||||
hashtagList.map {
|
||||
@@ -173,7 +181,26 @@ class TopNavFilterState(
|
||||
)
|
||||
}
|
||||
|
||||
return (communities + hashtags + geotags + relays).sortedBy { it.name.name() }
|
||||
// Favorites can only be added through FavoriteAlgoFeedToggle, which itself checks
|
||||
// that the AppDefinitionEvent advertises kind 5300. Don't re-check here: on
|
||||
// cold start the AppDefinitionEvent may not be in cache yet, and dropping
|
||||
// the entry means the persisted TopFilter.FavoriteAlgoFeed can't find its chip
|
||||
// in the spinner (user sees "Select an option" while the banner fires the
|
||||
// RPC — the bug we had before this change).
|
||||
val favoriteAlgoFeeds =
|
||||
favoriteAlgoFeedsList.map { feedNote ->
|
||||
FeedDefinition(
|
||||
TopFilter.FavoriteAlgoFeed(feedNote.address),
|
||||
FavoriteAlgoFeedName(feedNote),
|
||||
)
|
||||
}
|
||||
|
||||
// Only show the "All favorite algo feeds" meta-chip when there is at least one
|
||||
// real favorite to merge; otherwise the chip opens to an empty feed.
|
||||
val allFavorites =
|
||||
if (favoriteAlgoFeeds.isNotEmpty()) listOf(allFavoriteAlgoFeedsFollow) else emptyList()
|
||||
|
||||
return (communities + hashtags + geotags + relays + allFavorites + favoriteAlgoFeeds).sortedBy { it.name.name() }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -183,6 +210,7 @@ class TopNavFilterState(
|
||||
account.geohashList.flow,
|
||||
account.communityList.flowNotes,
|
||||
account.relayFeedsList.flow,
|
||||
account.favoriteAlgoFeedsList.flowNotes,
|
||||
::mergeInterests,
|
||||
).onStart {
|
||||
emit(
|
||||
@@ -191,6 +219,7 @@ class TopNavFilterState(
|
||||
account.geohashList.flow.value,
|
||||
account.communityList.flowNotes.value,
|
||||
account.relayFeedsList.flow.value,
|
||||
account.favoriteAlgoFeedsList.flowNotes.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -298,6 +327,15 @@ class CommunityName(
|
||||
override fun name() = "/n/${(note.dTag())}"
|
||||
}
|
||||
|
||||
@Stable
|
||||
class FavoriteAlgoFeedName(
|
||||
val note: AddressableNote,
|
||||
) : Name() {
|
||||
override fun name(): String =
|
||||
(note.event as? AppDefinitionEvent)?.appMetaData()?.name?.takeIf { it.isNotBlank() }
|
||||
?: note.dTag()
|
||||
}
|
||||
|
||||
@Immutable
|
||||
class FeedDefinition(
|
||||
val code: TopFilter,
|
||||
|
||||
+9
-2
@@ -138,6 +138,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
@@ -1106,6 +1107,12 @@ class AccountViewModel(
|
||||
|
||||
fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) }
|
||||
|
||||
fun followFavoriteAlgoFeed(dvm: AddressBookmark) = launchSigner { account.followFavoriteAlgoFeed(dvm) }
|
||||
|
||||
fun unfollowFavoriteAlgoFeed(dvm: Address) = launchSigner { account.unfollowFavoriteAlgoFeed(dvm) }
|
||||
|
||||
fun refreshFavoriteAlgoFeed(dvm: Address) = account.favoriteAlgoFeedsOrchestrator.refresh(dvm)
|
||||
|
||||
fun followRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.followRelayFeed(url) }
|
||||
|
||||
fun unfollowRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.unfollowRelayFeed(url) }
|
||||
@@ -1820,8 +1827,8 @@ class AccountViewModel(
|
||||
onReady: (event: Note) -> Unit,
|
||||
) {
|
||||
launchSigner {
|
||||
account.requestDVMContentDiscovery(dvmPublicKey) {
|
||||
onReady(LocalCache.getOrCreateNote(it.id))
|
||||
account.requestDVMContentDiscovery(dvmPublicKey) { request, _ ->
|
||||
onReady(LocalCache.getOrCreateNote(request.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-35
@@ -21,11 +21,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -40,8 +38,8 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
|
||||
@@ -51,12 +49,12 @@ import com.vitorpamplona.amethyst.ui.note.LikeReaction
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapReaction
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.FavoriteAlgoFeedToggle
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.SimpleImageBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
import com.vitorpamplona.amethyst.ui.theme.nip05
|
||||
@@ -118,25 +116,12 @@ fun RenderContentDVMThumb(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = RowColSpacing5dp,
|
||||
) {
|
||||
LikeReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.onSurface,
|
||||
if (baseNote is AddressableNote) {
|
||||
FavoriteAlgoFeedToggle(
|
||||
appDefinitionNote = baseNote,
|
||||
accountViewModel = accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.onSurface,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
},
|
||||
onDescription = {
|
||||
card.description?.let {
|
||||
@@ -168,23 +153,17 @@ fun RenderContentDVMThumb(
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
amount = card.amount + " Sats"
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = Arrangement.Absolute.Right,
|
||||
) {
|
||||
Text(
|
||||
textAlign = TextAlign.End,
|
||||
text = " $amount ",
|
||||
color = color,
|
||||
maxLines = 3,
|
||||
maxLines = 1,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f, fill = false)
|
||||
.border(Dp(.1f), color, shape = RoundedCornerShape(20)),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
card.personalized?.let {
|
||||
var color = Color.DarkGray
|
||||
@@ -196,24 +175,35 @@ fun RenderContentDVMThumb(
|
||||
color = MaterialTheme.colorScheme.nip05
|
||||
name = "Generic"
|
||||
}
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = Arrangement.Absolute.Right,
|
||||
) {
|
||||
Text(
|
||||
textAlign = TextAlign.End,
|
||||
text = " $name ",
|
||||
color = color,
|
||||
maxLines = 3,
|
||||
maxLines = 1,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 4.dp)
|
||||
.weight(1f, fill = false)
|
||||
.border(Dp(.1f), color, shape = RoundedCornerShape(20)),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
horizontalArrangement = RowColSpacing5dp,
|
||||
) {
|
||||
LikeReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.onSurface,
|
||||
accountViewModel = accountViewModel,
|
||||
nav,
|
||||
)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
ZapReaction(
|
||||
baseNote = baseNote,
|
||||
grayTint = MaterialTheme.colorScheme.onSurface,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+26
-13
@@ -354,10 +354,28 @@ fun FeedDVM(
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
Text(currentStatus, textAlign = TextAlign.Center)
|
||||
|
||||
if (status.code == "payment-required") {
|
||||
DvmPaymentActions(
|
||||
latestStatus = latestStatus,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onStatusUpdate = { currentStatus = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DvmPaymentActions(
|
||||
latestStatus: NIP90StatusEvent,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
onStatusUpdate: (String) -> Unit,
|
||||
) {
|
||||
val status = latestStatus.status() ?: return
|
||||
|
||||
if (status.code != "payment-required") return
|
||||
|
||||
val amountTag = latestStatus.firstAmount()
|
||||
val amount = amountTag?.amount
|
||||
|
||||
val invoice = amountTag?.lnInvoice
|
||||
|
||||
val thankYou = stringRes(id = R.string.dvm_waiting_to_confirm_payment)
|
||||
@@ -371,10 +389,10 @@ fun FeedDVM(
|
||||
bolt11 = invoice,
|
||||
zappedNote = null,
|
||||
onSent = {
|
||||
currentStatus = nwcPaymentRequest
|
||||
onStatusUpdate(nwcPaymentRequest)
|
||||
},
|
||||
onResponse = { response ->
|
||||
currentStatus =
|
||||
onStatusUpdate(
|
||||
if (response is PayInvoiceErrorResponse) {
|
||||
stringRes(
|
||||
context,
|
||||
@@ -384,7 +402,8 @@ fun FeedDVM(
|
||||
)
|
||||
} else {
|
||||
thankYou
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
@@ -392,10 +411,10 @@ fun FeedDVM(
|
||||
invoice,
|
||||
context,
|
||||
onPaid = {
|
||||
currentStatus = thankYou
|
||||
onStatusUpdate(thankYou)
|
||||
},
|
||||
onError = {
|
||||
currentStatus = it
|
||||
onStatusUpdate(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -426,12 +445,6 @@ fun FeedDVM(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (status.code == "processing") {
|
||||
currentStatus = status.description
|
||||
} else if (status.code == "error") {
|
||||
currentStatus = status.description
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+30
-3
@@ -21,19 +21,25 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.MyExtensibleTopAppBar
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
|
||||
@Composable
|
||||
fun DvmTopBar(
|
||||
@@ -41,7 +47,7 @@ fun DvmTopBar(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
TopBarExtensibleWithBackButton(
|
||||
MyExtensibleTopAppBar(
|
||||
title = {
|
||||
LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote ->
|
||||
if (appDefinitionNote != null) {
|
||||
@@ -82,6 +88,27 @@ fun DvmTopBar(
|
||||
}
|
||||
}
|
||||
},
|
||||
popBack = nav::popBack,
|
||||
navigationIcon = { IconButton(onClick = nav::popBack) { ArrowBackIcon() } },
|
||||
actions = {
|
||||
// The route passes the event's hex id, so LoadNote returns a plain Note,
|
||||
// not the AddressableNote the toggle needs. Derive the AddressableNote
|
||||
// from the loaded AppDefinitionEvent's address() once the event exists.
|
||||
LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote ->
|
||||
if (appDefinitionNote != null) {
|
||||
val addressableNote by
|
||||
observeNoteAndMap(appDefinitionNote, accountViewModel) { note ->
|
||||
(note.event as? AppDefinitionEvent)?.let {
|
||||
LocalCache.getOrCreateAddressableNote(it.address())
|
||||
}
|
||||
}
|
||||
addressableNote?.let { target ->
|
||||
FavoriteAlgoFeedToggle(
|
||||
appDefinitionNote = target,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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.screen.loggedIn.dvms
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.outlined.StarBorder
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
|
||||
|
||||
/**
|
||||
* Inline star toggle that follows / unfollows a NIP-90 content-discovery DVM.
|
||||
*
|
||||
* Uses [ClickableBox] (no [androidx.compose.material3.IconButton] padding) so it
|
||||
* fits in a `LeftPictureLayout` title row alongside other compact reactions
|
||||
* without inflating the row to 48dp.
|
||||
*
|
||||
* Hidden until the underlying [AppDefinitionEvent] loads and we can confirm the
|
||||
* DVM advertises kind 5300 — favouriting any other DVM type would only stall on
|
||||
* a 6300 reply that never comes.
|
||||
*/
|
||||
@Composable
|
||||
fun FavoriteAlgoFeedToggle(
|
||||
appDefinitionNote: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
iconSizeModifier: Modifier = Size20Modifier,
|
||||
) {
|
||||
val supportsContentDiscovery by
|
||||
observeNoteAndMap(appDefinitionNote, accountViewModel) { note ->
|
||||
(note.event as? AppDefinitionEvent)?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true
|
||||
}
|
||||
|
||||
if (!supportsContentDiscovery) return
|
||||
|
||||
val favorites by accountViewModel.account.favoriteAlgoFeedsList.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val isFavorite = favorites.contains(appDefinitionNote.address)
|
||||
|
||||
ClickableBox(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
if (isFavorite) {
|
||||
accountViewModel.unfollowFavoriteAlgoFeed(appDefinitionNote.address)
|
||||
} else {
|
||||
accountViewModel.followFavoriteAlgoFeed(
|
||||
AddressBookmark(
|
||||
address = appDefinitionNote.address,
|
||||
relayHint = appDefinitionNote.relayHintUrl(),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (isFavorite) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Star,
|
||||
contentDescription = stringRes(R.string.remove_dvm_from_favorites),
|
||||
modifier = iconSizeModifier,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.StarBorder,
|
||||
contentDescription = stringRes(R.string.add_dvm_to_favorites),
|
||||
modifier = iconSizeModifier,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* 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.screen.loggedIn.dvms.favorites
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.BottomStart
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FavoriteAlgoFeedsListScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarWithBackButton(
|
||||
caption = stringRes(R.string.favorite_dvms_title),
|
||||
popBack = nav::popBack,
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
start = 16.dp,
|
||||
top = padding.calculateTopPadding(),
|
||||
end = 16.dp,
|
||||
bottom = padding.calculateBottomPadding(),
|
||||
).consumeWindowInsets(padding),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.favorite_dvms_explainer),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
|
||||
FavoriteAlgoFeedList(accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FavoriteAlgoFeedList(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val favorites by accountViewModel.account.favoriteAlgoFeedsList.flowNotes
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
if (favorites.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.favorite_dvms_empty),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
contentPadding = FeedPadding,
|
||||
) {
|
||||
items(
|
||||
items = favorites,
|
||||
key = { it.address.toValue() },
|
||||
) { feedNote ->
|
||||
FavoriteAlgoFeedRow(
|
||||
feedNote = feedNote,
|
||||
accountViewModel = accountViewModel,
|
||||
onOpen = { nav.nav(Route.ContentDiscovery(feedNote.idHex)) },
|
||||
onRemove = { accountViewModel.unfollowFavoriteAlgoFeed(feedNote.address) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun FavoriteAlgoFeedRow(
|
||||
feedNote: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
onOpen: () -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
val card = observeAppDefinition(feedNote, accountViewModel)
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(onClick = onOpen)
|
||||
.padding(vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
card.cover?.let { cover ->
|
||||
Box(contentAlignment = BottomStart) {
|
||||
MyAsyncImage(
|
||||
imageUrl = cover,
|
||||
contentDescription = card.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
mainImageModifier = Modifier,
|
||||
loadedImageModifier = SimpleImage35Modifier,
|
||||
accountViewModel = accountViewModel,
|
||||
onLoadingBackground = {
|
||||
feedNote.author?.let { author ->
|
||||
BannerImage(author, SimpleImage35Modifier, accountViewModel)
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
feedNote.author?.let { author ->
|
||||
BannerImage(author, SimpleImage35Modifier, accountViewModel)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
} ?: run {
|
||||
feedNote.author?.let { author ->
|
||||
BannerImage(author, SimpleImage35Modifier, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = DoubleHorzSpacer)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = card.name.ifBlank { feedNote.dTag() },
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
card.description?.takeIf { it.isNotBlank() }?.let {
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
Text(
|
||||
text = it,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.grayText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Delete,
|
||||
contentDescription = stringRes(R.string.remove_dvm_from_favorites),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* 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.screen.loggedIn.home
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmPaymentActions
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||
|
||||
@Composable
|
||||
fun HomeAlgoFeedStatusBanner(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val topFilter by accountViewModel.account.settings.defaultHomeFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
when (val filter = topFilter) {
|
||||
is TopFilter.FavoriteAlgoFeed -> SingleAlgoFeedBanner(filter, accountViewModel, nav, modifier)
|
||||
is TopFilter.AllFavoriteAlgoFeeds -> AllFavoriteAlgoFeedsBanner(accountViewModel, modifier)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SingleAlgoFeedBanner(
|
||||
favFeed: TopFilter.FavoriteAlgoFeed,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val snapshot by accountViewModel.account.favoriteAlgoFeedsOrchestrator
|
||||
.observe(favFeed.address)
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
// Hide the banner when the feed is already populated.
|
||||
if (snapshot.ids.isNotEmpty() || snapshot.addresses.isNotEmpty()) return
|
||||
|
||||
val feedAddressValue = favFeed.address.toValue()
|
||||
|
||||
LoadNote(baseNoteHex = feedAddressValue, accountViewModel = accountViewModel) { feedNote ->
|
||||
val resolvedName by
|
||||
observeNoteAndMap(feedNote ?: return@LoadNote, accountViewModel) { note ->
|
||||
(note.event as? AppDefinitionEvent)
|
||||
?.appMetaData()
|
||||
?.name
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: (note as? com.vitorpamplona.amethyst.model.AddressableNote)?.dTag()
|
||||
?: ""
|
||||
}
|
||||
|
||||
BannerCard(modifier) {
|
||||
val status = snapshot.latestStatus?.status()
|
||||
|
||||
when {
|
||||
snapshot.errorMessage != null -> {
|
||||
BannerMessageRow(
|
||||
message = stringRes(R.string.dvm_home_status_error),
|
||||
showSpinner = false,
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) }
|
||||
}
|
||||
|
||||
status?.code == "payment-required" -> {
|
||||
BannerMessageRow(
|
||||
message =
|
||||
status.description.ifBlank {
|
||||
stringRes(R.string.dvm_home_status_payment_required)
|
||||
},
|
||||
showSpinner = false,
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
var statusOverride by remember { mutableStateOf<String?>(null) }
|
||||
val msg = statusOverride
|
||||
if (msg != null) {
|
||||
Text(text = msg, style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
snapshot.latestStatus?.let {
|
||||
DvmPaymentActions(
|
||||
latestStatus = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
onStatusUpdate = { statusOverride = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
status?.code == "error" -> {
|
||||
BannerMessageRow(
|
||||
message =
|
||||
status.description.ifBlank {
|
||||
stringRes(R.string.dvm_home_status_error)
|
||||
},
|
||||
showSpinner = false,
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) }
|
||||
}
|
||||
|
||||
status?.code == "processing" -> {
|
||||
BannerMessageRow(
|
||||
message =
|
||||
status.description.ifBlank {
|
||||
stringRes(R.string.dvm_home_status_processing)
|
||||
},
|
||||
showSpinner = true,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
BannerMessageRow(
|
||||
message = stringRes(R.string.dvm_home_status_requesting, resolvedName),
|
||||
showSpinner = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AllFavoriteAlgoFeedsBanner(
|
||||
accountViewModel: AccountViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val addresses by accountViewModel.account.favoriteAlgoFeedsList.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
if (addresses.isEmpty()) return
|
||||
|
||||
// Observe each DVM's snapshot so we can decide whether to hide the banner
|
||||
// based on the aggregate state. Hide it as soon as any DVM has produced a
|
||||
// feed; only error out when every one of them has errored.
|
||||
val snapshots: List<FavoriteAlgoFeedsSnapshot> =
|
||||
addresses.map { address ->
|
||||
val snap by accountViewModel.account.favoriteAlgoFeedsOrchestrator
|
||||
.observe(address)
|
||||
.collectAsStateWithLifecycle()
|
||||
snap
|
||||
}
|
||||
|
||||
val anyResponded = snapshots.any { it.ids.isNotEmpty() || it.addresses.isNotEmpty() }
|
||||
if (anyResponded) return
|
||||
|
||||
val allErrored = snapshots.all { it.errorMessage != null || it.latestStatus?.status()?.code == "error" }
|
||||
|
||||
BannerCard(modifier) {
|
||||
if (allErrored) {
|
||||
BannerMessageRow(
|
||||
message = stringRes(R.string.dvm_home_status_error),
|
||||
showSpinner = false,
|
||||
)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
RetryButton {
|
||||
addresses.forEach { accountViewModel.refreshFavoriteAlgoFeed(it) }
|
||||
}
|
||||
} else {
|
||||
BannerMessageRow(
|
||||
message = stringRes(R.string.dvm_home_status_requesting_all),
|
||||
showSpinner = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerCard(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
// Sits above the feed instead of in the topBar Column, so adding/removing
|
||||
// the banner doesn't shift the tabs/filter up and down. tonalElevation
|
||||
// gives it a faint surface tint so it reads as a floating overlay.
|
||||
Surface(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 4.dp,
|
||||
shadowElevation = 4.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerMessageRow(
|
||||
message: String,
|
||||
showSpinner: Boolean,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
) {
|
||||
if (showSpinner) {
|
||||
LoadingAnimation(indicatorSize = 14.dp, circleWidth = 2.dp)
|
||||
Spacer(modifier = StdHorzSpacer)
|
||||
}
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RetryButton(onClick: () -> Unit) {
|
||||
OutlinedButton(onClick = onClick) {
|
||||
Text(stringRes(R.string.dvm_home_retry))
|
||||
}
|
||||
}
|
||||
+52
-3
@@ -23,11 +23,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
@@ -52,6 +55,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -209,9 +213,15 @@ private fun HomePages(
|
||||
HomeScreenFloatingButton(accountViewModel, nav)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
) { paddingValues ->
|
||||
// Wrap pager + banner in a Box so the banner can float over the feed
|
||||
// (anchored top-center) instead of living in the topBar Column where
|
||||
// it would push the tabs down every time it appears or disappears.
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(paddingValues),
|
||||
) {
|
||||
HorizontalPager(
|
||||
contentPadding = it,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
state = pagerState,
|
||||
userScrollEnabled = true,
|
||||
modifier =
|
||||
@@ -229,6 +239,13 @@ private fun HomePages(
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
HomeAlgoFeedStatusBanner(
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +298,23 @@ fun HomeFeeds(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
RefresheableBox(feedState, enablePullRefresh) {
|
||||
val activeFilter by accountViewModel.account.settings.defaultHomeFollowList
|
||||
.collectAsStateWithLifecycle()
|
||||
val favoriteAlgoFeedAddresses by accountViewModel.account.favoriteAlgoFeedsList.flow
|
||||
.collectAsStateWithLifecycle()
|
||||
|
||||
val onRefresh: () -> Unit = {
|
||||
feedState.invalidateData()
|
||||
// Swiping down on Home should also re-issue the kind-5300 request(s) so the
|
||||
// DVM(s) produce fresh feeds, not just re-render whatever's cached.
|
||||
when (val filter = activeFilter) {
|
||||
is TopFilter.FavoriteAlgoFeed -> accountViewModel.refreshFavoriteAlgoFeed(filter.address)
|
||||
is TopFilter.AllFavoriteAlgoFeeds -> favoriteAlgoFeedAddresses.forEach { accountViewModel.refreshFavoriteAlgoFeed(it) }
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
RefresheableHomeBox(onRefresh, enablePullRefresh) {
|
||||
SaveableFeedContentState(feedState, scrollStateKey) { listState ->
|
||||
RenderFeedContentState(
|
||||
feedContentState = feedState,
|
||||
@@ -290,12 +323,28 @@ fun HomeFeeds(
|
||||
nav = nav,
|
||||
routeForLastRead = routeForLastRead,
|
||||
onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, accountViewModel, nav) },
|
||||
onEmpty = { HomeFeedEmpty(feedState::invalidateData) },
|
||||
onEmpty = { HomeFeedEmpty(onRefresh) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RefresheableHomeBox(
|
||||
onRefresh: () -> Unit,
|
||||
enablePullRefresh: Boolean,
|
||||
content: @Composable androidx.compose.foundation.layout.BoxScope.() -> Unit,
|
||||
) {
|
||||
if (enablePullRefresh) {
|
||||
RefresheableBox(onRefresh = onRefresh, content = content)
|
||||
} else {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier.fillMaxSize(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun FeedLoaded(
|
||||
|
||||
+3
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
|
||||
@@ -42,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.f
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip64Chess.filterHomePostsByChess
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByAllCommunities
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds.filterHomePostsByAlgoFeedIds
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||
@@ -74,6 +76,7 @@ class HomeOutboxEventsEoseManager(
|
||||
is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
|
||||
is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince)
|
||||
is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince)
|
||||
is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince)
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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.screen.loggedIn.home.datasource.nip90AlgoFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
|
||||
|
||||
/**
|
||||
* Builds relay REQ filters for a favorite-DVM home feed.
|
||||
*
|
||||
* Two distinct subscription kinds with two distinct relay sets:
|
||||
*
|
||||
* - **Content fetch** — for each of the user's outbox/proxy relays, request the
|
||||
* note IDs and addressable references the DVM curated. Notes typically live on
|
||||
* the user's normal relays, so this is where we fetch them.
|
||||
*
|
||||
* - **Response listen** — for each relay the DVM advertised (where it received
|
||||
* the kind-5300 request and will publish its 6300/7000 reply), subscribe to
|
||||
* future kind 6300 / 7000 events tagged with the request id. The DVM almost
|
||||
* never publishes responses on the user's outbox, so listening anywhere else
|
||||
* would silently miss them.
|
||||
*/
|
||||
fun filterHomePostsByAlgoFeedIds(
|
||||
set: FavoriteAlgoFeedTopNavPerRelayFilterSet,
|
||||
@Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?,
|
||||
@Suppress("UNUSED_PARAMETER") defaultSince: Long?,
|
||||
): List<RelayBasedFilter> {
|
||||
val out = mutableListOf<RelayBasedFilter>()
|
||||
|
||||
set.contentFetches.forEach { (relay, filter) ->
|
||||
out += contentFetchFilters(relay, filter)
|
||||
}
|
||||
|
||||
if (set.requestIds.isNotEmpty()) {
|
||||
val requestIds = set.requestIds.toList()
|
||||
set.listenRelays.forEach { relay ->
|
||||
out += responseListenFilter(relay, requestIds)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private fun contentFetchFilters(
|
||||
relay: NormalizedRelayUrl,
|
||||
filter: FavoriteAlgoFeedTopNavPerRelayFilter,
|
||||
): List<RelayBasedFilter> {
|
||||
val out = mutableListOf<RelayBasedFilter>()
|
||||
|
||||
if (filter.ids.isNotEmpty()) {
|
||||
out +=
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
ids = filter.ids.toList(),
|
||||
limit = filter.ids.size,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (filter.addresses.isNotEmpty()) {
|
||||
out +=
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
tags = mapOf("a" to filter.addresses.toList()),
|
||||
limit = filter.addresses.size,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private fun responseListenFilter(
|
||||
relay: NormalizedRelayUrl,
|
||||
requestIds: List<HexKey>,
|
||||
) = RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
kinds =
|
||||
listOf(
|
||||
NIP90ContentDiscoveryResponseEvent.KIND,
|
||||
NIP90StatusEvent.KIND,
|
||||
),
|
||||
tags = mapOf("e" to requestIds),
|
||||
limit = 10 * requestIds.size,
|
||||
),
|
||||
)
|
||||
+8
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AutoAwesome
|
||||
import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material.icons.outlined.CloudUpload
|
||||
import androidx.compose.material.icons.outlined.DeleteForever
|
||||
@@ -122,6 +123,13 @@ fun AllSettingsScreen(
|
||||
onClick = { nav.nav(Route.EditMediaServers) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
SettingsNavigationRow(
|
||||
title = R.string.favorite_dvms_title,
|
||||
icon = Icons.Outlined.AutoAwesome,
|
||||
tint = tint,
|
||||
onClick = { nav.nav(Route.EditFavoriteAlgoFeeds) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
SettingsNavigationRow(
|
||||
title = R.string.reactions,
|
||||
icon = Icons.Outlined.FavoriteBorder,
|
||||
|
||||
@@ -1732,8 +1732,22 @@
|
||||
<string name="feed_group_locations">Locations</string>
|
||||
<string name="feed_group_communities">Communities</string>
|
||||
<string name="feed_group_lists">Lists</string>
|
||||
<string name="feed_group_dvms">Feed Algorithms</string>
|
||||
<string name="follow_list_all_favorite_dvms">All favorite feed algorithms</string>
|
||||
<string name="feed_group_relays">Relays</string>
|
||||
|
||||
<string name="add_dvm_to_favorites">Add feed algorithm to favorites</string>
|
||||
<string name="remove_dvm_from_favorites">Remove from favorites</string>
|
||||
<string name="favorite_dvms_title">Favorite Feed Algorithms</string>
|
||||
<string name="favorite_dvms_explainer">Feed algorithms you starred here appear as filter chips on the Home feed. Open Discover to add more.</string>
|
||||
<string name="favorite_dvms_empty">No favorite feed algorithms yet. Open Discover, tap one, and star it to add it here.</string>
|
||||
<string name="dvm_home_status_requesting">Asking %1$s for a feed…</string>
|
||||
<string name="dvm_home_status_requesting_all">Asking your favorite feed algorithms for feeds…</string>
|
||||
<string name="dvm_home_status_processing">Processing your feed…</string>
|
||||
<string name="dvm_home_status_payment_required">This feed algorithm requires payment</string>
|
||||
<string name="dvm_home_status_error">The feed algorithm returned an error</string>
|
||||
<string name="dvm_home_retry">Retry</string>
|
||||
|
||||
<string name="temporary_account">Log off on device lock</string>
|
||||
<string name="private_message">Private Message</string>
|
||||
<string name="public_message">Public Message</string>
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* 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.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FavoriteAlgoFeedTopNavFilterTest {
|
||||
private fun textNote(id: String) = TextNoteEvent(id = id, pubKey = "a".repeat(64), createdAt = 1, tags = emptyArray(), content = "", sig = "x".repeat(128))
|
||||
|
||||
private fun longFormNote(
|
||||
pubkey: String,
|
||||
dTag: String,
|
||||
) = LongTextNoteEvent(
|
||||
id = "0".repeat(64),
|
||||
pubKey = pubkey,
|
||||
createdAt = 1,
|
||||
tags = arrayOf(arrayOf("d", dTag)),
|
||||
content = "",
|
||||
sig = "x".repeat(128),
|
||||
)
|
||||
|
||||
private val dvmAddress = Address(31990, "d".repeat(64), "content")
|
||||
|
||||
@Test
|
||||
fun matchesNoteWhoseIdIsInAcceptedSet() {
|
||||
val filter =
|
||||
FavoriteAlgoFeedTopNavFilter(
|
||||
feedAddress = dvmAddress,
|
||||
acceptedIds = setOf("1".repeat(64)),
|
||||
acceptedAddresses = emptySet(),
|
||||
contentRelays = emptySet(),
|
||||
listenRelays = emptySet(),
|
||||
requestId = null,
|
||||
)
|
||||
|
||||
assertTrue(filter.match(textNote("1".repeat(64))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsNoteNotInAcceptedSet() {
|
||||
val filter =
|
||||
FavoriteAlgoFeedTopNavFilter(
|
||||
feedAddress = dvmAddress,
|
||||
acceptedIds = setOf("1".repeat(64)),
|
||||
acceptedAddresses = emptySet(),
|
||||
contentRelays = emptySet(),
|
||||
listenRelays = emptySet(),
|
||||
requestId = null,
|
||||
)
|
||||
|
||||
assertFalse(filter.match(textNote("2".repeat(64))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesAddressableEventByAddressTag() {
|
||||
val articleAuthor = "c".repeat(64)
|
||||
val articleDTag = "my-post"
|
||||
val articleAddress = "30023:$articleAuthor:$articleDTag"
|
||||
|
||||
val filter =
|
||||
FavoriteAlgoFeedTopNavFilter(
|
||||
feedAddress = dvmAddress,
|
||||
acceptedIds = emptySet(),
|
||||
acceptedAddresses = setOf(articleAddress),
|
||||
contentRelays = emptySet(),
|
||||
listenRelays = emptySet(),
|
||||
requestId = null,
|
||||
)
|
||||
|
||||
assertTrue(filter.match(longFormNote(articleAuthor, articleDTag)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullRequestIdCollapsesToEmptyRequestIdsInFilterSet() {
|
||||
val filter =
|
||||
FavoriteAlgoFeedTopNavFilter(
|
||||
feedAddress = dvmAddress,
|
||||
acceptedIds = emptySet(),
|
||||
acceptedAddresses = emptySet(),
|
||||
contentRelays = emptySet(),
|
||||
listenRelays = emptySet(),
|
||||
requestId = null,
|
||||
)
|
||||
|
||||
// passing a LocalCache is only needed because the method demands it;
|
||||
// FavoriteAlgoFeedTopNavFilter.startValue doesn't actually consult it.
|
||||
val set = filter.startValue(com.vitorpamplona.amethyst.model.LocalCache)
|
||||
assertTrue(set.requestIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonNullRequestIdProducesSingletonInFilterSet() {
|
||||
val filter =
|
||||
FavoriteAlgoFeedTopNavFilter(
|
||||
feedAddress = dvmAddress,
|
||||
acceptedIds = emptySet(),
|
||||
acceptedAddresses = emptySet(),
|
||||
contentRelays = emptySet(),
|
||||
listenRelays = emptySet(),
|
||||
requestId = "9".repeat(64),
|
||||
)
|
||||
|
||||
val set = filter.startValue(com.vitorpamplona.amethyst.model.LocalCache)
|
||||
assertTrue(set.requestIds == setOf("9".repeat(64)))
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* 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.screen.loggedIn.home.datasource.nip90AlgoFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
||||
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FilterHomePostsByAlgoFeedIdsTest {
|
||||
private val userRelay = RelayUrlNormalizer.normalizeOrNull("wss://user.example/")!!
|
||||
private val dvmRelay = RelayUrlNormalizer.normalizeOrNull("wss://dvm.example/")!!
|
||||
|
||||
@Test
|
||||
fun emptyFilterSetProducesNoRequests() {
|
||||
val set =
|
||||
FavoriteAlgoFeedTopNavPerRelayFilterSet(
|
||||
contentFetches = emptyMap(),
|
||||
listenRelays = emptySet(),
|
||||
requestIds = emptySet(),
|
||||
)
|
||||
|
||||
assertTrue(filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contentFetchIssuedOnUserRelayWithIdsFilter() {
|
||||
val ids = setOf("a".repeat(64), "b".repeat(64))
|
||||
val set =
|
||||
FavoriteAlgoFeedTopNavPerRelayFilterSet(
|
||||
contentFetches =
|
||||
mapOf(userRelay to FavoriteAlgoFeedTopNavPerRelayFilter(ids = ids, addresses = emptySet())),
|
||||
listenRelays = emptySet(),
|
||||
requestIds = emptySet(),
|
||||
)
|
||||
|
||||
val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null)
|
||||
|
||||
assertEquals(1, filters.size)
|
||||
val single = filters.single()
|
||||
assertEquals(userRelay, single.relay)
|
||||
assertEquals(ids.sorted(), single.filter.ids?.sorted())
|
||||
// Content fetch should not be restricted to a kind — the DVM curates freely.
|
||||
assertEquals(null, single.filter.kinds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listenFilterIssuedOnDvmRelayWithKinds6300And7000() {
|
||||
val requestId = "9".repeat(64)
|
||||
val set =
|
||||
FavoriteAlgoFeedTopNavPerRelayFilterSet(
|
||||
contentFetches = emptyMap(),
|
||||
listenRelays = setOf(dvmRelay),
|
||||
requestIds = setOf(requestId),
|
||||
)
|
||||
|
||||
val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null)
|
||||
|
||||
assertEquals(1, filters.size)
|
||||
val listen = filters.single()
|
||||
assertEquals(dvmRelay, listen.relay)
|
||||
assertEquals(
|
||||
listOf(NIP90ContentDiscoveryResponseEvent.KIND, NIP90StatusEvent.KIND),
|
||||
listen.filter.kinds,
|
||||
)
|
||||
val eTag = listen.filter.tags?.get("e")
|
||||
assertNotNull(eTag)
|
||||
assertEquals(listOf(requestId), eTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergedRequestIdsAllRideOnOneListenFilterPerRelay() {
|
||||
val req1 = "1".repeat(64)
|
||||
val req2 = "2".repeat(64)
|
||||
val set =
|
||||
FavoriteAlgoFeedTopNavPerRelayFilterSet(
|
||||
contentFetches = emptyMap(),
|
||||
listenRelays = setOf(dvmRelay),
|
||||
requestIds = setOf(req1, req2),
|
||||
)
|
||||
|
||||
val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null)
|
||||
|
||||
assertEquals(1, filters.size)
|
||||
val eTag =
|
||||
filters
|
||||
.single()
|
||||
.filter.tags
|
||||
?.get("e")
|
||||
.orEmpty()
|
||||
assertTrue(eTag.containsAll(listOf(req1, req2)))
|
||||
assertEquals(2, eTag.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contentAndListenSubscriptionsSplitAcrossTheirRespectiveRelays() {
|
||||
val ids = setOf("a".repeat(64))
|
||||
val requestId = "9".repeat(64)
|
||||
val set =
|
||||
FavoriteAlgoFeedTopNavPerRelayFilterSet(
|
||||
contentFetches =
|
||||
mapOf(userRelay to FavoriteAlgoFeedTopNavPerRelayFilter(ids = ids, addresses = emptySet())),
|
||||
listenRelays = setOf(dvmRelay),
|
||||
requestIds = setOf(requestId),
|
||||
)
|
||||
|
||||
val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null)
|
||||
|
||||
assertEquals(2, filters.size)
|
||||
assertTrue(filters.any { it.relay == userRelay && it.filter.ids != null })
|
||||
assertTrue(filters.any { it.relay == dvmRelay && it.filter.kinds != null })
|
||||
}
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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.nip51Lists.favoriteAlgoFeedsList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.core.fastAny
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
|
||||
import com.vitorpamplona.quartz.nip51Lists.remove
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@Immutable
|
||||
class FavoriteAlgoFeedsListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||
fun publicFavoriteAlgoFeeds(): List<AddressBookmark> = tags.mapNotNull(AddressBookmark::parse)
|
||||
|
||||
suspend fun privateFavoriteAlgoFeeds(signer: NostrSigner): List<AddressBookmark>? = privateTags(signer)?.mapNotNull(AddressBookmark::parse)
|
||||
|
||||
companion object {
|
||||
const val KIND = 10090
|
||||
const val ALT = "Favorite algo-feeds list"
|
||||
const val FIXED_D_TAG = ""
|
||||
|
||||
fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG)
|
||||
|
||||
suspend fun create(
|
||||
feed: AddressBookmark,
|
||||
isPrivate: Boolean,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): FavoriteAlgoFeedsListEvent =
|
||||
if (isPrivate) {
|
||||
create(
|
||||
publicFeeds = emptyList(),
|
||||
privateFeeds = listOf(feed),
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
} else {
|
||||
create(
|
||||
publicFeeds = listOf(feed),
|
||||
privateFeeds = emptyList(),
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun add(
|
||||
earlierVersion: FavoriteAlgoFeedsListEvent,
|
||||
feed: AddressBookmark,
|
||||
isPrivate: Boolean,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): FavoriteAlgoFeedsListEvent =
|
||||
if (isPrivate) {
|
||||
val privateTags =
|
||||
earlierVersion.privateTags(signer)
|
||||
?: throw SignerExceptions.UnauthorizedDecryptionException()
|
||||
resign(
|
||||
tags = earlierVersion.tags,
|
||||
privateTags = privateTags.remove(feed.toTagIdOnly()) + feed.toTagArray(),
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
} else {
|
||||
resign(
|
||||
content = earlierVersion.content,
|
||||
tags = earlierVersion.tags.remove(feed.toTagIdOnly()) + feed.toTagArray(),
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun remove(
|
||||
earlierVersion: FavoriteAlgoFeedsListEvent,
|
||||
feed: Address,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): FavoriteAlgoFeedsListEvent {
|
||||
val idOnly = AddressBookmark.assemble(feed, null)
|
||||
val privateTags = earlierVersion.privateTags(signer)
|
||||
return if (privateTags != null) {
|
||||
resign(
|
||||
privateTags = privateTags.remove(idOnly),
|
||||
tags = earlierVersion.tags.remove(idOnly),
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
} else {
|
||||
resign(
|
||||
content = earlierVersion.content,
|
||||
tags = earlierVersion.tags.remove(idOnly),
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resign(
|
||||
tags: TagArray,
|
||||
privateTags: TagArray,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
) = resign(
|
||||
content = PrivateTagsInContent.encryptNip44(privateTags, signer),
|
||||
tags = tags,
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
suspend fun resign(
|
||||
content: String,
|
||||
tags: TagArray,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): FavoriteAlgoFeedsListEvent {
|
||||
val newTags =
|
||||
if (tags.fastAny(AltTag::match)) {
|
||||
tags
|
||||
} else {
|
||||
tags + AltTag.assemble(ALT)
|
||||
}
|
||||
|
||||
return signer.sign(createdAt, KIND, newTags, content)
|
||||
}
|
||||
|
||||
suspend fun create(
|
||||
publicFeeds: List<AddressBookmark> = emptyList(),
|
||||
privateFeeds: List<AddressBookmark> = emptyList(),
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): FavoriteAlgoFeedsListEvent {
|
||||
val template = build(publicFeeds, privateFeeds, signer, createdAt)
|
||||
return signer.sign(template)
|
||||
}
|
||||
|
||||
suspend fun build(
|
||||
publicFeeds: List<AddressBookmark> = emptyList(),
|
||||
privateFeeds: List<AddressBookmark> = emptyList(),
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<FavoriteAlgoFeedsListEvent>.() -> Unit = {},
|
||||
) = eventTemplate<FavoriteAlgoFeedsListEvent>(
|
||||
kind = KIND,
|
||||
description =
|
||||
PrivateTagsInContent.encryptNip44(
|
||||
privateFeeds.map { it.toTagArray() }.toTypedArray(),
|
||||
signer,
|
||||
),
|
||||
createdAt = createdAt,
|
||||
) {
|
||||
alt(ALT)
|
||||
favoriteAlgoFeeds(publicFeeds)
|
||||
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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.nip51Lists.favoriteAlgoFeedsList
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
|
||||
fun TagArrayBuilder<FavoriteAlgoFeedsListEvent>.favoriteAlgoFeed(app: AddressBookmark) = add(app.toTagArray())
|
||||
|
||||
fun TagArrayBuilder<FavoriteAlgoFeedsListEvent>.favoriteAlgoFeeds(apps: List<AddressBookmark>) = addAll(apps.map { it.toTagArray() })
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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.nip51Lists.favoriteAlgoFeedsList
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
|
||||
fun TagArray.favoriteAlgoFeedsList() = mapNotNull(AddressBookmark::parseAddress)
|
||||
|
||||
fun TagArray.favoriteAlgoFeedsSet() = mapNotNullTo(mutableSetOf(), AddressBookmark::parseAddress)
|
||||
@@ -134,6 +134,7 @@ import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent
|
||||
@@ -422,6 +423,7 @@ class EventFactory {
|
||||
GoodWikiAuthorListEvent.KIND -> GoodWikiAuthorListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
GoodWikiRelayListEvent.KIND -> GoodWikiRelayListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
FavoriteAlgoFeedsListEvent.KIND -> FavoriteAlgoFeedsListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
HashtagListEvent.KIND -> HashtagListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* 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.nip51Lists.favoriteAlgoFeedsList
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.utils.nsecToKeyPair
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FavoriteAlgoFeedsListEventTest {
|
||||
private val signer = NostrSignerInternal("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair())
|
||||
|
||||
private fun dvm(
|
||||
pubkey: String,
|
||||
dTag: String = "content-discovery",
|
||||
) = AddressBookmark(Address(31990, pubkey, dTag))
|
||||
|
||||
@Test
|
||||
fun kindMatchesSpec() {
|
||||
assertEquals(10090, FavoriteAlgoFeedsListEvent.KIND)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addressesAreReplaceableWithFixedDTag() {
|
||||
val address = FavoriteAlgoFeedsListEvent.createAddress("a".repeat(64))
|
||||
assertEquals(10090, address.kind)
|
||||
assertEquals("", address.dTag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createStoresDvmAsATag() =
|
||||
runTest {
|
||||
val aFeed = dvm("a".repeat(64))
|
||||
|
||||
val event =
|
||||
FavoriteAlgoFeedsListEvent.create(
|
||||
feed = aFeed,
|
||||
isPrivate = false,
|
||||
signer = signer,
|
||||
createdAt = 1740669816,
|
||||
)
|
||||
|
||||
assertEquals(10090, event.kind)
|
||||
assertTrue(
|
||||
event.tags.any { it.size >= 2 && it[0] == "a" && it[1] == aFeed.address.toValue() },
|
||||
"public a tag for the favourited DVM should be present",
|
||||
)
|
||||
val favorites = event.publicFavoriteAlgoFeeds()
|
||||
assertEquals(1, favorites.size)
|
||||
assertEquals(aFeed.address, favorites.first().address)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addAppendsWithoutDuplicatingExistingEntry() =
|
||||
runTest {
|
||||
val aFeed = dvm("a".repeat(64))
|
||||
|
||||
val initial =
|
||||
FavoriteAlgoFeedsListEvent.create(
|
||||
feed = aFeed,
|
||||
isPrivate = false,
|
||||
signer = signer,
|
||||
createdAt = 1740669816,
|
||||
)
|
||||
|
||||
val afterDupeAdd =
|
||||
FavoriteAlgoFeedsListEvent.add(
|
||||
earlierVersion = initial,
|
||||
feed = aFeed,
|
||||
isPrivate = false,
|
||||
signer = signer,
|
||||
createdAt = 1740669817,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
1,
|
||||
afterDupeAdd.publicFavoriteAlgoFeeds().count { it.address == aFeed.address },
|
||||
"re-adding the same DVM must not produce a duplicate tag",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addPreservesOtherFavorites() =
|
||||
runTest {
|
||||
val first = dvm("a".repeat(64))
|
||||
val second = dvm("b".repeat(64))
|
||||
|
||||
val initial =
|
||||
FavoriteAlgoFeedsListEvent.create(
|
||||
feed = first,
|
||||
isPrivate = false,
|
||||
signer = signer,
|
||||
createdAt = 1740669816,
|
||||
)
|
||||
|
||||
val after =
|
||||
FavoriteAlgoFeedsListEvent.add(
|
||||
earlierVersion = initial,
|
||||
feed = second,
|
||||
isPrivate = false,
|
||||
signer = signer,
|
||||
createdAt = 1740669817,
|
||||
)
|
||||
|
||||
val addresses = after.publicFavoriteAlgoFeeds().map { it.address }.toSet()
|
||||
assertTrue(first.address in addresses)
|
||||
assertTrue(second.address in addresses)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeDropsTheRequestedDvmOnly() =
|
||||
runTest {
|
||||
val first = dvm("a".repeat(64))
|
||||
val second = dvm("b".repeat(64))
|
||||
|
||||
val initial =
|
||||
FavoriteAlgoFeedsListEvent.create(
|
||||
publicFeeds = listOf(first, second),
|
||||
privateFeeds = emptyList(),
|
||||
signer = signer,
|
||||
createdAt = 1740669816,
|
||||
)
|
||||
|
||||
val after =
|
||||
FavoriteAlgoFeedsListEvent.remove(
|
||||
earlierVersion = initial,
|
||||
feed = first.address,
|
||||
signer = signer,
|
||||
createdAt = 1740669817,
|
||||
)
|
||||
|
||||
val addresses = after.publicFavoriteAlgoFeeds().map { it.address }.toSet()
|
||||
assertFalse(first.address in addresses, "removed DVM should not survive")
|
||||
assertTrue(second.address in addresses, "other DVMs should be preserved")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user