adds support for NIP-51 favorite relay lists.

This commit is contained in:
Vitor Pamplona
2026-03-01 19:50:07 -05:00
parent 7fd163d6d4
commit 050ef73530
27 changed files with 501 additions and 0 deletions
@@ -25,6 +25,7 @@ import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.core.content.edit import androidx.core.content.edit
import com.vitorpamplona.amethyst.LocalPreferences.parseEventOrNull
import com.vitorpamplona.amethyst.model.ALL_FOLLOWS import com.vitorpamplona.amethyst.model.ALL_FOLLOWS
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS 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.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent 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.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
@@ -102,6 +104,7 @@ private object PrefKeys {
const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList" const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList"
const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList" const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList"
const val LATEST_INDEX_RELAY_LIST = "latestIndexRelayList" const val LATEST_INDEX_RELAY_LIST = "latestIndexRelayList"
const val LATEST_FAVORITE_RELAY_LIST = "latestFavoriteRelayList"
const val LATEST_BLOCKED_RELAY_LIST = "latestBlockedRelayList" const val LATEST_BLOCKED_RELAY_LIST = "latestBlockedRelayList"
const val LATEST_TRUSTED_RELAY_LIST = "latestTrustedRelayList" const val LATEST_TRUSTED_RELAY_LIST = "latestTrustedRelayList"
const val LATEST_MUTE_LIST = "latestMuteList" const val LATEST_MUTE_LIST = "latestMuteList"
@@ -340,6 +343,7 @@ object LocalPreferences {
putOrRemove(PrefKeys.LATEST_NIP65_RELAY_LIST, settings.backupNIP65RelayList) putOrRemove(PrefKeys.LATEST_NIP65_RELAY_LIST, settings.backupNIP65RelayList)
putOrRemove(PrefKeys.LATEST_SEARCH_RELAY_LIST, settings.backupSearchRelayList) putOrRemove(PrefKeys.LATEST_SEARCH_RELAY_LIST, settings.backupSearchRelayList)
putOrRemove(PrefKeys.LATEST_INDEX_RELAY_LIST, settings.backupIndexRelayList) 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_BLOCKED_RELAY_LIST, settings.backupBlockedRelayList)
putOrRemove(PrefKeys.LATEST_TRUSTED_RELAY_LIST, settings.backupTrustedRelayList) putOrRemove(PrefKeys.LATEST_TRUSTED_RELAY_LIST, settings.backupTrustedRelayList)
@@ -478,6 +482,7 @@ object LocalPreferences {
val latestNip65RelayList = parseEventOrNull<AdvertisedRelayListEvent>(PrefKeys.LATEST_NIP65_RELAY_LIST) val latestNip65RelayList = parseEventOrNull<AdvertisedRelayListEvent>(PrefKeys.LATEST_NIP65_RELAY_LIST)
val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST) val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST)
val latestIndexRelayList = parseEventOrNull<IndexerRelayListEvent>(PrefKeys.LATEST_INDEX_RELAY_LIST) val latestIndexRelayList = parseEventOrNull<IndexerRelayListEvent>(PrefKeys.LATEST_INDEX_RELAY_LIST)
val latestFavoriteRelayList = parseEventOrNull<FavoriteRelayListEvent>(PrefKeys.LATEST_FAVORITE_RELAY_LIST)
val latestBlockedRelayList = parseEventOrNull<BlockedRelayListEvent>(PrefKeys.LATEST_BLOCKED_RELAY_LIST) val latestBlockedRelayList = parseEventOrNull<BlockedRelayListEvent>(PrefKeys.LATEST_BLOCKED_RELAY_LIST)
val latestTrustedRelayList = parseEventOrNull<TrustedRelayListEvent>(PrefKeys.LATEST_TRUSTED_RELAY_LIST) val latestTrustedRelayList = parseEventOrNull<TrustedRelayListEvent>(PrefKeys.LATEST_TRUSTED_RELAY_LIST)
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST) val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
@@ -523,6 +528,7 @@ object LocalPreferences {
backupDMRelayList = latestDmRelayList, backupDMRelayList = latestDmRelayList,
backupSearchRelayList = latestSearchRelayList, backupSearchRelayList = latestSearchRelayList,
backupIndexRelayList = latestIndexRelayList, backupIndexRelayList = latestIndexRelayList,
backupFavoriteRelayList = latestFavoriteRelayList,
backupBlockedRelayList = latestBlockedRelayList, backupBlockedRelayList = latestBlockedRelayList,
backupTrustedRelayList = latestTrustedRelayList, backupTrustedRelayList = latestTrustedRelayList,
backupPrivateHomeRelayList = latestPrivateHomeRelayList, backupPrivateHomeRelayList = latestPrivateHomeRelayList,
@@ -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.blockedRelays.BlockedRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.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.GeohashListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
@@ -275,6 +277,9 @@ class Account(
val indexerRelayListDecryptionCache = IndexerRelayListDecryptionCache(signer) val indexerRelayListDecryptionCache = IndexerRelayListDecryptionCache(signer)
val indexerRelayList = IndexerRelayListState(signer, cache, indexerRelayListDecryptionCache, scope, settings) 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 blockedRelayListDecryptionCache = BlockedRelayListDecryptionCache(signer)
val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings) val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings)
@@ -1915,6 +1920,8 @@ class Account(
suspend fun saveTrustedRelayList(trustedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(trustedRelayList.saveRelayList(trustedRelays)) suspend fun saveTrustedRelayList(trustedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(trustedRelayList.saveRelayList(trustedRelays))
suspend fun saveFavoriteRelayList(trustedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(favoriteRelayList.saveRelayList(trustedRelays))
suspend fun saveBlockedRelayList(blockedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays)) suspend fun saveBlockedRelayList(blockedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays))
suspend fun sendNip65RelayList(relays: List<AdvertisedRelayInfo>) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays)) suspend fun sendNip65RelayList(relays: List<AdvertisedRelayInfo>) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays))
@@ -45,6 +45,7 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent 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.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType
@@ -135,6 +136,7 @@ class AccountSettings(
var backupNIP65RelayList: AdvertisedRelayListEvent? = null, var backupNIP65RelayList: AdvertisedRelayListEvent? = null,
var backupSearchRelayList: SearchRelayListEvent? = null, var backupSearchRelayList: SearchRelayListEvent? = null,
var backupIndexRelayList: IndexerRelayListEvent? = null, var backupIndexRelayList: IndexerRelayListEvent? = null,
var backupFavoriteRelayList: FavoriteRelayListEvent? = null,
var backupBlockedRelayList: BlockedRelayListEvent? = null, var backupBlockedRelayList: BlockedRelayListEvent? = null,
var backupTrustedRelayList: TrustedRelayListEvent? = null, var backupTrustedRelayList: TrustedRelayListEvent? = null,
var backupMuteList: MuteListEvent? = 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?) { fun updateBlockedRelayList(newBlockedRelayList: BlockedRelayListEvent?) {
if (newBlockedRelayList == null || newBlockedRelayList.tags.isEmpty()) return if (newBlockedRelayList == null || newBlockedRelayList.tags.isEmpty()) return
@@ -147,6 +147,7 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent 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.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
@@ -1201,6 +1202,12 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean, wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified) ) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume(
event: FavoriteRelayListEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
private fun consume( private fun consume(
event: TrustProviderListEvent, event: TrustProviderListEvent,
relay: NormalizedRelayUrl?, relay: NormalizedRelayUrl?,
@@ -3059,6 +3066,7 @@ object LocalCache : ILocalCache, ICacheProvider {
is GitPatchEvent -> consume(event, relay, wasVerified) is GitPatchEvent -> consume(event, relay, wasVerified)
is GitRepositoryEvent -> consume(event, relay, wasVerified) is GitRepositoryEvent -> consume(event, relay, wasVerified)
is ChessGameEvent -> consume(event, relay, wasVerified) is ChessGameEvent -> consume(event, relay, wasVerified)
is FavoriteRelayListEvent -> consume(event, relay, wasVerified)
is JesterEvent -> consume(event, relay, wasVerified) is JesterEvent -> consume(event, relay, wasVerified)
is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified) is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified)
is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified) is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified)
@@ -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<FavoriteRelayListEvent>(signer)
@@ -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<NoteState> = 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<NormalizedRelayUrl> = favoriteListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: emptySet()
suspend fun normalizeFavoriteRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = 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<NormalizedRelayUrl>): 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)
}
}
}
}
}
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent 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.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
@@ -69,6 +70,7 @@ val AccountInfoAndListsFromKeyKinds2 =
GeohashListEvent.KIND, GeohashListEvent.KIND,
TrustProviderListEvent.KIND, TrustProviderListEvent.KIND,
PaymentTargetsEvent.KIND, PaymentTargetsEvent.KIND,
FavoriteRelayListEvent.KIND,
) )
val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND) val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND)
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent 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.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
@@ -61,6 +62,7 @@ val BasicAccountInfoKinds2 =
HashtagListEvent.KIND, HashtagListEvent.KIND,
GeohashListEvent.KIND, GeohashListEvent.KIND,
TrustProviderListEvent.KIND, TrustProviderListEvent.KIND,
FavoriteRelayListEvent.KIND,
) )
fun filterBasicAccountInfoFromKeys( fun filterBasicAccountInfoFromKeys(
@@ -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.DisplayBlockedRelayList
import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList
import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList 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.DisplayFollowList
import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList
import com.vitorpamplona.amethyst.ui.note.types.DisplayNIP65RelayList 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.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent 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.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
@@ -834,6 +836,10 @@ private fun RenderNoteRow(
DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav) DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav)
} }
is FavoriteRelayListEvent -> {
DisplayFavoriteRelayList(baseNote, backgroundColor, accountViewModel, nav)
}
is IndexerRelayListEvent -> { is IndexerRelayListEvent -> {
DisplayIndexerRelayList(baseNote, backgroundColor, accountViewModel, nav) DisplayIndexerRelayList(baseNote, backgroundColor, accountViewModel, nav)
} }
@@ -234,6 +234,27 @@ fun DisplayTrustedRelayList(
) )
} }
@Composable
fun DisplayFavoriteRelayList(
baseNote: Note,
backgroundColor: MutableState<Color>,
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 @Composable
fun DisplayProxyRelayList( fun DisplayProxyRelayList(
baseNote: Note, baseNote: Note,
@@ -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.connected.renderConnectedItems
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.DMRelayListViewModel 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.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.IndexerRelayListViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer.renderIndexerItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer.renderIndexerItems
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local.LocalRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local.LocalRelayListViewModel
@@ -93,6 +95,7 @@ fun AllRelayListScreen(
val broadcastViewModel: BroadcastRelayListViewModel = viewModel() val broadcastViewModel: BroadcastRelayListViewModel = viewModel()
val indexerViewModel: IndexerRelayListViewModel = viewModel() val indexerViewModel: IndexerRelayListViewModel = viewModel()
val proxyViewModel: ProxyRelayListViewModel = viewModel() val proxyViewModel: ProxyRelayListViewModel = viewModel()
val favoriteViewModel: FavoriteRelayListViewModel = viewModel()
dmViewModel.init(accountViewModel) dmViewModel.init(accountViewModel)
nip65ViewModel.init(accountViewModel) nip65ViewModel.init(accountViewModel)
@@ -105,6 +108,7 @@ fun AllRelayListScreen(
broadcastViewModel.init(accountViewModel) broadcastViewModel.init(accountViewModel)
indexerViewModel.init(accountViewModel) indexerViewModel.init(accountViewModel)
proxyViewModel.init(accountViewModel) proxyViewModel.init(accountViewModel)
favoriteViewModel.init(accountViewModel)
LaunchedEffect(accountViewModel) { LaunchedEffect(accountViewModel) {
dmViewModel.load() dmViewModel.load()
@@ -118,6 +122,7 @@ fun AllRelayListScreen(
broadcastViewModel.load() broadcastViewModel.load()
indexerViewModel.load() indexerViewModel.load()
proxyViewModel.load() proxyViewModel.load()
favoriteViewModel.load()
} }
MappedAllRelayListView( MappedAllRelayListView(
@@ -132,6 +137,7 @@ fun AllRelayListScreen(
broadcastViewModel, broadcastViewModel,
indexerViewModel, indexerViewModel,
proxyViewModel, proxyViewModel,
favoriteViewModel,
accountViewModel, accountViewModel,
nav, nav,
) )
@@ -151,6 +157,7 @@ fun MappedAllRelayListView(
broadcastViewModel: BroadcastRelayListViewModel, broadcastViewModel: BroadcastRelayListViewModel,
indexerViewModel: IndexerRelayListViewModel, indexerViewModel: IndexerRelayListViewModel,
proxyViewModel: ProxyRelayListViewModel, proxyViewModel: ProxyRelayListViewModel,
favoriteViewModel: FavoriteRelayListViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
newNav: INav, newNav: INav,
) { ) {
@@ -166,6 +173,7 @@ fun MappedAllRelayListView(
val broadcastRelays by broadcastViewModel.relays.collectAsStateWithLifecycle() val broadcastRelays by broadcastViewModel.relays.collectAsStateWithLifecycle()
val indexerRelays by indexerViewModel.relays.collectAsStateWithLifecycle() val indexerRelays by indexerViewModel.relays.collectAsStateWithLifecycle()
val proxyRelays by proxyViewModel.relays.collectAsStateWithLifecycle() val proxyRelays by proxyViewModel.relays.collectAsStateWithLifecycle()
val favoriteFeedState by favoriteViewModel.relays.collectAsStateWithLifecycle()
Scaffold( Scaffold(
topBar = { topBar = {
@@ -182,6 +190,7 @@ fun MappedAllRelayListView(
broadcastViewModel.clear() broadcastViewModel.clear()
indexerViewModel.clear() indexerViewModel.clear()
proxyViewModel.clear() proxyViewModel.clear()
favoriteViewModel.clear()
newNav.popBack() newNav.popBack()
}, },
onPost = { onPost = {
@@ -195,6 +204,7 @@ fun MappedAllRelayListView(
broadcastViewModel.create() broadcastViewModel.create()
indexerViewModel.create() indexerViewModel.create()
proxyViewModel.create() proxyViewModel.create()
favoriteViewModel.create()
newNav.popBack() newNav.popBack()
}, },
) )
@@ -310,6 +320,15 @@ fun MappedAllRelayListView(
} }
renderTrustedItems(trustedFeedState, trustedViewModel, accountViewModel, newNav) renderTrustedItems(trustedFeedState, trustedViewModel, accountViewModel, newNav)
item {
SettingsCategory(
R.string.favorite_section,
R.string.favorite_section_explainer,
SettingsCategorySpacingModifier,
)
}
renderFavoriteItems(favoriteFeedState, favoriteViewModel, accountViewModel, newNav)
item { item {
SettingsCategory( SettingsCategory(
R.string.blocked_section, R.string.blocked_section,
@@ -196,6 +196,7 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent 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.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
@@ -498,6 +499,7 @@ private fun kindDisplayName(
CalendarRSVPEvent.KIND -> "Appt RSVP" CalendarRSVPEvent.KIND -> "Appt RSVP"
ChessGameEvent.KIND -> "Chess Games" ChessGameEvent.KIND -> "Chess Games"
JesterEvent.KIND -> "Chess Auth" JesterEvent.KIND -> "Chess Auth"
FavoriteRelayListEvent.KIND -> "Favorite Relays"
LiveChessGameChallengeEvent.KIND -> "Chess Challenges" LiveChessGameChallengeEvent.KIND -> "Chess Challenges"
LiveChessGameAcceptEvent.KIND -> "Chess Game Accept" LiveChessGameAcceptEvent.KIND -> "Chess Game Accept"
LiveChessMoveEvent.KIND -> "Chess Move" LiveChessMoveEvent.KIND -> "Chess Move"
@@ -20,9 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked 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.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Stable
class BlockedRelayListViewModel : BasicRelaySetupInfoModel() { class BlockedRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<NormalizedRelayUrl>? = override fun getRelayList(): List<NormalizedRelayUrl>? =
account.blockedRelayList.flow.value account.blockedRelayList.flow.value
@@ -20,9 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast 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.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Stable
class BroadcastRelayListViewModel : BasicRelaySetupInfoModel() { class BroadcastRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<NormalizedRelayUrl>? = override fun getRelayList(): List<NormalizedRelayUrl>? =
account.broadcastRelayList.flow.value account.broadcastRelayList.flow.value
@@ -20,12 +20,14 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.LocalCache 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.BasicRelaySetupInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Stable
class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() { class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() {
override fun relayListBuilder(): List<BasicRelaySetupInfo> { override fun relayListBuilder(): List<BasicRelaySetupInfo> {
val relayList = getRelayList() val relayList = getRelayList()
@@ -20,9 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm 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.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Stable
class DMRelayListViewModel : BasicRelaySetupInfoModel() { class DMRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<NormalizedRelayUrl>? = account.dmRelayList.getDMRelayList()?.relays() override fun getRelayList(): List<NormalizedRelayUrl>? = account.dmRelayList.getDMRelayList()?.relays()
@@ -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<BasicRelaySetupInfo>,
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)) }
}
}
@@ -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<NormalizedRelayUrl>? =
account.favoriteRelayList.flow.value
.toList()
override suspend fun saveRelayList(urlList: List<NormalizedRelayUrl>) {
account.saveFavoriteRelayList(urlList)
}
}
@@ -20,9 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer 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.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Stable
class IndexerRelayListViewModel : BasicRelaySetupInfoModel() { class IndexerRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<NormalizedRelayUrl>? = override fun getRelayList(): List<NormalizedRelayUrl>? =
account.indexerRelayList.flowNoDefaults.value account.indexerRelayList.flowNoDefaults.value
@@ -20,9 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local 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.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Stable
class LocalRelayListViewModel : BasicRelaySetupInfoModel() { class LocalRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<NormalizedRelayUrl> = override fun getRelayList(): List<NormalizedRelayUrl> =
account.localRelayList.flow.value account.localRelayList.flow.value
@@ -20,9 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37 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.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Stable
class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() { class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<NormalizedRelayUrl>? = override fun getRelayList(): List<NormalizedRelayUrl>? =
account.privateStorageRelayList.flow.value account.privateStorageRelayList.flow.value
@@ -20,9 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search 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.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@Stable
class SearchRelayListViewModel : BasicRelaySetupInfoModel() { class SearchRelayListViewModel : BasicRelaySetupInfoModel() {
override fun getRelayList(): List<NormalizedRelayUrl>? = override fun getRelayList(): List<NormalizedRelayUrl>? =
account.searchRelayList.flowNoDefaults.value account.searchRelayList.flowNoDefaults.value
@@ -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.DisplayBlockedRelayList
import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList
import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList 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.DisplayFollowList
import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList
import com.vitorpamplona.amethyst.ui.note.types.DisplayNIP65RelayList 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.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent 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.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
@@ -619,6 +621,8 @@ private fun FullBleedNoteCompose(
DisplayProxyRelayList(baseNote, backgroundColor, accountViewModel, nav) DisplayProxyRelayList(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is TrustedRelayListEvent) { } else if (noteEvent is TrustedRelayListEvent) {
DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav) DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is FavoriteRelayListEvent) {
DisplayFavoriteRelayList(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is IndexerRelayListEvent) { } else if (noteEvent is IndexerRelayListEvent) {
DisplayIndexerRelayList(baseNote, backgroundColor, accountViewModel, nav) DisplayIndexerRelayList(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is BroadcastRelayListEvent) { } else if (noteEvent is BroadcastRelayListEvent) {
+4
View File
@@ -1317,6 +1317,10 @@
<string name="trusted_section">Trusted Relays</string> <string name="trusted_section">Trusted Relays</string>
<string name="trusted_section_explainer">Relays you trust to not need a Tor connection for</string> <string name="trusted_section_explainer">Relays you trust to not need a Tor connection for</string>
<string name="favorite_relays_title">Favorite Relay Feeds</string>
<string name="favorite_section">Favorite Relay Feeds</string>
<string name="favorite_section_explainer">Relays you frequently go to see their Global feed</string>
<string name="proxy_relays_title">Proxy Relays</string> <string name="proxy_relays_title">Proxy Relays</string>
<string name="proxy_section">Proxy Relays</string> <string name="proxy_section">Proxy Relays</string>
<string name="proxy_section_explainer">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.</string> <string name="proxy_section_explainer">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.</string>
@@ -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<Array<String>>,
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<NormalizedRelayUrl> = 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<NormalizedRelayUrl>,
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<NormalizedRelayUrl>,
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<NormalizedRelayUrl>,
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<NormalizedRelayUrl> = emptyList(),
privateRelays: List<NormalizedRelayUrl> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<FavoriteRelayListEvent>.() -> Unit = {},
) = eventTemplate<FavoriteRelayListEvent>(
kind = KIND,
description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer),
createdAt = createdAt,
) {
alt(ALT)
favoriteRelays(publicRelays)
initializer()
}
}
}
@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent 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.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
@@ -50,3 +51,5 @@ fun TagArrayBuilder<ProxyRelayListEvent>.proxyRelays(relays: List<NormalizedRela
fun TagArrayBuilder<PrivateOutboxRelayListEvent>.privateRelays(relays: List<NormalizedRelayUrl>) = addAll(relays.map { RelayTag.assemble(it) }) fun TagArrayBuilder<PrivateOutboxRelayListEvent>.privateRelays(relays: List<NormalizedRelayUrl>) = addAll(relays.map { RelayTag.assemble(it) })
fun TagArrayBuilder<RelaySetEvent>.relaySet(relays: List<NormalizedRelayUrl>) = addAll(relays.map { RelayTag.assemble(it) }) fun TagArrayBuilder<RelaySetEvent>.relaySet(relays: List<NormalizedRelayUrl>) = addAll(relays.map { RelayTag.assemble(it) })
fun TagArrayBuilder<FavoriteRelayListEvent>.favoriteRelays(relays: List<NormalizedRelayUrl>) = addAll(relays.map { RelayTag.assemble(it) })
@@ -88,6 +88,7 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent 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.IndexerRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent
@@ -193,6 +194,7 @@ class EventFactory {
CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig)
CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig)
ChessGameEvent.KIND -> ChessGameEvent(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) JesterEvent.KIND -> JesterEvent(id, pubKey, createdAt, tags, content, sig)
LiveChessGameChallengeEvent.KIND -> LiveChessGameChallengeEvent(id, pubKey, createdAt, tags, content, sig) LiveChessGameChallengeEvent.KIND -> LiveChessGameChallengeEvent(id, pubKey, createdAt, tags, content, sig)
LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig) LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig)