Merge pull request #2449 from vitorpamplona/claude/add-favorite-dvm-filter-DfveW

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