From b800b5a664014fba6a0c58e5e1d7939156507c4f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 7 Jul 2025 19:49:23 -0400 Subject: [PATCH] Early support for Blocked and Trusted Relay Lists --- .../amethyst/LocalPreferences.kt | 26 ++++ .../vitorpamplona/amethyst/model/Account.kt | 24 +++- .../amethyst/model/AccountSettings.kt | 24 ++++ .../amethyst/model/LocalCache.kt | 16 +++ .../FollowListOutboxRelays.kt | 37 +++--- .../nip02FollowLists/FollowsPerOutboxRelay.kt | 40 ++++--- .../model/nip51Lists/BlockedRelayListState.kt | 113 ++++++++++++++++++ .../model/nip51Lists/TrustedRelayListState.kt | 113 ++++++++++++++++++ .../serverList/TrustedRelayListsState.kt | 49 ++++---- .../topNavFeeds/FeedTopNavFilterState.kt | 5 +- .../AllFollowsByOutboxTopNavFilter.kt | 7 +- .../allFollows/AllFollowsFeedFlow.kt | 3 + .../topNavFeeds/noteBased/NoteFeedFlow.kt | 41 ++++--- .../AllCommunitiesTopNavFilter.kt | 22 ++-- .../author/AuthorsByOutboxTopNavFilter.kt | 17 ++- .../community/SingleCommunityTopNavFilter.kt | 45 +++---- .../muted/MutedAuthorsByOutboxTopNavFilter.kt | 17 ++- .../FilterAccountInfoAndListsFromKey.kt | 18 +++ .../FilterBasicAccountInfoFromKeys.kt | 4 + .../amethyst/ui/navigation/AppNavigation.kt | 2 +- .../amethyst/ui/navigation/DrawerContent.kt | 2 +- .../amethyst/ui/navigation/Routes.kt | 4 +- .../amethyst/ui/note/NoteCompose.kt | 6 + .../amethyst/ui/note/types/RelayList.kt | 62 +++++++++- .../FollowingEphemeralChatSubAssembler.kt | 2 +- .../FollowingPublicChatSubAssembler.kt | 2 +- .../DiscoveryFollowsDiscoverySubAssembler1.kt | 2 +- .../DiscoveryFollowsDiscoverySubAssembler2.kt | 2 +- .../DiscoveryFollowsDiscoverySubAssembler3.kt | 2 +- .../loggedIn/profile/relays/RelayFeedView.kt | 8 +- .../loggedIn/relays/AllRelayListScreen.kt | 38 +++++- .../relays/blocked/BlockedRelayListView.kt | 81 +++++++++++++ .../blocked/BlockedRelayListViewModel.kt | 32 +++++ .../relays/trusted/TrustedRelayListView.kt | 81 +++++++++++++ .../trusted/TrustedRelayListViewModel.kt | 32 +++++ .../loggedIn/threadview/ThreadFeedView.kt | 8 ++ .../VideoOutboxEventsFilterSubAssembler.kt | 2 +- amethyst/src/main/res/values/strings.xml | 9 ++ .../com/vitorpamplona/quartz/EventFactory.kt | 4 + .../nip01Core/relay/client/NostrClient.kt | 8 +- .../nip51Lists/BlockedRelayListEvent.kt | 105 ++++++++++++++++ .../nip51Lists/TrustedRelayListEvent.kt | 105 ++++++++++++++++ 42 files changed, 1088 insertions(+), 132 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/TrustedRelayListState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/BlockedRelayListEvent.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TrustedRelayListEvent.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index b9c909df5..bd52ded9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -51,7 +51,9 @@ import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -96,6 +98,8 @@ private object PrefKeys { const val LATEST_DM_RELAY_LIST = "latestDMRelayList" const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList" const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList" + const val LATEST_BLOCKED_RELAY_LIST = "latestSearchRelayList" + const val LATEST_TRUSTED_RELAY_LIST = "latestSearchRelayList" const val LATEST_MUTE_LIST = "latestMuteList" const val LATEST_PRIVATE_HOME_RELAY_LIST = "latestPrivateHomeRelayList" const val LATEST_APP_SPECIFIC_DATA = "latestAppSpecificData" @@ -367,6 +371,24 @@ object LocalPreferences { remove(PrefKeys.LATEST_SEARCH_RELAY_LIST) } + if (settings.backupBlockedRelayList != null) { + putString( + PrefKeys.LATEST_BLOCKED_RELAY_LIST, + EventMapper.mapper.writeValueAsString(settings.backupBlockedRelayList), + ) + } else { + remove(PrefKeys.LATEST_BLOCKED_RELAY_LIST) + } + + if (settings.backupTrustedRelayList != null) { + putString( + PrefKeys.LATEST_TRUSTED_RELAY_LIST, + EventMapper.mapper.writeValueAsString(settings.backupTrustedRelayList), + ) + } else { + remove(PrefKeys.LATEST_TRUSTED_RELAY_LIST) + } + if (settings.localRelayServers.value.isNotEmpty()) { putStringSet(PrefKeys.LOCAL_RELAY_SERVERS, settings.localRelayServers.value) } else { @@ -562,6 +584,8 @@ object LocalPreferences { val latestDmRelayList = parseEventOrNull(PrefKeys.LATEST_DM_RELAY_LIST) val latestNip65RelayList = parseEventOrNull(PrefKeys.LATEST_NIP65_RELAY_LIST) val latestSearchRelayList = parseEventOrNull(PrefKeys.LATEST_SEARCH_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) val latestPrivateHomeRelayList = parseEventOrNull(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST) val latestAppSpecificData = parseEventOrNull(PrefKeys.LATEST_APP_SPECIFIC_DATA) @@ -604,6 +628,8 @@ object LocalPreferences { backupNIP65RelayList = latestNip65RelayList, backupDMRelayList = latestDmRelayList, backupSearchRelayList = latestSearchRelayList, + backupBlockedRelayList = latestBlockedRelayList, + backupTrustedRelayList = latestTrustedRelayList, backupPrivateHomeRelayList = latestPrivateHomeRelayList, backupMuteList = latestMuteList, backupAppSpecificData = latestAppSpecificData, 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 f52cf5521..84b08bb37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -43,10 +43,12 @@ import com.vitorpamplona.amethyst.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.model.nip38UserStatuses.UserStatusAction import com.vitorpamplona.amethyst.model.nip50Search.SearchRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.BlockPeopleListState +import com.vitorpamplona.amethyst.model.nip51Lists.BlockedRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.GeohashListState import com.vitorpamplona.amethyst.model.nip51Lists.HashtagListState import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.nip51Lists.MuteListState +import com.vitorpamplona.amethyst.model.nip51Lists.TrustedRelayListState import com.vitorpamplona.amethyst.model.nip56Reports.ReportAction import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState @@ -239,6 +241,8 @@ class Account( val searchRelayList = SearchRelayListState(signer, cache, scope, settings) val privateStorageRelayList = PrivateStorageRelayListState(signer, cache, scope, settings) val localRelayList = LocalRelayListState(signer, cache, scope, settings) + val trustedRelayList = TrustedRelayListState(signer, cache, scope, settings) + val blockedRelayList = BlockedRelayListState(signer, cache, scope, settings) val kind3FollowList = FollowListState(signer, cache, scope, settings) val ephemeralChatList = EphemeralChatListState(signer, cache, scope, settings) @@ -264,14 +268,14 @@ class Account( val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope) val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope) - val trustedRelays = TrustedRelayListsState(nip65RelayList, privateStorageRelayList, localRelayList, dmRelayList, searchRelayList, scope) + val trustedRelays = TrustedRelayListsState(nip65RelayList, privateStorageRelayList, localRelayList, dmRelayList, searchRelayList, trustedRelayList, scope) // Follows Relays - val followOutboxes = FollowListOutboxRelays(kind3FollowList, cache, scope) + val followOutboxes = FollowListOutboxRelays(kind3FollowList, blockedRelayList, cache, scope) val followPlusAllMine = MergedFollowPlusMineRelayListsState(followOutboxes, nip65RelayList, privateStorageRelayList, localRelayList, scope) // keeps a cache of the outbox relays for each author - val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, cache, scope).flow + val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, blockedRelayList, cache, scope).flow // Merges all follow lists to create a single All Follows feed. val allFollows = MergedFollowListsState(kind3FollowList, hashtagList, geohashList, communityList, scope) @@ -283,6 +287,7 @@ class Account( allFollows = allFollows.flow, locationFlow = geolocationFlow, followsRelays = followPlusAllMine.flow, + blockedRelays = blockedRelayList.flow, signer = signer, scope = scope, ).flow @@ -295,6 +300,7 @@ class Account( allFollows = allFollows.flow, locationFlow = geolocationFlow, followsRelays = followPlusAllMine.flow, + blockedRelays = blockedRelayList.flow, signer = signer, scope = scope, ).flow @@ -307,6 +313,7 @@ class Account( allFollows = allFollows.flow, locationFlow = geolocationFlow, followsRelays = followPlusAllMine.flow, + blockedRelays = blockedRelayList.flow, signer = signer, scope = scope, ).flow @@ -319,6 +326,7 @@ class Account( allFollows = allFollows.flow, locationFlow = geolocationFlow, followsRelays = followPlusAllMine.flow, + blockedRelays = blockedRelayList.flow, signer = signer, scope = scope, ).flow @@ -2100,6 +2108,16 @@ class Account( searchRelayList.saveRelayList(searchRelays, ::sendMyPublicAndPrivateOutbox) } + fun saveTrustedRelayList(trustedRelays: List) { + if (!isWriteable()) return + trustedRelayList.saveRelayList(trustedRelays, ::sendMyPublicAndPrivateOutbox) + } + + fun saveBlockedRelayList(blockedRelays: List) { + if (!isWriteable()) return + blockedRelayList.saveRelayList(blockedRelays, ::sendMyPublicAndPrivateOutbox) + } + fun sendNip65RelayList(relays: List) { if (!isWriteable()) return nip65RelayList.saveRelayList(relays, ::sendMyPublicAndPrivateOutbox) 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 4f19f92c0..bfba3d93f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -40,7 +40,9 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent import com.vitorpamplona.quartz.nip55AndroidSigner.ExternalSignerLauncher @@ -116,6 +118,8 @@ class AccountSettings( var backupDMRelayList: ChatMessageRelayListEvent? = null, var backupNIP65RelayList: AdvertisedRelayListEvent? = null, var backupSearchRelayList: SearchRelayListEvent? = null, + var backupBlockedRelayList: BlockedRelayListEvent? = null, + var backupTrustedRelayList: TrustedRelayListEvent? = null, var backupMuteList: MuteListEvent? = null, var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null, var backupAppSpecificData: AppSpecificDataEvent? = null, @@ -351,6 +355,26 @@ class AccountSettings( } } + fun updateBlockedRelayList(newBlockedRelayList: BlockedRelayListEvent?) { + if (newBlockedRelayList == null || newBlockedRelayList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupBlockedRelayList?.id != newBlockedRelayList.id) { + backupBlockedRelayList = newBlockedRelayList + saveAccountSettings() + } + } + + fun updateTrustedRelayList(newTrustedRelayList: TrustedRelayListEvent?) { + if (newTrustedRelayList == null || newTrustedRelayList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupTrustedRelayList?.id != newTrustedRelayList.id) { + backupTrustedRelayList = newTrustedRelayList + saveAccountSettings() + } + } + fun updatePrivateHomeRelayList(newPrivateHomeRelayList: PrivateOutboxRelayListEvent?) { if (newPrivateHomeRelayList == null || newPrivateHomeRelayList.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 c90eac138..fccb6c74b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -125,12 +125,14 @@ import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent @@ -972,6 +974,18 @@ object LocalCache : ILocalCache { wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) + private fun consume( + event: BlockedRelayListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + private fun consume( + event: TrustedRelayListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + private fun consume( event: CommunityDefinitionEvent, relay: NormalizedRelayUrl?, @@ -3045,6 +3059,7 @@ object LocalCache : ILocalCache { is BadgeAwardEvent -> consume(event, relay, wasVerified) is BadgeDefinitionEvent -> consume(event, relay, wasVerified) is BadgeProfilesEvent -> consume(event, relay, wasVerified) + is BlockedRelayListEvent -> consume(event, relay, wasVerified) is BlossomServersEvent -> consume(event, relay, wasVerified) is BookmarkListEvent -> consume(event, relay, wasVerified) is CalendarEvent -> consume(event, relay, wasVerified) @@ -3126,6 +3141,7 @@ object LocalCache : ILocalCache { is TextNoteModificationEvent -> consume(event, relay, wasVerified) is TorrentEvent -> consume(event, relay, wasVerified) is TorrentCommentEvent -> consume(event, relay, wasVerified) + is TrustedRelayListEvent -> consume(event, relay, wasVerified) is VideoHorizontalEvent -> consume(event, relay, wasVerified) is VideoVerticalEvent -> consume(event, relay, wasVerified) is WikiNoteEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxRelays.kt index 32bab4160..7bfeaa71b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListOutboxRelays.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip02FollowLists import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.amethyst.model.nip51Lists.BlockedRelayListState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -33,17 +34,20 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.transformLatest +import kotlin.collections.flatten import kotlin.collections.map +import kotlin.collections.minus import kotlin.collections.toSet class FollowListOutboxRelays( kind3Follows: FollowListState, + blockedRelayList: BlockedRelayListState, val cache: LocalCache, scope: CoroutineScope, ) { @@ -57,29 +61,32 @@ class FollowListOutboxRelays( fun allRelayListFlows(followList: Set): List> = followList.map { getNIP65RelayListFlow(it) } - fun combineAllFlows(flows: List>): Flow> = + fun combineAllFlows( + flows: List>, + blockedRelays: Set, + ): Flow> = combine(flows) { relayListNotes: Array -> relayListNotes.mapNotNull { (it.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm() } }.map { - it.flatten().toSet() + it.flatten().toSet().minus(blockedRelays) } @OptIn(ExperimentalCoroutinesApi::class) val flow: StateFlow> = - kind3Follows.flow - .transformLatest { followList -> - val flows: List> = allRelayListFlows(followList.authors) - val relayListFlows = combineAllFlows(flows) - emitAll(relayListFlows) - }.onStart { - kind3Follows.flow.value.authors - .mapNotNull { - getNIP65RelayList(it)?.writeRelaysNorm() - }.flatten() - .toSet() - }.flowOn(Dispatchers.Default) + combineTransform(kind3Follows.flow, blockedRelayList.flow) { followList, blockedRelays -> + val flows: List> = allRelayListFlows(followList.authors) + val relayListFlows = combineAllFlows(flows, blockedRelays) + emitAll(relayListFlows) + }.onStart { + val blocked = blockedRelayList.flow.value.toSet() + kind3Follows.flow.value.authors + .mapNotNull { + getNIP65RelayList(it)?.writeRelaysNorm()?.minus(blocked) + }.flatten() + .toSet() + }.flowOn(Dispatchers.Default) .stateIn( scope, SharingStarted.Eagerly, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt index dfd57f996..204ae9a6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowsPerOutboxRelay.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip02FollowLists import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.amethyst.model.nip51Lists.BlockedRelayListState import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -34,15 +35,16 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.transformLatest import kotlin.collections.map class FollowsPerOutboxRelay( kind3Follows: FollowListState, + blockedRelayList: BlockedRelayListState, val cache: LocalCache, scope: CoroutineScope, ) { @@ -56,13 +58,18 @@ class FollowsPerOutboxRelay( fun allRelayListFlows(followList: Set): List> = followList.map { getNIP65RelayListFlow(it) } - fun combineAllFlows(flows: List>): Flow>> = + fun combineAllFlows( + flows: List>, + blockedList: Set, + ): Flow>> = combine(flows) { relayListNotes: Array -> mapOfSet { relayListNotes.forEach { noteState -> noteState.note.author?.pubkeyHex?.let { authorHex -> (noteState.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()?.forEach { relay -> - add(relay, authorHex) + if (relay !in blockedList) { + add(relay, authorHex) + } } } } @@ -71,22 +78,23 @@ class FollowsPerOutboxRelay( @OptIn(ExperimentalCoroutinesApi::class) val flow: StateFlow>> = - kind3Follows.flow - .transformLatest { followList -> - val flows: List> = allRelayListFlows(followList.authors) - val relayListFlows = combineAllFlows(flows) - emitAll(relayListFlows) - }.onStart { - emit( - mapOfSet { - kind3Follows.flow.value.authors.map { authorHex -> - getNIP65RelayList(authorHex)?.writeRelaysNorm()?.forEach { relay -> + combineTransform(kind3Follows.flow, blockedRelayList.flow) { followList, blockedList -> + val flows: List> = allRelayListFlows(followList.authors) + val relayListFlows = combineAllFlows(flows, blockedList) + emitAll(relayListFlows) + }.onStart { + emit( + mapOfSet { + kind3Follows.flow.value.authors.map { authorHex -> + getNIP65RelayList(authorHex)?.writeRelaysNorm()?.forEach { relay -> + if (relay !in blockedRelayList.flow.value) { add(relay, authorHex) } } - }, - ) - }.flowOn(Dispatchers.Default) + } + }, + ) + }.flowOn(Dispatchers.Default) .stateIn( scope, SharingStarted.Eagerly, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListState.kt new file mode 100644 index 000000000..14dcbbefc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/BlockedRelayListState.kt @@ -0,0 +1,113 @@ +/** + * 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 + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.BlockedRelayListEvent +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 BlockedRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getBlockedRelayListAddress() = BlockedRelayListEvent.createAddress(signer.pubKey) + + fun getBlockedRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getBlockedRelayListAddress()) + + fun getBlockedRelayListFlow(): StateFlow = getBlockedRelayListNote().flow().metadata.stateFlow + + fun getBlockedRelayList(): BlockedRelayListEvent? = getBlockedRelayListNote().event as? BlockedRelayListEvent + + fun normalizeBlockedRelayListWithBackup(note: Note): Set { + val event = note.event as? BlockedRelayListEvent ?: settings.backupBlockedRelayList + return event?.relays()?.toSet() ?: emptySet() + } + + val flow = + getBlockedRelayListFlow() + .map { normalizeBlockedRelayListWithBackup(it.note) } + .onStart { emit(normalizeBlockedRelayListWithBackup(getBlockedRelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + fun saveRelayList( + blockedRelays: List, + onDone: (BlockedRelayListEvent) -> Unit, + ) { + val relayListForBlocked = getBlockedRelayList() + + if (relayListForBlocked != null && relayListForBlocked.tags.isNotEmpty()) { + BlockedRelayListEvent.updateRelayList( + earlierVersion = relayListForBlocked, + relays = blockedRelays, + signer = signer, + onReady = onDone, + ) + } else { + BlockedRelayListEvent.createFromScratch( + relays = blockedRelays, + signer = signer, + onReady = onDone, + ) + } + } + + init { + settings.backupBlockedRelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved Blocked relay list ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Blocked Relay List Collector Start") + getBlockedRelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Blocked Relay List for ${signer.pubKey}") + (it.note.event as? BlockedRelayListEvent)?.let { + settings.updateBlockedRelayList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/TrustedRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/TrustedRelayListState.kt new file mode 100644 index 000000000..dc611b54f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/TrustedRelayListState.kt @@ -0,0 +1,113 @@ +/** + * 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 + +import android.util.Log +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent +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 TrustedRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + fun getTrustedRelayListAddress() = TrustedRelayListEvent.createAddress(signer.pubKey) + + fun getTrustedRelayListNote(): AddressableNote = LocalCache.getOrCreateAddressableNote(getTrustedRelayListAddress()) + + fun getTrustedRelayListFlow(): StateFlow = getTrustedRelayListNote().flow().metadata.stateFlow + + fun getTrustedRelayList(): TrustedRelayListEvent? = getTrustedRelayListNote().event as? TrustedRelayListEvent + + fun normalizeTrustedRelayListWithBackup(note: Note): Set { + val event = note.event as? TrustedRelayListEvent ?: settings.backupTrustedRelayList + return event?.relays()?.toSet() ?: emptySet() + } + + val flow = + getTrustedRelayListFlow() + .map { normalizeTrustedRelayListWithBackup(it.note) } + .onStart { emit(normalizeTrustedRelayListWithBackup(getTrustedRelayListNote())) } + .flowOn(Dispatchers.Default) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + fun saveRelayList( + trustedRelays: List, + onDone: (TrustedRelayListEvent) -> Unit, + ) { + val relayListForTrusted = getTrustedRelayList() + + if (relayListForTrusted != null && relayListForTrusted.tags.isNotEmpty()) { + TrustedRelayListEvent.updateRelayList( + earlierVersion = relayListForTrusted, + relays = trustedRelays, + signer = signer, + onReady = onDone, + ) + } else { + TrustedRelayListEvent.createFromScratch( + relays = trustedRelays, + signer = signer, + onReady = onDone, + ) + } + } + + init { + settings.backupTrustedRelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved Trusted relay list ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.Default) { + Log.d("AccountRegisterObservers", "Trusted Relay List Collector Start") + getTrustedRelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Trusted Relay List for ${signer.pubKey}") + (it.note.event as? TrustedRelayListEvent)?.let { + settings.updateTrustedRelayList(it) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/TrustedRelayListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/TrustedRelayListsState.kt index ba3a5984c..e0bf5faa0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/TrustedRelayListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/TrustedRelayListsState.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState import com.vitorpamplona.amethyst.model.nip50Search.SearchRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.TrustedRelayListState import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.CoroutineScope @@ -41,32 +42,33 @@ class TrustedRelayListsState( val localRelayList: LocalRelayListState, val dmRelayList: DmRelayListState, val searchRelayListState: SearchRelayListState, + val trustedRelayList: TrustedRelayListState, val scope: CoroutineScope, ) { - fun mergeLists( - nip65: Set, - private: Set, - local: Set, - dm: Set, - search: Set, - ): Set = nip65 + private + local + dm + search + fun mergeLists(lists: Array>): Set = lists.reduce { acc, set -> acc + set } val flow: StateFlow> = combine( - nip65RelayList.allFlow, - privateOutboxRelayList.flow, - localRelayList.flow, - dmRelayList.flow, - searchRelayListState.flow, + listOf( + nip65RelayList.allFlow, + privateOutboxRelayList.flow, + localRelayList.flow, + dmRelayList.flow, + searchRelayListState.flow, + trustedRelayList.flow, + ), ::mergeLists, ).onStart { emit( mergeLists( - nip65RelayList.allFlow.value, - privateOutboxRelayList.flow.value, - localRelayList.flow.value, - dmRelayList.flow.value, - searchRelayListState.flow.value, + arrayOf( + nip65RelayList.allFlow.value, + privateOutboxRelayList.flow.value, + localRelayList.flow.value, + dmRelayList.flow.value, + searchRelayListState.flow.value, + trustedRelayList.flow.value, + ), ), ) }.flowOn(Dispatchers.Default) @@ -74,11 +76,14 @@ class TrustedRelayListsState( scope, SharingStarted.Eagerly, mergeLists( - nip65RelayList.allFlow.value, - privateOutboxRelayList.flow.value, - localRelayList.flow.value, - dmRelayList.flow.value, - searchRelayListState.flow.value, + arrayOf( + nip65RelayList.allFlow.value, + privateOutboxRelayList.flow.value, + localRelayList.flow.value, + dmRelayList.flow.value, + searchRelayListState.flow.value, + trustedRelayList.flow.value, + ), ), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index ed0b9bccd..dbaf694a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -50,18 +50,19 @@ class FeedTopNavFilterState( val allFollows: StateFlow, val locationFlow: StateFlow, val followsRelays: StateFlow>, + val blockedRelays: StateFlow>, val signer: NostrSigner, val scope: CoroutineScope, ) { fun loadFlowsFor(listName: String): IFeedFlowsType = when (listName) { GLOBAL_FOLLOWS -> GlobalFeedFlow(followsRelays) - ALL_FOLLOWS -> AllFollowsFeedFlow(allFollows, followsRelays) + ALL_FOLLOWS -> AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays) AROUND_ME -> AroundMeFeedFlow(locationFlow, followsRelays) else -> { val note = LocalCache.checkGetOrCreateAddressableNote(listName) if (note != null) { - NoteFeedFlow(note.flow().metadata.stateFlow, signer, followsRelays) + NoteFeedFlow(note.flow().metadata.stateFlow, signer, followsRelays, blockedRelays) } else { UnknownFeedFlow(listName) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt index b840ce75d..79778a61d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsByOutboxTopNavFilter.kt @@ -50,6 +50,7 @@ class AllFollowsByOutboxTopNavFilter( val geotags: Set? = null, val communities: Set? = null, val defaultRelays: StateFlow>, + val blockedRelays: StateFlow>, ) : IFeedTopNavFilter { val geotagScopes: Set? = geotags?.mapTo(mutableSetOf()) { GeohashId.Companion.toScope(it) } val hashtagScopes: Set? = hashtags?.mapTo(mutableSetOf()) { HashtagId.Companion.toScope(it) } @@ -91,8 +92,8 @@ class AllFollowsByOutboxTopNavFilter( MutableStateFlow(emptyMap()) } - return combine(authorsPerRelay, communitiesPerRelay, defaultRelays) { perRelayAuthors, perRelayCommunities, default -> - val allRelays = (perRelayAuthors.keys + perRelayCommunities.keys).ifEmpty { default } + return combine(authorsPerRelay, communitiesPerRelay, defaultRelays, blockedRelays) { perRelayAuthors, perRelayCommunities, default, blockedRelays -> + val allRelays = (perRelayAuthors.keys + perRelayCommunities.keys).filter { it !in blockedRelays }.ifEmpty { default } AllFollowsByOutboxTopNavPerRelayFilterSet( allRelays.associateWith { @@ -121,7 +122,7 @@ class AllFollowsByOutboxTopNavFilter( emptyMap() } - val allRelays = (authorsPerRelay.keys + communitiesPerRelay.keys).ifEmpty { defaultRelays.value } + val allRelays = (authorsPerRelay.keys + communitiesPerRelay.keys).filter { it !in blockedRelays.value }.ifEmpty { defaultRelays.value } return AllFollowsByOutboxTopNavPerRelayFilterSet( allRelays.associateWith { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsFeedFlow.kt index e443909f5..112919801 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/allFollows/AllFollowsFeedFlow.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.map class AllFollowsFeedFlow( val allFollows: StateFlow, val followsRelays: StateFlow>, + val blockedRelays: StateFlow>, ) : IFeedFlowsType { fun convert(kind3: FollowListState.Kind3Follows?): AllFollowsByOutboxTopNavFilter = if (kind3 != null) { @@ -40,11 +41,13 @@ class AllFollowsFeedFlow( geotags = kind3.geotags, communities = kind3.communities, defaultRelays = followsRelays, + blockedRelays = blockedRelays, ) } else { AllFollowsByOutboxTopNavFilter( authors = emptySet(), defaultRelays = followsRelays, + blockedRelays = blockedRelays, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt index 71104c81c..36b9672e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/NoteFeedFlow.kt @@ -48,24 +48,25 @@ class NoteFeedFlow( val metadataFlow: StateFlow, val signer: NostrSigner, val allFollowRelays: StateFlow>, + val blockedRelays: StateFlow>, ) : IFeedFlowsType { fun process(noteEvent: Event): IFeedTopNavFilter = when (noteEvent) { is PeopleListEvent -> { if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { - MutedAuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedPrivateUsersAndWords().users) + MutedAuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedPrivateUsersAndWords().users, blockedRelays) } else { - AuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedPrivateUsersAndWords().users) + AuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedPrivateUsersAndWords().users, blockedRelays) } } is MuteListEvent -> { - MutedAuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedUsersAndWords().users) + MutedAuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedUsersAndWords().users, blockedRelays) } is FollowListEvent -> { - AuthorsByOutboxTopNavFilter(noteEvent.pubKeys().toSet()) + AuthorsByOutboxTopNavFilter(noteEvent.pubKeys().toSet(), blockedRelays) } is CommunityListEvent -> { - AllCommunitiesTopNavFilter(noteEvent.publicAndCachedPrivateCommunityIds().toSet()) + AllCommunitiesTopNavFilter(noteEvent.publicAndCachedPrivateCommunityIds().toSet(), blockedRelays) } is HashtagListEvent -> { HashtagTopNavFilter(noteEvent.publicAndCachedPrivateHashtags(), allFollowRelays) @@ -78,44 +79,45 @@ class NoteFeedFlow( community = noteEvent.addressTag(), authors = noteEvent.moderatorKeys().toSet().ifEmpty { null }, relays = noteEvent.relayUrls().toSet(), + blockedRelays = blockedRelays, ) } - else -> AuthorsByOutboxTopNavFilter(emptySet()) + else -> AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays) } suspend fun FlowCollector.process(noteEvent: Event) { when (noteEvent) { is PeopleListEvent -> { if (noteEvent.dTag() == PeopleListEvent.Companion.BLOCK_LIST_D_TAG) { - emit(MutedAuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedPrivateUsersAndWords().users)) + emit(MutedAuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedPrivateUsersAndWords().users, blockedRelays)) noteEvent.publicAndPrivateUsersAndWords(signer)?.let { - emit(MutedAuthorsByOutboxTopNavFilter(it.users)) + emit(MutedAuthorsByOutboxTopNavFilter(it.users, blockedRelays)) } } else { - emit(AuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedPrivateUsersAndWords().users)) + emit(AuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedPrivateUsersAndWords().users, blockedRelays)) noteEvent.publicAndPrivateUsersAndWords(signer)?.let { - emit(AuthorsByOutboxTopNavFilter(it.users)) + emit(AuthorsByOutboxTopNavFilter(it.users, blockedRelays)) } } } is MuteListEvent -> { - emit(MutedAuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedUsersAndWords().users)) + emit(MutedAuthorsByOutboxTopNavFilter(noteEvent.publicAndCachedUsersAndWords().users, blockedRelays)) noteEvent.publicAndPrivateUsersAndWords(signer)?.let { - emit(MutedAuthorsByOutboxTopNavFilter(it.users)) + emit(MutedAuthorsByOutboxTopNavFilter(it.users, blockedRelays)) } } is FollowListEvent -> { - emit(AuthorsByOutboxTopNavFilter(noteEvent.pubKeys().toSet())) + emit(AuthorsByOutboxTopNavFilter(noteEvent.pubKeys().toSet(), blockedRelays)) } is CommunityListEvent -> { - emit(AllCommunitiesTopNavFilter(noteEvent.publicCommunityIds().toSet())) + emit(AllCommunitiesTopNavFilter(noteEvent.publicCommunityIds().toSet(), blockedRelays)) noteEvent.publicAndPrivateCommunities(signer)?.let { val communities = it.map { it.addressId }.toSet() - emit(AllCommunitiesTopNavFilter(communities)) + emit(AllCommunitiesTopNavFilter(communities, blockedRelays)) } } is HashtagListEvent -> { @@ -138,12 +140,13 @@ class NoteFeedFlow( community = noteEvent.addressTag(), authors = noteEvent.moderatorKeys().toSet().ifEmpty { null }, relays = noteEvent.relayUrls().toSet(), + blockedRelays = blockedRelays, ), ) } else -> emit( - AuthorsByOutboxTopNavFilter(emptySet()), + AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays), ) } } @@ -153,7 +156,7 @@ class NoteFeedFlow( metadataFlow.transformLatest { noteState -> val noteEvent = noteState?.note?.event if (noteEvent == null) { - AuthorsByOutboxTopNavFilter(emptySet()) + AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays) } else { process(noteEvent) } @@ -162,7 +165,7 @@ class NoteFeedFlow( override fun startValue(): IFeedTopNavFilter { val noteEvent = metadataFlow.value?.note?.event if (noteEvent == null) { - return AuthorsByOutboxTopNavFilter(emptySet()) + return AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays) } else { return process(noteEvent) } @@ -171,7 +174,7 @@ class NoteFeedFlow( override suspend fun startValue(collector: FlowCollector) { val noteEvent = metadataFlow.value?.note?.event if (noteEvent == null) { - collector.emit(AuthorsByOutboxTopNavFilter(emptySet())) + collector.emit(AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays)) } else { collector.process(noteEvent) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt index 741ef9068..57df0edcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/allcommunities/AllCommunitiesTopNavFilter.kt @@ -29,10 +29,14 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNotes import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlin.collections.minus @Immutable class AllCommunitiesTopNavFilter( val communities: Set, + val blockedRelays: StateFlow>, ) : IFeedTopNavFilter { override fun matchAuthor(pubkey: HexKey): Boolean = true @@ -43,13 +47,17 @@ class AllCommunitiesTopNavFilter( map.mapValues { AllCommunitiesTopNavPerRelayFilter(it.value) }, ) - override fun toPerRelayFlow(cache: LocalCache): Flow = - CommunityRelayLoader.toCommunitiesPerRelayFlow(communities, cache) { - convert(it) - } + override fun toPerRelayFlow(cache: LocalCache): Flow { + val communitiesPerRelay = CommunityRelayLoader.toCommunitiesPerRelayFlow(communities, cache) { it } - override fun startValue(cache: LocalCache): AllCommunitiesTopNavPerRelayFilterSet = - CommunityRelayLoader.communitiesPerRelaySnapshot(communities, cache) { - convert(it) + return combine(communitiesPerRelay, blockedRelays) { communitiesPerRelay, blockedRelays -> + convert(communitiesPerRelay.minus(blockedRelays)) } + } + + override fun startValue(cache: LocalCache): AllCommunitiesTopNavPerRelayFilterSet { + val communitiesPerRelay = CommunityRelayLoader.communitiesPerRelaySnapshot(communities, cache) { it } + + return convert(communitiesPerRelay.minus(blockedRelays.value)) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt index e6b2e5c1c..1b65e529f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/author/AuthorsByOutboxTopNavFilter.kt @@ -29,10 +29,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine @Immutable class AuthorsByOutboxTopNavFilter( val authors: Set, + val blockedRelays: StateFlow>, ) : IFeedTopNavFilter { override fun matchAuthor(pubkey: HexKey) = pubkey in authors @@ -48,7 +51,17 @@ class AuthorsByOutboxTopNavFilter( map.mapValues { AuthorsByOutboxTopNavPerRelayFilter(it.value) }, ) - override fun toPerRelayFlow(cache: LocalCache): Flow = OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache, ::convert) + override fun toPerRelayFlow(cache: LocalCache): Flow { + val authorsPerRelay = OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it } - override fun startValue(cache: LocalCache): AuthorsByOutboxTopNavPerRelayFilterSet = OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache, ::convert) + return combine(authorsPerRelay, blockedRelays) { authors, blocked -> + convert(authors.minus(blocked)) + } + } + + override fun startValue(cache: LocalCache): AuthorsByOutboxTopNavPerRelayFilterSet { + val authorsPerRelay = OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it } + + return convert(authorsPerRelay.minus(blockedRelays.value)) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt index 273d373b8..7b090f141 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/community/SingleCommunityTopNavFilter.kt @@ -31,13 +31,16 @@ import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableN import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map @Immutable class SingleCommunityTopNavFilter( val community: String, val authors: Set?, val relays: Set, + val blockedRelays: StateFlow>, ) : IFeedTopNavFilter { override fun matchAuthor(pubkey: HexKey) = authors == null || pubkey in authors @@ -53,20 +56,22 @@ class SingleCommunityTopNavFilter( override fun toPerRelayFlow(cache: LocalCache): Flow { // relay field takes priority if (relays.isNotEmpty()) { - return MutableStateFlow( + return blockedRelays.map { blocked -> SingleCommunityTopNavPerRelayFilterSet( - relays.associateWith { + relays.minus(blocked).associateWith { SingleCommunityTopNavPerRelayFilter(community, authors) }, - ), - ) + ) + } } if (authors != null) { // go by authors - return OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { + val authorsPerRelay = OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it } + + return combine(authorsPerRelay, blockedRelays) { authorsPerRelay, blocked -> SingleCommunityTopNavPerRelayFilterSet( - it.mapValues { + authorsPerRelay.minus(blocked).mapValues { SingleCommunityTopNavPerRelayFilter(community, it.value) }, ) @@ -74,20 +79,20 @@ class SingleCommunityTopNavFilter( } // go by hints - return MutableStateFlow( + return blockedRelays.map { blocked -> SingleCommunityTopNavPerRelayFilterSet( - cache.relayHints.hintsForAddress(community).associateWith { + cache.relayHints.hintsForAddress(community).minus(blocked).associateWith { SingleCommunityTopNavPerRelayFilter(community, authors) }, - ), - ) + ) + } } override fun startValue(cache: LocalCache): SingleCommunityTopNavPerRelayFilterSet { // relay field takes priority if (relays.isNotEmpty()) { return SingleCommunityTopNavPerRelayFilterSet( - relays.associateWith { + relays.minus(blockedRelays.value).associateWith { SingleCommunityTopNavPerRelayFilter(community, authors) }, ) @@ -95,18 +100,18 @@ class SingleCommunityTopNavFilter( if (authors != null) { // go by authors - return OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { - SingleCommunityTopNavPerRelayFilterSet( - it.mapValues { - SingleCommunityTopNavPerRelayFilter(community, it.value) - }, - ) - } + val authorsPerRelay = OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it } + + return SingleCommunityTopNavPerRelayFilterSet( + authorsPerRelay.minus(blockedRelays.value).mapValues { + SingleCommunityTopNavPerRelayFilter(community, it.value) + }, + ) } // go by hints return SingleCommunityTopNavPerRelayFilterSet( - cache.relayHints.hintsForAddress(community).associateWith { + cache.relayHints.hintsForAddress(community).minus(blockedRelays.value).associateWith { SingleCommunityTopNavPerRelayFilter(community, authors) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt index d90db485d..d1bd98be0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/noteBased/muted/MutedAuthorsByOutboxTopNavFilter.kt @@ -29,10 +29,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine @Immutable class MutedAuthorsByOutboxTopNavFilter( val authors: Set, + val blockedRelays: StateFlow>, ) : IFeedTopNavFilter { override fun matchAuthor(pubkey: HexKey) = pubkey in authors @@ -48,7 +51,17 @@ class MutedAuthorsByOutboxTopNavFilter( map.mapValues { MutedAuthorsByOutboxTopNavPerRelayFilter(it.value) }, ) - override fun toPerRelayFlow(cache: LocalCache): Flow = OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache, ::convert) + override fun toPerRelayFlow(cache: LocalCache): Flow { + val authorsPerRelay = OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache) { it } - override fun startValue(cache: LocalCache): MutedAuthorsByOutboxTopNavPerRelayFilterSet = OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache, ::convert) + return combine(authorsPerRelay, blockedRelays) { authors, blocked -> + convert(authors.minus(blocked)) + } + } + + override fun startValue(cache: LocalCache): MutedAuthorsByOutboxTopNavPerRelayFilterSet { + val authorsPerRelay = OutboxRelayLoader.authorsPerRelaySnapshot(authors, cache) { it } + + return convert(authorsPerRelay.minus(blockedRelays.value)) + } } 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 8909f2b97..13e0c59e5 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 @@ -31,6 +31,8 @@ import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent @@ -49,6 +51,12 @@ val AccountInfoAndListsFromKeyKinds = PrivateOutboxRelayListEvent.KIND, ) +val AccountInfoAndListsFromKeyKinds2 = + listOf( + BlockedRelayListEvent.KIND, + TrustedRelayListEvent.KIND, + ) + val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND) val AmethystMetadataTagMapFilter = mapOf("d" to listOf(APP_SPECIFIC_DATA_D_TAG)) @@ -70,6 +78,16 @@ fun filterAccountInfoAndListsFromKey( since = since, ), ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = AccountInfoAndListsFromKeyKinds2, + authors = listOf(pubkey), + limit = 20, + since = since, + ), + ), RelayBasedFilter( relay = relay, filter = 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 3ab934bd2..7e65c20e4 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 @@ -28,6 +28,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent @@ -41,6 +43,8 @@ val BasicAccountInfoKinds = SearchRelayListEvent.KIND, FileServersEvent.KIND, BlossomServersEvent.KIND, + BlockedRelayListEvent.KIND, + TrustedRelayListEvent.KIND, ) fun filterBasicAccountInfoFromKeys( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 53d3312a7..bc1c2bc36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -127,7 +127,7 @@ fun AppNavigation( composableFromEnd { SettingsScreen(sharedPreferencesViewModel, accountViewModel, nav) } composableFromEnd { UserSettingsScreen(accountViewModel, nav) } composableFromBottomArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } - composableFromEndArgs { AllRelayListScreen(it.toAdd, accountViewModel, nav) } + composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ProfileScreen(it.id, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 7799af097..21504cd17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -455,7 +455,7 @@ fun ListContent( accountViewModel = accountViewModel, onClick = { nav.closeDrawer() - nav.nav(Route.EditRelays()) + nav.nav(Route.EditRelays) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 6f904d675..8a7a58c0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -53,9 +53,7 @@ sealed class Route { @Serializable object EditProfile : Route() - @Serializable data class EditRelays( - val toAdd: String? = null, - ) : Route() + @Serializable object EditRelays : Route() @Serializable data class Nip47NWCSetup( val nip47: String? = null, 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 c55ea2a92..3c7c1a249 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 @@ -78,12 +78,14 @@ import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay +import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList import com.vitorpamplona.amethyst.ui.note.types.DisplayNIP65RelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet import com.vitorpamplona.amethyst.ui.note.types.DisplaySearchRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayTrustedRelayList import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.note.types.EmptyState import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay @@ -181,10 +183,12 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent @@ -635,6 +639,8 @@ private fun RenderNoteRow( is ChatMessageRelayListEvent -> DisplayDMRelayList(baseNote, backgroundColor, accountViewModel, nav) is AdvertisedRelayListEvent -> DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) is SearchRelayListEvent -> DisplaySearchRelayList(baseNote, backgroundColor, accountViewModel, nav) + is BlockedRelayListEvent -> DisplayBlockedRelayList(baseNote, backgroundColor, accountViewModel, nav) + is TrustedRelayListEvent -> DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav) is PinListEvent -> RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav) is EmojiPackEvent -> RenderEmojiPack(baseNote, true, backgroundColor, accountViewModel) is LiveActivitiesEvent -> RenderLiveActivityEvent(baseNote, 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 1f03df636..15d3b6550 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 @@ -37,6 +37,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -57,7 +59,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -188,6 +192,58 @@ fun DisplaySearchRelayList( ) } +@Composable +fun DisplayBlockedRelayList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = baseNote.event as? BlockedRelayListEvent ?: return + + val relays by + remember(baseNote) { + mutableStateOf( + noteEvent.relays().toImmutableList(), + ) + } + + DisplayRelaySet( + relays, + stringRes(id = R.string.blocked_relays_title), + null, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +fun DisplayTrustedRelayList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = baseNote.event as? TrustedRelayListEvent ?: return + + val relays by + remember(baseNote) { + mutableStateOf( + noteEvent.relays().toImmutableList(), + ) + } + + DisplayRelaySet( + relays, + stringRes(id = R.string.trusted_relays_title), + null, + backgroundColor, + accountViewModel, + nav, + ) +} + @Composable fun DisplayRelaySet( relays: ImmutableList, @@ -280,14 +336,16 @@ private fun RelayOptionsAction( nav: INav, ) { val isCurrentlyOnTheUsersList by observeUserRelayIntoList(accountViewModel.userProfile(), relay, accountViewModel) + val clipboardManager = LocalClipboardManager.current if (isCurrentlyOnTheUsersList) { AddRelayButton { - nav.nav(Route.EditRelays(relay.url)) + clipboardManager.setText(AnnotatedString(relay.url)) + nav.nav(Route.EditRelays) } } else { RemoveRelayButton { - nav.nav(Route.EditRelays(relay.url)) + nav.nav(Route.EditRelays) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt index 8d3595b26..7723ed123 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingEphemeralChatSubAssembler.kt @@ -56,7 +56,7 @@ class FollowingEphemeralChatSubAssembler( userJobMap[key.account.userProfile()] = listOf( key.account.scope.launch(Dispatchers.Default) { - key.account.ephemeralChatList.liveEphemeralChatList.sample(5000).collectLatest { + key.account.ephemeralChatList.liveEphemeralChatList.sample(500).collectLatest { invalidateFilters() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt index 61b3de874..fdbfef14d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/FollowingPublicChatSubAssembler.kt @@ -57,7 +57,7 @@ class FollowingPublicChatSubAssembler( userJobMap[key.account.userProfile()] = listOf( key.account.scope.launch(Dispatchers.Default) { - key.account.publicChatList.flowSet.sample(5000).collectLatest { + key.account.publicChatList.flowSet.sample(500).collectLatest { invalidateFilters() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler1.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler1.kt index 52464b051..86c065b85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler1.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler1.kt @@ -77,7 +77,7 @@ class DiscoveryFollowsDiscoverySubAssembler1( } }, key.scope.launch(Dispatchers.Default) { - key.followsPerRelayFlow().sample(5000).collectLatest { + key.followsPerRelayFlow().sample(500).collectLatest { invalidateFilters() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler2.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler2.kt index 922385f2e..4a4cd2068 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler2.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler2.kt @@ -75,7 +75,7 @@ class DiscoveryFollowsDiscoverySubAssembler2( } }, key.scope.launch(Dispatchers.Default) { - key.followsPerRelayFlow().sample(5000).collectLatest { + key.followsPerRelayFlow().sample(500).collectLatest { invalidateFilters() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler3.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler3.kt index 0c9d240ab..97d694191 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler3.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsDiscoverySubAssembler3.kt @@ -75,7 +75,7 @@ class DiscoveryFollowsDiscoverySubAssembler3( } }, key.scope.launch(Dispatchers.Default) { - key.followsPerRelayFlow().sample(5000).collectLatest { + key.followsPerRelayFlow().sample(500).collectLatest { invalidateFilters() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt index 42281203d..3076df88d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedView.kt @@ -26,6 +26,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.navigation.INav @@ -46,6 +48,7 @@ fun RelayFeedView( RefresheableBox(viewModel, enablePullRefresh) { val listState = rememberLazyListState() + val clipboardManager = LocalClipboardManager.current LazyColumn( contentPadding = FeedPadding, @@ -56,10 +59,11 @@ fun RelayFeedView( item, accountViewModel = accountViewModel, onAddRelay = { - nav.nav(Route.EditRelays(item.url.url)) + clipboardManager.setText(AnnotatedString(item.url.url)) + nav.nav(Route.EditRelays) }, onRemoveRelay = { - nav.nav(Route.EditRelays(item.url.url)) + nav.nav(Route.EditRelays) }, ) HorizontalDivider( 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 68ae265cc..d23aa6479 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 @@ -54,6 +54,8 @@ import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.BlockedRelayListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked.renderBlockedItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected.ConnectedRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected.renderConnectedItems @@ -68,6 +70,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65.renderNip65Hom import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip65.renderNip65NotifItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.SearchRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.renderSearchItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.trusted.TrustedRelayListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.trusted.renderTrustedItems import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.MinHorzSpacer @@ -79,17 +83,15 @@ import com.vitorpamplona.amethyst.ui.theme.grayText @Composable fun AllRelayListScreen( - relayToAdd: String? = null, accountViewModel: AccountViewModel, nav: INav, ) { - MappedAllRelayListView(relayToAdd ?: "", accountViewModel, nav) + MappedAllRelayListView(accountViewModel, nav) } @OptIn(ExperimentalMaterial3Api::class) @Composable fun MappedAllRelayListView( - relayToAdd: String = "", accountViewModel: AccountViewModel, newNav: INav, ) { @@ -106,6 +108,12 @@ fun MappedAllRelayListView( val searchViewModel: SearchRelayListViewModel = viewModel() val searchFeedState by searchViewModel.relays.collectAsStateWithLifecycle() + val blockedViewModel: BlockedRelayListViewModel = viewModel() + val blockedFeedState by blockedViewModel.relays.collectAsStateWithLifecycle() + + val trustedViewModel: TrustedRelayListViewModel = viewModel() + val trustedFeedState by trustedViewModel.relays.collectAsStateWithLifecycle() + val localViewModel: LocalRelayListViewModel = viewModel() val localFeedState by localViewModel.relays.collectAsStateWithLifecycle() @@ -119,6 +127,8 @@ fun MappedAllRelayListView( localViewModel.load(accountViewModel.account) privateOutboxViewModel.load(accountViewModel.account) connectedViewModel.load(accountViewModel.account) + blockedViewModel.load(accountViewModel.account) + trustedViewModel.load(accountViewModel.account) } Scaffold( @@ -148,6 +158,8 @@ fun MappedAllRelayListView( searchViewModel.create() localViewModel.create() privateOutboxViewModel.create() + trustedViewModel.create() + blockedViewModel.create() newNav.popBack() }, true, @@ -164,6 +176,8 @@ fun MappedAllRelayListView( searchViewModel.clear() localViewModel.clear() privateOutboxViewModel.clear() + trustedViewModel.clear() + blockedViewModel.clear() newNav.popBack() }, ) @@ -248,6 +262,24 @@ fun MappedAllRelayListView( } renderLocalItems(localFeedState, localViewModel, accountViewModel, newNav) + item { + SettingsCategory( + stringRes(R.string.trusted_section), + stringRes(R.string.trusted_section_explainer), + SettingsCategorySpacingModifier, + ) + } + renderTrustedItems(trustedFeedState, trustedViewModel, accountViewModel, newNav) + + item { + SettingsCategory( + stringRes(R.string.blocked_section), + stringRes(R.string.blocked_section_explainer), + SettingsCategorySpacingModifier, + ) + } + renderBlockedItems(blockedFeedState, blockedViewModel, accountViewModel, newNav) + item { SettingsCategory( stringRes(R.string.connected_section), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.kt new file mode 100644 index 000000000..05169ebab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListView.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.blocked + +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.INav +import com.vitorpamplona.amethyst.ui.navigation.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 BlockedRelayList( + postViewModel: BlockedRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: INav, +) { + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + val newNav = rememberExtendedNav(nav, onClose) + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderBlockedItems(feedState, postViewModel, accountViewModel, newNav) + } + } +} + +fun LazyListScope.renderBlockedItems( + feedState: List, + postViewModel: BlockedRelayListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed(feedState, key = { _, item -> "Trusted" + item.relay }) { 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/blocked/BlockedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt new file mode 100644 index 000000000..a0bb1fa64 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt @@ -0,0 +1,32 @@ +/** + * 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.blocked + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class BlockedRelayListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List? = account.blockedRelayList.getBlockedRelayList()?.relays() + + override fun saveRelayList(urlList: List) { + account.saveBlockedRelayList(urlList) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.kt new file mode 100644 index 000000000..1b9d872f8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListView.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.trusted + +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.INav +import com.vitorpamplona.amethyst.ui.navigation.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 TrustedRelayList( + postViewModel: TrustedRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: INav, +) { + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + val newNav = rememberExtendedNav(nav, onClose) + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderTrustedItems(feedState, postViewModel, accountViewModel, newNav) + } + } +} + +fun LazyListScope.renderTrustedItems( + feedState: List, + postViewModel: TrustedRelayListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed(feedState, key = { _, item -> "Trusted" + item.relay }) { 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/trusted/TrustedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt new file mode 100644 index 000000000..6c3e4600f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt @@ -0,0 +1,32 @@ +/** + * 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.trusted + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class TrustedRelayListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List? = account.trustedRelayList.getTrustedRelayList()?.relays() + + override fun saveRelayList(urlList: List) { + account.saveTrustedRelayList(urlList) + } +} 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 f4a333bda..ba91a7668 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 @@ -117,12 +117,14 @@ import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.types.AudioHeader import com.vitorpamplona.amethyst.ui.note.types.AudioTrackHeader import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay +import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList import com.vitorpamplona.amethyst.ui.note.types.DisplayNIP65RelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayPeopleList import com.vitorpamplona.amethyst.ui.note.types.DisplayRelaySet import com.vitorpamplona.amethyst.ui.note.types.DisplaySearchRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayTrustedRelayList import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay @@ -201,10 +203,12 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup @@ -589,6 +593,10 @@ private fun FullBleedNoteCompose( DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is SearchRelayListEvent) { DisplaySearchRelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is BlockedRelayListEvent) { + DisplayBlockedRelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is TrustedRelayListEvent) { + DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is FhirResourceEvent) { RenderFhirResource(baseNote, accountViewModel, nav) } else if (noteEvent is GitRepositoryEvent) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt index cc5787e9b..9d0311ad3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt @@ -92,7 +92,7 @@ class VideoOutboxEventsFilterSubAssembler( userJobMap[user] = listOf( key.scope.launch(Dispatchers.Default) { - key.followsPerRelayFlow().sample(5000).collectLatest { + key.followsPerRelayFlow().sample(500).collectLatest { invalidateFilters() } }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bac3065b0..e7083237c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1052,6 +1052,9 @@ Insert between 1–3 relays to use when searching for content or tagging users. Make sure your chosen relays implement NIP-50 Good options are:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Blocked Relays + Trusted Relays + DM Upload Relay Settings Public Outbox/Home Relays @@ -1076,6 +1079,12 @@ Local Relays List of relays that are running in this device. + Trusted Relays + Relays you trust to not need a Tor connection for + + Blocked Relays + Amethyst will never connect to these relays + Zap the Devs! Your donation helps us make a difference. Every sat counts! Donate Now diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt index 6fd03794e..acf2c7df0 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/EventFactory.kt @@ -74,12 +74,14 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.RelaySetEvent +import com.vitorpamplona.quartz.nip51Lists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.locations.GeohashListEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent @@ -145,6 +147,7 @@ class EventFactory { BadgeAwardEvent.KIND -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig) BadgeDefinitionEvent.KIND -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig) BadgeProfilesEvent.KIND -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig) + BlockedRelayListEvent.KIND -> BlockedRelayListEvent(id, pubKey, createdAt, tags, content, sig) BlossomServersEvent.KIND -> BlossomServersEvent(id, pubKey, createdAt, tags, content, sig) BlossomAuthorizationEvent.KIND -> BlossomAuthorizationEvent(id, pubKey, createdAt, tags, content, sig) BookmarkListEvent.KIND -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig) @@ -257,6 +260,7 @@ class EventFactory { TextNoteModificationEvent.KIND -> TextNoteModificationEvent(id, pubKey, createdAt, tags, content, sig) TorrentEvent.KIND -> TorrentEvent(id, pubKey, createdAt, tags, content, sig) TorrentCommentEvent.KIND -> TorrentCommentEvent(id, pubKey, createdAt, tags, content, sig) + TrustedRelayListEvent.KIND -> TrustedRelayListEvent(id, pubKey, createdAt, tags, content, sig) VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index ea512b6ca..a90876b99 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -38,6 +38,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart @@ -70,9 +71,10 @@ class NostrClient( reqs + counts + outbox }.onStart { activeRequests.relays.value + activeCounts.relays.value + eventOutbox.relays.value - }.onEach { - relayPool.updatePool(it) - }.flowOn(Dispatchers.Default) + }.debounce(300) + .onEach { + relayPool.updatePool(it) + }.flowOn(Dispatchers.Default) .stateIn( scope, SharingStarted.Companion.Eagerly, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/BlockedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/BlockedRelayListEvent.kt new file mode 100644 index 000000000..12c371db2 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/BlockedRelayListEvent.kt @@ -0,0 +1,105 @@ +/** + * 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 + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +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.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip50Search.tags.RelayTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class BlockedRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun relays(): List = tags.mapNotNull(RelayTag::parse) + + companion object { + const val KIND = 10006 + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null) + + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) + + fun createTagArray(relays: List): Array> = + relays + .map { + RelayTag.assemble(it) + }.plusElement(AltTag.assemble("Relay list to use for trusted connections")) + .toTypedArray() + + fun updateRelayList( + earlierVersion: BlockedRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BlockedRelayListEvent) -> Unit, + ) { + val tags = + earlierVersion.tags + .filter(RelayTag::notMatch) + .plus( + relays.map { + RelayTag.assemble(it) + }, + ).toTypedArray() + + signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) + } + + fun createFromScratch( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BlockedRelayListEvent) -> Unit, + ) { + create(relays, signer, createdAt, onReady) + } + + fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (BlockedRelayListEvent) -> Unit, + ) { + signer.sign(createdAt, KIND, createTagArray(relays), "", onReady) + } + + fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): BlockedRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "") + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TrustedRelayListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TrustedRelayListEvent.kt new file mode 100644 index 000000000..22436fb97 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/TrustedRelayListEvent.kt @@ -0,0 +1,105 @@ +/** + * 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 + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +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.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip50Search.tags.RelayTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class TrustedRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun relays(): List = tags.mapNotNull(RelayTag::parse) + + companion object { + const val KIND = 10089 + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null) + + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) + + fun createTagArray(relays: List): Array> = + relays + .map { + RelayTag.assemble(it) + }.plusElement(AltTag.assemble("Relay list to use for trusted connections")) + .toTypedArray() + + fun updateRelayList( + earlierVersion: TrustedRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (TrustedRelayListEvent) -> Unit, + ) { + val tags = + earlierVersion.tags + .filter(RelayTag::notMatch) + .plus( + relays.map { + RelayTag.assemble(it) + }, + ).toTypedArray() + + signer.sign(createdAt, KIND, tags, earlierVersion.content, onReady) + } + + fun createFromScratch( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (TrustedRelayListEvent) -> Unit, + ) { + create(relays, signer, createdAt, onReady) + } + + fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (TrustedRelayListEvent) -> Unit, + ) { + signer.sign(createdAt, KIND, createTagArray(relays), "", onReady) + } + + fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): TrustedRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "") + } +}