From 25ecd94487be84347156cfce439c3b545a696b34 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 19:12:47 +0000 Subject: [PATCH 01/12] feat(home): add favourite-DVM top-nav filter Lets users mark NIP-90 content-discovery DVMs (kind 31990 with k=5300) as favourite and surface each as a chip in the Home top-nav alongside hashtags/communities. Selecting a chip publishes the 5300 request, listens for 6300/7000 responses, and renders the curated feed in place. A banner above the feed reports processing / payment-required / error status and reuses the NWC pay flow extracted from DvmContentDiscoveryScreen. - quartz: FavoriteDvmListEvent (NIP-51-style replaceable, kind 10090) - model: FavoriteDvmListState + backup-on-save + Account mutators - model: FavoriteDvmOrchestrator for the 5300 request/6300 response lifecycle, exposing a per-address StateFlow - topNavFeeds/favoriteDvm: TopFilter.FavoriteDvm variant + filter classes + FeedFlow wired into FeedTopNavFilterState - home: FilterHomePostsByDvmIds dispatched by HomeOutboxEventsEoseManager - UI: FavoriteDvmToggle (star icon on DVM cards + DvmTopBar), HomeDvmStatusBanner, new DVMS group and icon in FeedFilterSpinner --- .../vitorpamplona/amethyst/model/Account.kt | 16 ++ .../amethyst/model/AccountSettings.kt | 17 ++ .../model/dvms/FavoriteDvmOrchestrator.kt | 185 +++++++++++++++++ .../FavoriteDvmListDecryptionCache.kt | 36 ++++ .../favoriteDvmLists/FavoriteDvmListState.kt | 128 ++++++++++++ .../topNavFeeds/FeedTopNavFilterState.kt | 12 ++ .../favoriteDvm/FavoriteDvmFeedFlow.kt | 69 +++++++ .../favoriteDvm/FavoriteDvmTopNavFilter.kt | 68 ++++++ .../FavoriteDvmTopNavPerRelayFilter.kt | 33 +++ .../FavoriteDvmTopNavPerRelayFilterSet.kt | 28 +++ .../navigation/topbars/FeedFilterSpinner.kt | 27 +++ .../amethyst/ui/screen/TopNavFilterState.kt | 23 ++- .../ui/screen/loggedIn/AccountViewModel.kt | 7 + .../loggedIn/discover/nip90DVMs/DVMCard.kt | 8 + .../dvms/DvmContentDiscoveryScreen.kt | 153 +++++++------- .../ui/screen/loggedIn/dvms/DvmTopBar.kt | 19 +- .../screen/loggedIn/dvms/FavoriteDvmToggle.kt | 82 ++++++++ .../screen/loggedIn/home/DvmStatusBanner.kt | 195 ++++++++++++++++++ .../ui/screen/loggedIn/home/HomeScreen.kt | 1 + .../HomeOutboxEventsEoseManager.kt | 3 + .../nip90Dvms/FilterHomePostsByDvmIds.kt | 99 +++++++++ amethyst/src/main/res/values/strings.xml | 9 + .../favoriteDvmList/FavoriteDvmListEvent.kt | 192 +++++++++++++++++ .../favoriteDvmList/TagArrayBuilderExt.kt | 28 +++ .../nip51Lists/favoriteDvmList/TagArrayExt.kt | 28 +++ 25 files changed, 1392 insertions(+), 74 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 025bf0ffce..ec3df21db2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusActi import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.logTime +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState @@ -66,6 +67,8 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists.FavoriteDvmListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists.FavoriteDvmListState import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache @@ -134,6 +137,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.mimeType import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +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 @@ -186,6 +190,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent @@ -322,6 +327,10 @@ class Account( val hashtagListDecryptionCache = HashtagListDecryptionCache(signer) val hashtagList = HashtagListState(signer, cache, hashtagListDecryptionCache, scope, settings) + val favoriteDvmListDecryptionCache = FavoriteDvmListDecryptionCache(signer) + val favoriteDvmList = FavoriteDvmListState(signer, cache, favoriteDvmListDecryptionCache, scope, settings) + val favoriteDvmOrchestrator = FavoriteDvmOrchestrator(this, scope) + val geohashListDecryptionCache = GeohashListDecryptionCache(signer) val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings) @@ -417,6 +426,7 @@ class Account( caches = feedDecryptionCaches, signer = signer, scope = scope, + favoriteDvmOrchestrator = favoriteDvmOrchestrator, ).flow // App-ready Feeds @@ -1012,6 +1022,12 @@ class Account( suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag)) + suspend fun followFavoriteDvm(dvm: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteDvmList.follow(dvm)) + + suspend fun unfollowFavoriteDvm(dvm: Address) = sendMyPublicAndPrivateOutbox(favoriteDvmList.unfollow(dvm)) + + fun isFavoriteDvm(dvm: Address): Boolean = favoriteDvmList.flow.value.contains(dvm) + suspend fun followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash)) suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index d091bf5c33..0f327a1742 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayList import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent @@ -154,6 +155,11 @@ sealed class TopFilter( class Relay( val url: String, ) : TopFilter("Relay/$url") + + @Serializable + class FavoriteDvm( + val address: Address, + ) : TopFilter("FavoriteDvm/${address.toValue()}") } @Stable @@ -195,6 +201,7 @@ class AccountSettings( var backupChannelList: ChannelListEvent? = null, var backupCommunityList: CommunityListEvent? = null, var backupHashtagList: HashtagListEvent? = null, + var backupFavoriteDvmList: FavoriteDvmListEvent? = null, var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, var backupTrustProviderList: TrustProviderListEvent? = null, @@ -718,6 +725,16 @@ class AccountSettings( } } + fun updateFavoriteDvmListTo(newFavoriteDvmList: FavoriteDvmListEvent?) { + if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupFavoriteDvmList?.id != newFavoriteDvmList.id) { + backupFavoriteDvmList = newFavoriteDvmList + saveAccountSettings() + } + } + fun updateCommunityListTo(newCommunityList: CommunityListEvent?) { if (newCommunityList == null || newCommunityList.tags.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt new file mode 100644 index 0000000000..f4058cccb8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt @@ -0,0 +1,185 @@ +/* + * 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.dvms + +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.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.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 + +/** + * Immutable snapshot of a favourite DVM's current request/response state. + * + * - [requestId] is the id of the most recently published kind-5300 request. + * - [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 FavoriteDvmSnapshot( + val requestId: HexKey? = null, + val ids: Set = emptySet(), + val addresses: Set = emptySet(), + val latestStatus: NIP90StatusEvent? = null, + val errorMessage: String? = null, +) + +/** + * Manages the NIP-90 content-discovery RPC cycle for each favourite DVM 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.FavoriteDvm` selected. The orchestrator merely observes + * what the relays deliver into `LocalCache`. + */ +class FavoriteDvmOrchestrator( + val account: Account, + val scope: CoroutineScope, +) { + private val flows = mutableMapOf>() + private val jobs = mutableMapOf() + private val mutex = Mutex() + + fun observe(dvmAddress: Address): StateFlow { + flows[dvmAddress]?.let { return it.asStateFlow() } + + val seed = MutableStateFlow(FavoriteDvmSnapshot()) + flows[dvmAddress] = seed + scope.launch { startFor(dvmAddress, seed) } + return seed.asStateFlow() + } + + fun refresh(dvmAddress: Address) { + val seed = flows[dvmAddress] ?: return + scope.launch { + mutex.withLock { + jobs.remove(dvmAddress)?.cancel() + } + startFor(dvmAddress, seed) + } + } + + fun stop(dvmAddress: Address) { + scope.launch { + mutex.withLock { + jobs.remove(dvmAddress)?.cancel() + flows.remove(dvmAddress) + } + } + } + + private suspend fun startFor( + dvmAddress: Address, + seed: MutableStateFlow, + ) { + val user = account.cache.checkGetOrCreateUser(dvmAddress.pubKeyHex) ?: return + val job = + scope.launch(Dispatchers.IO) { + try { + account.requestDVMContentDiscovery(user) { request -> + seed.update { + it.copy( + requestId = request.id, + ids = emptySet(), + addresses = emptySet(), + latestStatus = null, + errorMessage = null, + ) + } + } + + val requestId = seed.value.requestId ?: return@launch + + launch { + account.cache + .observeLatestEvent( + 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( + Filter( + kinds = listOf(NIP90StatusEvent.KIND), + tags = mapOf("e" to listOf(requestId)), + limit = 1, + ), + ).collectLatest { status -> + seed.update { it.copy(latestStatus = status) } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("FavoriteDvmOrchestrator", "Failed to start DVM request: ${e.message}", e) + seed.update { it.copy(errorMessage = e.message ?: "Unknown error") } + } + } + + mutex.withLock { jobs[dvmAddress] = job } + } + + private fun splitInnerTags(innerTags: List): Pair, Set> { + val ids = mutableSetOf() + val addresses = mutableSetOf() + innerTags.forEach { value -> + if (value.contains(':')) { + addresses.add(value) + } else if (value.length == 64) { + ids.add(value) + } + } + return ids to addresses + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt new file mode 100644 index 0000000000..8af3bfa97e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt @@ -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.favoriteDvmLists + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.favoriteDvmSet + +class FavoriteDvmListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedFavoriteDvms(event: FavoriteDvmListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteDvmSet() + + suspend fun favoriteDvms(event: FavoriteDvmListEvent) = cachedPrivateLists.mergeTagList(event).favoriteDvmSet() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt new file mode 100644 index 0000000000..db41026d56 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt @@ -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.favoriteDvmLists + +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.favoriteDvmList.FavoriteDvmListEvent +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 FavoriteDvmListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: FavoriteDvmListDecryptionCache, + 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 favoriteDvmListNote = cache.getOrCreateAddressableNote(getFavoriteDvmListAddress()) + + fun getFavoriteDvmListAddress() = FavoriteDvmListEvent.createAddress(signer.pubKey) + + fun getFavoriteDvmListFlow(): StateFlow = favoriteDvmListNote.flow().metadata.stateFlow + + fun getFavoriteDvmList(): FavoriteDvmListEvent? = favoriteDvmListNote.event as? FavoriteDvmListEvent + + suspend fun favoriteDvmListWithBackup(note: Note): Set
{ + val event = note.event as? FavoriteDvmListEvent ?: settings.backupFavoriteDvmList + return event?.let { decryptionCache.favoriteDvms(it) } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + getFavoriteDvmListFlow() + .transformLatest { noteState -> + emit(favoriteDvmListWithBackup(noteState.note)) + }.onStart { + emit(favoriteDvmListWithBackup(favoriteDvmListNote)) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val flowNotes: StateFlow> = + 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): FavoriteDvmListEvent { + val list = getFavoriteDvmList() + return if (list == null) { + FavoriteDvmListEvent.create(dvm, false, signer) + } else { + FavoriteDvmListEvent.add(list, dvm, false, signer) + } + } + + suspend fun unfollow(dvm: Address): FavoriteDvmListEvent? { + val list = getFavoriteDvmList() ?: return null + return FavoriteDvmListEvent.remove(list, dvm, signer) + } + + init { + settings.backupFavoriteDvmList?.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") + getFavoriteDvmListFlow().collect { + Log.d("AccountRegisterObservers") { "Favorite DVM List for ${signer.pubKey}" } + (it.note.event as? FavoriteDvmListEvent)?.let { + settings.updateFavoriteDvmListTo(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index e9ae1dbc77..27635f0197 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow @@ -30,6 +31,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFoll import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.GeohashFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow @@ -62,6 +64,7 @@ class FeedTopNavFilterState( val caches: FeedDecryptionCaches, val signer: NostrSigner, val scope: CoroutineScope, + val favoriteDvmOrchestrator: FavoriteDvmOrchestrator, ) { fun loadFlowsFor(listName: TopFilter): IFeedFlowsType = when (listName) { @@ -142,6 +145,15 @@ class FeedTopNavFilterState( is TopFilter.Relay -> { RelayFeedFlow(listName.url.normalizeRelayUrl()) } + + is TopFilter.FavoriteDvm -> { + FavoriteDvmFeedFlow( + dvmAddress = listName.address, + orchestrator = favoriteDvmOrchestrator, + outboxRelays = followsRelays, + proxyRelays = proxyRelays, + ) + } } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt new file mode 100644 index 0000000000..f919d62a0b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt @@ -0,0 +1,69 @@ +/* + * 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.favoriteDvm + +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator +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 FavoriteDvmFeedFlow( + val dvmAddress: Address, + val orchestrator: FavoriteDvmOrchestrator, + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + private fun resolveRelays( + outbox: Set, + proxy: Set, + ): Set = if (proxy.isNotEmpty()) proxy else outbox + + private fun buildFilter( + snapshot: com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot, + relays: Set, + ) = FavoriteDvmTopNavFilter( + dvmAddress = dvmAddress, + acceptedIds = snapshot.ids, + acceptedAddresses = snapshot.addresses, + relayList = relays, + requestId = snapshot.requestId, + ) + + override fun flow(): Flow = + combine(orchestrator.observe(dvmAddress), outboxRelays, proxyRelays) { snap, outbox, proxy -> + buildFilter(snap, resolveRelays(outbox, proxy)) + } + + override fun startValue(): FavoriteDvmTopNavFilter = + buildFilter( + snapshot = orchestrator.observe(dvmAddress).value, + relays = resolveRelays(outboxRelays.value, proxyRelays.value), + ) + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt new file mode 100644 index 0000000000..093fc390de --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt @@ -0,0 +1,68 @@ +/* + * 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.favoriteDvm + +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 favourite DVM. + * + * 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 [FavoriteDvmFeedFlow] and replaces the active filter. + */ +@Immutable +class FavoriteDvmTopNavFilter( + val dvmAddress: Address, + val acceptedIds: Set, + val acceptedAddresses: Set, + val relayList: Set, + 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 = MutableStateFlow(startValue(cache)) + + override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet = + FavoriteDvmTopNavPerRelayFilterSet( + relayList.associateWith { + FavoriteDvmTopNavPerRelayFilter( + dvmPubkey = dvmAddress.pubKeyHex, + requestId = requestId, + ids = acceptedIds, + addresses = acceptedAddresses, + ) + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt new file mode 100644 index 0000000000..5a8f3d7f66 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt @@ -0,0 +1,33 @@ +/* + * 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.favoriteDvm + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +@Immutable +class FavoriteDvmTopNavPerRelayFilter( + val dvmPubkey: HexKey, + val requestId: HexKey?, + val ids: Set, + val addresses: Set, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt new file mode 100644 index 0000000000..4ec47da11d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt @@ -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.amethyst.model.topNavFeeds.favoriteDvm + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class FavoriteDvmTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index c3f7bba7ca..64c3ac1e9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -40,6 +40,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ViewList import androidx.compose.material.icons.automirrored.outlined.VolumeOff import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material.icons.outlined.Person @@ -84,6 +85,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.CommunityName +import com.vitorpamplona.amethyst.ui.screen.FavoriteDvmName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.GeoHashName import com.vitorpamplona.amethyst.ui.screen.HashtagName @@ -100,6 +102,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import kotlinx.collections.immutable.ImmutableList @OptIn(ExperimentalPermissionsApi::class) @@ -329,6 +332,20 @@ fun RenderOption( color = MaterialTheme.colorScheme.onSurface, ) } + + is FavoriteDvmName -> { + 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 +363,7 @@ private enum class FeedGroup( COMMUNITIES(R.string.feed_group_communities), LOCATIONS(R.string.feed_group_locations), LISTS(R.string.feed_group_lists), + DVMS(R.string.feed_group_dvms), RELAYS(R.string.feed_group_relays), } @@ -373,6 +391,10 @@ private fun groupFeedDefinitions(options: ImmutableList): Map { + FeedGroup.DVMS + } + is ResourceName -> { when (entry.item.code) { is TopFilter.AroundMe -> FeedGroup.LOCATIONS @@ -541,12 +563,17 @@ private fun FeedIcon( Icons.AutoMirrored.Outlined.ViewList } + is TopFilter.FavoriteDvm -> { + Icons.Outlined.AutoAwesome + } + else -> { when (item.name) { is GeoHashName -> Icons.Outlined.LocationOn is RelayName -> Icons.Outlined.Storage is CommunityName -> Icons.Outlined.Groups is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList + is FavoriteDvmName -> Icons.Outlined.AutoAwesome else -> Icons.Outlined.Person } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 50763b54d7..811ff15089 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -140,6 +141,7 @@ class TopNavFilterState( geotagList: Set, communityList: List, relayList: Set, + favoriteDvmList: List, ): List { val hashtags = hashtagList.map { @@ -173,7 +175,15 @@ class TopNavFilterState( ) } - return (communities + hashtags + geotags + relays).sortedBy { it.name.name() } + val favoriteDvms = + favoriteDvmList.map { dvmNote -> + FeedDefinition( + TopFilter.FavoriteDvm(dvmNote.address), + FavoriteDvmName(dvmNote), + ) + } + + return (communities + hashtags + geotags + relays + favoriteDvms).sortedBy { it.name.name() } } @OptIn(ExperimentalCoroutinesApi::class) @@ -183,6 +193,7 @@ class TopNavFilterState( account.geohashList.flow, account.communityList.flowNotes, account.relayFeedsList.flow, + account.favoriteDvmList.flowNotes, ::mergeInterests, ).onStart { emit( @@ -191,6 +202,7 @@ class TopNavFilterState( account.geohashList.flow.value, account.communityList.flowNotes.value, account.relayFeedsList.flow.value, + account.favoriteDvmList.flowNotes.value, ), ) } @@ -298,6 +310,15 @@ class CommunityName( override fun name() = "/n/${(note.dTag())}" } +@Stable +class FavoriteDvmName( + val note: AddressableNote, +) : Name() { + override fun name(): String = + (note.event as? AppDefinitionEvent)?.appMetaData()?.name?.takeIf { it.isNotBlank() } + ?: note.dTag() +} + @Immutable class FeedDefinition( val code: TopFilter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 56d1640939..c8ebc96433 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -138,6 +138,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -1088,6 +1089,12 @@ class AccountViewModel( fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) } + fun followFavoriteDvm(dvm: AddressBookmark) = launchSigner { account.followFavoriteDvm(dvm) } + + fun unfollowFavoriteDvm(dvm: Address) = launchSigner { account.unfollowFavoriteDvm(dvm) } + + fun refreshFavoriteDvm(dvm: Address) = account.favoriteDvmOrchestrator.refresh(dvm) + fun followRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.followRelayFeed(url) } fun unfollowRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.unfollowRelayFeed(url) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt index 6fa904415a..4ac46b109c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.MyAsyncImage @@ -51,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.FavoriteDvmToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp @@ -123,6 +125,12 @@ fun RenderContentDVMThumb( verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing5dp, ) { + if (baseNote is AddressableNote) { + FavoriteDvmToggle( + appDefinitionNote = baseNote, + accountViewModel = accountViewModel, + ) + } LikeReaction( baseNote = baseNote, grayTint = MaterialTheme.colorScheme.onSurface, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index 0d81a4cc8f..3f5dea38e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -354,82 +354,95 @@ fun FeedDVM( Spacer(modifier = DoubleVertSpacer) Text(currentStatus, textAlign = TextAlign.Center) - if (status.code == "payment-required") { - val amountTag = latestStatus.firstAmount() - val amount = amountTag?.amount + DvmPaymentActions( + latestStatus = latestStatus, + accountViewModel = accountViewModel, + nav = nav, + onStatusUpdate = { currentStatus = it }, + ) + } +} - val invoice = amountTag?.lnInvoice +@Composable +fun DvmPaymentActions( + latestStatus: NIP90StatusEvent, + accountViewModel: AccountViewModel, + nav: INav, + onStatusUpdate: (String) -> Unit, +) { + val status = latestStatus.status() ?: return - val thankYou = stringRes(id = R.string.dvm_waiting_to_confirm_payment) - val nwcPaymentRequest = stringRes(id = R.string.nwc_payment_request) + if (status.code != "payment-required") return - if (invoice != null) { - val context = LocalContext.current - Button(onClick = { - if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { - accountViewModel.sendZapPaymentRequestFor( - bolt11 = invoice, - zappedNote = null, - onSent = { - currentStatus = nwcPaymentRequest - }, - onResponse = { response -> - currentStatus = - if (response is PayInvoiceErrorResponse) { - stringRes( - context, - R.string.wallet_connect_pay_invoice_error_error, - response.error?.message - ?: response.error?.code?.toString() ?: "Error parsing error message", - ) - } else { - thankYou - } + val amountTag = latestStatus.firstAmount() + val amount = amountTag?.amount + val invoice = amountTag?.lnInvoice + + val thankYou = stringRes(id = R.string.dvm_waiting_to_confirm_payment) + val nwcPaymentRequest = stringRes(id = R.string.nwc_payment_request) + + if (invoice != null) { + val context = LocalContext.current + Button(onClick = { + if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { + accountViewModel.sendZapPaymentRequestFor( + bolt11 = invoice, + zappedNote = null, + onSent = { + onStatusUpdate(nwcPaymentRequest) + }, + onResponse = { response -> + onStatusUpdate( + if (response is PayInvoiceErrorResponse) { + stringRes( + context, + R.string.wallet_connect_pay_invoice_error_error, + response.error?.message + ?: response.error?.code?.toString() ?: "Error parsing error message", + ) + } else { + thankYou }, ) - } else { - payViaIntent( - invoice, - context, - onPaid = { - currentStatus = thankYou - }, - onError = { - currentStatus = it - }, - ) - } - }) { - val amountInInvoice = - try { - LnInvoiceUtil.getAmountInSats(invoice).toLong() - } catch (_: Exception) { - null - } - - if (amountInInvoice != null) { - Text(text = "Pay $amountInInvoice sats to the DVM") - } else { - Text(text = "Pay Invoice from the DVM") - } - } - } else if (amount != null) { - LoadNote(baseNoteHex = latestStatus.id, accountViewModel = accountViewModel) { stateNote -> - stateNote?.let { - ZapDVMButton( - baseNote = it, - amount = amount, - grayTint = MaterialTheme.colorScheme.onPrimary, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } + }, + ) + } else { + payViaIntent( + invoice, + context, + onPaid = { + onStatusUpdate(thankYou) + }, + onError = { + onStatusUpdate(it) + }, + ) + } + }) { + val amountInInvoice = + try { + LnInvoiceUtil.getAmountInSats(invoice).toLong() + } catch (_: Exception) { + null + } + + if (amountInInvoice != null) { + Text(text = "Pay $amountInInvoice sats to the DVM") + } else { + Text(text = "Pay Invoice from the DVM") + } + } + } else if (amount != null) { + LoadNote(baseNoteHex = latestStatus.id, accountViewModel = accountViewModel) { stateNote -> + stateNote?.let { + ZapDVMButton( + baseNote = it, + amount = amount, + grayTint = MaterialTheme.colorScheme.onPrimary, + accountViewModel = accountViewModel, + nav = nav, + ) } - } else if (status.code == "processing") { - currentStatus = status.description - } else if (status.code == "error") { - currentStatus = status.description } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt index 928a2d9a8d..d3dcecc660 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt @@ -21,15 +21,18 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.topbars.MyExtensibleTopAppBar +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer @@ -41,7 +44,7 @@ fun DvmTopBar( accountViewModel: AccountViewModel, nav: INav, ) { - TopBarExtensibleWithBackButton( + MyExtensibleTopAppBar( title = { LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote -> if (appDefinitionNote != null) { @@ -82,6 +85,16 @@ fun DvmTopBar( } } }, - popBack = nav::popBack, + navigationIcon = { IconButton(onClick = nav::popBack) { ArrowBackIcon() } }, + actions = { + LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote -> + if (appDefinitionNote is AddressableNote) { + FavoriteDvmToggle( + appDefinitionNote = appDefinitionNote, + accountViewModel = accountViewModel, + ) + } + } + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt new file mode 100644 index 0000000000..f79acafcd2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt @@ -0,0 +1,82 @@ +/* + * 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.IconButton +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.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 + +@Composable +fun FavoriteDvmToggle( + appDefinitionNote: AddressableNote, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val favorites by accountViewModel.account.favoriteDvmList.flow + .collectAsStateWithLifecycle() + + val isFavorite = favorites.contains(appDefinitionNote.address) + + IconButton( + onClick = { + if (isFavorite) { + accountViewModel.unfollowFavoriteDvm(appDefinitionNote.address) + } else { + accountViewModel.followFavoriteDvm( + AddressBookmark( + address = appDefinitionNote.address, + relayHint = appDefinitionNote.relayHintUrl(), + ), + ) + } + }, + modifier = modifier, + ) { + if (isFavorite) { + Icon( + imageVector = Icons.Filled.Star, + contentDescription = stringRes(R.string.remove_dvm_from_favorites), + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + imageVector = Icons.Outlined.StarBorder, + contentDescription = stringRes(R.string.add_dvm_to_favorites), + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt new file mode 100644 index 0000000000..487a9524b7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt @@ -0,0 +1,195 @@ +/* + * 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.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 HomeDvmStatusBanner( + accountViewModel: AccountViewModel, + nav: INav, +) { + val topFilter by accountViewModel.account.settings.defaultHomeFollowList + .collectAsStateWithLifecycle() + + val favDvm = topFilter as? TopFilter.FavoriteDvm ?: return + + val snapshot by accountViewModel.account.favoriteDvmOrchestrator + .observe(favDvm.address) + .collectAsStateWithLifecycle() + + // Hide the banner when the feed is already populated. + if (snapshot.ids.isNotEmpty() || snapshot.addresses.isNotEmpty()) return + + val dvmAddressValue = favDvm.address.toValue() + + LoadNote(baseNoteHex = dvmAddressValue, accountViewModel = accountViewModel) { dvmNote -> + val resolvedName by + observeNoteAndMap(dvmNote ?: return@LoadNote, accountViewModel) { note -> + (note.event as? AppDefinitionEvent) + ?.appMetaData() + ?.name + ?.takeIf { it.isNotBlank() } + ?: (note as? com.vitorpamplona.amethyst.model.AddressableNote)?.dTag() + ?: "" + } + + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column(modifier = Modifier.padding(12.dp)) { + val status = snapshot.latestStatus?.status() + + when { + snapshot.errorMessage != null -> { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_error), + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + RetryButton(favDvm, accountViewModel) + } + + 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(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(favDvm, accountViewModel) + } + + 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 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( + favDvm: TopFilter.FavoriteDvm, + accountViewModel: AccountViewModel, +) { + OutlinedButton(onClick = { accountViewModel.refreshFavoriteDvm(favDvm.address) }) { + Text(stringRes(R.string.dvm_home_retry)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 77adf8355f..38e38dae6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -179,6 +179,7 @@ private fun HomePages( topBar = { Column { HomeTopBar(accountViewModel, nav) + HomeDvmStatusBanner(accountViewModel, nav) SecondaryTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt index 39459be38a..29abc2ae62 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet @@ -42,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.f import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip64Chess.filterHomePostsByChess import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90Dvms.filterHomePostsByDvmIds import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription @@ -74,6 +76,7 @@ class HomeOutboxEventsEoseManager( is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince) is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince) is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince) + is FavoriteDvmTopNavPerRelayFilterSet -> filterHomePostsByDvmIds(feedSettings, since, newThreadSince) else -> emptyList() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt new file mode 100644 index 0000000000..644cc1f548 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt @@ -0,0 +1,99 @@ +/* + * 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.nip90Dvms + +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent + +/** + * Builds relay REQ filters for a favourite-DVM home feed. + * + * Three filters are issued per relay: + * - fetch notes whose ids are in the DVM's latest kind-6300 response + * - fetch addressable notes referenced by `a` tags in the DVM's response + * - subscribe to the DVM's future kind 6300 / 7000 events so the filter snapshot + * keeps up to date while this filter is active + */ +fun filterHomePostsByDvmIds( + set: FavoriteDvmTopNavPerRelayFilterSet, + @Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?, + @Suppress("UNUSED_PARAMETER") defaultSince: Long?, +): List = + set.set.flatMap { (relay, filter) -> + buildFiltersFor(relay, filter) + } + +private fun buildFiltersFor( + relay: NormalizedRelayUrl, + filter: FavoriteDvmTopNavPerRelayFilter, +): List { + val out = mutableListOf() + + 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, + ), + ) + } + + val requestId = filter.requestId + if (requestId != null) { + out += + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = + listOf( + NIP90ContentDiscoveryResponseEvent.KIND, + NIP90StatusEvent.KIND, + ), + tags = mapOf("e" to listOf(requestId)), + limit = 10, + ), + ) + } + + return out +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e2ffc91efc..9b74fd43b4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1728,8 +1728,17 @@ Locations Communities Lists + DVMs Relays + Add DVM to favorites + Remove from favorites + Asking %1$s for a feed… + Processing your feed… + This DVM requires payment + DVM returned an error + Retry + Log off on device lock Private Message Public Message diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt new file mode 100644 index 0000000000..34f28bf7c5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt @@ -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.favoriteDvmList + +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 FavoriteDvmListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicFavoriteDvms(): List = tags.mapNotNull(AddressBookmark::parse) + + suspend fun privateFavoriteDvms(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(AddressBookmark::parse) + + companion object { + const val KIND = 10090 + const val ALT = "Favorite DVM list" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + dvm: AddressBookmark, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent = + if (isPrivate) { + create( + publicDvms = emptyList(), + privateDvms = listOf(dvm), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicDvms = listOf(dvm), + privateDvms = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: FavoriteDvmListEvent, + dvm: AddressBookmark, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent = + if (isPrivate) { + val privateTags = + earlierVersion.privateTags(signer) + ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.remove(dvm.toTagIdOnly()) + dvm.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(dvm.toTagIdOnly()) + dvm.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: FavoriteDvmListEvent, + dvm: Address, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent { + val idOnly = AddressBookmark.assemble(dvm, 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(), + ): FavoriteDvmListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicDvms: List = emptyList(), + privateDvms: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent { + val template = build(publicDvms, privateDvms, signer, createdAt) + return signer.sign(template) + } + + suspend fun build( + publicDvms: List = emptyList(), + privateDvms: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = + PrivateTagsInContent.encryptNip44( + privateDvms.map { it.toTagArray() }.toTypedArray(), + signer, + ), + createdAt = createdAt, + ) { + alt(ALT) + favoriteDvms(publicDvms) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..6f49fd98b1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt @@ -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.favoriteDvmList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +fun TagArrayBuilder.favoriteDvm(app: AddressBookmark) = add(app.toTagArray()) + +fun TagArrayBuilder.favoriteDvms(apps: List) = addAll(apps.map { it.toTagArray() }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt new file mode 100644 index 0000000000..0f2d3501c5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt @@ -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.favoriteDvmList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +fun TagArray.favoriteDvmList() = mapNotNull(AddressBookmark::parseAddress) + +fun TagArray.favoriteDvmSet() = mapNotNullTo(mutableSetOf(), AddressBookmark::parseAddress) From 843da0b3839a5ab1b468e31abe820aba1cf0771f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 21:27:13 +0000 Subject: [PATCH 02/12] fix(home): restrict favourite DVM list to content-discovery DVMs Only NIP-90 content discovery (kind 5300) DVMs can produce a feed. Other DVM types (image generation, translation, search, etc.) would silently hang the home banner waiting for a 6300 reply that will never come. Defence in depth: - FavoriteDvmToggle hides itself when the AppDefinitionEvent doesn't advertise kind 5300, so users can't favourite the wrong type. - TopNavFilterState.mergeInterests filters out any list entry whose AppDefinitionEvent doesn't include kind 5300, so a stale or cross-client entry won't surface as a Home chip. --- .../amethyst/ui/screen/TopNavFilterState.kt | 11 ++++++++++- .../ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 811ff15089..a4d9cb8b65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -175,8 +176,16 @@ class TopNavFilterState( ) } + // Only DVMs that advertise NIP-90 content discovery (kind 5300) can produce a + // home feed. Hide entries whose AppDefinitionEvent isn't loaded yet OR doesn't + // include kind 5300 so the chip doesn't appear for image-generation, translation, + // search, or other DVM kinds that would never reply. val favoriteDvms = - favoriteDvmList.map { dvmNote -> + favoriteDvmList.mapNotNull { dvmNote -> + val supports5300 = + (dvmNote.event as? AppDefinitionEvent) + ?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true + if (!supports5300) return@mapNotNull null FeedDefinition( TopFilter.FavoriteDvm(dvmNote.address), FavoriteDvmName(dvmNote), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt index f79acafcd2..98cd9f062b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt @@ -32,10 +32,13 @@ 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.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 @Composable fun FavoriteDvmToggle( @@ -43,6 +46,15 @@ fun FavoriteDvmToggle( accountViewModel: AccountViewModel, modifier: Modifier = Modifier, ) { + // Only NIP-90 content-discovery DVMs (kind 5300) produce a feed; hide the toggle + // for any other DVM type so users don't favourite something that would never reply. + val supportsContentDiscovery by + observeNoteAndMap(appDefinitionNote, accountViewModel) { note -> + (note.event as? AppDefinitionEvent)?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true + } + + if (!supportsContentDiscovery) return + val favorites by accountViewModel.account.favoriteDvmList.flow .collectAsStateWithLifecycle() From ee94dba570ae897bedbd40fa34507db1b77e4cec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 21:57:56 +0000 Subject: [PATCH 03/12] fix(home): route DVM listen subscription to DVM relays + wire pull-to-refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from the first pass: 1. Response routing. The kind-5300 request goes to the DVM's own inbox/used relays, but `filterHomePostsByDvmIds` was subscribing for kind 6300/7000 on the user's outbox/proxy relays. Most DVMs only publish their replies on their own relays, so the listen subscription would silently miss every response. Fix: `Account.requestDVMContentDiscovery` now exposes the relay set the request was sent to, the orchestrator stores it on the snapshot as `responseRelays`, and `FavoriteDvmTopNavPerRelayFilterSet` carries two distinct relay sets — `contentFetches` (user's outbox, for the actual notes) and `listenRelays` (DVM's relays, for the 6300/7000 reply subscription). `filterHomePostsByDvmIds` issues each on the right relay. 2. Pull-to-refresh. Swiping down on Home only re-rendered the cached feed. When a `TopFilter.FavoriteDvm` is active it now also calls `orchestrator.refresh(addr)` so a fresh kind-5300 request is published to the DVM. --- .../vitorpamplona/amethyst/model/Account.kt | 4 +- .../model/dvms/FavoriteDvmOrchestrator.kt | 8 ++- .../favoriteDvm/FavoriteDvmFeedFlow.kt | 7 +- .../favoriteDvm/FavoriteDvmTopNavFilter.kt | 20 +++--- .../FavoriteDvmTopNavPerRelayFilter.kt | 2 - .../FavoriteDvmTopNavPerRelayFilterSet.kt | 13 +++- .../ui/screen/loggedIn/AccountViewModel.kt | 4 +- .../ui/screen/loggedIn/home/HomeScreen.kt | 33 ++++++++- .../nip90Dvms/FilterHomePostsByDvmIds.kt | 72 ++++++++++++------- 9 files changed, 114 insertions(+), 49 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index ec3df21db2..c6562a0001 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2357,7 +2357,7 @@ class Account( suspend fun requestDVMContentDiscovery( dvmPublicKey: User, - onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit, + onReady: (event: NIP90ContentDiscoveryRequestEvent, relays: Set) -> Unit, ) { val relays = nip65RelayList.inboxFlow.value.toSet() val request = signer.sign(NIP90ContentDiscoveryRequestEvent.build(dvmPublicKey.pubkeyHex, signer.pubKey, relays)) @@ -2367,7 +2367,7 @@ class Account( ?: (dvmPublicKey.allUsedRelays() + cache.relayHints.hintsForKey(dvmPublicKey.pubkeyHex)) cache.justConsumeMyOwnEvent(request) - onReady(request) + onReady(request, relayList.toSet()) delay(100) client.publish(request, relayList) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt index f4058cccb8..cd92afa0fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt @@ -24,6 +24,7 @@ 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 @@ -44,12 +45,16 @@ import kotlinx.coroutines.sync.withLock * Immutable snapshot of a favourite DVM'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 FavoriteDvmSnapshot( val requestId: HexKey? = null, + val responseRelays: Set = emptySet(), val ids: Set = emptySet(), val addresses: Set = emptySet(), val latestStatus: NIP90StatusEvent? = null, @@ -114,10 +119,11 @@ class FavoriteDvmOrchestrator( val job = scope.launch(Dispatchers.IO) { try { - account.requestDVMContentDiscovery(user) { request -> + account.requestDVMContentDiscovery(user) { request, relays -> seed.update { it.copy( requestId = request.id, + responseRelays = relays, ids = emptySet(), addresses = emptySet(), latestStatus = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt index f919d62a0b..04d35e0d67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt @@ -43,12 +43,13 @@ class FavoriteDvmFeedFlow( private fun buildFilter( snapshot: com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot, - relays: Set, + contentRelays: Set, ) = FavoriteDvmTopNavFilter( dvmAddress = dvmAddress, acceptedIds = snapshot.ids, acceptedAddresses = snapshot.addresses, - relayList = relays, + contentRelays = contentRelays, + listenRelays = snapshot.responseRelays, requestId = snapshot.requestId, ) @@ -60,7 +61,7 @@ class FavoriteDvmFeedFlow( override fun startValue(): FavoriteDvmTopNavFilter = buildFilter( snapshot = orchestrator.observe(dvmAddress).value, - relays = resolveRelays(outboxRelays.value, proxyRelays.value), + contentRelays = resolveRelays(outboxRelays.value, proxyRelays.value), ) override suspend fun startValue(collector: FlowCollector) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt index 093fc390de..e521c6d5b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt @@ -43,7 +43,8 @@ class FavoriteDvmTopNavFilter( val dvmAddress: Address, val acceptedIds: Set, val acceptedAddresses: Set, - val relayList: Set, + val contentRelays: Set, + val listenRelays: Set, val requestId: HexKey?, ) : IFeedTopNavFilter { override fun matchAuthor(pubkey: HexKey): Boolean = true @@ -56,13 +57,14 @@ class FavoriteDvmTopNavFilter( override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet = FavoriteDvmTopNavPerRelayFilterSet( - relayList.associateWith { - FavoriteDvmTopNavPerRelayFilter( - dvmPubkey = dvmAddress.pubKeyHex, - requestId = requestId, - ids = acceptedIds, - addresses = acceptedAddresses, - ) - }, + contentFetches = + contentRelays.associateWith { + FavoriteDvmTopNavPerRelayFilter( + ids = acceptedIds, + addresses = acceptedAddresses, + ) + }, + listenRelays = listenRelays, + requestId = requestId, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt index 5a8f3d7f66..4dcbeddaac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt @@ -26,8 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey @Immutable class FavoriteDvmTopNavPerRelayFilter( - val dvmPubkey: HexKey, - val requestId: HexKey?, val ids: Set, val addresses: Set, ) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt index 4ec47da11d..321f5ca2c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt @@ -21,8 +21,19 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm 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 DVM's own publish relays (where it will deliver future + * kind 6300 / 7000 events for this request). + */ class FavoriteDvmTopNavPerRelayFilterSet( - val set: Map, + val contentFetches: Map, + val listenRelays: Set, + val requestId: HexKey?, ) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index c8ebc96433..97b01c3363 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1809,8 +1809,8 @@ class AccountViewModel( onReady: (event: Note) -> Unit, ) { launchSigner { - account.requestDVMContentDiscovery(dvmPublicKey) { - onReady(LocalCache.getOrCreateNote(it.id)) + account.requestDVMContentDiscovery(dvmPublicKey) { request, _ -> + onReady(LocalCache.getOrCreateNote(request.id)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 38e38dae6c..70a539a9ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -282,7 +282,20 @@ fun HomeFeeds( accountViewModel: AccountViewModel, nav: INav, ) { - RefresheableBox(feedState, enablePullRefresh) { + val activeFilter by accountViewModel.account.settings.defaultHomeFollowList + .collectAsStateWithLifecycle() + val activeDvm = activeFilter as? TopFilter.FavoriteDvm + + val onRefresh: () -> Unit = { + feedState.invalidateData() + if (activeDvm != null) { + // Swiping down on Home should also re-issue the kind-5300 request so the + // DVM produces a fresh feed, not just re-render whatever's cached. + accountViewModel.refreshFavoriteDvm(activeDvm.address) + } + } + + RefresheableHomeBox(onRefresh, enablePullRefresh) { SaveableFeedContentState(feedState, scrollStateKey) { listState -> RenderFeedContentState( feedContentState = feedState, @@ -291,12 +304,28 @@ fun HomeFeeds( nav = nav, routeForLastRead = routeForLastRead, onLoaded = { FeedLoaded(it, listState, routeForLastRead, liveSection, accountViewModel, nav) }, - onEmpty = { HomeFeedEmpty(feedState::invalidateData) }, + onEmpty = { HomeFeedEmpty(onRefresh) }, ) } } } +@Composable +private fun RefresheableHomeBox( + onRefresh: () -> Unit, + enablePullRefresh: Boolean, + content: @Composable androidx.compose.foundation.layout.BoxScope.() -> Unit, +) { + if (enablePullRefresh) { + RefresheableBox(onRefresh = onRefresh, content = content) + } else { + androidx.compose.foundation.layout.Box( + Modifier.fillMaxSize(), + content = content, + ) + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun FeedLoaded( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt index 644cc1f548..856f6fd9c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90Dvms import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet 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 @@ -32,22 +33,40 @@ import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent /** * Builds relay REQ filters for a favourite-DVM home feed. * - * Three filters are issued per relay: - * - fetch notes whose ids are in the DVM's latest kind-6300 response - * - fetch addressable notes referenced by `a` tags in the DVM's response - * - subscribe to the DVM's future kind 6300 / 7000 events so the filter snapshot - * keeps up to date while this filter is active + * 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 filterHomePostsByDvmIds( set: FavoriteDvmTopNavPerRelayFilterSet, @Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?, @Suppress("UNUSED_PARAMETER") defaultSince: Long?, -): List = - set.set.flatMap { (relay, filter) -> - buildFiltersFor(relay, filter) +): List { + val out = mutableListOf() + + set.contentFetches.forEach { (relay, filter) -> + out += contentFetchFilters(relay, filter) } -private fun buildFiltersFor( + val requestId = set.requestId + if (requestId != null) { + set.listenRelays.forEach { relay -> + out += responseListenFilter(relay, requestId) + } + } + + return out +} + +private fun contentFetchFilters( relay: NormalizedRelayUrl, filter: FavoriteDvmTopNavPerRelayFilter, ): List { @@ -77,23 +96,22 @@ private fun buildFiltersFor( ) } - val requestId = filter.requestId - if (requestId != null) { - out += - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = - listOf( - NIP90ContentDiscoveryResponseEvent.KIND, - NIP90StatusEvent.KIND, - ), - tags = mapOf("e" to listOf(requestId)), - limit = 10, - ), - ) - } - return out } + +private fun responseListenFilter( + relay: NormalizedRelayUrl, + requestId: HexKey, +) = RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = + listOf( + NIP90ContentDiscoveryResponseEvent.KIND, + NIP90StatusEvent.KIND, + ), + tags = mapOf("e" to listOf(requestId)), + limit = 10, + ), +) From 8555074c3f4822347bd2bb4e19e3c7d6e4c1f51a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 22:48:55 +0000 Subject: [PATCH 04/12] feat(dvm-favorites): settings page + revert DVM card regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove FavoriteDvmToggle from DVMCard's title row; the extra IconButton was expanding the row and pushing the title down so the whole Discover→DVMs card layout looked broken. The toggle stays on the DvmContentDiscoveryScreen top bar, which was the user's expected place to follow/unfollow. - Add a "Favourite DVMs" page under Settings (Route.EditFavoriteDvms, modelled after the Blossom servers settings) listing each favourited content-discovery DVM with its avatar/name/description and a delete button. Tapping a row opens the DVM detail screen. Empty state points users to Discover for adding more. --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/discover/nip90DVMs/DVMCard.kt | 8 - .../dvms/favorites/FavoriteDvmListScreen.kt | 226 ++++++++++++++++++ .../loggedIn/settings/AllSettingsScreen.kt | 8 + amethyst/src/main/res/values/strings.xml | 3 + 6 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 510234327a..972befbcd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.Long import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteDvmListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen @@ -277,6 +278,7 @@ fun BuildNavigation( composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd { VanishEventsScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } + composableFromEnd { FavoriteDvmListScreen(accountViewModel, nav) } composableFromEnd { PaymentTargetsScreen(accountViewModel, nav) } composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index fdba690342..057a243971 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -171,6 +171,8 @@ sealed class Route { @Serializable object EditMediaServers : Route() + @Serializable object EditFavoriteDvms : Route() + @Serializable object EditPaymentTargets : Route() @Serializable object UpdateReactionType : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt index 4ac46b109c..6fa904415a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt @@ -42,7 +42,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.MyAsyncImage @@ -52,7 +51,6 @@ import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.FavoriteDvmToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp @@ -125,12 +123,6 @@ fun RenderContentDVMThumb( verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing5dp, ) { - if (baseNote is AddressableNote) { - FavoriteDvmToggle( - appDefinitionNote = baseNote, - accountViewModel = accountViewModel, - ) - } LikeReaction( baseNote = baseNote, grayTint = MaterialTheme.colorScheme.onSurface, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt new file mode 100644 index 0000000000..0f22ae4ecf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt @@ -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 FavoriteDvmListScreen( + 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, + ) + + FavoriteDvmList(accountViewModel, nav) + } + } +} + +@Composable +private fun FavoriteDvmList( + accountViewModel: AccountViewModel, + nav: INav, +) { + val favorites by accountViewModel.account.favoriteDvmList.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() }, + ) { dvmNote -> + FavoriteDvmRow( + dvmNote = dvmNote, + accountViewModel = accountViewModel, + onOpen = { nav.nav(Route.ContentDiscovery(dvmNote.idHex)) }, + onRemove = { accountViewModel.unfollowFavoriteDvm(dvmNote.address) }, + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun FavoriteDvmRow( + dvmNote: AddressableNote, + accountViewModel: AccountViewModel, + onOpen: () -> Unit, + onRemove: () -> Unit, +) { + val card = observeAppDefinition(dvmNote, 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 = { + dvmNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + }, + onError = { + dvmNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + }, + ) + } + } ?: run { + dvmNote.author?.let { author -> + BannerImage(author, SimpleImage35Modifier, accountViewModel) + } + } + + Spacer(modifier = DoubleHorzSpacer) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = card.name.ifBlank { dvmNote.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), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 7d0796c8e5..69207a03bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.DeleteForever @@ -122,6 +123,13 @@ fun AllSettingsScreen( onClick = { nav.nav(Route.EditMediaServers) }, ) HorizontalDivider() + SettingsNavigationRow( + title = R.string.favorite_dvms_title, + icon = Icons.Outlined.AutoAwesome, + tint = tint, + onClick = { nav.nav(Route.EditFavoriteDvms) }, + ) + HorizontalDivider() SettingsNavigationRow( title = R.string.reactions, icon = Icons.Outlined.FavoriteBorder, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 9b74fd43b4..aa11b8aa38 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1733,6 +1733,9 @@ Add DVM to favorites Remove from favorites + Favourite DVMs + Content-discovery DVMs you starred here appear as filter chips on the Home feed. Open Discover to add more. + No favourite DVMs yet. Open Discover, tap a content-discovery DVM, and star it to add it here. Asking %1$s for a feed… Processing your feed… This DVM requires payment From 309e474a3dd4c570d1a86edfce41962a594b1ab4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 23:02:53 +0000 Subject: [PATCH 05/12] fix(dvm-card): star top-right + move reactions to bottom row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Putting the star back where the user actually sees DVMs first — the Discover Content card. Two changes that together stop the layout from breaking: - FavoriteDvmToggle now uses ClickableBox + small Icon (no IconButton padding) so it occupies the same compact footprint as LikeReaction. Adding it to LeftPictureLayout's title row no longer inflates the row height. - DVMCard's title row now holds only the name (weight 1) plus the star toggle on the right. LikeReaction and ZapReaction move down to the bottom row, pushed to the far right with a Spacer(weight 1) so the amount/personalised chips stay on the left. --- .../loggedIn/discover/nip90DVMs/DVMCard.kt | 98 +++++++++---------- .../screen/loggedIn/dvms/FavoriteDvmToggle.kt | 24 +++-- 2 files changed, 61 insertions(+), 61 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt index 6fa904415a..0ca21209bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt @@ -21,11 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -40,8 +38,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.MyAsyncImage @@ -51,12 +49,12 @@ import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.FavoriteDvmToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp import com.vitorpamplona.amethyst.ui.theme.SimpleImageBorder import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.bitcoinColor import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.nip05 @@ -118,25 +116,12 @@ fun RenderContentDVMThumb( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - Spacer(modifier = StdVertSpacer) - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = RowColSpacing5dp, - ) { - LikeReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onSurface, + if (baseNote is AddressableNote) { + FavoriteDvmToggle( + appDefinitionNote = baseNote, accountViewModel = accountViewModel, - nav, ) } - Spacer(modifier = StdHorzSpacer) - ZapReaction( - baseNote = baseNote, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) }, onDescription = { card.description?.let { @@ -168,22 +153,16 @@ fun RenderContentDVMThumb( color = MaterialTheme.colorScheme.primary amount = card.amount + " Sats" } - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Absolute.Right, - ) { - Text( - textAlign = TextAlign.End, - text = " $amount ", - color = color, - maxLines = 3, - modifier = - Modifier - .weight(1f, fill = false) - .border(Dp(.1f), color, shape = RoundedCornerShape(20)), - fontSize = 12.sp, - ) - } + Text( + textAlign = TextAlign.End, + text = " $amount ", + color = color, + maxLines = 1, + modifier = + Modifier + .border(Dp(.1f), color, shape = RoundedCornerShape(20)), + fontSize = 12.sp, + ) } Spacer(modifier = StdHorzSpacer) card.personalized?.let { @@ -196,24 +175,35 @@ fun RenderContentDVMThumb( color = MaterialTheme.colorScheme.nip05 name = "Generic" } - Spacer(modifier = StdVertSpacer) - Row( - verticalAlignment = CenterVertically, - horizontalArrangement = Arrangement.Absolute.Right, - ) { - Text( - textAlign = TextAlign.End, - text = " $name ", - color = color, - maxLines = 3, - modifier = - Modifier - .padding(start = 4.dp) - .weight(1f, fill = false) - .border(Dp(.1f), color, shape = RoundedCornerShape(20)), - fontSize = 12.sp, - ) - } + Text( + textAlign = TextAlign.End, + text = " $name ", + color = color, + maxLines = 1, + modifier = + Modifier + .border(Dp(.1f), color, shape = RoundedCornerShape(20)), + fontSize = 12.sp, + ) + } + Spacer(modifier = Modifier.weight(1f)) + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = RowColSpacing5dp, + ) { + LikeReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = baseNote, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt index 98cd9f062b..1bed004b77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt @@ -24,7 +24,6 @@ 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.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -33,6 +32,7 @@ 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 @@ -40,14 +40,24 @@ 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 FavoriteDvmToggle( appDefinitionNote: AddressableNote, accountViewModel: AccountViewModel, modifier: Modifier = Modifier, + iconSizeModifier: Modifier = Size20Modifier, ) { - // Only NIP-90 content-discovery DVMs (kind 5300) produce a feed; hide the toggle - // for any other DVM type so users don't favourite something that would never reply. val supportsContentDiscovery by observeNoteAndMap(appDefinitionNote, accountViewModel) { note -> (note.event as? AppDefinitionEvent)?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true @@ -60,7 +70,8 @@ fun FavoriteDvmToggle( val isFavorite = favorites.contains(appDefinitionNote.address) - IconButton( + ClickableBox( + modifier = modifier, onClick = { if (isFavorite) { accountViewModel.unfollowFavoriteDvm(appDefinitionNote.address) @@ -73,20 +84,19 @@ fun FavoriteDvmToggle( ) } }, - modifier = modifier, ) { if (isFavorite) { Icon( imageVector = Icons.Filled.Star, contentDescription = stringRes(R.string.remove_dvm_from_favorites), - modifier = Size20Modifier, + modifier = iconSizeModifier, tint = MaterialTheme.colorScheme.primary, ) } else { Icon( imageVector = Icons.Outlined.StarBorder, contentDescription = stringRes(R.string.add_dvm_to_favorites), - modifier = Size20Modifier, + modifier = iconSizeModifier, tint = MaterialTheme.colorScheme.onSurface, ) } From 9ba2d2f5cc8819aae9f9508bc52dab8be3154966 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 00:12:26 +0000 Subject: [PATCH 06/12] feat(dvm-favorites): timeout, tests, and merged "All favourite DVMs" chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. DVM response timeout. FavoriteDvmOrchestrator now times out after 20s if neither a 6300 response nor any 7000 status arrives, and sets errorMessage = "timeout" on the snapshot so the home banner switches from the "Asking…" spinner to a Retry button instead of hanging forever. 2. Tests. FavoriteDvmListEventTest covers create/add/remove round trips and the fixed-empty d-tag invariant; FavoriteDvmTopNavFilter match by id and by `a` address; FilterHomePostsByDvmIdsTest covers the two-relay-set split (content fetch on user relays, listen on DVM relays) and the multi-requestId merge case. Also registered kind 10090 in EventFactory so Quartz can deserialise FavoriteDvmListEvent (required for the round-trip tests and for reading the list back from relays). 3. Merged "All favourite DVMs" chip. New TopFilter.AllFavoriteDvms that unions every favourite's latest 6300 response into one feed. AllFavoriteDvmsFeedFlow uses flatMapLatest over the favourite-list flow so subscriptions rewire when the user adds/removes a DVM. FavoriteDvmTopNavPerRelayFilterSet now carries Set requestIds (was a single nullable) so the filter can subscribe to N kind 6300/7000 streams in one REQ per DVM relay. Banner renders "Asking your favourite DVMs for feeds…" while all are pending and a single Retry-all on collective error; pull-to-refresh re-issues every DVM's kind-5300. --- .../vitorpamplona/amethyst/model/Account.kt | 1 + .../amethyst/model/AccountSettings.kt | 2 + .../model/dvms/FavoriteDvmOrchestrator.kt | 20 ++ .../topNavFeeds/FeedTopNavFilterState.kt | 12 + .../favoriteDvm/AllFavoriteDvmsFeedFlow.kt | 115 ++++++++++ .../AllFavoriteDvmsTopNavFilter.kt | 67 ++++++ .../favoriteDvm/FavoriteDvmTopNavFilter.kt | 2 +- .../FavoriteDvmTopNavPerRelayFilterSet.kt | 9 +- .../navigation/topbars/FeedFilterSpinner.kt | 5 + .../amethyst/ui/screen/TopNavFilterState.kt | 13 +- .../screen/loggedIn/home/DvmStatusBanner.kt | 206 +++++++++++------- .../ui/screen/loggedIn/home/HomeScreen.kt | 13 +- .../nip90Dvms/FilterHomePostsByDvmIds.kt | 12 +- amethyst/src/main/res/values/strings.xml | 2 + .../FavoriteDvmTopNavFilterTest.kt | 129 +++++++++++ .../nip90Dvms/FilterHomePostsByDvmIdsTest.kt | 136 ++++++++++++ .../quartz/utils/EventFactory.kt | 2 + .../FavoriteDvmListEventTest.kt | 159 ++++++++++++++ 18 files changed, 814 insertions(+), 91 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c6562a0001..4f52e43d77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -427,6 +427,7 @@ class Account( signer = signer, scope = scope, favoriteDvmOrchestrator = favoriteDvmOrchestrator, + favoriteDvmAddresses = favoriteDvmList.flow, ).flow // App-ready Feeds diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 0f327a1742..15332850ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -160,6 +160,8 @@ sealed class TopFilter( class FavoriteDvm( val address: Address, ) : TopFilter("FavoriteDvm/${address.toValue()}") + + @Serializable object AllFavoriteDvms : TopFilter(" All Favourite DVMs ") } @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt index cd92afa0fa..ddf24813ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt @@ -32,6 +32,7 @@ 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 @@ -41,6 +42,8 @@ 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 favourite DVM's current request/response state. * @@ -166,6 +169,23 @@ class FavoriteDvmOrchestrator( 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("FavoriteDvmOrchestrator", "Failed to start DVM request: ${e.message}", e) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index 27635f0197..be72d1b265 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -31,12 +31,14 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFoll import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.GeohashFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.AllFavoriteDvmsFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayFeedFlow import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -65,6 +67,7 @@ class FeedTopNavFilterState( val signer: NostrSigner, val scope: CoroutineScope, val favoriteDvmOrchestrator: FavoriteDvmOrchestrator, + val favoriteDvmAddresses: StateFlow>, ) { fun loadFlowsFor(listName: TopFilter): IFeedFlowsType = when (listName) { @@ -154,6 +157,15 @@ class FeedTopNavFilterState( proxyRelays = proxyRelays, ) } + + TopFilter.AllFavoriteDvms -> { + AllFavoriteDvmsFeedFlow( + favoriteDvmAddresses = favoriteDvmAddresses, + orchestrator = favoriteDvmOrchestrator, + outboxRelays = followsRelays, + proxyRelays = proxyRelays, + ) + } } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt new file mode 100644 index 0000000000..8f880a4b15 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt @@ -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.favoriteDvm + +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot +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-favourited DVM into a + * single [AllFavoriteDvmsTopNavFilter]. Re-wires subscriptions whenever the + * favourite set changes. + */ +class AllFavoriteDvmsFeedFlow( + val favoriteDvmAddresses: StateFlow>, + val orchestrator: FavoriteDvmOrchestrator, + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + private fun resolveContentRelays( + outbox: Set, + proxy: Set, + ): Set = if (proxy.isNotEmpty()) proxy else outbox + + private fun merge( + snapshots: List, + contentRelays: Set, + ): AllFavoriteDvmsTopNavFilter { + val ids = mutableSetOf() + val addresses = mutableSetOf() + val listen = mutableSetOf() + val requestIds = mutableSetOf() + snapshots.forEach { snap -> + ids += snap.ids + addresses += snap.addresses + listen += snap.responseRelays + snap.requestId?.let { requestIds += it } + } + return AllFavoriteDvmsTopNavFilter( + acceptedIds = ids, + acceptedAddresses = addresses, + contentRelays = contentRelays, + listenRelays = listen, + requestIds = requestIds, + ) + } + + private fun emptyFilter(contentRelays: Set): AllFavoriteDvmsTopNavFilter = + AllFavoriteDvmsTopNavFilter( + acceptedIds = emptySet(), + acceptedAddresses = emptySet(), + contentRelays = contentRelays, + listenRelays = emptySet(), + requestIds = emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + override fun flow(): Flow = + favoriteDvmAddresses.flatMapLatest { addresses -> + if (addresses.isEmpty()) { + combine(outboxRelays, proxyRelays) { outbox, proxy -> + emptyFilter(resolveContentRelays(outbox, proxy)) + } + } else { + val snapshotFlows: List> = 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(): AllFavoriteDvmsTopNavFilter { + val contentRelays = resolveContentRelays(outboxRelays.value, proxyRelays.value) + val addresses = favoriteDvmAddresses.value + return if (addresses.isEmpty()) { + emptyFilter(contentRelays) + } else { + merge(addresses.map { orchestrator.observe(it).value }, contentRelays) + } + } + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt new file mode 100644 index 0000000000..f686bec2b4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt @@ -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.favoriteDvm + +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 + * favourited DVM. Behaves like [FavoriteDvmTopNavFilter] (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 AllFavoriteDvmsTopNavFilter( + val acceptedIds: Set, + val acceptedAddresses: Set, + val contentRelays: Set, + val listenRelays: Set, + val requestIds: Set, +) : 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 = MutableStateFlow(startValue(cache)) + + override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = + contentRelays.associateWith { + FavoriteDvmTopNavPerRelayFilter( + ids = acceptedIds, + addresses = acceptedAddresses, + ) + }, + listenRelays = listenRelays, + requestIds = requestIds, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt index e521c6d5b1..88924a2a4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt @@ -65,6 +65,6 @@ class FavoriteDvmTopNavFilter( ) }, listenRelays = listenRelays, - requestId = requestId, + requestIds = setOfNotNull(requestId), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt index 321f5ca2c3..399d2ab31c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt @@ -29,11 +29,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl * * - [contentFetches] — for each user-configured content relay, the ids/addresses * we want to pull (the actual notes the DVM curated). - * - [listenRelays] — the DVM's own publish relays (where it will deliver future - * kind 6300 / 7000 events for this request). + * - [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-DVM filter carries one; the merged "All favourite DVMs" + * filter carries one per favourite DVM. */ class FavoriteDvmTopNavPerRelayFilterSet( val contentFetches: Map, val listenRelays: Set, - val requestId: HexKey?, + val requestIds: Set, ) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 64c3ac1e9d..c08cf8ba48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -399,6 +399,7 @@ private fun groupFeedDefinitions(options: ImmutableList): Map FeedGroup.LOCATIONS is TopFilter.Global -> FeedGroup.RELAYS + is TopFilter.AllFavoriteDvms -> FeedGroup.DVMS else -> FeedGroup.FEEDS } } @@ -567,6 +568,10 @@ private fun FeedIcon( Icons.Outlined.AutoAwesome } + is TopFilter.AllFavoriteDvms -> { + Icons.Outlined.AutoAwesome + } + else -> { when (item.name) { is GeoHashName -> Icons.Outlined.LocationOn diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index a4d9cb8b65..7d2003e85f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -98,6 +98,12 @@ class TopNavFilterState( name = ResourceName(R.string.follow_list_chess), ) + val allFavoriteDvmsFollow = + FeedDefinition( + code = TopFilter.AllFavoriteDvms, + name = ResourceName(R.string.follow_list_all_favorite_dvms), + ) + val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow) fun mergePeopleLists( @@ -192,7 +198,12 @@ class TopNavFilterState( ) } - return (communities + hashtags + geotags + relays + favoriteDvms).sortedBy { it.name.name() } + // Only show the "All favourite DVMs" meta-chip when there is at least one + // real favourite to merge; otherwise the chip opens to an empty feed. + val allFavorites = + if (favoriteDvms.isNotEmpty()) listOf(allFavoriteDvmsFollow) else emptyList() + + return (communities + hashtags + geotags + relays + allFavorites + favoriteDvms).sortedBy { it.name.name() } } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt index 487a9524b7..de3335425c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt @@ -42,6 +42,7 @@ 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.dvms.FavoriteDvmSnapshot import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.LoadingAnimation @@ -61,8 +62,19 @@ fun HomeDvmStatusBanner( val topFilter by accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() - val favDvm = topFilter as? TopFilter.FavoriteDvm ?: return + when (val filter = topFilter) { + is TopFilter.FavoriteDvm -> SingleDvmBanner(filter, accountViewModel, nav) + is TopFilter.AllFavoriteDvms -> AllFavoriteDvmsBanner(accountViewModel) + else -> Unit + } +} +@Composable +private fun SingleDvmBanner( + favDvm: TopFilter.FavoriteDvm, + accountViewModel: AccountViewModel, + nav: INav, +) { val snapshot by accountViewModel.account.favoriteDvmOrchestrator .observe(favDvm.address) .collectAsStateWithLifecycle() @@ -83,86 +95,133 @@ fun HomeDvmStatusBanner( ?: "" } - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 6.dp), - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow, - ) { - Column(modifier = Modifier.padding(12.dp)) { - val status = snapshot.latestStatus?.status() + BannerCard { + val status = snapshot.latestStatus?.status() - when { - snapshot.errorMessage != null -> { - BannerMessageRow( - message = stringRes(R.string.dvm_home_status_error), - showSpinner = false, - ) + when { + snapshot.errorMessage != null -> { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_error), + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + RetryButton { accountViewModel.refreshFavoriteDvm(favDvm.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(null) } + val msg = statusOverride + if (msg != null) { + Text(text = msg, style = MaterialTheme.typography.bodySmall) Spacer(modifier = StdVertSpacer) - RetryButton(favDvm, accountViewModel) } - - 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(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(favDvm, accountViewModel) - } - - 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, + 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.refreshFavoriteDvm(favDvm.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 AllFavoriteDvmsBanner(accountViewModel: AccountViewModel) { + val addresses by accountViewModel.account.favoriteDvmList.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 = + addresses.map { address -> + val snap by accountViewModel.account.favoriteDvmOrchestrator + .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 { + if (allErrored) { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_error), + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + RetryButton { + addresses.forEach { accountViewModel.refreshFavoriteDvm(it) } + } + } else { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_requesting_all), + showSpinner = true, + ) + } + } +} + +@Composable +private fun BannerCard(content: @Composable () -> Unit) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column(modifier = Modifier.padding(12.dp)) { content() } + } +} + @Composable private fun BannerMessageRow( message: String, @@ -185,11 +244,8 @@ private fun BannerMessageRow( } @Composable -private fun RetryButton( - favDvm: TopFilter.FavoriteDvm, - accountViewModel: AccountViewModel, -) { - OutlinedButton(onClick = { accountViewModel.refreshFavoriteDvm(favDvm.address) }) { +private fun RetryButton(onClick: () -> Unit) { + OutlinedButton(onClick = onClick) { Text(stringRes(R.string.dvm_home_retry)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 70a539a9ec..76b574544a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -284,14 +284,17 @@ fun HomeFeeds( ) { val activeFilter by accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() - val activeDvm = activeFilter as? TopFilter.FavoriteDvm + val favoriteDvmAddresses by accountViewModel.account.favoriteDvmList.flow + .collectAsStateWithLifecycle() val onRefresh: () -> Unit = { feedState.invalidateData() - if (activeDvm != null) { - // Swiping down on Home should also re-issue the kind-5300 request so the - // DVM produces a fresh feed, not just re-render whatever's cached. - accountViewModel.refreshFavoriteDvm(activeDvm.address) + // 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.FavoriteDvm -> accountViewModel.refreshFavoriteDvm(filter.address) + is TopFilter.AllFavoriteDvms -> favoriteDvmAddresses.forEach { accountViewModel.refreshFavoriteDvm(it) } + else -> Unit } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt index 856f6fd9c4..57afdd401a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt @@ -56,10 +56,10 @@ fun filterHomePostsByDvmIds( out += contentFetchFilters(relay, filter) } - val requestId = set.requestId - if (requestId != null) { + if (set.requestIds.isNotEmpty()) { + val requestIds = set.requestIds.toList() set.listenRelays.forEach { relay -> - out += responseListenFilter(relay, requestId) + out += responseListenFilter(relay, requestIds) } } @@ -101,7 +101,7 @@ private fun contentFetchFilters( private fun responseListenFilter( relay: NormalizedRelayUrl, - requestId: HexKey, + requestIds: List, ) = RelayBasedFilter( relay = relay, filter = @@ -111,7 +111,7 @@ private fun responseListenFilter( NIP90ContentDiscoveryResponseEvent.KIND, NIP90StatusEvent.KIND, ), - tags = mapOf("e" to listOf(requestId)), - limit = 10, + tags = mapOf("e" to requestIds), + limit = 10 * requestIds.size, ), ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index aa11b8aa38..cbd3e01541 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1729,6 +1729,7 @@ Communities Lists DVMs + All favourite DVMs Relays Add DVM to favorites @@ -1737,6 +1738,7 @@ Content-discovery DVMs you starred here appear as filter chips on the Home feed. Open Discover to add more. No favourite DVMs yet. Open Discover, tap a content-discovery DVM, and star it to add it here. Asking %1$s for a feed… + Asking your favourite DVMs for feeds… Processing your feed… This DVM requires payment DVM returned an error diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt new file mode 100644 index 0000000000..c55e3927ec --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt @@ -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.favoriteDvm + +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 FavoriteDvmTopNavFilterTest { + 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 = + FavoriteDvmTopNavFilter( + dvmAddress = 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 = + FavoriteDvmTopNavFilter( + dvmAddress = 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 = + FavoriteDvmTopNavFilter( + dvmAddress = dvmAddress, + acceptedIds = emptySet(), + acceptedAddresses = setOf(articleAddress), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = null, + ) + + assertTrue(filter.match(longFormNote(articleAuthor, articleDTag))) + } + + @Test + fun nullRequestIdCollapsesToEmptyRequestIdsInFilterSet() { + val filter = + FavoriteDvmTopNavFilter( + dvmAddress = dvmAddress, + acceptedIds = emptySet(), + acceptedAddresses = emptySet(), + contentRelays = emptySet(), + listenRelays = emptySet(), + requestId = null, + ) + + // passing a LocalCache is only needed because the method demands it; + // FavoriteDvmTopNavFilter.startValue doesn't actually consult it. + val set = filter.startValue(com.vitorpamplona.amethyst.model.LocalCache) + assertTrue(set.requestIds.isEmpty()) + } + + @Test + fun nonNullRequestIdProducesSingletonInFilterSet() { + val filter = + FavoriteDvmTopNavFilter( + dvmAddress = 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))) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt new file mode 100644 index 0000000000..62086f8b3f --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt @@ -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.nip90Dvms + +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet +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 FilterHomePostsByDvmIdsTest { + private val userRelay = RelayUrlNormalizer.normalizeOrNull("wss://user.example/")!! + private val dvmRelay = RelayUrlNormalizer.normalizeOrNull("wss://dvm.example/")!! + + @Test + fun emptyFilterSetProducesNoRequests() { + val set = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = emptyMap(), + listenRelays = emptySet(), + requestIds = emptySet(), + ) + + assertTrue(filterHomePostsByDvmIds(set, since = null, defaultSince = null).isEmpty()) + } + + @Test + fun contentFetchIssuedOnUserRelayWithIdsFilter() { + val ids = setOf("a".repeat(64), "b".repeat(64)) + val set = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = + mapOf(userRelay to FavoriteDvmTopNavPerRelayFilter(ids = ids, addresses = emptySet())), + listenRelays = emptySet(), + requestIds = emptySet(), + ) + + val filters = filterHomePostsByDvmIds(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 = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = emptyMap(), + listenRelays = setOf(dvmRelay), + requestIds = setOf(requestId), + ) + + val filters = filterHomePostsByDvmIds(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 = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = emptyMap(), + listenRelays = setOf(dvmRelay), + requestIds = setOf(req1, req2), + ) + + val filters = filterHomePostsByDvmIds(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 = + FavoriteDvmTopNavPerRelayFilterSet( + contentFetches = + mapOf(userRelay to FavoriteDvmTopNavPerRelayFilter(ids = ids, addresses = emptySet())), + listenRelays = setOf(dvmRelay), + requestIds = setOf(requestId), + ) + + val filters = filterHomePostsByDvmIds(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 }) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 2ebafdbadc..1e525e1480 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -134,6 +134,7 @@ import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent @@ -422,6 +423,7 @@ class EventFactory { GoodWikiAuthorListEvent.KIND -> GoodWikiAuthorListEvent(id, pubKey, createdAt, tags, content, sig) GoodWikiRelayListEvent.KIND -> GoodWikiRelayListEvent(id, pubKey, createdAt, tags, content, sig) GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) + FavoriteDvmListEvent.KIND -> FavoriteDvmListEvent(id, pubKey, createdAt, tags, content, sig) HashtagListEvent.KIND -> HashtagListEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt new file mode 100644 index 0000000000..88c36c6e49 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt @@ -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.favoriteDvmList + +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 FavoriteDvmListEventTest { + 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, FavoriteDvmListEvent.KIND) + } + + @Test + fun addressesAreReplaceableWithFixedDTag() { + val address = FavoriteDvmListEvent.createAddress("a".repeat(64)) + assertEquals(10090, address.kind) + assertEquals("", address.dTag) + } + + @Test + fun createStoresDvmAsATag() = + runTest { + val dvm = dvm("a".repeat(64)) + + val event = + FavoriteDvmListEvent.create( + dvm = dvm, + isPrivate = false, + signer = signer, + createdAt = 1740669816, + ) + + assertEquals(10090, event.kind) + assertTrue( + event.tags.any { it.size >= 2 && it[0] == "a" && it[1] == dvm.address.toValue() }, + "public a tag for the favourited DVM should be present", + ) + val favorites = event.publicFavoriteDvms() + assertEquals(1, favorites.size) + assertEquals(dvm.address, favorites.first().address) + } + + @Test + fun addAppendsWithoutDuplicatingExistingEntry() = + runTest { + val dvm = dvm("a".repeat(64)) + + val initial = + FavoriteDvmListEvent.create( + dvm = dvm, + isPrivate = false, + signer = signer, + createdAt = 1740669816, + ) + + val afterDupeAdd = + FavoriteDvmListEvent.add( + earlierVersion = initial, + dvm = dvm, + isPrivate = false, + signer = signer, + createdAt = 1740669817, + ) + + assertEquals( + 1, + afterDupeAdd.publicFavoriteDvms().count { it.address == dvm.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 = + FavoriteDvmListEvent.create( + dvm = first, + isPrivate = false, + signer = signer, + createdAt = 1740669816, + ) + + val after = + FavoriteDvmListEvent.add( + earlierVersion = initial, + dvm = second, + isPrivate = false, + signer = signer, + createdAt = 1740669817, + ) + + val addresses = after.publicFavoriteDvms().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 = + FavoriteDvmListEvent.create( + publicDvms = listOf(first, second), + privateDvms = emptyList(), + signer = signer, + createdAt = 1740669816, + ) + + val after = + FavoriteDvmListEvent.remove( + earlierVersion = initial, + dvm = first.address, + signer = signer, + createdAt = 1740669817, + ) + + val addresses = after.publicFavoriteDvms().map { it.address }.toSet() + assertFalse(first.address in addresses, "removed DVM should not survive") + assertTrue(second.address in addresses, "other DVMs should be preserved") + } +} From d8b313088da73dca93554fbe30c15292b30b7537 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 14:34:07 +0000 Subject: [PATCH 07/12] fix(dvm-favorites): star click is a no-op + star missing from DVM detail top bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two distinct bugs with the same symptom ("clicking the star doesn't seem to do anything"): 1. LocalCache didn't know how to route kind 10090. FavoriteDvmListEvent was missing from LocalCache's event-type dispatch, so after Account.followFavoriteDvm signs and publishes the event, the cache silently dropped it — never updated favoriteDvmListNote, so account.favoriteDvmList.flow never re-emitted, so the star's filled/outlined state (and the spinner chip) never flipped. Add `is FavoriteDvmListEvent -> consumeBaseReplaceable(...)`. 2. DvmTopBar's toggle never rendered. The Home feed passes the DVM's hex event id via Route.ContentDiscovery, so LoadNote(baseNoteHex) returns a plain Note, not the AddressableNote the toggle needs (the `is AddressableNote` guard was always false). Derive the AddressableNote from the loaded AppDefinitionEvent.address() so the toggle shows up with the correct backing object. --- .../amethyst/model/LocalCache.kt | 2 ++ .../ui/screen/loggedIn/dvms/DvmTopBar.kt | 26 ++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 347df0b52a..339747d758 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -149,6 +149,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent @@ -2635,6 +2636,7 @@ object LocalCache : ILocalCache, ICacheProvider { is LiveChessGameEndEvent -> consumeBaseReplaceable(event, relay, wasVerified) is LiveChessDrawOfferEvent -> consumeBaseReplaceable(event, relay, wasVerified) is HashtagListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is FavoriteDvmListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is HighlightEvent -> consumeRegularEvent(event, relay, wasVerified) is IndexerRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is InteractiveStoryPrologueEvent -> consumeBaseReplaceable(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt index d3dcecc660..4ae8cefe5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt @@ -24,10 +24,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow -import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -37,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.SimpleImage35Modifier +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @Composable fun DvmTopBar( @@ -87,12 +90,23 @@ fun DvmTopBar( }, 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 is AddressableNote) { - FavoriteDvmToggle( - appDefinitionNote = appDefinitionNote, - accountViewModel = accountViewModel, - ) + if (appDefinitionNote != null) { + val addressableNote by + observeNoteAndMap(appDefinitionNote, accountViewModel) { note -> + (note.event as? AppDefinitionEvent)?.let { + LocalCache.getOrCreateAddressableNote(it.address()) + } + } + addressableNote?.let { target -> + FavoriteDvmToggle( + appDefinitionNote = target, + accountViewModel = accountViewModel, + ) + } } } }, From dc41432c5bdf94873e88844060091b760601fa79 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:37:07 +0000 Subject: [PATCH 08/12] fix(topnav): long filter names no longer wrap + hide expand icon Selecting a DVM with a long name (or any long-named filter) made the Text in the top-nav spinner wrap to two lines, pushing the ExpandMore icon out of the visible area. The Column holding the Text had no width constraint and the Text itself had no maxLines. - Constrain the Column with `Modifier.weight(1f, fill = false)` so it reserves space for the icon instead of consuming it. - Add `maxLines = 1, overflow = TextOverflow.Ellipsis` to every text path in the spinner (primary label, geohash city-loading, AroundMe location labels). Single-line + ellipsis for all filter types, icon always visible. --- .../navigation/topbars/FeedFilterSpinner.kt | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index c08cf8ba48..b4cf9a7199 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -68,6 +68,7 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog @@ -153,23 +154,40 @@ fun FeedFilterSpinner( Row(verticalAlignment = Alignment.CenterVertically) { Spacer(modifier = Size20Modifier) - Column(horizontalAlignment = Alignment.CenterHorizontally) { + // Bound the Column so long filter names (e.g. DVM titles) get truncated + // instead of wrapping to multiple lines and shoving the expand icon out. + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f, fill = false), + ) { val filter = selected?.code if (filter is TopFilter.Geohash) { LoadCityName( geohashStr = filter.tag, onLoading = { Row { - Text(filter.tag) + Text( + text = filter.tag, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) Spacer(modifier = StdHorzSpacer) LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) } }, ) { cityName -> - Text(cityName) + Text( + text = cityName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } else { - Text(currentText) + Text( + text = currentText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } if (filter is TopFilter.AroundMe) { @@ -181,6 +199,8 @@ fun FeedFilterSpinner( text = stringRes(R.string.lack_location_permissions), fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } else { val location by Amethyst.instance.locationManager.geohashStateFlow @@ -196,6 +216,8 @@ fun FeedFilterSpinner( text = "(${myLocation.geoHash})", fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) Spacer(modifier = StdHorzSpacer) LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp) @@ -206,6 +228,8 @@ fun FeedFilterSpinner( text = "($cityName)", fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } @@ -215,6 +239,8 @@ fun FeedFilterSpinner( text = stringRes(R.string.lack_location_permissions), fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } @@ -223,6 +249,8 @@ fun FeedFilterSpinner( text = stringRes(R.string.loading_location), fontSize = Font12SP, lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } From b0f75d4dd464821a9e328c18a89ebac74cafd0d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 15:53:23 +0000 Subject: [PATCH 09/12] fix(home-banner): float DVM status banner over the feed instead of in the top bar Putting HomeDvmStatusBanner inside the top-bar Column meant the SecondaryTabRow shifted up/down every time the banner appeared or disappeared (filter selection, refresh, response arrival). Annoying. Move the banner into a Box that wraps the HorizontalPager and align it to TopCenter so it floats over the feed: - topBar Column is back to just HomeTopBar + SecondaryTabRow; tabs no longer reflow when the banner toggles. - BannerCard takes a Modifier and gets tonalElevation + shadowElevation + a slightly stronger surface tint so it reads as a floating overlay rather than part of the scaffold chrome. --- .../screen/loggedIn/home/DvmStatusBanner.kt | 29 ++++++++--- .../ui/screen/loggedIn/home/HomeScreen.kt | 50 ++++++++++++------- 2 files changed, 54 insertions(+), 25 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt index de3335425c..723eea88ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt @@ -58,13 +58,14 @@ import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent fun HomeDvmStatusBanner( accountViewModel: AccountViewModel, nav: INav, + modifier: Modifier = Modifier, ) { val topFilter by accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() when (val filter = topFilter) { - is TopFilter.FavoriteDvm -> SingleDvmBanner(filter, accountViewModel, nav) - is TopFilter.AllFavoriteDvms -> AllFavoriteDvmsBanner(accountViewModel) + is TopFilter.FavoriteDvm -> SingleDvmBanner(filter, accountViewModel, nav, modifier) + is TopFilter.AllFavoriteDvms -> AllFavoriteDvmsBanner(accountViewModel, modifier) else -> Unit } } @@ -74,6 +75,7 @@ private fun SingleDvmBanner( favDvm: TopFilter.FavoriteDvm, accountViewModel: AccountViewModel, nav: INav, + modifier: Modifier = Modifier, ) { val snapshot by accountViewModel.account.favoriteDvmOrchestrator .observe(favDvm.address) @@ -95,7 +97,7 @@ private fun SingleDvmBanner( ?: "" } - BannerCard { + BannerCard(modifier) { val status = snapshot.latestStatus?.status() when { @@ -167,7 +169,10 @@ private fun SingleDvmBanner( } @Composable -private fun AllFavoriteDvmsBanner(accountViewModel: AccountViewModel) { +private fun AllFavoriteDvmsBanner( + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { val addresses by accountViewModel.account.favoriteDvmList.flow .collectAsStateWithLifecycle() @@ -189,7 +194,7 @@ private fun AllFavoriteDvmsBanner(accountViewModel: AccountViewModel) { val allErrored = snapshots.all { it.errorMessage != null || it.latestStatus?.status()?.code == "error" } - BannerCard { + BannerCard(modifier) { if (allErrored) { BannerMessageRow( message = stringRes(R.string.dvm_home_status_error), @@ -209,14 +214,22 @@ private fun AllFavoriteDvmsBanner(accountViewModel: AccountViewModel) { } @Composable -private fun BannerCard(content: @Composable () -> Unit) { +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 + modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 6.dp), shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceContainerLow, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 4.dp, + shadowElevation = 4.dp, ) { Column(modifier = Modifier.padding(12.dp)) { content() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 76b574544a..d3f676146a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -23,11 +23,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow @@ -52,6 +55,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R @@ -179,7 +183,6 @@ private fun HomePages( topBar = { Column { HomeTopBar(accountViewModel, nav) - HomeDvmStatusBanner(accountViewModel, nav) SecondaryTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, @@ -210,24 +213,37 @@ private fun HomePages( HomeScreenFloatingButton(accountViewModel, nav) }, accountViewModel = accountViewModel, - ) { - HorizontalPager( - contentPadding = it, - state = pagerState, - userScrollEnabled = true, - modifier = - Modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ), - ) { page -> - HomeFeeds( - feedState = tabs[page].feedState, - routeForLastRead = tabs[page].routeForLastRead, - scrollStateKey = tabs[page].scrollStateKey, - liveSection = tabs[page].liveSection, + ) { paddingValues -> + // Wrap pager + banner in a Box so the banner can float over the feed + // (anchored top-center) instead of living in the topBar Column where + // it would push the tabs down every time it appears or disappears. + Box( + modifier = Modifier.fillMaxSize().padding(paddingValues), + ) { + HorizontalPager( + contentPadding = PaddingValues(0.dp), + state = pagerState, + userScrollEnabled = true, + modifier = + Modifier.zonedDrawerSwipe( + pagerState = pagerState, + openDrawer = nav::openDrawer, + ), + ) { page -> + HomeFeeds( + feedState = tabs[page].feedState, + routeForLastRead = tabs[page].routeForLastRead, + scrollStateKey = tabs[page].scrollStateKey, + liveSection = tabs[page].liveSection, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + HomeDvmStatusBanner( accountViewModel = accountViewModel, nav = nav, + modifier = Modifier.align(Alignment.TopCenter), ) } } From fc284e3cfe48b90e7eb8974016f619ca0636ce2a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 16:43:46 +0000 Subject: [PATCH 10/12] feat(dvm-favorites): rename user-facing to "Favorite Feed Algorithms" + fix cold-start race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User-facing rename. All user-visible strings switch from "favourite" to "favorite" (American spelling) and from "DVM(s)" to "Feed Algorithm(s)": settings entry, spinner group label, empty state, banner messages, menu accessibility labels. Code identifiers still reference NIP-90's "DVM" protocol name since that's the wire spec. - Cold-start race. When the app relaunches with a persisted TopFilter.FavoriteDvm, the AppDefinitionEvent may not be in cache yet. mergeInterests was filtering out any favorite whose AppDefinitionEvent didn't yet advertise kind 5300, so the spinner didn't include a chip matching the persisted selection — it showed "Select an option" while the orchestrator quietly fired the RPC with no UI to ground it. Always include every favorite; the 5300 check at add time (in FavoriteDvmToggle) is enough. --- .../model/dvms/FavoriteDvmOrchestrator.kt | 4 ++-- .../favoriteDvm/AllFavoriteDvmsFeedFlow.kt | 4 ++-- .../AllFavoriteDvmsTopNavFilter.kt | 2 +- .../favoriteDvm/FavoriteDvmTopNavFilter.kt | 2 +- .../FavoriteDvmTopNavPerRelayFilterSet.kt | 4 ++-- .../amethyst/ui/screen/TopNavFilterState.kt | 21 ++++++++----------- .../nip90Dvms/FilterHomePostsByDvmIds.kt | 2 +- amethyst/src/main/res/values/strings.xml | 18 ++++++++-------- 8 files changed, 27 insertions(+), 30 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt index ddf24813ad..860cdfd0e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt @@ -45,7 +45,7 @@ import kotlinx.coroutines.sync.withLock private const val RESPONSE_TIMEOUT_MS = 20_000L /** - * Immutable snapshot of a favourite DVM's current request/response state. + * Immutable snapshot of a favorite DVM'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 @@ -65,7 +65,7 @@ data class FavoriteDvmSnapshot( ) /** - * Manages the NIP-90 content-discovery RPC cycle for each favourite DVM the user + * Manages the NIP-90 content-discovery RPC cycle for each favorite DVM the user * pins to the top-nav. * * The orchestrator is lazy: it starts a request/response cycle the first time any diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt index 8f880a4b15..c35c2ba72a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt @@ -34,9 +34,9 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest /** - * Feed flow that merges snapshots from every currently-favourited DVM into a + * Feed flow that merges snapshots from every currently-favorited DVM into a * single [AllFavoriteDvmsTopNavFilter]. Re-wires subscriptions whenever the - * favourite set changes. + * favorite set changes. */ class AllFavoriteDvmsFeedFlow( val favoriteDvmAddresses: StateFlow>, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt index f686bec2b4..36e7e6b6fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.flow.MutableStateFlow /** * Top-nav filter that unions the latest kind-6300 responses from every currently - * favourited DVM. Behaves like [FavoriteDvmTopNavFilter] (pure membership check + * favorited DVM. Behaves like [FavoriteDvmTopNavFilter] (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. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt index 88924a2a4b..9534bc1c84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt @@ -32,7 +32,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow /** - * Top-nav filter backed by the latest kind-6300 response from a favourite DVM. + * Top-nav filter backed by the latest kind-6300 response from a favorite DVM. * * 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt index 399d2ab31c..3188403846 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt @@ -32,8 +32,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl * - [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-DVM filter carries one; the merged "All favourite DVMs" - * filter carries one per favourite DVM. + * for. A single-DVM filter carries one; the merged "All favorite DVMs" + * filter carries one per favorite DVM. */ class FavoriteDvmTopNavPerRelayFilterSet( val contentFetches: Map, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 7d2003e85f..7b65a43e1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -35,7 +35,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent -import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -182,24 +181,22 @@ class TopNavFilterState( ) } - // Only DVMs that advertise NIP-90 content discovery (kind 5300) can produce a - // home feed. Hide entries whose AppDefinitionEvent isn't loaded yet OR doesn't - // include kind 5300 so the chip doesn't appear for image-generation, translation, - // search, or other DVM kinds that would never reply. + // Favorites can only be added through FavoriteDvmToggle, 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.FavoriteDvm 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 favoriteDvms = - favoriteDvmList.mapNotNull { dvmNote -> - val supports5300 = - (dvmNote.event as? AppDefinitionEvent) - ?.includeKind(NIP90ContentDiscoveryRequestEvent.KIND) == true - if (!supports5300) return@mapNotNull null + favoriteDvmList.map { dvmNote -> FeedDefinition( TopFilter.FavoriteDvm(dvmNote.address), FavoriteDvmName(dvmNote), ) } - // Only show the "All favourite DVMs" meta-chip when there is at least one - // real favourite to merge; otherwise the chip opens to an empty feed. + // Only show the "All favorite DVMs" meta-chip when there is at least one + // real favorite to merge; otherwise the chip opens to an empty feed. val allFavorites = if (favoriteDvms.isNotEmpty()) listOf(allFavoriteDvmsFollow) else emptyList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt index 57afdd401a..c042199a6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt @@ -31,7 +31,7 @@ import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentD import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent /** - * Builds relay REQ filters for a favourite-DVM home feed. + * Builds relay REQ filters for a favorite-DVM home feed. * * Two distinct subscription kinds with two distinct relay sets: * diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cbd3e01541..9a6ce629f4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1728,20 +1728,20 @@ Locations Communities Lists - DVMs - All favourite DVMs + Feed Algorithms + All favorite feed algorithms Relays - Add DVM to favorites + Add feed algorithm to favorites Remove from favorites - Favourite DVMs - Content-discovery DVMs you starred here appear as filter chips on the Home feed. Open Discover to add more. - No favourite DVMs yet. Open Discover, tap a content-discovery DVM, and star it to add it here. + Favorite Feed Algorithms + Feed algorithms you starred here appear as filter chips on the Home feed. Open Discover to add more. + No favorite feed algorithms yet. Open Discover, tap one, and star it to add it here. Asking %1$s for a feed… - Asking your favourite DVMs for feeds… + Asking your favorite feed algorithms for feeds… Processing your feed… - This DVM requires payment - DVM returned an error + This feed algorithm requires payment + The feed algorithm returned an error Retry Log off on device lock From 72598026a3328a11986fb98b2ca80eedd8c2f4c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 17:37:55 +0000 Subject: [PATCH 11/12] refactor(quartz): rename FavoriteDvmListEvent -> FavoriteAlgoFeedsListEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list event only stores content-discovery feeds (kind-5300 DVMs), not every DVM, so the old name was misleading. Rename the wire-level type to reflect what's actually in it. - Quartz: package nip51Lists.favoriteDvmList -> favoriteAlgoFeedsList; class FavoriteDvmListEvent -> FavoriteAlgoFeedsListEvent. - DSL helpers renamed: favoriteDvm/favoriteDvms builder extensions -> favoriteAlgoFeed/favoriteAlgoFeeds; TagArray.favoriteDvmList/Set -> favoriteAlgoFeedsList/Set. - create/add/remove parameter names dvm -> feed, publicDvms/privateDvms -> publicFeeds/privateFeeds; public/private accessors publicFavoriteDvms/privateFavoriteDvms -> publicFavoriteAlgoFeeds/ privateFavoriteAlgoFeeds. ALT string updated. - EventFactory + LocalCache dispatch branches + AccountSettings backup field type + FavoriteDvmListState + FavoriteDvmListDecryptionCache all import the new type. Internal amethyst-side classes (FavoriteDvmListState, FavoriteDvmListDecryptionCache), the top-nav filter classes (FavoriteDvm*, AllFavoriteDvms*) and the orchestrator keep their names — they still deal with content-discovery DVMs specifically, and the narrower rename here is scoped to the Nostr wire format the user was asking about. - Quartz test renamed + rewired. Build + tests green on both modules. --- .../amethyst/model/AccountSettings.kt | 6 +- .../amethyst/model/LocalCache.kt | 4 +- .../FavoriteDvmListDecryptionCache.kt | 10 +-- .../favoriteDvmLists/FavoriteDvmListState.kt | 20 +++--- .../FavoriteAlgoFeedsListEvent.kt} | 62 +++++++++---------- .../TagArrayBuilderExt.kt | 6 +- .../TagArrayExt.kt | 6 +- .../quartz/utils/EventFactory.kt | 4 +- .../FavoriteAlgoFeedsListEventTest.kt} | 54 ++++++++-------- 9 files changed, 86 insertions(+), 86 deletions(-) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/{favoriteDvmList/FavoriteDvmListEvent.kt => favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt} (75%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/{favoriteDvmList => favoriteAlgoFeedsList}/TagArrayBuilderExt.kt (80%) rename quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/{favoriteDvmList => favoriteAlgoFeedsList}/TagArrayExt.kt (83%) rename quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/{favoriteDvmList/FavoriteDvmListEventTest.kt => favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt} (75%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 15332850ef..2a5f6b6814 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -43,7 +43,7 @@ import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayList import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent -import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent @@ -203,7 +203,7 @@ class AccountSettings( var backupChannelList: ChannelListEvent? = null, var backupCommunityList: CommunityListEvent? = null, var backupHashtagList: HashtagListEvent? = null, - var backupFavoriteDvmList: FavoriteDvmListEvent? = null, + var backupFavoriteDvmList: FavoriteAlgoFeedsListEvent? = null, var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, var backupTrustProviderList: TrustProviderListEvent? = null, @@ -727,7 +727,7 @@ class AccountSettings( } } - fun updateFavoriteDvmListTo(newFavoriteDvmList: FavoriteDvmListEvent?) { + fun updateFavoriteDvmListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) { if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return // Events might be different objects, we have to compare their ids. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 339747d758..aed8edf69a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -149,7 +149,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent @@ -2636,7 +2636,7 @@ object LocalCache : ILocalCache, ICacheProvider { is LiveChessGameEndEvent -> consumeBaseReplaceable(event, relay, wasVerified) is LiveChessDrawOfferEvent -> consumeBaseReplaceable(event, relay, wasVerified) is HashtagListEvent -> consumeBaseReplaceable(event, relay, wasVerified) - is FavoriteDvmListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is FavoriteAlgoFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is HighlightEvent -> consumeRegularEvent(event, relay, wasVerified) is IndexerRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is InteractiveStoryPrologueEvent -> consumeBaseReplaceable(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt index 8af3bfa97e..fef6663850 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt @@ -22,15 +22,15 @@ package com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache -import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent -import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.favoriteDvmSet +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.favoriteAlgoFeedsSet class FavoriteDvmListDecryptionCache( val signer: NostrSigner, ) { - val cachedPrivateLists = PrivateTagArrayEventCache(signer) + val cachedPrivateLists = PrivateTagArrayEventCache(signer) - fun cachedFavoriteDvms(event: FavoriteDvmListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteDvmSet() + fun cachedFavoriteDvms(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteAlgoFeedsSet() - suspend fun favoriteDvms(event: FavoriteDvmListEvent) = cachedPrivateLists.mergeTagList(event).favoriteDvmSet() + suspend fun favoriteDvms(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteAlgoFeedsSet() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt index db41026d56..6181d8b025 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt @@ -28,7 +28,7 @@ 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.favoriteDvmList.FavoriteDvmListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -53,14 +53,14 @@ class FavoriteDvmListState( // Creates a long-term reference for this note so that the GC doesn't collect the note itself val favoriteDvmListNote = cache.getOrCreateAddressableNote(getFavoriteDvmListAddress()) - fun getFavoriteDvmListAddress() = FavoriteDvmListEvent.createAddress(signer.pubKey) + fun getFavoriteDvmListAddress() = FavoriteAlgoFeedsListEvent.createAddress(signer.pubKey) fun getFavoriteDvmListFlow(): StateFlow = favoriteDvmListNote.flow().metadata.stateFlow - fun getFavoriteDvmList(): FavoriteDvmListEvent? = favoriteDvmListNote.event as? FavoriteDvmListEvent + fun getFavoriteDvmList(): FavoriteAlgoFeedsListEvent? = favoriteDvmListNote.event as? FavoriteAlgoFeedsListEvent suspend fun favoriteDvmListWithBackup(note: Note): Set
{ - val event = note.event as? FavoriteDvmListEvent ?: settings.backupFavoriteDvmList + val event = note.event as? FavoriteAlgoFeedsListEvent ?: settings.backupFavoriteDvmList return event?.let { decryptionCache.favoriteDvms(it) } ?: emptySet() } @@ -92,18 +92,18 @@ class FavoriteDvmListState( emptyList(), ) - suspend fun follow(dvm: AddressBookmark): FavoriteDvmListEvent { + suspend fun follow(dvm: AddressBookmark): FavoriteAlgoFeedsListEvent { val list = getFavoriteDvmList() return if (list == null) { - FavoriteDvmListEvent.create(dvm, false, signer) + FavoriteAlgoFeedsListEvent.create(dvm, false, signer) } else { - FavoriteDvmListEvent.add(list, dvm, false, signer) + FavoriteAlgoFeedsListEvent.add(list, dvm, false, signer) } } - suspend fun unfollow(dvm: Address): FavoriteDvmListEvent? { + suspend fun unfollow(dvm: Address): FavoriteAlgoFeedsListEvent? { val list = getFavoriteDvmList() ?: return null - return FavoriteDvmListEvent.remove(list, dvm, signer) + return FavoriteAlgoFeedsListEvent.remove(list, dvm, signer) } init { @@ -119,7 +119,7 @@ class FavoriteDvmListState( Log.d("AccountRegisterObservers", "Favorite DVM List Collector Start") getFavoriteDvmListFlow().collect { Log.d("AccountRegisterObservers") { "Favorite DVM List for ${signer.pubKey}" } - (it.note.event as? FavoriteDvmListEvent)?.let { + (it.note.event as? FavoriteAlgoFeedsListEvent)?.let { settings.updateFavoriteDvmListTo(it) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt similarity index 75% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt index 34f28bf7c5..d4b673582b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEvent.kt @@ -18,7 +18,7 @@ * 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.favoriteDvmList +package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Address @@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.utils.TimeUtils @Immutable -class FavoriteDvmListEvent( +class FavoriteAlgoFeedsListEvent( id: HexKey, pubKey: HexKey, createdAt: Long, @@ -46,72 +46,72 @@ class FavoriteDvmListEvent( content: String, sig: HexKey, ) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - fun publicFavoriteDvms(): List = tags.mapNotNull(AddressBookmark::parse) + fun publicFavoriteAlgoFeeds(): List = tags.mapNotNull(AddressBookmark::parse) - suspend fun privateFavoriteDvms(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(AddressBookmark::parse) + suspend fun privateFavoriteAlgoFeeds(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(AddressBookmark::parse) companion object { const val KIND = 10090 - const val ALT = "Favorite DVM list" + 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( - dvm: AddressBookmark, + feed: AddressBookmark, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteDvmListEvent = + ): FavoriteAlgoFeedsListEvent = if (isPrivate) { create( - publicDvms = emptyList(), - privateDvms = listOf(dvm), + publicFeeds = emptyList(), + privateFeeds = listOf(feed), signer = signer, createdAt = createdAt, ) } else { create( - publicDvms = listOf(dvm), - privateDvms = emptyList(), + publicFeeds = listOf(feed), + privateFeeds = emptyList(), signer = signer, createdAt = createdAt, ) } suspend fun add( - earlierVersion: FavoriteDvmListEvent, - dvm: AddressBookmark, + earlierVersion: FavoriteAlgoFeedsListEvent, + feed: AddressBookmark, isPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteDvmListEvent = + ): FavoriteAlgoFeedsListEvent = if (isPrivate) { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() resign( tags = earlierVersion.tags, - privateTags = privateTags.remove(dvm.toTagIdOnly()) + dvm.toTagArray(), + privateTags = privateTags.remove(feed.toTagIdOnly()) + feed.toTagArray(), signer = signer, createdAt = createdAt, ) } else { resign( content = earlierVersion.content, - tags = earlierVersion.tags.remove(dvm.toTagIdOnly()) + dvm.toTagArray(), + tags = earlierVersion.tags.remove(feed.toTagIdOnly()) + feed.toTagArray(), signer = signer, createdAt = createdAt, ) } suspend fun remove( - earlierVersion: FavoriteDvmListEvent, - dvm: Address, + earlierVersion: FavoriteAlgoFeedsListEvent, + feed: Address, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteDvmListEvent { - val idOnly = AddressBookmark.assemble(dvm, null) + ): FavoriteAlgoFeedsListEvent { + val idOnly = AddressBookmark.assemble(feed, null) val privateTags = earlierVersion.privateTags(signer) return if (privateTags != null) { resign( @@ -147,7 +147,7 @@ class FavoriteDvmListEvent( tags: TagArray, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteDvmListEvent { + ): FavoriteAlgoFeedsListEvent { val newTags = if (tags.fastAny(AltTag::match)) { tags @@ -159,32 +159,32 @@ class FavoriteDvmListEvent( } suspend fun create( - publicDvms: List = emptyList(), - privateDvms: List = emptyList(), + publicFeeds: List = emptyList(), + privateFeeds: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): FavoriteDvmListEvent { - val template = build(publicDvms, privateDvms, signer, createdAt) + ): FavoriteAlgoFeedsListEvent { + val template = build(publicFeeds, privateFeeds, signer, createdAt) return signer.sign(template) } suspend fun build( - publicDvms: List = emptyList(), - privateDvms: List = emptyList(), + publicFeeds: List = emptyList(), + privateFeeds: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate( + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( kind = KIND, description = PrivateTagsInContent.encryptNip44( - privateDvms.map { it.toTagArray() }.toTypedArray(), + privateFeeds.map { it.toTagArray() }.toTypedArray(), signer, ), createdAt = createdAt, ) { alt(ALT) - favoriteDvms(publicDvms) + favoriteAlgoFeeds(publicFeeds) initializer() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayBuilderExt.kt similarity index 80% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayBuilderExt.kt index 6f49fd98b1..4214d7627a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayBuilderExt.kt @@ -18,11 +18,11 @@ * 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.favoriteDvmList +package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark -fun TagArrayBuilder.favoriteDvm(app: AddressBookmark) = add(app.toTagArray()) +fun TagArrayBuilder.favoriteAlgoFeed(app: AddressBookmark) = add(app.toTagArray()) -fun TagArrayBuilder.favoriteDvms(apps: List) = addAll(apps.map { it.toTagArray() }) +fun TagArrayBuilder.favoriteAlgoFeeds(apps: List) = addAll(apps.map { it.toTagArray() }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayExt.kt similarity index 83% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayExt.kt index 0f2d3501c5..5024bb91e3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/TagArrayExt.kt @@ -18,11 +18,11 @@ * 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.favoriteDvmList +package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark -fun TagArray.favoriteDvmList() = mapNotNull(AddressBookmark::parseAddress) +fun TagArray.favoriteAlgoFeedsList() = mapNotNull(AddressBookmark::parseAddress) -fun TagArray.favoriteDvmSet() = mapNotNullTo(mutableSetOf(), AddressBookmark::parseAddress) +fun TagArray.favoriteAlgoFeedsSet() = mapNotNullTo(mutableSetOf(), AddressBookmark::parseAddress) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 1e525e1480..8c46307f20 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -134,7 +134,7 @@ import com.vitorpamplona.quartz.nip51Lists.appCurationSet.AppCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.articleCurationSet.ArticleCurationSetEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent -import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.gitAuthorList.GitAuthorListEvent @@ -423,7 +423,7 @@ class EventFactory { GoodWikiAuthorListEvent.KIND -> GoodWikiAuthorListEvent(id, pubKey, createdAt, tags, content, sig) GoodWikiRelayListEvent.KIND -> GoodWikiRelayListEvent(id, pubKey, createdAt, tags, content, sig) GoalEvent.KIND -> GoalEvent(id, pubKey, createdAt, tags, content, sig) - FavoriteDvmListEvent.KIND -> FavoriteDvmListEvent(id, pubKey, createdAt, tags, content, sig) + FavoriteAlgoFeedsListEvent.KIND -> FavoriteAlgoFeedsListEvent(id, pubKey, createdAt, tags, content, sig) HashtagListEvent.KIND -> HashtagListEvent(id, pubKey, createdAt, tags, content, sig) HighlightEvent.KIND -> HighlightEvent(id, pubKey, createdAt, tags, content, sig) HTTPAuthorizationEvent.KIND -> HTTPAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt similarity index 75% rename from quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt rename to quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt index 88c36c6e49..f3ac5ff24b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteAlgoFeedsList/FavoriteAlgoFeedsListEventTest.kt @@ -18,7 +18,7 @@ * 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.favoriteDvmList +package com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -30,7 +30,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -class FavoriteDvmListEventTest { +class FavoriteAlgoFeedsListEventTest { private val signer = NostrSignerInternal("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) private fun dvm( @@ -40,12 +40,12 @@ class FavoriteDvmListEventTest { @Test fun kindMatchesSpec() { - assertEquals(10090, FavoriteDvmListEvent.KIND) + assertEquals(10090, FavoriteAlgoFeedsListEvent.KIND) } @Test fun addressesAreReplaceableWithFixedDTag() { - val address = FavoriteDvmListEvent.createAddress("a".repeat(64)) + val address = FavoriteAlgoFeedsListEvent.createAddress("a".repeat(64)) assertEquals(10090, address.kind) assertEquals("", address.dTag) } @@ -53,11 +53,11 @@ class FavoriteDvmListEventTest { @Test fun createStoresDvmAsATag() = runTest { - val dvm = dvm("a".repeat(64)) + val aFeed = dvm("a".repeat(64)) val event = - FavoriteDvmListEvent.create( - dvm = dvm, + FavoriteAlgoFeedsListEvent.create( + feed = aFeed, isPrivate = false, signer = signer, createdAt = 1740669816, @@ -65,31 +65,31 @@ class FavoriteDvmListEventTest { assertEquals(10090, event.kind) assertTrue( - event.tags.any { it.size >= 2 && it[0] == "a" && it[1] == dvm.address.toValue() }, + 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.publicFavoriteDvms() + val favorites = event.publicFavoriteAlgoFeeds() assertEquals(1, favorites.size) - assertEquals(dvm.address, favorites.first().address) + assertEquals(aFeed.address, favorites.first().address) } @Test fun addAppendsWithoutDuplicatingExistingEntry() = runTest { - val dvm = dvm("a".repeat(64)) + val aFeed = dvm("a".repeat(64)) val initial = - FavoriteDvmListEvent.create( - dvm = dvm, + FavoriteAlgoFeedsListEvent.create( + feed = aFeed, isPrivate = false, signer = signer, createdAt = 1740669816, ) val afterDupeAdd = - FavoriteDvmListEvent.add( + FavoriteAlgoFeedsListEvent.add( earlierVersion = initial, - dvm = dvm, + feed = aFeed, isPrivate = false, signer = signer, createdAt = 1740669817, @@ -97,7 +97,7 @@ class FavoriteDvmListEventTest { assertEquals( 1, - afterDupeAdd.publicFavoriteDvms().count { it.address == dvm.address }, + afterDupeAdd.publicFavoriteAlgoFeeds().count { it.address == aFeed.address }, "re-adding the same DVM must not produce a duplicate tag", ) } @@ -109,23 +109,23 @@ class FavoriteDvmListEventTest { val second = dvm("b".repeat(64)) val initial = - FavoriteDvmListEvent.create( - dvm = first, + FavoriteAlgoFeedsListEvent.create( + feed = first, isPrivate = false, signer = signer, createdAt = 1740669816, ) val after = - FavoriteDvmListEvent.add( + FavoriteAlgoFeedsListEvent.add( earlierVersion = initial, - dvm = second, + feed = second, isPrivate = false, signer = signer, createdAt = 1740669817, ) - val addresses = after.publicFavoriteDvms().map { it.address }.toSet() + val addresses = after.publicFavoriteAlgoFeeds().map { it.address }.toSet() assertTrue(first.address in addresses) assertTrue(second.address in addresses) } @@ -137,22 +137,22 @@ class FavoriteDvmListEventTest { val second = dvm("b".repeat(64)) val initial = - FavoriteDvmListEvent.create( - publicDvms = listOf(first, second), - privateDvms = emptyList(), + FavoriteAlgoFeedsListEvent.create( + publicFeeds = listOf(first, second), + privateFeeds = emptyList(), signer = signer, createdAt = 1740669816, ) val after = - FavoriteDvmListEvent.remove( + FavoriteAlgoFeedsListEvent.remove( earlierVersion = initial, - dvm = first.address, + feed = first.address, signer = signer, createdAt = 1740669817, ) - val addresses = after.publicFavoriteDvms().map { it.address }.toSet() + 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") } From f99fc4c3334d70136ae8e47af22d2a7c878277cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 17:56:16 +0000 Subject: [PATCH 12/12] refactor: rename FavoriteDvm* -> FavoriteAlgoFeed* throughout amethyst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire-level class was renamed to FavoriteAlgoFeedsListEvent in a prior commit. Finish the rename through the rest of the feature so the internal vocabulary (packages, classes, properties, routes, DSL helpers, TopFilter variants, composables) matches what users see. Packages: - model.nip51Lists.favoriteDvmLists -> favoriteAlgoFeedsLists - model.dvms -> algoFeeds - model.topNavFeeds.favoriteDvm -> favoriteAlgoFeeds - home.datasource.nip90Dvms -> nip90AlgoFeeds Classes/composables renamed (selected): - FavoriteDvmListState/DecryptionCache -> FavoriteAlgoFeedsListState/DecryptionCache - FavoriteDvmOrchestrator / FavoriteDvmSnapshot -> FavoriteAlgoFeedsOrchestrator / FavoriteAlgoFeedsSnapshot - FavoriteDvmTopNavFilter{,PerRelayFilter{,Set}}, FavoriteDvmFeedFlow -> FavoriteAlgoFeedTopNavFilter{,PerRelayFilter{,Set}}, FavoriteAlgoFeedFlow - AllFavoriteDvmsTopNavFilter / AllFavoriteDvmsFeedFlow / AllFavoriteDvmsBanner -> AllFavoriteAlgoFeedsTopNavFilter / AllFavoriteAlgoFeedsFlow / AllFavoriteAlgoFeedsBanner - FavoriteDvmName -> FavoriteAlgoFeedName - FavoriteDvmToggle -> FavoriteAlgoFeedToggle - FavoriteDvmListScreen -> FavoriteAlgoFeedsListScreen - HomeDvmStatusBanner / SingleDvmBanner / DvmStatusBanner -> HomeAlgoFeedStatusBanner / SingleAlgoFeedBanner / AlgoFeedStatusBanner - filterHomePostsByDvmIds -> filterHomePostsByAlgoFeedIds - TopFilter.FavoriteDvm / TopFilter.AllFavoriteDvms -> TopFilter.FavoriteAlgoFeed / TopFilter.AllFavoriteAlgoFeeds - Route.EditFavoriteDvms -> Route.EditFavoriteAlgoFeeds Account / AccountSettings / AccountViewModel properties & mutators renamed consistently: favoriteDvmList -> favoriteAlgoFeedsList, backupFavoriteDvmList -> backupFavoriteAlgoFeedsList, follow/unfollow/ isFavoriteDvm -> follow/unfollow/isFavoriteAlgoFeed, refreshFavoriteDvm -> refreshFavoriteAlgoFeed, dvmAddress kwargs -> feedAddress, dvmNote locals -> feedNote, etc. Persisted TopFilter.FavoriteAlgoFeed/AllFavoriteAlgoFeeds `code` strings changed (old "FavoriteDvm/…" / " All Favourite DVMs " values don't round-trip anymore) — kotlinx-serialization polymorphism keys the sealed class by class name, so cold-start of pre-existing installs will fall back to AllFollows for this filter. Acceptable since this is a new feature only on this branch. Kept unchanged: ui/screen/loggedIn/dvms/ folder (hosts generic DvmContentDiscoveryScreen / DvmTopBar / DvmPaymentActions), quartz nip90Dvms package (NIP-90 wire-protocol naming). XML string resource keys (favorite_dvms_title, dvm_home_* etc) kept stable; only their values were updated previously. Build + tests green on both modules. --- .../vitorpamplona/amethyst/model/Account.kt | 22 ++++----- .../amethyst/model/AccountSettings.kt | 14 +++--- .../FavoriteAlgoFeedsOrchestrator.kt} | 48 +++++++++---------- .../FavoriteAlgoFeedsListDecryptionCache.kt} | 8 ++-- .../FavoriteAlgoFeedsListState.kt} | 36 +++++++------- .../topNavFeeds/FeedTopNavFilterState.kt | 26 +++++----- .../AllFavoriteAlgoFeedsFlow.kt} | 32 ++++++------- .../AllFavoriteAlgoFeedsTopNavFilter.kt} | 14 +++--- .../FavoriteAlgoFeedFlow.kt} | 22 ++++----- .../FavoriteAlgoFeedTopNavFilter.kt} | 18 +++---- .../FavoriteAlgoFeedTopNavPerRelayFilter.kt} | 4 +- ...avoriteAlgoFeedTopNavPerRelayFilterSet.kt} | 10 ++-- .../amethyst/ui/navigation/AppNavigation.kt | 4 +- .../amethyst/ui/navigation/routes/Routes.kt | 2 +- .../navigation/topbars/FeedFilterSpinner.kt | 14 +++--- .../amethyst/ui/screen/TopNavFilterState.kt | 30 ++++++------ .../ui/screen/loggedIn/AccountViewModel.kt | 6 +-- .../loggedIn/discover/nip90DVMs/DVMCard.kt | 4 +- .../ui/screen/loggedIn/dvms/DvmTopBar.kt | 2 +- ...DvmToggle.kt => FavoriteAlgoFeedToggle.kt} | 8 ++-- ...reen.kt => FavoriteAlgoFeedsListScreen.kt} | 32 ++++++------- ...tatusBanner.kt => AlgoFeedStatusBanner.kt} | 36 +++++++------- .../ui/screen/loggedIn/home/HomeScreen.kt | 8 ++-- .../HomeOutboxEventsEoseManager.kt | 6 +-- .../FilterHomePostsByAlgoFeedIds.kt} | 12 ++--- .../loggedIn/settings/AllSettingsScreen.kt | 2 +- .../FavoriteAlgoFeedTopNavFilterTest.kt} | 26 +++++----- .../FilterHomePostsByAlgoFeedIdsTest.kt} | 32 ++++++------- 28 files changed, 239 insertions(+), 239 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/{dvms/FavoriteDvmOrchestrator.kt => algoFeeds/FavoriteAlgoFeedsOrchestrator.kt} (86%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/{favoriteDvmLists/FavoriteDvmListDecryptionCache.kt => favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt} (79%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/{favoriteDvmLists/FavoriteDvmListState.kt => favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt} (77%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/AllFavoriteDvmsFeedFlow.kt => favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt} (80%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/AllFavoriteDvmsTopNavFilter.kt => favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt} (85%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/FavoriteDvmFeedFlow.kt => favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt} (79%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/FavoriteDvmTopNavFilter.kt => favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt} (84%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt => favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt} (92%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt => favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt} (85%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/{FavoriteDvmToggle.kt => FavoriteAlgoFeedToggle.kt} (94%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/{FavoriteDvmListScreen.kt => FavoriteAlgoFeedsListScreen.kt} (90%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/{DvmStatusBanner.kt => AlgoFeedStatusBanner.kt} (89%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/{nip90Dvms/FilterHomePostsByDvmIds.kt => nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt} (91%) rename amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/{favoriteDvm/FavoriteDvmTopNavFilterTest.kt => favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt} (87%) rename amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/{nip90Dvms/FilterHomePostsByDvmIdsTest.kt => nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt} (78%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 4f52e43d77..fe977c64b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusActi import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.logTime -import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator +import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState @@ -67,8 +67,8 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState -import com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists.FavoriteDvmListDecryptionCache -import com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists.FavoriteDvmListState +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache @@ -327,9 +327,9 @@ class Account( val hashtagListDecryptionCache = HashtagListDecryptionCache(signer) val hashtagList = HashtagListState(signer, cache, hashtagListDecryptionCache, scope, settings) - val favoriteDvmListDecryptionCache = FavoriteDvmListDecryptionCache(signer) - val favoriteDvmList = FavoriteDvmListState(signer, cache, favoriteDvmListDecryptionCache, scope, settings) - val favoriteDvmOrchestrator = FavoriteDvmOrchestrator(this, scope) + val favoriteAlgoFeedsListDecryptionCache = FavoriteAlgoFeedsListDecryptionCache(signer) + val favoriteAlgoFeedsList = FavoriteAlgoFeedsListState(signer, cache, favoriteAlgoFeedsListDecryptionCache, scope, settings) + val favoriteAlgoFeedsOrchestrator = FavoriteAlgoFeedsOrchestrator(this, scope) val geohashListDecryptionCache = GeohashListDecryptionCache(signer) val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings) @@ -426,8 +426,8 @@ class Account( caches = feedDecryptionCaches, signer = signer, scope = scope, - favoriteDvmOrchestrator = favoriteDvmOrchestrator, - favoriteDvmAddresses = favoriteDvmList.flow, + favoriteAlgoFeedsOrchestrator = favoriteAlgoFeedsOrchestrator, + favoriteAlgoFeedAddresses = favoriteAlgoFeedsList.flow, ).flow // App-ready Feeds @@ -1023,11 +1023,11 @@ class Account( suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag)) - suspend fun followFavoriteDvm(dvm: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteDvmList.follow(dvm)) + suspend fun followFavoriteAlgoFeed(dvm: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteAlgoFeedsList.follow(dvm)) - suspend fun unfollowFavoriteDvm(dvm: Address) = sendMyPublicAndPrivateOutbox(favoriteDvmList.unfollow(dvm)) + suspend fun unfollowFavoriteAlgoFeed(dvm: Address) = sendMyPublicAndPrivateOutbox(favoriteAlgoFeedsList.unfollow(dvm)) - fun isFavoriteDvm(dvm: Address): Boolean = favoriteDvmList.flow.value.contains(dvm) + fun isFavoriteAlgoFeed(dvm: Address): Boolean = favoriteAlgoFeedsList.flow.value.contains(dvm) suspend fun followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 2a5f6b6814..4b7319d04b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -157,11 +157,11 @@ sealed class TopFilter( ) : TopFilter("Relay/$url") @Serializable - class FavoriteDvm( + class FavoriteAlgoFeed( val address: Address, - ) : TopFilter("FavoriteDvm/${address.toValue()}") + ) : TopFilter("FavoriteAlgoFeed/${address.toValue()}") - @Serializable object AllFavoriteDvms : TopFilter(" All Favourite DVMs ") + @Serializable object AllFavoriteAlgoFeeds : TopFilter(" All Favourite DVMs ") } @Stable @@ -203,7 +203,7 @@ class AccountSettings( var backupChannelList: ChannelListEvent? = null, var backupCommunityList: CommunityListEvent? = null, var backupHashtagList: HashtagListEvent? = null, - var backupFavoriteDvmList: FavoriteAlgoFeedsListEvent? = null, + var backupFavoriteAlgoFeedsList: FavoriteAlgoFeedsListEvent? = null, var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, var backupTrustProviderList: TrustProviderListEvent? = null, @@ -727,12 +727,12 @@ class AccountSettings( } } - fun updateFavoriteDvmListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) { + fun updateFavoriteAlgoFeedsListTo(newFavoriteDvmList: FavoriteAlgoFeedsListEvent?) { if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return // Events might be different objects, we have to compare their ids. - if (backupFavoriteDvmList?.id != newFavoriteDvmList.id) { - backupFavoriteDvmList = newFavoriteDvmList + if (backupFavoriteAlgoFeedsList?.id != newFavoriteDvmList.id) { + backupFavoriteAlgoFeedsList = newFavoriteDvmList saveAccountSettings() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt index 860cdfd0e0..0496ad62dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/algoFeeds/FavoriteAlgoFeedsOrchestrator.kt @@ -18,7 +18,7 @@ * 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.dvms +package com.vitorpamplona.amethyst.model.algoFeeds import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.core.Address @@ -45,7 +45,7 @@ import kotlinx.coroutines.sync.withLock private const val RESPONSE_TIMEOUT_MS = 20_000L /** - * Immutable snapshot of a favorite DVM's current request/response state. + * 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 @@ -55,7 +55,7 @@ private const val RESPONSE_TIMEOUT_MS = 20_000L * - [latestStatus] is the latest kind-7000 status event (processing, payment-required, error, …). * - [errorMessage] captures any client-side failure while publishing the request. */ -data class FavoriteDvmSnapshot( +data class FavoriteAlgoFeedsSnapshot( val requestId: HexKey? = null, val responseRelays: Set = emptySet(), val ids: Set = emptySet(), @@ -65,7 +65,7 @@ data class FavoriteDvmSnapshot( ) /** - * Manages the NIP-90 content-discovery RPC cycle for each favorite DVM the user + * 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 @@ -75,50 +75,50 @@ data class FavoriteDvmSnapshot( * * 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.FavoriteDvm` selected. The orchestrator merely observes + * user has a `TopFilter.FavoriteAlgoFeed` selected. The orchestrator merely observes * what the relays deliver into `LocalCache`. */ -class FavoriteDvmOrchestrator( +class FavoriteAlgoFeedsOrchestrator( val account: Account, val scope: CoroutineScope, ) { - private val flows = mutableMapOf>() + private val flows = mutableMapOf>() private val jobs = mutableMapOf() private val mutex = Mutex() - fun observe(dvmAddress: Address): StateFlow { - flows[dvmAddress]?.let { return it.asStateFlow() } + fun observe(feedAddress: Address): StateFlow { + flows[feedAddress]?.let { return it.asStateFlow() } - val seed = MutableStateFlow(FavoriteDvmSnapshot()) - flows[dvmAddress] = seed - scope.launch { startFor(dvmAddress, seed) } + val seed = MutableStateFlow(FavoriteAlgoFeedsSnapshot()) + flows[feedAddress] = seed + scope.launch { startFor(feedAddress, seed) } return seed.asStateFlow() } - fun refresh(dvmAddress: Address) { - val seed = flows[dvmAddress] ?: return + fun refresh(feedAddress: Address) { + val seed = flows[feedAddress] ?: return scope.launch { mutex.withLock { - jobs.remove(dvmAddress)?.cancel() + jobs.remove(feedAddress)?.cancel() } - startFor(dvmAddress, seed) + startFor(feedAddress, seed) } } - fun stop(dvmAddress: Address) { + fun stop(feedAddress: Address) { scope.launch { mutex.withLock { - jobs.remove(dvmAddress)?.cancel() - flows.remove(dvmAddress) + jobs.remove(feedAddress)?.cancel() + flows.remove(feedAddress) } } } private suspend fun startFor( - dvmAddress: Address, - seed: MutableStateFlow, + feedAddress: Address, + seed: MutableStateFlow, ) { - val user = account.cache.checkGetOrCreateUser(dvmAddress.pubKeyHex) ?: return + val user = account.cache.checkGetOrCreateUser(feedAddress.pubKeyHex) ?: return val job = scope.launch(Dispatchers.IO) { try { @@ -188,12 +188,12 @@ class FavoriteDvmOrchestrator( } } catch (e: Exception) { if (e is CancellationException) throw e - Log.w("FavoriteDvmOrchestrator", "Failed to start DVM request: ${e.message}", e) + Log.w("FavoriteAlgoFeedsOrchestrator", "Failed to start DVM request: ${e.message}", e) seed.update { it.copy(errorMessage = e.message ?: "Unknown error") } } } - mutex.withLock { jobs[dvmAddress] = job } + mutex.withLock { jobs[feedAddress] = job } } private fun splitInnerTags(innerTags: List): Pair, Set> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt similarity index 79% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt index fef6663850..03b3f85367 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListDecryptionCache.kt @@ -18,19 +18,19 @@ * 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.favoriteDvmLists +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 FavoriteDvmListDecryptionCache( +class FavoriteAlgoFeedsListDecryptionCache( val signer: NostrSigner, ) { val cachedPrivateLists = PrivateTagArrayEventCache(signer) - fun cachedFavoriteDvms(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteAlgoFeedsSet() + fun cachedFavoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteAlgoFeedsSet() - suspend fun favoriteDvms(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteAlgoFeedsSet() + suspend fun favoriteAlgoFeeds(event: FavoriteAlgoFeedsListEvent) = cachedPrivateLists.mergeTagList(event).favoriteAlgoFeedsSet() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt index 6181d8b025..b39d78e80b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteAlgoFeedsLists/FavoriteAlgoFeedsListState.kt @@ -18,7 +18,7 @@ * 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.favoriteDvmLists +package com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AddressableNote @@ -43,34 +43,34 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch -class FavoriteDvmListState( +class FavoriteAlgoFeedsListState( val signer: NostrSigner, val cache: LocalCache, - val decryptionCache: FavoriteDvmListDecryptionCache, + 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 favoriteDvmListNote = cache.getOrCreateAddressableNote(getFavoriteDvmListAddress()) + val favoriteAlgoFeedsListNote = cache.getOrCreateAddressableNote(getFavoriteAlgoFeedsListAddress()) - fun getFavoriteDvmListAddress() = FavoriteAlgoFeedsListEvent.createAddress(signer.pubKey) + fun getFavoriteAlgoFeedsListAddress() = FavoriteAlgoFeedsListEvent.createAddress(signer.pubKey) - fun getFavoriteDvmListFlow(): StateFlow = favoriteDvmListNote.flow().metadata.stateFlow + fun getFavoriteAlgoFeedsListFlow(): StateFlow = favoriteAlgoFeedsListNote.flow().metadata.stateFlow - fun getFavoriteDvmList(): FavoriteAlgoFeedsListEvent? = favoriteDvmListNote.event as? FavoriteAlgoFeedsListEvent + fun getFavoriteAlgoFeedsList(): FavoriteAlgoFeedsListEvent? = favoriteAlgoFeedsListNote.event as? FavoriteAlgoFeedsListEvent - suspend fun favoriteDvmListWithBackup(note: Note): Set
{ - val event = note.event as? FavoriteAlgoFeedsListEvent ?: settings.backupFavoriteDvmList - return event?.let { decryptionCache.favoriteDvms(it) } ?: emptySet() + suspend fun favoriteAlgoFeedsListWithBackup(note: Note): Set
{ + val event = note.event as? FavoriteAlgoFeedsListEvent ?: settings.backupFavoriteAlgoFeedsList + return event?.let { decryptionCache.favoriteAlgoFeeds(it) } ?: emptySet() } @OptIn(ExperimentalCoroutinesApi::class) val flow: StateFlow> = - getFavoriteDvmListFlow() + getFavoriteAlgoFeedsListFlow() .transformLatest { noteState -> - emit(favoriteDvmListWithBackup(noteState.note)) + emit(favoriteAlgoFeedsListWithBackup(noteState.note)) }.onStart { - emit(favoriteDvmListWithBackup(favoriteDvmListNote)) + emit(favoriteAlgoFeedsListWithBackup(favoriteAlgoFeedsListNote)) }.flowOn(Dispatchers.IO) .stateIn( scope, @@ -93,7 +93,7 @@ class FavoriteDvmListState( ) suspend fun follow(dvm: AddressBookmark): FavoriteAlgoFeedsListEvent { - val list = getFavoriteDvmList() + val list = getFavoriteAlgoFeedsList() return if (list == null) { FavoriteAlgoFeedsListEvent.create(dvm, false, signer) } else { @@ -102,12 +102,12 @@ class FavoriteDvmListState( } suspend fun unfollow(dvm: Address): FavoriteAlgoFeedsListEvent? { - val list = getFavoriteDvmList() ?: return null + val list = getFavoriteAlgoFeedsList() ?: return null return FavoriteAlgoFeedsListEvent.remove(list, dvm, signer) } init { - settings.backupFavoriteDvmList?.let { event -> + settings.backupFavoriteAlgoFeedsList?.let { event -> Log.d("AccountRegisterObservers") { "Loading saved Favorite DVM list ${event.toJson()}" } @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { @@ -117,10 +117,10 @@ class FavoriteDvmListState( scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "Favorite DVM List Collector Start") - getFavoriteDvmListFlow().collect { + getFavoriteAlgoFeedsListFlow().collect { Log.d("AccountRegisterObservers") { "Favorite DVM List for ${signer.pubKey}" } (it.note.event as? FavoriteAlgoFeedsListEvent)?.let { - settings.updateFavoriteDvmListTo(it) + settings.updateFavoriteAlgoFeedsListTo(it) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index be72d1b265..d8269aa435 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.TopFilter -import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator +import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow @@ -31,8 +31,8 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFoll import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.GeohashFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessFeedFlow -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.AllFavoriteDvmsFeedFlow -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.AllFavoriteAlgoFeedsFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow @@ -66,8 +66,8 @@ class FeedTopNavFilterState( val caches: FeedDecryptionCaches, val signer: NostrSigner, val scope: CoroutineScope, - val favoriteDvmOrchestrator: FavoriteDvmOrchestrator, - val favoriteDvmAddresses: StateFlow>, + val favoriteAlgoFeedsOrchestrator: FavoriteAlgoFeedsOrchestrator, + val favoriteAlgoFeedAddresses: StateFlow>, ) { fun loadFlowsFor(listName: TopFilter): IFeedFlowsType = when (listName) { @@ -149,19 +149,19 @@ class FeedTopNavFilterState( RelayFeedFlow(listName.url.normalizeRelayUrl()) } - is TopFilter.FavoriteDvm -> { - FavoriteDvmFeedFlow( - dvmAddress = listName.address, - orchestrator = favoriteDvmOrchestrator, + is TopFilter.FavoriteAlgoFeed -> { + FavoriteAlgoFeedFlow( + feedAddress = listName.address, + orchestrator = favoriteAlgoFeedsOrchestrator, outboxRelays = followsRelays, proxyRelays = proxyRelays, ) } - TopFilter.AllFavoriteDvms -> { - AllFavoriteDvmsFeedFlow( - favoriteDvmAddresses = favoriteDvmAddresses, - orchestrator = favoriteDvmOrchestrator, + TopFilter.AllFavoriteAlgoFeeds -> { + AllFavoriteAlgoFeedsFlow( + favoriteAlgoFeedAddresses = favoriteAlgoFeedAddresses, + orchestrator = favoriteAlgoFeedsOrchestrator, outboxRelays = followsRelays, proxyRelays = proxyRelays, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt similarity index 80% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt index c35c2ba72a..f239307222 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsFlow.kt @@ -18,10 +18,10 @@ * 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.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds -import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator -import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot +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 @@ -35,12 +35,12 @@ import kotlinx.coroutines.flow.flatMapLatest /** * Feed flow that merges snapshots from every currently-favorited DVM into a - * single [AllFavoriteDvmsTopNavFilter]. Re-wires subscriptions whenever the + * single [AllFavoriteAlgoFeedsTopNavFilter]. Re-wires subscriptions whenever the * favorite set changes. */ -class AllFavoriteDvmsFeedFlow( - val favoriteDvmAddresses: StateFlow>, - val orchestrator: FavoriteDvmOrchestrator, +class AllFavoriteAlgoFeedsFlow( + val favoriteAlgoFeedAddresses: StateFlow>, + val orchestrator: FavoriteAlgoFeedsOrchestrator, val outboxRelays: StateFlow>, val proxyRelays: StateFlow>, ) : IFeedFlowsType { @@ -50,9 +50,9 @@ class AllFavoriteDvmsFeedFlow( ): Set = if (proxy.isNotEmpty()) proxy else outbox private fun merge( - snapshots: List, + snapshots: List, contentRelays: Set, - ): AllFavoriteDvmsTopNavFilter { + ): AllFavoriteAlgoFeedsTopNavFilter { val ids = mutableSetOf() val addresses = mutableSetOf() val listen = mutableSetOf() @@ -63,7 +63,7 @@ class AllFavoriteDvmsFeedFlow( listen += snap.responseRelays snap.requestId?.let { requestIds += it } } - return AllFavoriteDvmsTopNavFilter( + return AllFavoriteAlgoFeedsTopNavFilter( acceptedIds = ids, acceptedAddresses = addresses, contentRelays = contentRelays, @@ -72,8 +72,8 @@ class AllFavoriteDvmsFeedFlow( ) } - private fun emptyFilter(contentRelays: Set): AllFavoriteDvmsTopNavFilter = - AllFavoriteDvmsTopNavFilter( + private fun emptyFilter(contentRelays: Set): AllFavoriteAlgoFeedsTopNavFilter = + AllFavoriteAlgoFeedsTopNavFilter( acceptedIds = emptySet(), acceptedAddresses = emptySet(), contentRelays = contentRelays, @@ -83,13 +83,13 @@ class AllFavoriteDvmsFeedFlow( @OptIn(ExperimentalCoroutinesApi::class) override fun flow(): Flow = - favoriteDvmAddresses.flatMapLatest { addresses -> + favoriteAlgoFeedAddresses.flatMapLatest { addresses -> if (addresses.isEmpty()) { combine(outboxRelays, proxyRelays) { outbox, proxy -> emptyFilter(resolveContentRelays(outbox, proxy)) } } else { - val snapshotFlows: List> = addresses.map { orchestrator.observe(it) } + val snapshotFlows: List> = addresses.map { orchestrator.observe(it) } combine(snapshotFlows) { it.toList() } .let { merged -> combine(merged, outboxRelays, proxyRelays) { snaps, outbox, proxy -> @@ -99,9 +99,9 @@ class AllFavoriteDvmsFeedFlow( } } - override fun startValue(): AllFavoriteDvmsTopNavFilter { + override fun startValue(): AllFavoriteAlgoFeedsTopNavFilter { val contentRelays = resolveContentRelays(outboxRelays.value, proxyRelays.value) - val addresses = favoriteDvmAddresses.value + val addresses = favoriteAlgoFeedAddresses.value return if (addresses.isEmpty()) { emptyFilter(contentRelays) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt index 36e7e6b6fa..eba50b6bcf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/AllFavoriteDvmsTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/AllFavoriteAlgoFeedsTopNavFilter.kt @@ -18,7 +18,7 @@ * 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.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.LocalCache @@ -32,12 +32,12 @@ import kotlinx.coroutines.flow.MutableStateFlow /** * Top-nav filter that unions the latest kind-6300 responses from every currently - * favorited DVM. Behaves like [FavoriteDvmTopNavFilter] (pure membership check + * 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 AllFavoriteDvmsTopNavFilter( +class AllFavoriteAlgoFeedsTopNavFilter( val acceptedIds: Set, val acceptedAddresses: Set, val contentRelays: Set, @@ -50,13 +50,13 @@ class AllFavoriteDvmsTopNavFilter( noteEvent.id in acceptedIds || (noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses) - override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) + override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) - override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet = - FavoriteDvmTopNavPerRelayFilterSet( + override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet = + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = contentRelays.associateWith { - FavoriteDvmTopNavPerRelayFilter( + FavoriteAlgoFeedTopNavPerRelayFilter( ids = acceptedIds, addresses = acceptedAddresses, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt similarity index 79% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt index 04d35e0d67..e17accd43a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedFlow.kt @@ -18,9 +18,9 @@ * 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.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds -import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator +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 @@ -30,9 +30,9 @@ import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -class FavoriteDvmFeedFlow( - val dvmAddress: Address, - val orchestrator: FavoriteDvmOrchestrator, +class FavoriteAlgoFeedFlow( + val feedAddress: Address, + val orchestrator: FavoriteAlgoFeedsOrchestrator, val outboxRelays: StateFlow>, val proxyRelays: StateFlow>, ) : IFeedFlowsType { @@ -42,10 +42,10 @@ class FavoriteDvmFeedFlow( ): Set = if (proxy.isNotEmpty()) proxy else outbox private fun buildFilter( - snapshot: com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot, + snapshot: com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsSnapshot, contentRelays: Set, - ) = FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + ) = FavoriteAlgoFeedTopNavFilter( + feedAddress = feedAddress, acceptedIds = snapshot.ids, acceptedAddresses = snapshot.addresses, contentRelays = contentRelays, @@ -54,13 +54,13 @@ class FavoriteDvmFeedFlow( ) override fun flow(): Flow = - combine(orchestrator.observe(dvmAddress), outboxRelays, proxyRelays) { snap, outbox, proxy -> + combine(orchestrator.observe(feedAddress), outboxRelays, proxyRelays) { snap, outbox, proxy -> buildFilter(snap, resolveRelays(outbox, proxy)) } - override fun startValue(): FavoriteDvmTopNavFilter = + override fun startValue(): FavoriteAlgoFeedTopNavFilter = buildFilter( - snapshot = orchestrator.observe(dvmAddress).value, + snapshot = orchestrator.observe(feedAddress).value, contentRelays = resolveRelays(outboxRelays.value, proxyRelays.value), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt index 9534bc1c84..6cd70135c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilter.kt @@ -18,7 +18,7 @@ * 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.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.LocalCache @@ -32,15 +32,15 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow /** - * Top-nav filter backed by the latest kind-6300 response from a favorite DVM. + * 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 [FavoriteDvmFeedFlow] and replaces the active filter. + * emitted through [FavoriteAlgoFeedFlow] and replaces the active filter. */ @Immutable -class FavoriteDvmTopNavFilter( - val dvmAddress: Address, +class FavoriteAlgoFeedTopNavFilter( + val feedAddress: Address, val acceptedIds: Set, val acceptedAddresses: Set, val contentRelays: Set, @@ -53,13 +53,13 @@ class FavoriteDvmTopNavFilter( noteEvent.id in acceptedIds || (noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses) - override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) + override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) - override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet = - FavoriteDvmTopNavPerRelayFilterSet( + override fun startValue(cache: LocalCache): FavoriteAlgoFeedTopNavPerRelayFilterSet = + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = contentRelays.associateWith { - FavoriteDvmTopNavPerRelayFilter( + FavoriteAlgoFeedTopNavPerRelayFilter( ids = acceptedIds, addresses = acceptedAddresses, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt similarity index 92% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt index 4dcbeddaac..374f5ecf0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilter.kt @@ -18,14 +18,14 @@ * 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.favoriteDvm +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 FavoriteDvmTopNavPerRelayFilter( +class FavoriteAlgoFeedTopNavPerRelayFilter( val ids: Set, val addresses: Set, ) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt similarity index 85% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt index 3188403846..a111c93613 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavPerRelayFilterSet.kt @@ -18,7 +18,7 @@ * 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.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -32,11 +32,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl * - [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-DVM filter carries one; the merged "All favorite DVMs" - * filter carries one per favorite DVM. + * for. A single feed carries one; the merged "All favorite algo feeds" + * filter carries one per favorite algo feed. */ -class FavoriteDvmTopNavPerRelayFilterSet( - val contentFetches: Map, +class FavoriteAlgoFeedTopNavPerRelayFilterSet( + val contentFetches: Map, val listenRelays: Set, val requestIds: Set, ) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 972befbcd2..3ba6d22b3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -95,7 +95,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.Long import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteDvmListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen @@ -278,7 +278,7 @@ fun BuildNavigation( composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd { VanishEventsScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } - composableFromEnd { FavoriteDvmListScreen(accountViewModel, nav) } + composableFromEnd { FavoriteAlgoFeedsListScreen(accountViewModel, nav) } composableFromEnd { PaymentTargetsScreen(accountViewModel, nav) } composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 057a243971..70a12a0ddd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -171,7 +171,7 @@ sealed class Route { @Serializable object EditMediaServers : Route() - @Serializable object EditFavoriteDvms : Route() + @Serializable object EditFavoriteAlgoFeeds : Route() @Serializable object EditPaymentTargets : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index b4cf9a7199..9d01c8a124 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -86,7 +86,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.CommunityName -import com.vitorpamplona.amethyst.ui.screen.FavoriteDvmName +import com.vitorpamplona.amethyst.ui.screen.FavoriteAlgoFeedName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.GeoHashName import com.vitorpamplona.amethyst.ui.screen.HashtagName @@ -361,7 +361,7 @@ fun RenderOption( ) } - is FavoriteDvmName -> { + is FavoriteAlgoFeedName -> { val noteState by observeNote(option.note, accountViewModel) val name = (noteState.note.event as? AppDefinitionEvent) @@ -419,7 +419,7 @@ private fun groupFeedDefinitions(options: ImmutableList): Map { + is FavoriteAlgoFeedName -> { FeedGroup.DVMS } @@ -427,7 +427,7 @@ private fun groupFeedDefinitions(options: ImmutableList): Map FeedGroup.LOCATIONS is TopFilter.Global -> FeedGroup.RELAYS - is TopFilter.AllFavoriteDvms -> FeedGroup.DVMS + is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS else -> FeedGroup.FEEDS } } @@ -592,11 +592,11 @@ private fun FeedIcon( Icons.AutoMirrored.Outlined.ViewList } - is TopFilter.FavoriteDvm -> { + is TopFilter.FavoriteAlgoFeed -> { Icons.Outlined.AutoAwesome } - is TopFilter.AllFavoriteDvms -> { + is TopFilter.AllFavoriteAlgoFeeds -> { Icons.Outlined.AutoAwesome } @@ -606,7 +606,7 @@ private fun FeedIcon( is RelayName -> Icons.Outlined.Storage is CommunityName -> Icons.Outlined.Groups is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList - is FavoriteDvmName -> Icons.Outlined.AutoAwesome + is FavoriteAlgoFeedName -> Icons.Outlined.AutoAwesome else -> Icons.Outlined.Person } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 7b65a43e1e..69060103f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -97,9 +97,9 @@ class TopNavFilterState( name = ResourceName(R.string.follow_list_chess), ) - val allFavoriteDvmsFollow = + val allFavoriteAlgoFeedsFollow = FeedDefinition( - code = TopFilter.AllFavoriteDvms, + code = TopFilter.AllFavoriteAlgoFeeds, name = ResourceName(R.string.follow_list_all_favorite_dvms), ) @@ -147,7 +147,7 @@ class TopNavFilterState( geotagList: Set, communityList: List, relayList: Set, - favoriteDvmList: List, + favoriteAlgoFeedsList: List, ): List { val hashtags = hashtagList.map { @@ -181,26 +181,26 @@ class TopNavFilterState( ) } - // Favorites can only be added through FavoriteDvmToggle, which itself checks + // 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.FavoriteDvm can't find its chip + // 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 favoriteDvms = - favoriteDvmList.map { dvmNote -> + val favoriteAlgoFeeds = + favoriteAlgoFeedsList.map { feedNote -> FeedDefinition( - TopFilter.FavoriteDvm(dvmNote.address), - FavoriteDvmName(dvmNote), + TopFilter.FavoriteAlgoFeed(feedNote.address), + FavoriteAlgoFeedName(feedNote), ) } - // Only show the "All favorite DVMs" meta-chip when there is at least one + // 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 (favoriteDvms.isNotEmpty()) listOf(allFavoriteDvmsFollow) else emptyList() + if (favoriteAlgoFeeds.isNotEmpty()) listOf(allFavoriteAlgoFeedsFollow) else emptyList() - return (communities + hashtags + geotags + relays + allFavorites + favoriteDvms).sortedBy { it.name.name() } + return (communities + hashtags + geotags + relays + allFavorites + favoriteAlgoFeeds).sortedBy { it.name.name() } } @OptIn(ExperimentalCoroutinesApi::class) @@ -210,7 +210,7 @@ class TopNavFilterState( account.geohashList.flow, account.communityList.flowNotes, account.relayFeedsList.flow, - account.favoriteDvmList.flowNotes, + account.favoriteAlgoFeedsList.flowNotes, ::mergeInterests, ).onStart { emit( @@ -219,7 +219,7 @@ class TopNavFilterState( account.geohashList.flow.value, account.communityList.flowNotes.value, account.relayFeedsList.flow.value, - account.favoriteDvmList.flowNotes.value, + account.favoriteAlgoFeedsList.flowNotes.value, ), ) } @@ -328,7 +328,7 @@ class CommunityName( } @Stable -class FavoriteDvmName( +class FavoriteAlgoFeedName( val note: AddressableNote, ) : Name() { override fun name(): String = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 97b01c3363..e08814ee40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1089,11 +1089,11 @@ class AccountViewModel( fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) } - fun followFavoriteDvm(dvm: AddressBookmark) = launchSigner { account.followFavoriteDvm(dvm) } + fun followFavoriteAlgoFeed(dvm: AddressBookmark) = launchSigner { account.followFavoriteAlgoFeed(dvm) } - fun unfollowFavoriteDvm(dvm: Address) = launchSigner { account.unfollowFavoriteDvm(dvm) } + fun unfollowFavoriteAlgoFeed(dvm: Address) = launchSigner { account.unfollowFavoriteAlgoFeed(dvm) } - fun refreshFavoriteDvm(dvm: Address) = account.favoriteDvmOrchestrator.refresh(dvm) + fun refreshFavoriteAlgoFeed(dvm: Address) = account.favoriteAlgoFeedsOrchestrator.refresh(dvm) fun followRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.followRelayFeed(url) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt index 0ca21209bf..329444e2f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt @@ -49,7 +49,7 @@ import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.FavoriteDvmToggle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.FavoriteAlgoFeedToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp @@ -117,7 +117,7 @@ fun RenderContentDVMThumb( modifier = Modifier.weight(1f), ) if (baseNote is AddressableNote) { - FavoriteDvmToggle( + FavoriteAlgoFeedToggle( appDefinitionNote = baseNote, accountViewModel = accountViewModel, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt index 4ae8cefe5c..78e65eed30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt @@ -102,7 +102,7 @@ fun DvmTopBar( } } addressableNote?.let { target -> - FavoriteDvmToggle( + FavoriteAlgoFeedToggle( appDefinitionNote = target, accountViewModel = accountViewModel, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt similarity index 94% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt index 1bed004b77..3838bef642 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteAlgoFeedToggle.kt @@ -52,7 +52,7 @@ import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDi * a 6300 reply that never comes. */ @Composable -fun FavoriteDvmToggle( +fun FavoriteAlgoFeedToggle( appDefinitionNote: AddressableNote, accountViewModel: AccountViewModel, modifier: Modifier = Modifier, @@ -65,7 +65,7 @@ fun FavoriteDvmToggle( if (!supportsContentDiscovery) return - val favorites by accountViewModel.account.favoriteDvmList.flow + val favorites by accountViewModel.account.favoriteAlgoFeedsList.flow .collectAsStateWithLifecycle() val isFavorite = favorites.contains(appDefinitionNote.address) @@ -74,9 +74,9 @@ fun FavoriteDvmToggle( modifier = modifier, onClick = { if (isFavorite) { - accountViewModel.unfollowFavoriteDvm(appDefinitionNote.address) + accountViewModel.unfollowFavoriteAlgoFeed(appDefinitionNote.address) } else { - accountViewModel.followFavoriteDvm( + accountViewModel.followFavoriteAlgoFeed( AddressBookmark( address = appDefinitionNote.address, relayHint = appDefinitionNote.relayHintUrl(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt index 0f22ae4ecf..8d71292bc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteDvmListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/favorites/FavoriteAlgoFeedsListScreen.kt @@ -70,7 +70,7 @@ import com.vitorpamplona.amethyst.ui.theme.grayText @OptIn(ExperimentalMaterial3Api::class) @Composable -fun FavoriteDvmListScreen( +fun FavoriteAlgoFeedsListScreen( accountViewModel: AccountViewModel, nav: INav, ) { @@ -101,17 +101,17 @@ fun FavoriteDvmListScreen( color = MaterialTheme.colorScheme.grayText, ) - FavoriteDvmList(accountViewModel, nav) + FavoriteAlgoFeedList(accountViewModel, nav) } } } @Composable -private fun FavoriteDvmList( +private fun FavoriteAlgoFeedList( accountViewModel: AccountViewModel, nav: INav, ) { - val favorites by accountViewModel.account.favoriteDvmList.flowNotes + val favorites by accountViewModel.account.favoriteAlgoFeedsList.flowNotes .collectAsStateWithLifecycle() if (favorites.isEmpty()) { @@ -136,12 +136,12 @@ private fun FavoriteDvmList( items( items = favorites, key = { it.address.toValue() }, - ) { dvmNote -> - FavoriteDvmRow( - dvmNote = dvmNote, + ) { feedNote -> + FavoriteAlgoFeedRow( + feedNote = feedNote, accountViewModel = accountViewModel, - onOpen = { nav.nav(Route.ContentDiscovery(dvmNote.idHex)) }, - onRemove = { accountViewModel.unfollowFavoriteDvm(dvmNote.address) }, + onOpen = { nav.nav(Route.ContentDiscovery(feedNote.idHex)) }, + onRemove = { accountViewModel.unfollowFavoriteAlgoFeed(feedNote.address) }, ) } } @@ -149,13 +149,13 @@ private fun FavoriteDvmList( @OptIn(ExperimentalFoundationApi::class) @Composable -private fun FavoriteDvmRow( - dvmNote: AddressableNote, +private fun FavoriteAlgoFeedRow( + feedNote: AddressableNote, accountViewModel: AccountViewModel, onOpen: () -> Unit, onRemove: () -> Unit, ) { - val card = observeAppDefinition(dvmNote, accountViewModel) + val card = observeAppDefinition(feedNote, accountViewModel) Row( modifier = @@ -175,19 +175,19 @@ private fun FavoriteDvmRow( loadedImageModifier = SimpleImage35Modifier, accountViewModel = accountViewModel, onLoadingBackground = { - dvmNote.author?.let { author -> + feedNote.author?.let { author -> BannerImage(author, SimpleImage35Modifier, accountViewModel) } }, onError = { - dvmNote.author?.let { author -> + feedNote.author?.let { author -> BannerImage(author, SimpleImage35Modifier, accountViewModel) } }, ) } } ?: run { - dvmNote.author?.let { author -> + feedNote.author?.let { author -> BannerImage(author, SimpleImage35Modifier, accountViewModel) } } @@ -198,7 +198,7 @@ private fun FavoriteDvmRow( modifier = Modifier.weight(1f), ) { Text( - text = card.name.ifBlank { dvmNote.dTag() }, + text = card.name.ifBlank { feedNote.dTag() }, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt index 723eea88ae..22d10c444b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/AlgoFeedStatusBanner.kt @@ -42,7 +42,7 @@ 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.dvms.FavoriteDvmSnapshot +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 @@ -55,7 +55,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @Composable -fun HomeDvmStatusBanner( +fun HomeAlgoFeedStatusBanner( accountViewModel: AccountViewModel, nav: INav, modifier: Modifier = Modifier, @@ -64,31 +64,31 @@ fun HomeDvmStatusBanner( .collectAsStateWithLifecycle() when (val filter = topFilter) { - is TopFilter.FavoriteDvm -> SingleDvmBanner(filter, accountViewModel, nav, modifier) - is TopFilter.AllFavoriteDvms -> AllFavoriteDvmsBanner(accountViewModel, modifier) + is TopFilter.FavoriteAlgoFeed -> SingleAlgoFeedBanner(filter, accountViewModel, nav, modifier) + is TopFilter.AllFavoriteAlgoFeeds -> AllFavoriteAlgoFeedsBanner(accountViewModel, modifier) else -> Unit } } @Composable -private fun SingleDvmBanner( - favDvm: TopFilter.FavoriteDvm, +private fun SingleAlgoFeedBanner( + favFeed: TopFilter.FavoriteAlgoFeed, accountViewModel: AccountViewModel, nav: INav, modifier: Modifier = Modifier, ) { - val snapshot by accountViewModel.account.favoriteDvmOrchestrator - .observe(favDvm.address) + 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 dvmAddressValue = favDvm.address.toValue() + val feedAddressValue = favFeed.address.toValue() - LoadNote(baseNoteHex = dvmAddressValue, accountViewModel = accountViewModel) { dvmNote -> + LoadNote(baseNoteHex = feedAddressValue, accountViewModel = accountViewModel) { feedNote -> val resolvedName by - observeNoteAndMap(dvmNote ?: return@LoadNote, accountViewModel) { note -> + observeNoteAndMap(feedNote ?: return@LoadNote, accountViewModel) { note -> (note.event as? AppDefinitionEvent) ?.appMetaData() ?.name @@ -107,7 +107,7 @@ private fun SingleDvmBanner( showSpinner = false, ) Spacer(modifier = StdVertSpacer) - RetryButton { accountViewModel.refreshFavoriteDvm(favDvm.address) } + RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) } } status?.code == "payment-required" -> { @@ -144,7 +144,7 @@ private fun SingleDvmBanner( showSpinner = false, ) Spacer(modifier = StdVertSpacer) - RetryButton { accountViewModel.refreshFavoriteDvm(favDvm.address) } + RetryButton { accountViewModel.refreshFavoriteAlgoFeed(favFeed.address) } } status?.code == "processing" -> { @@ -169,11 +169,11 @@ private fun SingleDvmBanner( } @Composable -private fun AllFavoriteDvmsBanner( +private fun AllFavoriteAlgoFeedsBanner( accountViewModel: AccountViewModel, modifier: Modifier = Modifier, ) { - val addresses by accountViewModel.account.favoriteDvmList.flow + val addresses by accountViewModel.account.favoriteAlgoFeedsList.flow .collectAsStateWithLifecycle() if (addresses.isEmpty()) return @@ -181,9 +181,9 @@ private fun AllFavoriteDvmsBanner( // 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 = + val snapshots: List = addresses.map { address -> - val snap by accountViewModel.account.favoriteDvmOrchestrator + val snap by accountViewModel.account.favoriteAlgoFeedsOrchestrator .observe(address) .collectAsStateWithLifecycle() snap @@ -202,7 +202,7 @@ private fun AllFavoriteDvmsBanner( ) Spacer(modifier = StdVertSpacer) RetryButton { - addresses.forEach { accountViewModel.refreshFavoriteDvm(it) } + addresses.forEach { accountViewModel.refreshFavoriteAlgoFeed(it) } } } else { BannerMessageRow( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index d3f676146a..fb2ee89146 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -240,7 +240,7 @@ private fun HomePages( ) } - HomeDvmStatusBanner( + HomeAlgoFeedStatusBanner( accountViewModel = accountViewModel, nav = nav, modifier = Modifier.align(Alignment.TopCenter), @@ -300,7 +300,7 @@ fun HomeFeeds( ) { val activeFilter by accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() - val favoriteDvmAddresses by accountViewModel.account.favoriteDvmList.flow + val favoriteAlgoFeedAddresses by accountViewModel.account.favoriteAlgoFeedsList.flow .collectAsStateWithLifecycle() val onRefresh: () -> Unit = { @@ -308,8 +308,8 @@ fun HomeFeeds( // 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.FavoriteDvm -> accountViewModel.refreshFavoriteDvm(filter.address) - is TopFilter.AllFavoriteDvms -> favoriteDvmAddresses.forEach { accountViewModel.refreshFavoriteDvm(it) } + is TopFilter.FavoriteAlgoFeed -> accountViewModel.refreshFavoriteAlgoFeed(filter.address) + is TopFilter.AllFavoriteAlgoFeeds -> favoriteAlgoFeedAddresses.forEach { accountViewModel.refreshFavoriteAlgoFeed(it) } else -> Unit } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt index 29abc2ae62..09488ead92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFilterSet -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet @@ -43,7 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.f import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip64Chess.filterHomePostsByChess import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90Dvms.filterHomePostsByDvmIds +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds.filterHomePostsByAlgoFeedIds import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription @@ -76,7 +76,7 @@ class HomeOutboxEventsEoseManager( is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince) is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince) is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince) - is FavoriteDvmTopNavPerRelayFilterSet -> filterHomePostsByDvmIds(feedSettings, since, newThreadSince) + is FavoriteAlgoFeedTopNavPerRelayFilterSet -> filterHomePostsByAlgoFeedIds(feedSettings, since, newThreadSince) else -> emptyList() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt index c042199a6b..029546417a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIds.kt @@ -18,10 +18,10 @@ * 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.nip90Dvms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet +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 @@ -45,8 +45,8 @@ import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent * never publishes responses on the user's outbox, so listening anywhere else * would silently miss them. */ -fun filterHomePostsByDvmIds( - set: FavoriteDvmTopNavPerRelayFilterSet, +fun filterHomePostsByAlgoFeedIds( + set: FavoriteAlgoFeedTopNavPerRelayFilterSet, @Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?, @Suppress("UNUSED_PARAMETER") defaultSince: Long?, ): List { @@ -68,7 +68,7 @@ fun filterHomePostsByDvmIds( private fun contentFetchFilters( relay: NormalizedRelayUrl, - filter: FavoriteDvmTopNavPerRelayFilter, + filter: FavoriteAlgoFeedTopNavPerRelayFilter, ): List { val out = mutableListOf() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index 69207a03bd..6175115acd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -127,7 +127,7 @@ fun AllSettingsScreen( title = R.string.favorite_dvms_title, icon = Icons.Outlined.AutoAwesome, tint = tint, - onClick = { nav.nav(Route.EditFavoriteDvms) }, + onClick = { nav.nav(Route.EditFavoriteAlgoFeeds) }, ) HorizontalDivider() SettingsNavigationRow( diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt similarity index 87% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt rename to amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt index c55e3927ec..0e8594af85 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilterTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteAlgoFeeds/FavoriteAlgoFeedTopNavFilterTest.kt @@ -18,7 +18,7 @@ * 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.favoriteDvm +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -27,7 +27,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -class FavoriteDvmTopNavFilterTest { +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( @@ -47,8 +47,8 @@ class FavoriteDvmTopNavFilterTest { @Test fun matchesNoteWhoseIdIsInAcceptedSet() { val filter = - FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, acceptedIds = setOf("1".repeat(64)), acceptedAddresses = emptySet(), contentRelays = emptySet(), @@ -62,8 +62,8 @@ class FavoriteDvmTopNavFilterTest { @Test fun rejectsNoteNotInAcceptedSet() { val filter = - FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, acceptedIds = setOf("1".repeat(64)), acceptedAddresses = emptySet(), contentRelays = emptySet(), @@ -81,8 +81,8 @@ class FavoriteDvmTopNavFilterTest { val articleAddress = "30023:$articleAuthor:$articleDTag" val filter = - FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, acceptedIds = emptySet(), acceptedAddresses = setOf(articleAddress), contentRelays = emptySet(), @@ -96,8 +96,8 @@ class FavoriteDvmTopNavFilterTest { @Test fun nullRequestIdCollapsesToEmptyRequestIdsInFilterSet() { val filter = - FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, acceptedIds = emptySet(), acceptedAddresses = emptySet(), contentRelays = emptySet(), @@ -106,7 +106,7 @@ class FavoriteDvmTopNavFilterTest { ) // passing a LocalCache is only needed because the method demands it; - // FavoriteDvmTopNavFilter.startValue doesn't actually consult it. + // FavoriteAlgoFeedTopNavFilter.startValue doesn't actually consult it. val set = filter.startValue(com.vitorpamplona.amethyst.model.LocalCache) assertTrue(set.requestIds.isEmpty()) } @@ -114,8 +114,8 @@ class FavoriteDvmTopNavFilterTest { @Test fun nonNullRequestIdProducesSingletonInFilterSet() { val filter = - FavoriteDvmTopNavFilter( - dvmAddress = dvmAddress, + FavoriteAlgoFeedTopNavFilter( + feedAddress = dvmAddress, acceptedIds = emptySet(), acceptedAddresses = emptySet(), contentRelays = emptySet(), diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt similarity index 78% rename from amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt rename to amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt index 62086f8b3f..5ae5d27a18 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIdsTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90AlgoFeeds/FilterHomePostsByAlgoFeedIdsTest.kt @@ -18,10 +18,10 @@ * 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.nip90Dvms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90AlgoFeeds -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter -import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet +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 @@ -30,34 +30,34 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test -class FilterHomePostsByDvmIdsTest { +class FilterHomePostsByAlgoFeedIdsTest { private val userRelay = RelayUrlNormalizer.normalizeOrNull("wss://user.example/")!! private val dvmRelay = RelayUrlNormalizer.normalizeOrNull("wss://dvm.example/")!! @Test fun emptyFilterSetProducesNoRequests() { val set = - FavoriteDvmTopNavPerRelayFilterSet( + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = emptyMap(), listenRelays = emptySet(), requestIds = emptySet(), ) - assertTrue(filterHomePostsByDvmIds(set, since = null, defaultSince = null).isEmpty()) + assertTrue(filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null).isEmpty()) } @Test fun contentFetchIssuedOnUserRelayWithIdsFilter() { val ids = setOf("a".repeat(64), "b".repeat(64)) val set = - FavoriteDvmTopNavPerRelayFilterSet( + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = - mapOf(userRelay to FavoriteDvmTopNavPerRelayFilter(ids = ids, addresses = emptySet())), + mapOf(userRelay to FavoriteAlgoFeedTopNavPerRelayFilter(ids = ids, addresses = emptySet())), listenRelays = emptySet(), requestIds = emptySet(), ) - val filters = filterHomePostsByDvmIds(set, since = null, defaultSince = null) + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) assertEquals(1, filters.size) val single = filters.single() @@ -71,13 +71,13 @@ class FilterHomePostsByDvmIdsTest { fun listenFilterIssuedOnDvmRelayWithKinds6300And7000() { val requestId = "9".repeat(64) val set = - FavoriteDvmTopNavPerRelayFilterSet( + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = emptyMap(), listenRelays = setOf(dvmRelay), requestIds = setOf(requestId), ) - val filters = filterHomePostsByDvmIds(set, since = null, defaultSince = null) + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) assertEquals(1, filters.size) val listen = filters.single() @@ -96,13 +96,13 @@ class FilterHomePostsByDvmIdsTest { val req1 = "1".repeat(64) val req2 = "2".repeat(64) val set = - FavoriteDvmTopNavPerRelayFilterSet( + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = emptyMap(), listenRelays = setOf(dvmRelay), requestIds = setOf(req1, req2), ) - val filters = filterHomePostsByDvmIds(set, since = null, defaultSince = null) + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) assertEquals(1, filters.size) val eTag = @@ -120,14 +120,14 @@ class FilterHomePostsByDvmIdsTest { val ids = setOf("a".repeat(64)) val requestId = "9".repeat(64) val set = - FavoriteDvmTopNavPerRelayFilterSet( + FavoriteAlgoFeedTopNavPerRelayFilterSet( contentFetches = - mapOf(userRelay to FavoriteDvmTopNavPerRelayFilter(ids = ids, addresses = emptySet())), + mapOf(userRelay to FavoriteAlgoFeedTopNavPerRelayFilter(ids = ids, addresses = emptySet())), listenRelays = setOf(dvmRelay), requestIds = setOf(requestId), ) - val filters = filterHomePostsByDvmIds(set, since = null, defaultSince = null) + val filters = filterHomePostsByAlgoFeedIds(set, since = null, defaultSince = null) assertEquals(2, filters.size) assertTrue(filters.any { it.relay == userRelay && it.filter.ids != null })