From 050ef7353092a078dfd5de1859c2e994b6134b04 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 1 Mar 2026 19:50:07 -0500 Subject: [PATCH] adds support for NIP-51 favorite relay lists. --- .../amethyst/LocalPreferences.kt | 6 + .../vitorpamplona/amethyst/model/Account.kt | 7 + .../amethyst/model/AccountSettings.kt | 12 ++ .../amethyst/model/LocalCache.kt | 8 ++ .../FavoriteRelayListDecryptionCache.kt | 29 +++++ .../favoriteRelays/FavoriteRelayListState.kt | 121 ++++++++++++++++++ .../FilterAccountInfoAndListsFromKey.kt | 2 + .../FilterBasicAccountInfoFromKeys.kt | 2 + .../amethyst/ui/note/NoteCompose.kt | 6 + .../amethyst/ui/note/types/RelayList.kt | 21 +++ .../loggedIn/relays/AllRelayListScreen.kt | 19 +++ .../loggedIn/relays/RelayInformationScreen.kt | 2 + .../blocked/BlockedRelayListViewModel.kt | 2 + .../broadcast/BroadcastRelayListViewModel.kt | 2 + .../connected/ConnectedRelayListViewModel.kt | 2 + .../relays/dm/DMRelayListViewModel.kt | 2 + .../relays/favorites/FavoriteRelayListView.kt | 81 ++++++++++++ .../favorites/FavoriteRelayListViewModel.kt | 36 ++++++ .../indexer/IndexerRelayListViewModel.kt | 2 + .../relays/local/LocalRelayListViewModel.kt | 2 + .../nip37/PrivateOutboxRelayListViewModel.kt | 2 + .../relays/search/SearchRelayListViewModel.kt | 2 + .../loggedIn/threadview/ThreadFeedView.kt | 4 + amethyst/src/main/res/values/strings.xml | 4 + .../relayLists/FavoriteRelayListEvent.kt | 120 +++++++++++++++++ .../relayLists/tags/TagArrayBuilderExt.kt | 3 + .../quartz/utils/EventFactory.kt | 2 + 27 files changed, 501 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListDecryptionCache.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListView.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListViewModel.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index e7011a919..4200306e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -25,6 +25,7 @@ import android.content.Context import android.content.SharedPreferences import androidx.compose.runtime.Immutable import androidx.core.content.edit +import com.vitorpamplona.amethyst.LocalPreferences.parseEventOrNull import com.vitorpamplona.amethyst.model.ALL_FOLLOWS import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS @@ -54,6 +55,7 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -102,6 +104,7 @@ private object PrefKeys { const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList" const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList" const val LATEST_INDEX_RELAY_LIST = "latestIndexRelayList" + const val LATEST_FAVORITE_RELAY_LIST = "latestFavoriteRelayList" const val LATEST_BLOCKED_RELAY_LIST = "latestBlockedRelayList" const val LATEST_TRUSTED_RELAY_LIST = "latestTrustedRelayList" const val LATEST_MUTE_LIST = "latestMuteList" @@ -340,6 +343,7 @@ object LocalPreferences { putOrRemove(PrefKeys.LATEST_NIP65_RELAY_LIST, settings.backupNIP65RelayList) putOrRemove(PrefKeys.LATEST_SEARCH_RELAY_LIST, settings.backupSearchRelayList) putOrRemove(PrefKeys.LATEST_INDEX_RELAY_LIST, settings.backupIndexRelayList) + putOrRemove(PrefKeys.LATEST_FAVORITE_RELAY_LIST, settings.backupIndexRelayList) putOrRemove(PrefKeys.LATEST_BLOCKED_RELAY_LIST, settings.backupBlockedRelayList) putOrRemove(PrefKeys.LATEST_TRUSTED_RELAY_LIST, settings.backupTrustedRelayList) @@ -478,6 +482,7 @@ object LocalPreferences { val latestNip65RelayList = parseEventOrNull(PrefKeys.LATEST_NIP65_RELAY_LIST) val latestSearchRelayList = parseEventOrNull(PrefKeys.LATEST_SEARCH_RELAY_LIST) val latestIndexRelayList = parseEventOrNull(PrefKeys.LATEST_INDEX_RELAY_LIST) + val latestFavoriteRelayList = parseEventOrNull(PrefKeys.LATEST_FAVORITE_RELAY_LIST) val latestBlockedRelayList = parseEventOrNull(PrefKeys.LATEST_BLOCKED_RELAY_LIST) val latestTrustedRelayList = parseEventOrNull(PrefKeys.LATEST_TRUSTED_RELAY_LIST) val latestMuteList = parseEventOrNull(PrefKeys.LATEST_MUTE_LIST) @@ -523,6 +528,7 @@ object LocalPreferences { backupDMRelayList = latestDmRelayList, backupSearchRelayList = latestSearchRelayList, backupIndexRelayList = latestIndexRelayList, + backupFavoriteRelayList = latestFavoriteRelayList, backupBlockedRelayList = latestBlockedRelayList, backupTrustedRelayList = latestTrustedRelayList, backupPrivateHomeRelayList = latestPrivateHomeRelayList, 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 517776c00..4b7824e9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -60,6 +60,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.favoriteRelays.FavoriteRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays.FavoriteRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache @@ -275,6 +277,9 @@ class Account( val indexerRelayListDecryptionCache = IndexerRelayListDecryptionCache(signer) val indexerRelayList = IndexerRelayListState(signer, cache, indexerRelayListDecryptionCache, scope, settings) + val favoriteRelayListDecryptionCache = FavoriteRelayListDecryptionCache(signer) + val favoriteRelayList = FavoriteRelayListState(signer, cache, favoriteRelayListDecryptionCache, scope, settings) + val blockedRelayListDecryptionCache = BlockedRelayListDecryptionCache(signer) val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings) @@ -1915,6 +1920,8 @@ class Account( suspend fun saveTrustedRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(trustedRelayList.saveRelayList(trustedRelays)) + suspend fun saveFavoriteRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(favoriteRelayList.saveRelayList(trustedRelays)) + suspend fun saveBlockedRelayList(blockedRelays: List) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays)) suspend fun sendNip65RelayList(relays: List) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays)) 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 df414d60a..402678300 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -45,6 +45,7 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType @@ -135,6 +136,7 @@ class AccountSettings( var backupNIP65RelayList: AdvertisedRelayListEvent? = null, var backupSearchRelayList: SearchRelayListEvent? = null, var backupIndexRelayList: IndexerRelayListEvent? = null, + var backupFavoriteRelayList: FavoriteRelayListEvent? = null, var backupBlockedRelayList: BlockedRelayListEvent? = null, var backupTrustedRelayList: TrustedRelayListEvent? = null, var backupMuteList: MuteListEvent? = null, @@ -377,6 +379,16 @@ class AccountSettings( } } + fun updateFavoriteRelayList(newFavoriteRelayList: FavoriteRelayListEvent?) { + if (newFavoriteRelayList == null || newFavoriteRelayList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupFavoriteRelayList?.id != newFavoriteRelayList.id) { + backupFavoriteRelayList = newFavoriteRelayList + saveAccountSettings() + } + } + fun updateBlockedRelayList(newBlockedRelayList: BlockedRelayListEvent?) { if (newBlockedRelayList == null || newBlockedRelayList.tags.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 75df5628b..fb7134a69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -147,6 +147,7 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent @@ -1201,6 +1202,12 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) + private fun consume( + event: FavoriteRelayListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + private fun consume( event: TrustProviderListEvent, relay: NormalizedRelayUrl?, @@ -3059,6 +3066,7 @@ object LocalCache : ILocalCache, ICacheProvider { is GitPatchEvent -> consume(event, relay, wasVerified) is GitRepositoryEvent -> consume(event, relay, wasVerified) is ChessGameEvent -> consume(event, relay, wasVerified) + is FavoriteRelayListEvent -> consume(event, relay, wasVerified) is JesterEvent -> consume(event, relay, wasVerified) is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified) is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListDecryptionCache.kt new file mode 100644 index 000000000..dee688435 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListDecryptionCache.kt @@ -0,0 +1,29 @@ +/* + * 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.favoriteRelays + +import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent + +class FavoriteRelayListDecryptionCache( + signer: NostrSigner, +) : GenericRelayListCache(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.kt new file mode 100644 index 000000000..e77ef1079 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.kt @@ -0,0 +1,121 @@ +/* + * 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.favoriteRelays + +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +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.launch + +class FavoriteRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: FavoriteRelayListDecryptionCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + // Creates a long-term reference for this note so that the GC doesn't collect the note it self + val favoriteListNote = cache.getOrCreateAddressableNote(getFavoriteRelayListAddress()) + + fun getFavoriteRelayListAddress() = FavoriteRelayListEvent.createAddress(signer.pubKey) + + fun getFavoriteRelayListFlow(): StateFlow = favoriteListNote.flow().metadata.stateFlow + + fun getFavoriteRelayList(): FavoriteRelayListEvent? = favoriteListNote.event as? FavoriteRelayListEvent + + fun favoriteListEvent(note: Note) = note.event as? FavoriteRelayListEvent ?: settings.backupFavoriteRelayList + + suspend fun normalizeFavoriteRelayListWithBackup(note: Note): Set = favoriteListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: emptySet() + + suspend fun normalizeFavoriteRelayListWithBackupNoDefaults(note: Note): Set = favoriteListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet() + + val flow = + getFavoriteRelayListFlow() + .map { normalizeFavoriteRelayListWithBackup(it.note) } + .onStart { emit(normalizeFavoriteRelayListWithBackup(favoriteListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val flowNoDefaults = + getFavoriteRelayListFlow() + .map { normalizeFavoriteRelayListWithBackupNoDefaults(it.note) } + .onStart { emit(normalizeFavoriteRelayListWithBackupNoDefaults(favoriteListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun saveRelayList(FavoriteRelays: List): FavoriteRelayListEvent { + val relayListForFavorite = getFavoriteRelayList() + + return if (relayListForFavorite != null && relayListForFavorite.tags.isNotEmpty()) { + FavoriteRelayListEvent.updateRelayList( + earlierVersion = relayListForFavorite, + relays = FavoriteRelays, + signer = signer, + ) + } else { + FavoriteRelayListEvent.create( + relays = FavoriteRelays, + signer = signer, + ) + } + } + + init { + settings.backupFavoriteRelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved favorite relay list ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.IO) { + Log.d("AccountRegisterObservers", "Favorite Relay List Collector Start") + getFavoriteRelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Favorite Relay List for ${signer.pubKey}") + (it.note.event as? FavoriteRelayListEvent)?.let { + settings.updateFavoriteRelayList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 9016db47c..0b2e2d706 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent @@ -69,6 +70,7 @@ val AccountInfoAndListsFromKeyKinds2 = GeohashListEvent.KIND, TrustProviderListEvent.KIND, PaymentTargetsEvent.KIND, + FavoriteRelayListEvent.KIND, ) val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt index ef81d1d67..6d78b6a09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent @@ -61,6 +62,7 @@ val BasicAccountInfoKinds2 = HashtagListEvent.KIND, GeohashListEvent.KIND, TrustProviderListEvent.KIND, + FavoriteRelayListEvent.KIND, ) fun filterBasicAccountInfoFromKeys( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 97dad11df..4078072a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayFavoriteRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayNIP65RelayList @@ -202,6 +203,7 @@ import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent @@ -834,6 +836,10 @@ private fun RenderNoteRow( DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav) } + is FavoriteRelayListEvent -> { + DisplayFavoriteRelayList(baseNote, backgroundColor, accountViewModel, nav) + } + is IndexerRelayListEvent -> { DisplayIndexerRelayList(baseNote, backgroundColor, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt index b5e6d44d1..58e0e03f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt @@ -234,6 +234,27 @@ fun DisplayTrustedRelayList( ) } +@Composable +fun DisplayFavoriteRelayList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relays by accountViewModel.account.favoriteRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.favoriteRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) + + DisplayRelaySet( + relays, + stringRes(id = R.string.favorite_relays_title), + null, + backgroundColor, + accountViewModel, + nav, + ) +} + @Composable fun DisplayProxyRelayList( baseNote: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index 71cd1f920..44f9361a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -55,6 +55,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected.ConnectedR import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected.renderConnectedItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.DMRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.renderDMItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.favorites.FavoriteRelayListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.favorites.renderFavoriteItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer.IndexerRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer.renderIndexerItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local.LocalRelayListViewModel @@ -93,6 +95,7 @@ fun AllRelayListScreen( val broadcastViewModel: BroadcastRelayListViewModel = viewModel() val indexerViewModel: IndexerRelayListViewModel = viewModel() val proxyViewModel: ProxyRelayListViewModel = viewModel() + val favoriteViewModel: FavoriteRelayListViewModel = viewModel() dmViewModel.init(accountViewModel) nip65ViewModel.init(accountViewModel) @@ -105,6 +108,7 @@ fun AllRelayListScreen( broadcastViewModel.init(accountViewModel) indexerViewModel.init(accountViewModel) proxyViewModel.init(accountViewModel) + favoriteViewModel.init(accountViewModel) LaunchedEffect(accountViewModel) { dmViewModel.load() @@ -118,6 +122,7 @@ fun AllRelayListScreen( broadcastViewModel.load() indexerViewModel.load() proxyViewModel.load() + favoriteViewModel.load() } MappedAllRelayListView( @@ -132,6 +137,7 @@ fun AllRelayListScreen( broadcastViewModel, indexerViewModel, proxyViewModel, + favoriteViewModel, accountViewModel, nav, ) @@ -151,6 +157,7 @@ fun MappedAllRelayListView( broadcastViewModel: BroadcastRelayListViewModel, indexerViewModel: IndexerRelayListViewModel, proxyViewModel: ProxyRelayListViewModel, + favoriteViewModel: FavoriteRelayListViewModel, accountViewModel: AccountViewModel, newNav: INav, ) { @@ -166,6 +173,7 @@ fun MappedAllRelayListView( val broadcastRelays by broadcastViewModel.relays.collectAsStateWithLifecycle() val indexerRelays by indexerViewModel.relays.collectAsStateWithLifecycle() val proxyRelays by proxyViewModel.relays.collectAsStateWithLifecycle() + val favoriteFeedState by favoriteViewModel.relays.collectAsStateWithLifecycle() Scaffold( topBar = { @@ -182,6 +190,7 @@ fun MappedAllRelayListView( broadcastViewModel.clear() indexerViewModel.clear() proxyViewModel.clear() + favoriteViewModel.clear() newNav.popBack() }, onPost = { @@ -195,6 +204,7 @@ fun MappedAllRelayListView( broadcastViewModel.create() indexerViewModel.create() proxyViewModel.create() + favoriteViewModel.create() newNav.popBack() }, ) @@ -310,6 +320,15 @@ fun MappedAllRelayListView( } renderTrustedItems(trustedFeedState, trustedViewModel, accountViewModel, newNav) + item { + SettingsCategory( + R.string.favorite_section, + R.string.favorite_section_explainer, + SettingsCategorySpacingModifier, + ) + } + renderFavoriteItems(favoriteFeedState, favoriteViewModel, accountViewModel, newNav) + item { SettingsCategory( R.string.blocked_section, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index ed9c72e14..0561cab8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -196,6 +196,7 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent @@ -498,6 +499,7 @@ private fun kindDisplayName( CalendarRSVPEvent.KIND -> "Appt RSVP" ChessGameEvent.KIND -> "Chess Games" JesterEvent.KIND -> "Chess Auth" + FavoriteRelayListEvent.KIND -> "Favorite Relays" LiveChessGameChallengeEvent.KIND -> "Chess Challenges" LiveChessGameAcceptEvent.KIND -> "Chess Game Accept" LiveChessMoveEvent.KIND -> "Chess Move" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt index 8576fd313..25081c10b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class BlockedRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List? = account.blockedRelayList.flow.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt index 6601d26f5..608eb9405 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class BroadcastRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List? = account.broadcastRelayList.flow.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt index bec8dbc5f..196646c91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt @@ -20,12 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() { override fun relayListBuilder(): List { val relayList = getRelayList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt index 0af4125e1..d08612a48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class DMRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List? = account.dmRelayList.getDMRelayList()?.relays() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListView.kt new file mode 100644 index 000000000..f8af962a3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListView.kt @@ -0,0 +1,81 @@ +/* + * 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.relays.favorites + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun FavoriteRelayList( + postViewModel: FavoriteRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: INav, +) { + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + val newNav = rememberExtendedNav(nav, onClose) + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderFavoriteItems(feedState, postViewModel, accountViewModel, newNav) + } + } +} + +fun LazyListScope.renderFavoriteItems( + feedState: List, + postViewModel: FavoriteRelayListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed(feedState, key = { _, item -> "Favorite" + item.relay.url }) { index, item -> + BasicRelaySetupInfoDialog( + item, + onDelete = { postViewModel.deleteRelay(item) }, + accountViewModel = accountViewModel, + nav, + ) + } + + item { + Spacer(modifier = StdVertSpacer) + RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListViewModel.kt new file mode 100644 index 000000000..a0d3ff0a6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListViewModel.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.ui.screen.loggedIn.relays.favorites + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +@Stable +class FavoriteRelayListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List? = + account.favoriteRelayList.flow.value + .toList() + + override suspend fun saveRelayList(urlList: List) { + account.saveFavoriteRelayList(urlList) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt index 408e67527..b4af90aeb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class IndexerRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List? = account.indexerRelayList.flowNoDefaults.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt index 8a7ee6c19..aa518d2f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class LocalRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List = account.localRelayList.flow.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt index 6a2666231..628864a70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37 +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List? = account.privateStorageRelayList.flow.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt index 436905dbc..bbbbfc148 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class SearchRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List? = account.searchRelayList.flowNoDefaults.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 49d4bf954..7d4955f89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -124,6 +124,7 @@ import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayFavoriteRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayNIP65RelayList @@ -220,6 +221,7 @@ import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent @@ -619,6 +621,8 @@ private fun FullBleedNoteCompose( DisplayProxyRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is TrustedRelayListEvent) { DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is FavoriteRelayListEvent) { + DisplayFavoriteRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is IndexerRelayListEvent) { DisplayIndexerRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is BroadcastRelayListEvent) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b4a9a247d..7b0ec85e7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1317,6 +1317,10 @@ Trusted Relays Relays you trust to not need a Tor connection for + Favorite Relay Feeds + Favorite Relay Feeds + Relays you frequently go to see their Global feed + Proxy Relays Proxy Relays Aggregator relays the app must use to download your feeds from, like filter.nostr.wine. This replaces the outbox model and will make the app connect to only the relays in your lists. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt new file mode 100644 index 000000000..c1d61c018 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt @@ -0,0 +1,120 @@ +/* + * 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.relayLists + +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.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.favoriteRelays +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class FavoriteRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRelays() = tags.relays() + + suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays() + + suspend fun decryptRelays(signer: NostrSigner): List = publicRelays() + (decryptPrivateRelays(signer) ?: emptyList()) + + companion object { + const val KIND = 10012 + const val ALT = "Favorite Relays" + val TAGS = arrayOf(AltTag.assemble(ALT)) + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "") + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, "", null) + + fun createAddressTag(pubKey: HexKey): String = Address.Companion.assemble(KIND, pubKey, "") + + suspend fun updateRelayList( + earlierVersion: FavoriteRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteRelayListEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::match).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) + } + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): FavoriteRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + favoriteRelays(publicRelays) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt index faa9bfa29..67a12b239 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent @@ -50,3 +51,5 @@ fun TagArrayBuilder.proxyRelays(relays: List.privateRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) fun TagArrayBuilder.relaySet(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) + +fun TagArrayBuilder.favoriteRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index cbd2924ec..c570731f9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -88,6 +88,7 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent @@ -193,6 +194,7 @@ class EventFactory { CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig) + FavoriteRelayListEvent.KIND -> FavoriteRelayListEvent(id, pubKey, createdAt, tags, content, sig) JesterEvent.KIND -> JesterEvent(id, pubKey, createdAt, tags, content, sig) LiveChessGameChallengeEvent.KIND -> LiveChessGameChallengeEvent(id, pubKey, createdAt, tags, content, sig) LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig)