diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 025bf0ffc..ec3df21db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.nip38UserStatuses.UserStatusActi import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.logTime +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState @@ -66,6 +67,8 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists.FavoriteDvmListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists.FavoriteDvmListState import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache @@ -134,6 +137,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.mimeType import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -186,6 +190,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent @@ -322,6 +327,10 @@ class Account( val hashtagListDecryptionCache = HashtagListDecryptionCache(signer) val hashtagList = HashtagListState(signer, cache, hashtagListDecryptionCache, scope, settings) + val favoriteDvmListDecryptionCache = FavoriteDvmListDecryptionCache(signer) + val favoriteDvmList = FavoriteDvmListState(signer, cache, favoriteDvmListDecryptionCache, scope, settings) + val favoriteDvmOrchestrator = FavoriteDvmOrchestrator(this, scope) + val geohashListDecryptionCache = GeohashListDecryptionCache(signer) val geohashList = GeohashListState(signer, cache, geohashListDecryptionCache, scope, settings) @@ -417,6 +426,7 @@ class Account( caches = feedDecryptionCaches, signer = signer, scope = scope, + favoriteDvmOrchestrator = favoriteDvmOrchestrator, ).flow // App-ready Feeds @@ -1012,6 +1022,12 @@ class Account( suspend fun unfollowHashtag(tag: String) = sendMyPublicAndPrivateOutbox(hashtagList.unfollow(tag)) + suspend fun followFavoriteDvm(dvm: AddressBookmark) = sendMyPublicAndPrivateOutbox(favoriteDvmList.follow(dvm)) + + suspend fun unfollowFavoriteDvm(dvm: Address) = sendMyPublicAndPrivateOutbox(favoriteDvmList.unfollow(dvm)) + + fun isFavoriteDvm(dvm: Address): Boolean = favoriteDvmList.flow.value.contains(dvm) + suspend fun followGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.follow(geohash)) suspend fun unfollowGeohash(geohash: String) = sendMyPublicAndPrivateOutbox(geohashList.unfollow(geohash)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index d091bf5c3..0f327a174 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayList import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent @@ -154,6 +155,11 @@ sealed class TopFilter( class Relay( val url: String, ) : TopFilter("Relay/$url") + + @Serializable + class FavoriteDvm( + val address: Address, + ) : TopFilter("FavoriteDvm/${address.toValue()}") } @Stable @@ -195,6 +201,7 @@ class AccountSettings( var backupChannelList: ChannelListEvent? = null, var backupCommunityList: CommunityListEvent? = null, var backupHashtagList: HashtagListEvent? = null, + var backupFavoriteDvmList: FavoriteDvmListEvent? = null, var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, var backupTrustProviderList: TrustProviderListEvent? = null, @@ -718,6 +725,16 @@ class AccountSettings( } } + fun updateFavoriteDvmListTo(newFavoriteDvmList: FavoriteDvmListEvent?) { + if (newFavoriteDvmList == null || newFavoriteDvmList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupFavoriteDvmList?.id != newFavoriteDvmList.id) { + backupFavoriteDvmList = newFavoriteDvmList + saveAccountSettings() + } + } + fun updateCommunityListTo(newCommunityList: CommunityListEvent?) { if (newCommunityList == null || newCommunityList.tags.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt new file mode 100644 index 000000000..f4058cccb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/dvms/FavoriteDvmOrchestrator.kt @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.dvms + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Immutable snapshot of a favourite DVM's current request/response state. + * + * - [requestId] is the id of the most recently published kind-5300 request. + * - [ids] and [addresses] are the note references returned by the latest kind-6300 response. + * - [latestStatus] is the latest kind-7000 status event (processing, payment-required, error, …). + * - [errorMessage] captures any client-side failure while publishing the request. + */ +data class FavoriteDvmSnapshot( + val requestId: HexKey? = null, + val ids: Set = emptySet(), + val addresses: Set = emptySet(), + val latestStatus: NIP90StatusEvent? = null, + val errorMessage: String? = null, +) + +/** + * Manages the NIP-90 content-discovery RPC cycle for each favourite DVM the user + * pins to the top-nav. + * + * The orchestrator is lazy: it starts a request/response cycle the first time any + * consumer calls [observe] for a given DVM address, and keeps emitting updated + * snapshots until [stop] (or account tear-down). Call [refresh] to re-issue the + * kind-5300 request (e.g. pull-to-refresh). + * + * This class does not own the relay subscriptions that fetch DVM responses and + * matching notes. Those are issued by `HomeOutboxEventsEoseManager` while the + * user has a `TopFilter.FavoriteDvm` selected. The orchestrator merely observes + * what the relays deliver into `LocalCache`. + */ +class FavoriteDvmOrchestrator( + val account: Account, + val scope: CoroutineScope, +) { + private val flows = mutableMapOf>() + private val jobs = mutableMapOf() + private val mutex = Mutex() + + fun observe(dvmAddress: Address): StateFlow { + flows[dvmAddress]?.let { return it.asStateFlow() } + + val seed = MutableStateFlow(FavoriteDvmSnapshot()) + flows[dvmAddress] = seed + scope.launch { startFor(dvmAddress, seed) } + return seed.asStateFlow() + } + + fun refresh(dvmAddress: Address) { + val seed = flows[dvmAddress] ?: return + scope.launch { + mutex.withLock { + jobs.remove(dvmAddress)?.cancel() + } + startFor(dvmAddress, seed) + } + } + + fun stop(dvmAddress: Address) { + scope.launch { + mutex.withLock { + jobs.remove(dvmAddress)?.cancel() + flows.remove(dvmAddress) + } + } + } + + private suspend fun startFor( + dvmAddress: Address, + seed: MutableStateFlow, + ) { + val user = account.cache.checkGetOrCreateUser(dvmAddress.pubKeyHex) ?: return + val job = + scope.launch(Dispatchers.IO) { + try { + account.requestDVMContentDiscovery(user) { request -> + seed.update { + it.copy( + requestId = request.id, + ids = emptySet(), + addresses = emptySet(), + latestStatus = null, + errorMessage = null, + ) + } + } + + val requestId = seed.value.requestId ?: return@launch + + launch { + account.cache + .observeLatestEvent( + Filter( + kinds = listOf(NIP90ContentDiscoveryResponseEvent.KIND), + tags = mapOf("e" to listOf(requestId)), + limit = 1, + ), + ).collectLatest { response -> + if (response == null) return@collectLatest + val (eventIds, addresses) = splitInnerTags(response.innerTags()) + seed.update { + it.copy( + ids = eventIds, + addresses = addresses, + ) + } + } + } + + launch { + account.cache + .observeLatestEvent( + Filter( + kinds = listOf(NIP90StatusEvent.KIND), + tags = mapOf("e" to listOf(requestId)), + limit = 1, + ), + ).collectLatest { status -> + seed.update { it.copy(latestStatus = status) } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("FavoriteDvmOrchestrator", "Failed to start DVM request: ${e.message}", e) + seed.update { it.copy(errorMessage = e.message ?: "Unknown error") } + } + } + + mutex.withLock { jobs[dvmAddress] = job } + } + + private fun splitInnerTags(innerTags: List): Pair, Set> { + val ids = mutableSetOf() + val addresses = mutableSetOf() + innerTags.forEach { value -> + if (value.contains(':')) { + addresses.add(value) + } else if (value.length == 64) { + ids.add(value) + } + } + return ids to addresses + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt new file mode 100644 index 000000000..8af3bfa97 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListDecryptionCache.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.favoriteDvmSet + +class FavoriteDvmListDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateLists = PrivateTagArrayEventCache(signer) + + fun cachedFavoriteDvms(event: FavoriteDvmListEvent) = cachedPrivateLists.mergeTagListPrecached(event).favoriteDvmSet() + + suspend fun favoriteDvms(event: FavoriteDvmListEvent) = cachedPrivateLists.mergeTagList(event).favoriteDvmSet() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt new file mode 100644 index 000000000..db41026d5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteDvmLists/FavoriteDvmListState.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.favoriteDvmLists + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.favoriteDvmList.FavoriteDvmListEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +class FavoriteDvmListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: FavoriteDvmListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + // Creates a long-term reference for this note so that the GC doesn't collect the note itself + val favoriteDvmListNote = cache.getOrCreateAddressableNote(getFavoriteDvmListAddress()) + + fun getFavoriteDvmListAddress() = FavoriteDvmListEvent.createAddress(signer.pubKey) + + fun getFavoriteDvmListFlow(): StateFlow = favoriteDvmListNote.flow().metadata.stateFlow + + fun getFavoriteDvmList(): FavoriteDvmListEvent? = favoriteDvmListNote.event as? FavoriteDvmListEvent + + suspend fun favoriteDvmListWithBackup(note: Note): Set
{ + val event = note.event as? FavoriteDvmListEvent ?: settings.backupFavoriteDvmList + return event?.let { decryptionCache.favoriteDvms(it) } ?: emptySet() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val flow: StateFlow> = + getFavoriteDvmListFlow() + .transformLatest { noteState -> + emit(favoriteDvmListWithBackup(noteState.note)) + }.onStart { + emit(favoriteDvmListWithBackup(favoriteDvmListNote)) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + @OptIn(ExperimentalCoroutinesApi::class) + val flowNotes: StateFlow> = + flow + .map { addresses -> + addresses.map { cache.getOrCreateAddressableNote(it) } + }.onStart { + emit(flow.value.map { cache.getOrCreateAddressableNote(it) }) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + suspend fun follow(dvm: AddressBookmark): FavoriteDvmListEvent { + val list = getFavoriteDvmList() + return if (list == null) { + FavoriteDvmListEvent.create(dvm, false, signer) + } else { + FavoriteDvmListEvent.add(list, dvm, false, signer) + } + } + + suspend fun unfollow(dvm: Address): FavoriteDvmListEvent? { + val list = getFavoriteDvmList() ?: return null + return FavoriteDvmListEvent.remove(list, dvm, signer) + } + + init { + settings.backupFavoriteDvmList?.let { event -> + Log.d("AccountRegisterObservers") { "Loading saved Favorite DVM list ${event.toJson()}" } + @OptIn(DelicateCoroutinesApi::class) + scope.launch(Dispatchers.IO) { + LocalCache.justConsumeMyOwnEvent(event) + } + } + + scope.launch(Dispatchers.IO) { + Log.d("AccountRegisterObservers", "Favorite DVM List Collector Start") + getFavoriteDvmListFlow().collect { + Log.d("AccountRegisterObservers") { "Favorite DVM List for ${signer.pubKey}" } + (it.note.event as? FavoriteDvmListEvent)?.let { + settings.updateFavoriteDvmListTo(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index e9ae1dbc7..27635f019 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow @@ -30,6 +31,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFoll import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.GeohashFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow @@ -62,6 +64,7 @@ class FeedTopNavFilterState( val caches: FeedDecryptionCaches, val signer: NostrSigner, val scope: CoroutineScope, + val favoriteDvmOrchestrator: FavoriteDvmOrchestrator, ) { fun loadFlowsFor(listName: TopFilter): IFeedFlowsType = when (listName) { @@ -142,6 +145,15 @@ class FeedTopNavFilterState( is TopFilter.Relay -> { RelayFeedFlow(listName.url.normalizeRelayUrl()) } + + is TopFilter.FavoriteDvm -> { + FavoriteDvmFeedFlow( + dvmAddress = listName.address, + orchestrator = favoriteDvmOrchestrator, + outboxRelays = followsRelays, + proxyRelays = proxyRelays, + ) + } } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt new file mode 100644 index 000000000..f919d62a0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmFeedFlow.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm + +import com.vitorpamplona.amethyst.model.dvms.FavoriteDvmOrchestrator +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +class FavoriteDvmFeedFlow( + val dvmAddress: Address, + val orchestrator: FavoriteDvmOrchestrator, + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + private fun resolveRelays( + outbox: Set, + proxy: Set, + ): Set = if (proxy.isNotEmpty()) proxy else outbox + + private fun buildFilter( + snapshot: com.vitorpamplona.amethyst.model.dvms.FavoriteDvmSnapshot, + relays: Set, + ) = FavoriteDvmTopNavFilter( + dvmAddress = dvmAddress, + acceptedIds = snapshot.ids, + acceptedAddresses = snapshot.addresses, + relayList = relays, + requestId = snapshot.requestId, + ) + + override fun flow(): Flow = + combine(orchestrator.observe(dvmAddress), outboxRelays, proxyRelays) { snap, outbox, proxy -> + buildFilter(snap, resolveRelays(outbox, proxy)) + } + + override fun startValue(): FavoriteDvmTopNavFilter = + buildFilter( + snapshot = orchestrator.observe(dvmAddress).value, + relays = resolveRelays(outboxRelays.value, proxyRelays.value), + ) + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt new file mode 100644 index 000000000..093fc390d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavFilter.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Top-nav filter backed by the latest kind-6300 response from a favourite DVM. + * + * The filter is a pure immutable membership check: [match] accepts a note only if the + * DVM's latest response included it. When a new response arrives, a new instance is + * emitted through [FavoriteDvmFeedFlow] and replaces the active filter. + */ +@Immutable +class FavoriteDvmTopNavFilter( + val dvmAddress: Address, + val acceptedIds: Set, + val acceptedAddresses: Set, + val relayList: Set, + val requestId: HexKey?, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event): Boolean = + noteEvent.id in acceptedIds || + (noteEvent is AddressableEvent && noteEvent.addressTag() in acceptedAddresses) + + override fun toPerRelayFlow(cache: LocalCache): Flow = MutableStateFlow(startValue(cache)) + + override fun startValue(cache: LocalCache): FavoriteDvmTopNavPerRelayFilterSet = + FavoriteDvmTopNavPerRelayFilterSet( + relayList.associateWith { + FavoriteDvmTopNavPerRelayFilter( + dvmPubkey = dvmAddress.pubKeyHex, + requestId = requestId, + ids = acceptedIds, + addresses = acceptedAddresses, + ) + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt new file mode 100644 index 000000000..5a8f3d7f6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilter.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +@Immutable +class FavoriteDvmTopNavPerRelayFilter( + val dvmPubkey: HexKey, + val requestId: HexKey?, + val ids: Set, + val addresses: Set, +) : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt new file mode 100644 index 000000000..4ec47da11 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/favoriteDvm/FavoriteDvmTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class FavoriteDvmTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index c3f7bba7c..64c3ac1e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -40,6 +40,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ViewList import androidx.compose.material.icons.automirrored.outlined.VolumeOff import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.LocationOn import androidx.compose.material.icons.outlined.Person @@ -84,6 +85,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.CommunityName +import com.vitorpamplona.amethyst.ui.screen.FavoriteDvmName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.GeoHashName import com.vitorpamplona.amethyst.ui.screen.HashtagName @@ -100,6 +102,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import kotlinx.collections.immutable.ImmutableList @OptIn(ExperimentalPermissionsApi::class) @@ -329,6 +332,20 @@ fun RenderOption( color = MaterialTheme.colorScheme.onSurface, ) } + + is FavoriteDvmName -> { + val noteState by observeNote(option.note, accountViewModel) + val name = + (noteState.note.event as? AppDefinitionEvent) + ?.appMetaData() + ?.name + ?.takeIf { it.isNotBlank() } ?: option.note.dTag() + Text( + text = name, + fontSize = Font14SP, + color = MaterialTheme.colorScheme.onSurface, + ) + } } } @@ -346,6 +363,7 @@ private enum class FeedGroup( COMMUNITIES(R.string.feed_group_communities), LOCATIONS(R.string.feed_group_locations), LISTS(R.string.feed_group_lists), + DVMS(R.string.feed_group_dvms), RELAYS(R.string.feed_group_relays), } @@ -373,6 +391,10 @@ private fun groupFeedDefinitions(options: ImmutableList): Map { + FeedGroup.DVMS + } + is ResourceName -> { when (entry.item.code) { is TopFilter.AroundMe -> FeedGroup.LOCATIONS @@ -541,12 +563,17 @@ private fun FeedIcon( Icons.AutoMirrored.Outlined.ViewList } + is TopFilter.FavoriteDvm -> { + Icons.Outlined.AutoAwesome + } + else -> { when (item.name) { is GeoHashName -> Icons.Outlined.LocationOn is RelayName -> Icons.Outlined.Storage is CommunityName -> Icons.Outlined.Groups is PeopleListName -> Icons.AutoMirrored.Outlined.ViewList + is FavoriteDvmName -> Icons.Outlined.AutoAwesome else -> Icons.Outlined.Person } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 50763b54d..811ff1508 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -140,6 +141,7 @@ class TopNavFilterState( geotagList: Set, communityList: List, relayList: Set, + favoriteDvmList: List, ): List { val hashtags = hashtagList.map { @@ -173,7 +175,15 @@ class TopNavFilterState( ) } - return (communities + hashtags + geotags + relays).sortedBy { it.name.name() } + val favoriteDvms = + favoriteDvmList.map { dvmNote -> + FeedDefinition( + TopFilter.FavoriteDvm(dvmNote.address), + FavoriteDvmName(dvmNote), + ) + } + + return (communities + hashtags + geotags + relays + favoriteDvms).sortedBy { it.name.name() } } @OptIn(ExperimentalCoroutinesApi::class) @@ -183,6 +193,7 @@ class TopNavFilterState( account.geohashList.flow, account.communityList.flowNotes, account.relayFeedsList.flow, + account.favoriteDvmList.flowNotes, ::mergeInterests, ).onStart { emit( @@ -191,6 +202,7 @@ class TopNavFilterState( account.geohashList.flow.value, account.communityList.flowNotes.value, account.relayFeedsList.flow.value, + account.favoriteDvmList.flowNotes.value, ), ) } @@ -298,6 +310,15 @@ class CommunityName( override fun name() = "/n/${(note.dTag())}" } +@Stable +class FavoriteDvmName( + val note: AddressableNote, +) : Name() { + override fun name(): String = + (note.event as? AppDefinitionEvent)?.appMetaData()?.name?.takeIf { it.isNotBlank() } + ?: note.dTag() +} + @Immutable class FeedDefinition( val code: TopFilter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 56d164093..c8ebc9643 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -138,6 +138,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent @@ -1088,6 +1089,12 @@ class AccountViewModel( fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) } + fun followFavoriteDvm(dvm: AddressBookmark) = launchSigner { account.followFavoriteDvm(dvm) } + + fun unfollowFavoriteDvm(dvm: Address) = launchSigner { account.unfollowFavoriteDvm(dvm) } + + fun refreshFavoriteDvm(dvm: Address) = account.favoriteDvmOrchestrator.refresh(dvm) + fun followRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.followRelayFeed(url) } fun unfollowRelayFeed(url: NormalizedRelayUrl) = launchSigner { account.unfollowRelayFeed(url) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt index 6fa904415..4ac46b109 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DVMCard.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.MyAsyncImage @@ -51,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.note.LikeReaction import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.FavoriteDvmToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp @@ -123,6 +125,12 @@ fun RenderContentDVMThumb( verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing5dp, ) { + if (baseNote is AddressableNote) { + FavoriteDvmToggle( + appDefinitionNote = baseNote, + accountViewModel = accountViewModel, + ) + } LikeReaction( baseNote = baseNote, grayTint = MaterialTheme.colorScheme.onSurface, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index 0d81a4cc8..3f5dea38e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -354,82 +354,95 @@ fun FeedDVM( Spacer(modifier = DoubleVertSpacer) Text(currentStatus, textAlign = TextAlign.Center) - if (status.code == "payment-required") { - val amountTag = latestStatus.firstAmount() - val amount = amountTag?.amount + DvmPaymentActions( + latestStatus = latestStatus, + accountViewModel = accountViewModel, + nav = nav, + onStatusUpdate = { currentStatus = it }, + ) + } +} - val invoice = amountTag?.lnInvoice +@Composable +fun DvmPaymentActions( + latestStatus: NIP90StatusEvent, + accountViewModel: AccountViewModel, + nav: INav, + onStatusUpdate: (String) -> Unit, +) { + val status = latestStatus.status() ?: return - val thankYou = stringRes(id = R.string.dvm_waiting_to_confirm_payment) - val nwcPaymentRequest = stringRes(id = R.string.nwc_payment_request) + if (status.code != "payment-required") return - if (invoice != null) { - val context = LocalContext.current - Button(onClick = { - if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { - accountViewModel.sendZapPaymentRequestFor( - bolt11 = invoice, - zappedNote = null, - onSent = { - currentStatus = nwcPaymentRequest - }, - onResponse = { response -> - currentStatus = - if (response is PayInvoiceErrorResponse) { - stringRes( - context, - R.string.wallet_connect_pay_invoice_error_error, - response.error?.message - ?: response.error?.code?.toString() ?: "Error parsing error message", - ) - } else { - thankYou - } + val amountTag = latestStatus.firstAmount() + val amount = amountTag?.amount + val invoice = amountTag?.lnInvoice + + val thankYou = stringRes(id = R.string.dvm_waiting_to_confirm_payment) + val nwcPaymentRequest = stringRes(id = R.string.nwc_payment_request) + + if (invoice != null) { + val context = LocalContext.current + Button(onClick = { + if (accountViewModel.account.nip47SignerState.hasWalletConnectSetup()) { + accountViewModel.sendZapPaymentRequestFor( + bolt11 = invoice, + zappedNote = null, + onSent = { + onStatusUpdate(nwcPaymentRequest) + }, + onResponse = { response -> + onStatusUpdate( + if (response is PayInvoiceErrorResponse) { + stringRes( + context, + R.string.wallet_connect_pay_invoice_error_error, + response.error?.message + ?: response.error?.code?.toString() ?: "Error parsing error message", + ) + } else { + thankYou }, ) - } else { - payViaIntent( - invoice, - context, - onPaid = { - currentStatus = thankYou - }, - onError = { - currentStatus = it - }, - ) - } - }) { - val amountInInvoice = - try { - LnInvoiceUtil.getAmountInSats(invoice).toLong() - } catch (_: Exception) { - null - } - - if (amountInInvoice != null) { - Text(text = "Pay $amountInInvoice sats to the DVM") - } else { - Text(text = "Pay Invoice from the DVM") - } - } - } else if (amount != null) { - LoadNote(baseNoteHex = latestStatus.id, accountViewModel = accountViewModel) { stateNote -> - stateNote?.let { - ZapDVMButton( - baseNote = it, - amount = amount, - grayTint = MaterialTheme.colorScheme.onPrimary, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } + }, + ) + } else { + payViaIntent( + invoice, + context, + onPaid = { + onStatusUpdate(thankYou) + }, + onError = { + onStatusUpdate(it) + }, + ) + } + }) { + val amountInInvoice = + try { + LnInvoiceUtil.getAmountInSats(invoice).toLong() + } catch (_: Exception) { + null + } + + if (amountInInvoice != null) { + Text(text = "Pay $amountInInvoice sats to the DVM") + } else { + Text(text = "Pay Invoice from the DVM") + } + } + } else if (amount != null) { + LoadNote(baseNoteHex = latestStatus.id, accountViewModel = accountViewModel) { stateNote -> + stateNote?.let { + ZapDVMButton( + baseNote = it, + amount = amount, + grayTint = MaterialTheme.colorScheme.onPrimary, + accountViewModel = accountViewModel, + nav = nav, + ) } - } else if (status.code == "processing") { - currentStatus = status.description - } else if (status.code == "error") { - currentStatus = status.description } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt index 928a2d9a8..d3dcecc66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/DvmTopBar.kt @@ -21,15 +21,18 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.MyAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.topbars.MyExtensibleTopAppBar +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer @@ -41,7 +44,7 @@ fun DvmTopBar( accountViewModel: AccountViewModel, nav: INav, ) { - TopBarExtensibleWithBackButton( + MyExtensibleTopAppBar( title = { LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote -> if (appDefinitionNote != null) { @@ -82,6 +85,16 @@ fun DvmTopBar( } } }, - popBack = nav::popBack, + navigationIcon = { IconButton(onClick = nav::popBack) { ArrowBackIcon() } }, + actions = { + LoadNote(baseNoteHex = appDefinitionId, accountViewModel = accountViewModel) { appDefinitionNote -> + if (appDefinitionNote is AddressableNote) { + FavoriteDvmToggle( + appDefinitionNote = appDefinitionNote, + accountViewModel = accountViewModel, + ) + } + } + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt new file mode 100644 index 000000000..f79acafcd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/FavoriteDvmToggle.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.outlined.StarBorder +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +@Composable +fun FavoriteDvmToggle( + appDefinitionNote: AddressableNote, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val favorites by accountViewModel.account.favoriteDvmList.flow + .collectAsStateWithLifecycle() + + val isFavorite = favorites.contains(appDefinitionNote.address) + + IconButton( + onClick = { + if (isFavorite) { + accountViewModel.unfollowFavoriteDvm(appDefinitionNote.address) + } else { + accountViewModel.followFavoriteDvm( + AddressBookmark( + address = appDefinitionNote.address, + relayHint = appDefinitionNote.relayHintUrl(), + ), + ) + } + }, + modifier = modifier, + ) { + if (isFavorite) { + Icon( + imageVector = Icons.Filled.Star, + contentDescription = stringRes(R.string.remove_dvm_from_favorites), + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + imageVector = Icons.Outlined.StarBorder, + contentDescription = stringRes(R.string.add_dvm_to_favorites), + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt new file mode 100644 index 000000000..487a9524b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/DvmStatusBanner.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.LoadingAnimation +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmPaymentActions +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent + +@Composable +fun HomeDvmStatusBanner( + accountViewModel: AccountViewModel, + nav: INav, +) { + val topFilter by accountViewModel.account.settings.defaultHomeFollowList + .collectAsStateWithLifecycle() + + val favDvm = topFilter as? TopFilter.FavoriteDvm ?: return + + val snapshot by accountViewModel.account.favoriteDvmOrchestrator + .observe(favDvm.address) + .collectAsStateWithLifecycle() + + // Hide the banner when the feed is already populated. + if (snapshot.ids.isNotEmpty() || snapshot.addresses.isNotEmpty()) return + + val dvmAddressValue = favDvm.address.toValue() + + LoadNote(baseNoteHex = dvmAddressValue, accountViewModel = accountViewModel) { dvmNote -> + val resolvedName by + observeNoteAndMap(dvmNote ?: return@LoadNote, accountViewModel) { note -> + (note.event as? AppDefinitionEvent) + ?.appMetaData() + ?.name + ?.takeIf { it.isNotBlank() } + ?: (note as? com.vitorpamplona.amethyst.model.AddressableNote)?.dTag() + ?: "" + } + + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column(modifier = Modifier.padding(12.dp)) { + val status = snapshot.latestStatus?.status() + + when { + snapshot.errorMessage != null -> { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_error), + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + RetryButton(favDvm, accountViewModel) + } + + status?.code == "payment-required" -> { + BannerMessageRow( + message = + status.description.ifBlank { + stringRes(R.string.dvm_home_status_payment_required) + }, + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + var statusOverride by remember { mutableStateOf(null) } + val msg = statusOverride + if (msg != null) { + Text(text = msg, style = MaterialTheme.typography.bodySmall) + Spacer(modifier = StdVertSpacer) + } + snapshot.latestStatus?.let { + DvmPaymentActions( + latestStatus = it, + accountViewModel = accountViewModel, + nav = nav, + onStatusUpdate = { statusOverride = it }, + ) + } + } + + status?.code == "error" -> { + BannerMessageRow( + message = + status.description.ifBlank { + stringRes(R.string.dvm_home_status_error) + }, + showSpinner = false, + ) + Spacer(modifier = StdVertSpacer) + RetryButton(favDvm, accountViewModel) + } + + status?.code == "processing" -> { + BannerMessageRow( + message = + status.description.ifBlank { + stringRes(R.string.dvm_home_status_processing) + }, + showSpinner = true, + ) + } + + else -> { + BannerMessageRow( + message = stringRes(R.string.dvm_home_status_requesting, resolvedName), + showSpinner = true, + ) + } + } + } + } + } +} + +@Composable +private fun BannerMessageRow( + message: String, + showSpinner: Boolean, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + if (showSpinner) { + LoadingAnimation(indicatorSize = 14.dp, circleWidth = 2.dp) + Spacer(modifier = StdHorzSpacer) + } + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun RetryButton( + favDvm: TopFilter.FavoriteDvm, + accountViewModel: AccountViewModel, +) { + OutlinedButton(onClick = { accountViewModel.refreshFavoriteDvm(favDvm.address) }) { + Text(stringRes(R.string.dvm_home_retry)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 77adf8355..38e38dae6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -179,6 +179,7 @@ private fun HomePages( topBar = { Column { HomeTopBar(accountViewModel, nav) + HomeDvmStatusBanner(accountViewModel, nav) SecondaryTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt index 39459be38..29abc2ae6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet @@ -42,6 +43,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.f import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip64Chess.filterHomePostsByChess import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90Dvms.filterHomePostsByDvmIds import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription @@ -74,6 +76,7 @@ class HomeOutboxEventsEoseManager( is MutedAuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince) is RelayTopNavPerRelayFilterSet -> filterHomePostsByRelay(feedSettings, since, newThreadSince, repliesSince) is SingleCommunityTopNavPerRelayFilterSet -> filterHomePostsByCommunity(feedSettings, since, newThreadSince) + is FavoriteDvmTopNavPerRelayFilterSet -> filterHomePostsByDvmIds(feedSettings, since, newThreadSince) else -> emptyList() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt new file mode 100644 index 000000000..644cc1f54 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip90Dvms/FilterHomePostsByDvmIds.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip90Dvms + +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilter +import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteDvm.FavoriteDvmTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent + +/** + * Builds relay REQ filters for a favourite-DVM home feed. + * + * Three filters are issued per relay: + * - fetch notes whose ids are in the DVM's latest kind-6300 response + * - fetch addressable notes referenced by `a` tags in the DVM's response + * - subscribe to the DVM's future kind 6300 / 7000 events so the filter snapshot + * keeps up to date while this filter is active + */ +fun filterHomePostsByDvmIds( + set: FavoriteDvmTopNavPerRelayFilterSet, + @Suppress("UNUSED_PARAMETER") since: SincePerRelayMap?, + @Suppress("UNUSED_PARAMETER") defaultSince: Long?, +): List = + set.set.flatMap { (relay, filter) -> + buildFiltersFor(relay, filter) + } + +private fun buildFiltersFor( + relay: NormalizedRelayUrl, + filter: FavoriteDvmTopNavPerRelayFilter, +): List { + val out = mutableListOf() + + if (filter.ids.isNotEmpty()) { + out += + RelayBasedFilter( + relay = relay, + filter = + Filter( + ids = filter.ids.toList(), + limit = filter.ids.size, + ), + ) + } + + if (filter.addresses.isNotEmpty()) { + out += + RelayBasedFilter( + relay = relay, + filter = + Filter( + tags = mapOf("a" to filter.addresses.toList()), + limit = filter.addresses.size, + ), + ) + } + + val requestId = filter.requestId + if (requestId != null) { + out += + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = + listOf( + NIP90ContentDiscoveryResponseEvent.KIND, + NIP90StatusEvent.KIND, + ), + tags = mapOf("e" to listOf(requestId)), + limit = 10, + ), + ) + } + + return out +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e2ffc91ef..9b74fd43b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1728,8 +1728,17 @@ Locations Communities Lists + DVMs Relays + Add DVM to favorites + Remove from favorites + Asking %1$s for a feed… + Processing your feed… + This DVM requires payment + DVM returned an error + Retry + Log off on device lock Private Message Public Message diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt new file mode 100644 index 000000000..34f28bf7c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/FavoriteDvmListEvent.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.favoriteDvmList + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class FavoriteDvmListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicFavoriteDvms(): List = tags.mapNotNull(AddressBookmark::parse) + + suspend fun privateFavoriteDvms(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(AddressBookmark::parse) + + companion object { + const val KIND = 10090 + const val ALT = "Favorite DVM list" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) + + suspend fun create( + dvm: AddressBookmark, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent = + if (isPrivate) { + create( + publicDvms = emptyList(), + privateDvms = listOf(dvm), + signer = signer, + createdAt = createdAt, + ) + } else { + create( + publicDvms = listOf(dvm), + privateDvms = emptyList(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun add( + earlierVersion: FavoriteDvmListEvent, + dvm: AddressBookmark, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent = + if (isPrivate) { + val privateTags = + earlierVersion.privateTags(signer) + ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + tags = earlierVersion.tags, + privateTags = privateTags.remove(dvm.toTagIdOnly()) + dvm.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(dvm.toTagIdOnly()) + dvm.toTagArray(), + signer = signer, + createdAt = createdAt, + ) + } + + suspend fun remove( + earlierVersion: FavoriteDvmListEvent, + dvm: Address, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent { + val idOnly = AddressBookmark.assemble(dvm, null) + val privateTags = earlierVersion.privateTags(signer) + return if (privateTags != null) { + resign( + privateTags = privateTags.remove(idOnly), + tags = earlierVersion.tags.remove(idOnly), + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = earlierVersion.tags.remove(idOnly), + signer = signer, + createdAt = createdAt, + ) + } + } + + suspend fun resign( + tags: TagArray, + privateTags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ) = resign( + content = PrivateTagsInContent.encryptNip44(privateTags, signer), + tags = tags, + signer = signer, + createdAt = createdAt, + ) + + suspend fun resign( + content: String, + tags: TagArray, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent { + val newTags = + if (tags.fastAny(AltTag::match)) { + tags + } else { + tags + AltTag.assemble(ALT) + } + + return signer.sign(createdAt, KIND, newTags, content) + } + + suspend fun create( + publicDvms: List = emptyList(), + privateDvms: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteDvmListEvent { + val template = build(publicDvms, privateDvms, signer, createdAt) + return signer.sign(template) + } + + suspend fun build( + publicDvms: List = emptyList(), + privateDvms: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = + PrivateTagsInContent.encryptNip44( + privateDvms.map { it.toTagArray() }.toTypedArray(), + signer, + ), + createdAt = createdAt, + ) { + alt(ALT) + favoriteDvms(publicDvms) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt new file mode 100644 index 000000000..6f49fd98b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayBuilderExt.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.favoriteDvmList + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +fun TagArrayBuilder.favoriteDvm(app: AddressBookmark) = add(app.toTagArray()) + +fun TagArrayBuilder.favoriteDvms(apps: List) = addAll(apps.map { it.toTagArray() }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt new file mode 100644 index 000000000..0f2d3501c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/favoriteDvmList/TagArrayExt.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip51Lists.favoriteDvmList + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark + +fun TagArray.favoriteDvmList() = mapNotNull(AddressBookmark::parseAddress) + +fun TagArray.favoriteDvmSet() = mapNotNullTo(mutableSetOf(), AddressBookmark::parseAddress)