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.model.nip56Reports.ReportAction
|
||||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||||
import com.vitorpamplona.amethyst.logTime
|
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.PrivateStorageRelayListDecryptionCache
|
||||||
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
|
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
|
||||||
import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState
|
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.blockedRelays.BlockedRelayListState
|
||||||
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
|
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
|
||||||
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
|
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.GeohashListDecryptionCache
|
||||||
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
|
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
|
||||||
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
|
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.Request
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
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.nip56Reports.ReportType
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||||
@@ -323,6 +327,10 @@ class Account(
|
|||||||
val hashtagListDecryptionCache = HashtagListDecryptionCache(signer)
|
val hashtagListDecryptionCache = HashtagListDecryptionCache(signer)
|
||||||
val hashtagList = HashtagListState(signer, cache, hashtagListDecryptionCache, scope, settings)
|
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 geohashListDecryptionCache = GeohashListDecryptionCache(signer)
|
||||||
val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings)
|
val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings)
|
||||||
|
|
||||||
@@ -418,6 +426,8 @@ class Account(
|
|||||||
caches = feedDecryptionCaches,
|
caches = feedDecryptionCaches,
|
||||||
signer = signer,
|
signer = signer,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
|
favoriteAlgoFeedsOrchestrator = favoriteAlgoFeedsOrchestrator,
|
||||||
|
favoriteAlgoFeedAddresses = favoriteAlgoFeedsList.flow,
|
||||||
).flow
|
).flow
|
||||||
|
|
||||||
// App-ready Feeds
|
// App-ready Feeds
|
||||||
@@ -1013,6 +1023,12 @@ class Account(
|
|||||||
|
|
||||||
suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag))
|
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 followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash))
|
||||||
|
|
||||||
suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash))
|
suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash))
|
||||||
@@ -2367,7 +2383,7 @@ class Account(
|
|||||||
|
|
||||||
suspend fun requestDVMContentDiscovery(
|
suspend fun requestDVMContentDiscovery(
|
||||||
dvmPublicKey: User,
|
dvmPublicKey: User,
|
||||||
onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit,
|
onReady: (event: NIP90ContentDiscoveryRequestEvent, relays: Set<NormalizedRelayUrl>) -> Unit,
|
||||||
) {
|
) {
|
||||||
val relays = nip65RelayList.inboxFlow.value.toSet()
|
val relays = nip65RelayList.inboxFlow.value.toSet()
|
||||||
val request = signer.sign<NIP90ContentDiscoveryRequestEvent>(NIP90ContentDiscoveryRequestEvent.build(dvmPublicKey.pubkeyHex, signer.pubKey, relays))
|
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))
|
?: (dvmPublicKey.allUsedRelays() + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex))
|
||||||
|
|
||||||
cache.justConsumeMyOwnEvent(request)
|
cache.justConsumeMyOwnEvent(request)
|
||||||
onReady(request)
|
onReady(request, relayList.toSet())
|
||||||
delay(100)
|
delay(100)
|
||||||
client.publish(request, relayList)
|
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.nip42RelayAuth.RelayAuthEvent
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||||
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
|
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.geohashList.GeohashListEvent
|
||||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||||
@@ -154,6 +155,13 @@ sealed class TopFilter(
|
|||||||
class Relay(
|
class Relay(
|
||||||
val url: String,
|
val url: String,
|
||||||
) : TopFilter("Relay/$url")
|
) : TopFilter("Relay/$url")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class FavoriteAlgoFeed(
|
||||||
|
val address: Address,
|
||||||
|
) : TopFilter("FavoriteAlgoFeed/${address.toValue()}")
|
||||||
|
|
||||||
|
@Serializable object AllFavoriteAlgoFeeds : TopFilter(" All Favourite DVMs ")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
@@ -195,6 +203,7 @@ class AccountSettings(
|
|||||||
var backupChannelList: ChannelListEvent? = null,
|
var backupChannelList: ChannelListEvent? = null,
|
||||||
var backupCommunityList: CommunityListEvent? = null,
|
var backupCommunityList: CommunityListEvent? = null,
|
||||||
var backupHashtagList: HashtagListEvent? = null,
|
var backupHashtagList: HashtagListEvent? = null,
|
||||||
|
var backupFavoriteAlgoFeedsList: FavoriteAlgoFeedsListEvent? = null,
|
||||||
var backupGeohashList: GeohashListEvent? = null,
|
var backupGeohashList: GeohashListEvent? = null,
|
||||||
var backupEphemeralChatList: EphemeralChatListEvent? = null,
|
var backupEphemeralChatList: EphemeralChatListEvent? = null,
|
||||||
var backupTrustProviderList: TrustProviderListEvent? = 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?) {
|
fun updateCommunityListTo(newCommunityList: CommunityListEvent?) {
|
||||||
if (newCommunityList == null || newCommunityList.tags.isEmpty()) return
|
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.PinListEvent
|
||||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
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.followList.FollowListEvent
|
||||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||||
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
|
||||||
@@ -2635,6 +2636,7 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
is LiveChessGameEndEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
is LiveChessGameEndEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
is LiveChessDrawOfferEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
is LiveChessDrawOfferEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
is HashtagListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
is HashtagListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
|
is FavoriteAlgoFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
is HighlightEvent -> consumeRegularEvent(event, relay, wasVerified)
|
is HighlightEvent -> consumeRegularEvent(event, relay, wasVerified)
|
||||||
is IndexerRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
is IndexerRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified)
|
||||||
is InteractiveStoryPrologueEvent -> 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.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.TopFilter
|
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.nip02FollowLists.Kind3FollowListState
|
||||||
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
|
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow
|
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.AroundMeFeedFlow
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.GeohashFeedFlow
|
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.GeohashFeedFlow
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessFeedFlow
|
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.global.GlobalFeedFlow
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow
|
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow
|
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayFeedFlow
|
import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayFeedFlow
|
||||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
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.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||||
@@ -62,6 +66,8 @@ class FeedTopNavFilterState(
|
|||||||
val caches: FeedDecryptionCaches,
|
val caches: FeedDecryptionCaches,
|
||||||
val signer: NostrSigner,
|
val signer: NostrSigner,
|
||||||
val scope: CoroutineScope,
|
val scope: CoroutineScope,
|
||||||
|
val favoriteAlgoFeedsOrchestrator: FavoriteAlgoFeedsOrchestrator,
|
||||||
|
val favoriteAlgoFeedAddresses: StateFlow<Set<Address>>,
|
||||||
) {
|
) {
|
||||||
fun loadFlowsFor(listName: TopFilter): IFeedFlowsType =
|
fun loadFlowsFor(listName: TopFilter): IFeedFlowsType =
|
||||||
when (listName) {
|
when (listName) {
|
||||||
@@ -142,6 +148,24 @@ class FeedTopNavFilterState(
|
|||||||
is TopFilter.Relay -> {
|
is TopFilter.Relay -> {
|
||||||
RelayFeedFlow(listName.url.normalizeRelayUrl())
|
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)
|
@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.discover.nip99Classifieds.NewProductScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
|
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.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.followPacks.feed.FollowPackFeedScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen
|
||||||
@@ -277,6 +278,7 @@ fun BuildNavigation(
|
|||||||
composableFromEnd<Route.RequestToVanish> { RequestToVanishScreen(accountViewModel, nav) }
|
composableFromEnd<Route.RequestToVanish> { RequestToVanishScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
|
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
|
||||||
|
composableFromEnd<Route.EditFavoriteAlgoFeeds> { FavoriteAlgoFeedsListScreen(accountViewModel, nav) }
|
||||||
composableFromEnd<Route.EditPaymentTargets> { PaymentTargetsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.EditPaymentTargets> { PaymentTargetsScreen(accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
|
composableFromEndArgs<Route.UpdateReactionType> { UpdateReactionTypeScreen(accountViewModel, nav) }
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object EditMediaServers : Route()
|
@Serializable object EditMediaServers : Route()
|
||||||
|
|
||||||
|
@Serializable object EditFavoriteAlgoFeeds : Route()
|
||||||
|
|
||||||
@Serializable object EditPaymentTargets : Route()
|
@Serializable object EditPaymentTargets : Route()
|
||||||
|
|
||||||
@Serializable object UpdateReactionType : 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.ViewList
|
||||||
import androidx.compose.material.icons.automirrored.outlined.VolumeOff
|
import androidx.compose.material.icons.automirrored.outlined.VolumeOff
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
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.Groups
|
||||||
import androidx.compose.material.icons.outlined.LocationOn
|
import androidx.compose.material.icons.outlined.LocationOn
|
||||||
import androidx.compose.material.icons.outlined.Person
|
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.semantics
|
||||||
import androidx.compose.ui.semantics.stateDescription
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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 androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
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.components.LoadingAnimation
|
||||||
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
|
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
|
||||||
import com.vitorpamplona.amethyst.ui.screen.CommunityName
|
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.FeedDefinition
|
||||||
import com.vitorpamplona.amethyst.ui.screen.GeoHashName
|
import com.vitorpamplona.amethyst.ui.screen.GeoHashName
|
||||||
import com.vitorpamplona.amethyst.ui.screen.HashtagName
|
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.amethyst.ui.theme.placeholderText
|
||||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||||
|
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
@OptIn(ExperimentalPermissionsApi::class)
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
@@ -150,23 +154,40 @@ fun FeedFilterSpinner(
|
|||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Spacer(modifier = Size20Modifier)
|
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
|
val filter = selected?.code
|
||||||
if (filter is TopFilter.Geohash) {
|
if (filter is TopFilter.Geohash) {
|
||||||
LoadCityName(
|
LoadCityName(
|
||||||
geohashStr = filter.tag,
|
geohashStr = filter.tag,
|
||||||
onLoading = {
|
onLoading = {
|
||||||
Row {
|
Row {
|
||||||
Text(filter.tag)
|
Text(
|
||||||
|
text = filter.tag,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
Spacer(modifier = StdHorzSpacer)
|
Spacer(modifier = StdHorzSpacer)
|
||||||
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
|
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { cityName ->
|
) { cityName ->
|
||||||
Text(cityName)
|
Text(
|
||||||
|
text = cityName,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(currentText)
|
Text(
|
||||||
|
text = currentText,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter is TopFilter.AroundMe) {
|
if (filter is TopFilter.AroundMe) {
|
||||||
@@ -178,6 +199,8 @@ fun FeedFilterSpinner(
|
|||||||
text = stringRes(R.string.lack_location_permissions),
|
text = stringRes(R.string.lack_location_permissions),
|
||||||
fontSize = Font12SP,
|
fontSize = Font12SP,
|
||||||
lineHeight = 12.sp,
|
lineHeight = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val location by Amethyst.instance.locationManager.geohashStateFlow
|
val location by Amethyst.instance.locationManager.geohashStateFlow
|
||||||
@@ -193,6 +216,8 @@ fun FeedFilterSpinner(
|
|||||||
text = "(${myLocation.geoHash})",
|
text = "(${myLocation.geoHash})",
|
||||||
fontSize = Font12SP,
|
fontSize = Font12SP,
|
||||||
lineHeight = 12.sp,
|
lineHeight = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Spacer(modifier = StdHorzSpacer)
|
Spacer(modifier = StdHorzSpacer)
|
||||||
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
|
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
|
||||||
@@ -203,6 +228,8 @@ fun FeedFilterSpinner(
|
|||||||
text = "($cityName)",
|
text = "($cityName)",
|
||||||
fontSize = Font12SP,
|
fontSize = Font12SP,
|
||||||
lineHeight = 12.sp,
|
lineHeight = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,6 +239,8 @@ fun FeedFilterSpinner(
|
|||||||
text = stringRes(R.string.lack_location_permissions),
|
text = stringRes(R.string.lack_location_permissions),
|
||||||
fontSize = Font12SP,
|
fontSize = Font12SP,
|
||||||
lineHeight = 12.sp,
|
lineHeight = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +249,8 @@ fun FeedFilterSpinner(
|
|||||||
text = stringRes(R.string.loading_location),
|
text = stringRes(R.string.loading_location),
|
||||||
fontSize = Font12SP,
|
fontSize = Font12SP,
|
||||||
lineHeight = 12.sp,
|
lineHeight = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,6 +360,20 @@ fun RenderOption(
|
|||||||
color = MaterialTheme.colorScheme.onSurface,
|
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),
|
COMMUNITIES(R.string.feed_group_communities),
|
||||||
LOCATIONS(R.string.feed_group_locations),
|
LOCATIONS(R.string.feed_group_locations),
|
||||||
LISTS(R.string.feed_group_lists),
|
LISTS(R.string.feed_group_lists),
|
||||||
|
DVMS(R.string.feed_group_dvms),
|
||||||
RELAYS(R.string.feed_group_relays),
|
RELAYS(R.string.feed_group_relays),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,10 +419,15 @@ private fun groupFeedDefinitions(options: ImmutableList<FeedDefinition>): Map<Fe
|
|||||||
FeedGroup.LOCATIONS
|
FeedGroup.LOCATIONS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is FavoriteAlgoFeedName -> {
|
||||||
|
FeedGroup.DVMS
|
||||||
|
}
|
||||||
|
|
||||||
is ResourceName -> {
|
is ResourceName -> {
|
||||||
when (entry.item.code) {
|
when (entry.item.code) {
|
||||||
is TopFilter.AroundMe -> FeedGroup.LOCATIONS
|
is TopFilter.AroundMe -> FeedGroup.LOCATIONS
|
||||||
is TopFilter.Global -> FeedGroup.RELAYS
|
is TopFilter.Global -> FeedGroup.RELAYS
|
||||||
|
is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS
|
||||||
else -> FeedGroup.FEEDS
|
else -> FeedGroup.FEEDS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -541,12 +592,21 @@ private fun FeedIcon(
|
|||||||
Icons.AutoMirrored.Outlined.ViewList
|
Icons.AutoMirrored.Outlined.ViewList
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is TopFilter.FavoriteAlgoFeed -> {
|
||||||
|
Icons.Outlined.AutoAwesome
|
||||||
|
}
|
||||||
|
|
||||||
|
is TopFilter.AllFavoriteAlgoFeeds -> {
|
||||||
|
Icons.Outlined.AutoAwesome
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
when (item.name) {
|
when (item.name) {
|
||||||
is GeoHashName -> Icons.Outlined.LocationOn
|
is GeoHashName -> Icons.Outlined.LocationOn
|
||||||
is RelayName -> Icons.Outlined.Storage
|
is RelayName -> Icons.Outlined.Storage
|
||||||
is CommunityName -> Icons.Outlined.Groups
|
is CommunityName -> Icons.Outlined.Groups
|
||||||
is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList
|
is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList
|
||||||
|
is FavoriteAlgoFeedName -> Icons.Outlined.AutoAwesome
|
||||||
else -> Icons.Outlined.Person
|
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.nip01Core.relay.normalizer.displayUrl
|
||||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||||
|
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
@@ -96,6 +97,12 @@ class TopNavFilterState(
|
|||||||
name = ResourceName(R.string.follow_list_chess),
|
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)
|
val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)
|
||||||
|
|
||||||
fun mergePeopleLists(
|
fun mergePeopleLists(
|
||||||
@@ -140,6 +147,7 @@ class TopNavFilterState(
|
|||||||
geotagList: Set<String>,
|
geotagList: Set<String>,
|
||||||
communityList: List<AddressableNote>,
|
communityList: List<AddressableNote>,
|
||||||
relayList: Set<NormalizedRelayUrl>,
|
relayList: Set<NormalizedRelayUrl>,
|
||||||
|
favoriteAlgoFeedsList: List<AddressableNote>,
|
||||||
): List<FeedDefinition> {
|
): List<FeedDefinition> {
|
||||||
val hashtags =
|
val hashtags =
|
||||||
hashtagList.map {
|
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)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@@ -183,6 +210,7 @@ class TopNavFilterState(
|
|||||||
account.geohashList.flow,
|
account.geohashList.flow,
|
||||||
account.communityList.flowNotes,
|
account.communityList.flowNotes,
|
||||||
account.relayFeedsList.flow,
|
account.relayFeedsList.flow,
|
||||||
|
account.favoriteAlgoFeedsList.flowNotes,
|
||||||
::mergeInterests,
|
::mergeInterests,
|
||||||
).onStart {
|
).onStart {
|
||||||
emit(
|
emit(
|
||||||
@@ -191,6 +219,7 @@ class TopNavFilterState(
|
|||||||
account.geohashList.flow.value,
|
account.geohashList.flow.value,
|
||||||
account.communityList.flowNotes.value,
|
account.communityList.flowNotes.value,
|
||||||
account.relayFeedsList.flow.value,
|
account.relayFeedsList.flow.value,
|
||||||
|
account.favoriteAlgoFeedsList.flowNotes.value,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -298,6 +327,15 @@ class CommunityName(
|
|||||||
override fun name() = "/n/${(note.dTag())}"
|
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
|
@Immutable
|
||||||
class FeedDefinition(
|
class FeedDefinition(
|
||||||
val code: TopFilter,
|
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.PinListEvent
|
||||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
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.nip51Lists.hashtagList.HashtagListEvent
|
||||||
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
import com.vitorpamplona.quartz.nip56Reports.ReportType
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
@@ -1106,6 +1107,12 @@ class AccountViewModel(
|
|||||||
|
|
||||||
fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) }
|
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 followRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.followRelayFeed(url) }
|
||||||
|
|
||||||
fun unfollowRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.unfollowRelayFeed(url) }
|
fun unfollowRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.unfollowRelayFeed(url) }
|
||||||
@@ -1820,8 +1827,8 @@ class AccountViewModel(
|
|||||||
onReady: (event: Note) -> Unit,
|
onReady: (event: Note) -> Unit,
|
||||||
) {
|
) {
|
||||||
launchSigner {
|
launchSigner {
|
||||||
account.requestDVMContentDiscovery(dvmPublicKey) {
|
account.requestDVMContentDiscovery(dvmPublicKey) { request, _ ->
|
||||||
onReady(LocalCache.getOrCreateNote(it.id))
|
onReady(LocalCache.getOrCreateNote(request.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-35
@@ -21,11 +21,9 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs
|
||||||
|
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
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.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
|
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
|
||||||
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
|
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.ZapReaction
|
||||||
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
|
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
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.screen.loggedIn.dvms.observeAppDefinition
|
||||||
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
|
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
|
||||||
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp
|
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp
|
||||||
import com.vitorpamplona.amethyst.ui.theme.SimpleImageBorder
|
import com.vitorpamplona.amethyst.ui.theme.SimpleImageBorder
|
||||||
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
|
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.bitcoinColor
|
||||||
import com.vitorpamplona.amethyst.ui.theme.grayText
|
import com.vitorpamplona.amethyst.ui.theme.grayText
|
||||||
import com.vitorpamplona.amethyst.ui.theme.nip05
|
import com.vitorpamplona.amethyst.ui.theme.nip05
|
||||||
@@ -118,25 +116,12 @@ fun RenderContentDVMThumb(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
Spacer(modifier = StdVertSpacer)
|
if (baseNote is AddressableNote) {
|
||||||
Row(
|
FavoriteAlgoFeedToggle(
|
||||||
verticalAlignment = CenterVertically,
|
appDefinitionNote = baseNote,
|
||||||
horizontalArrangement = RowColSpacing5dp,
|
|
||||||
) {
|
|
||||||
LikeReaction(
|
|
||||||
baseNote = baseNote,
|
|
||||||
grayTint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = StdHorzSpacer)
|
|
||||||
ZapReaction(
|
|
||||||
baseNote = baseNote,
|
|
||||||
grayTint = MaterialTheme.colorScheme.onSurface,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
nav = nav,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onDescription = {
|
onDescription = {
|
||||||
card.description?.let {
|
card.description?.let {
|
||||||
@@ -168,23 +153,17 @@ fun RenderContentDVMThumb(
|
|||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
amount = card.amount + " Sats"
|
amount = card.amount + " Sats"
|
||||||
}
|
}
|
||||||
Row(
|
|
||||||
verticalAlignment = CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.Absolute.Right,
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
textAlign = TextAlign.End,
|
textAlign = TextAlign.End,
|
||||||
text = " $amount ",
|
text = " $amount ",
|
||||||
color = color,
|
color = color,
|
||||||
maxLines = 3,
|
maxLines = 1,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f, fill = false)
|
|
||||||
.border(Dp(.1f), color, shape = RoundedCornerShape(20)),
|
.border(Dp(.1f), color, shape = RoundedCornerShape(20)),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Spacer(modifier = StdHorzSpacer)
|
Spacer(modifier = StdHorzSpacer)
|
||||||
card.personalized?.let {
|
card.personalized?.let {
|
||||||
var color = Color.DarkGray
|
var color = Color.DarkGray
|
||||||
@@ -196,24 +175,35 @@ fun RenderContentDVMThumb(
|
|||||||
color = MaterialTheme.colorScheme.nip05
|
color = MaterialTheme.colorScheme.nip05
|
||||||
name = "Generic"
|
name = "Generic"
|
||||||
}
|
}
|
||||||
Spacer(modifier = StdVertSpacer)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.Absolute.Right,
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
textAlign = TextAlign.End,
|
textAlign = TextAlign.End,
|
||||||
text = " $name ",
|
text = " $name ",
|
||||||
color = color,
|
color = color,
|
||||||
maxLines = 3,
|
maxLines = 1,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 4.dp)
|
|
||||||
.weight(1f, fill = false)
|
|
||||||
.border(Dp(.1f), color, shape = RoundedCornerShape(20)),
|
.border(Dp(.1f), color, shape = RoundedCornerShape(20)),
|
||||||
fontSize = 12.sp,
|
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)
|
Spacer(modifier = DoubleVertSpacer)
|
||||||
Text(currentStatus, textAlign = TextAlign.Center)
|
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 amountTag = latestStatus.firstAmount()
|
||||||
val amount = amountTag?.amount
|
val amount = amountTag?.amount
|
||||||
|
|
||||||
val invoice = amountTag?.lnInvoice
|
val invoice = amountTag?.lnInvoice
|
||||||
|
|
||||||
val thankYou = stringRes(id = R.string.dvm_waiting_to_confirm_payment)
|
val thankYou = stringRes(id = R.string.dvm_waiting_to_confirm_payment)
|
||||||
@@ -371,10 +389,10 @@ fun FeedDVM(
|
|||||||
bolt11 = invoice,
|
bolt11 = invoice,
|
||||||
zappedNote = null,
|
zappedNote = null,
|
||||||
onSent = {
|
onSent = {
|
||||||
currentStatus = nwcPaymentRequest
|
onStatusUpdate(nwcPaymentRequest)
|
||||||
},
|
},
|
||||||
onResponse = { response ->
|
onResponse = { response ->
|
||||||
currentStatus =
|
onStatusUpdate(
|
||||||
if (response is PayInvoiceErrorResponse) {
|
if (response is PayInvoiceErrorResponse) {
|
||||||
stringRes(
|
stringRes(
|
||||||
context,
|
context,
|
||||||
@@ -384,7 +402,8 @@ fun FeedDVM(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
thankYou
|
thankYou
|
||||||
}
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -392,10 +411,10 @@ fun FeedDVM(
|
|||||||
invoice,
|
invoice,
|
||||||
context,
|
context,
|
||||||
onPaid = {
|
onPaid = {
|
||||||
currentStatus = thankYou
|
onStatusUpdate(thankYou)
|
||||||
},
|
},
|
||||||
onError = {
|
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
|
@Composable
|
||||||
|
|||||||
+30
-3
@@ -21,19 +21,25 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.LoadNote
|
||||||
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
|
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
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.note.elements.BannerImage
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
|
||||||
import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier
|
import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier
|
||||||
|
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DvmTopBar(
|
fun DvmTopBar(
|
||||||
@@ -41,7 +47,7 @@ fun DvmTopBar(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
TopBarExtensibleWithBackButton(
|
MyExtensibleTopAppBar(
|
||||||
title = {
|
title = {
|
||||||
LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote ->
|
LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote ->
|
||||||
if (appDefinitionNote != null) {
|
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.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
@@ -52,6 +55,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vitorpamplona.amethyst.Amethyst
|
import com.vitorpamplona.amethyst.Amethyst
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
@@ -209,9 +213,15 @@ private fun HomePages(
|
|||||||
HomeScreenFloatingButton(accountViewModel, nav)
|
HomeScreenFloatingButton(accountViewModel, nav)
|
||||||
},
|
},
|
||||||
accountViewModel = accountViewModel,
|
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(
|
HorizontalPager(
|
||||||
contentPadding = it,
|
contentPadding = PaddingValues(0.dp),
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
userScrollEnabled = true,
|
userScrollEnabled = true,
|
||||||
modifier =
|
modifier =
|
||||||
@@ -229,6 +239,13 @@ private fun HomePages(
|
|||||||
nav = nav,
|
nav = nav,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HomeAlgoFeedStatusBanner(
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
nav = nav,
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +298,23 @@ fun HomeFeeds(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
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 ->
|
SaveableFeedContentState(feedState, scrollStateKey) { listState ->
|
||||||
RenderFeedContentState(
|
RenderFeedContentState(
|
||||||
feedContentState = feedState,
|
feedContentState = feedState,
|
||||||
@@ -290,12 +323,28 @@ fun HomeFeeds(
|
|||||||
nav = nav,
|
nav = nav,
|
||||||
routeForLastRead = routeForLastRead,
|
routeForLastRead = routeForLastRead,
|
||||||
onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, accountViewModel, nav) },
|
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)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun FeedLoaded(
|
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.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
|
import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFilterSet
|
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.global.GlobalTopNavPerRelayFilterSet
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
||||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
|
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.nip64Chess.filterHomePostsByChess
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByAllCommunities
|
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.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.INostrClient
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||||
@@ -74,6 +76,7 @@ class HomeOutboxEventsEoseManager(
|
|||||||
is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
|
is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince)
|
||||||
is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince)
|
is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince)
|
||||||
is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince)
|
is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince)
|
||||||
|
is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince)
|
||||||
else -> emptyList()
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
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.Bolt
|
||||||
import androidx.compose.material.icons.outlined.CloudUpload
|
import androidx.compose.material.icons.outlined.CloudUpload
|
||||||
import androidx.compose.material.icons.outlined.DeleteForever
|
import androidx.compose.material.icons.outlined.DeleteForever
|
||||||
@@ -122,6 +123,13 @@ fun AllSettingsScreen(
|
|||||||
onClick = { nav.nav(Route.EditMediaServers) },
|
onClick = { nav.nav(Route.EditMediaServers) },
|
||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
|
SettingsNavigationRow(
|
||||||
|
title = R.string.favorite_dvms_title,
|
||||||
|
icon = Icons.Outlined.AutoAwesome,
|
||||||
|
tint = tint,
|
||||||
|
onClick = { nav.nav(Route.EditFavoriteAlgoFeeds) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
title = R.string.reactions,
|
title = R.string.reactions,
|
||||||
icon = Icons.Outlined.FavoriteBorder,
|
icon = Icons.Outlined.FavoriteBorder,
|
||||||
|
|||||||
@@ -1732,8 +1732,22 @@
|
|||||||
<string name="feed_group_locations">Locations</string>
|
<string name="feed_group_locations">Locations</string>
|
||||||
<string name="feed_group_communities">Communities</string>
|
<string name="feed_group_communities">Communities</string>
|
||||||
<string name="feed_group_lists">Lists</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="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="temporary_account">Log off on device lock</string>
|
||||||
<string name="private_message">Private Message</string>
|
<string name="private_message">Private Message</string>
|
||||||
<string name="public_message">Public 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.articleCurationSet.ArticleCurationSetEvent
|
||||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
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.followList.FollowListEvent
|
||||||
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
|
||||||
import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent
|
import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent
|
||||||
@@ -422,6 +423,7 @@ class EventFactory {
|
|||||||
GoodWikiAuthorListEvent.KIND -> GoodWikiAuthorListEvent(id, pubKey, createdAt, tags, content, sig)
|
GoodWikiAuthorListEvent.KIND -> GoodWikiAuthorListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
GoodWikiRelayListEvent.KIND -> GoodWikiRelayListEvent(id, pubKey, createdAt, tags, content, sig)
|
GoodWikiRelayListEvent.KIND -> GoodWikiRelayListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
GoalEvent.KIND -> GoalEvent(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)
|
HashtagListEvent.KIND -> HashtagListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
|
HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(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