Early support for Blocked and Trusted Relay Lists

This commit is contained in:
Vitor Pamplona
2025-07-07 19:49:23 -04:00
parent d084714a21
commit b800b5a664
42 changed files with 1088 additions and 132 deletions
@@ -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<ChatMessageRelayListEvent>(PrefKeys.LATEST_DM_RELAY_LIST)
val latestNip65RelayList = parseEventOrNull<AdvertisedRelayListEvent>(PrefKeys.LATEST_NIP65_RELAY_LIST)
val latestSearchRelayList = parseEventOrNull<SearchRelayListEvent>(PrefKeys.LATEST_SEARCH_RELAY_LIST)
val latestBlockedRelayList = parseEventOrNull<BlockedRelayListEvent>(PrefKeys.LATEST_BLOCKED_RELAY_LIST)
val latestTrustedRelayList = parseEventOrNull<TrustedRelayListEvent>(PrefKeys.LATEST_TRUSTED_RELAY_LIST)
val latestMuteList = parseEventOrNull<MuteListEvent>(PrefKeys.LATEST_MUTE_LIST)
val latestPrivateHomeRelayList = parseEventOrNull<PrivateOutboxRelayListEvent>(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST)
val latestAppSpecificData = parseEventOrNull<AppSpecificDataEvent>(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,
@@ -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<NormalizedRelayUrl>) {
if (!isWriteable()) return
trustedRelayList.saveRelayList(trustedRelays, ::sendMyPublicAndPrivateOutbox)
}
fun saveBlockedRelayList(blockedRelays: List<NormalizedRelayUrl>) {
if (!isWriteable()) return
blockedRelayList.saveRelayList(blockedRelays, ::sendMyPublicAndPrivateOutbox)
}
fun sendNip65RelayList(relays: List<AdvertisedRelayInfo>) {
if (!isWriteable()) return
nip65RelayList.saveRelayList(relays, ::sendMyPublicAndPrivateOutbox)
@@ -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
@@ -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)
@@ -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,26 +61,29 @@ class FollowListOutboxRelays(
fun allRelayListFlows(followList: Set<HexKey>): List<StateFlow<NoteState>> = followList.map { getNIP65RelayListFlow(it) }
fun combineAllFlows(flows: List<StateFlow<NoteState>>): Flow<Set<NormalizedRelayUrl>> =
fun combineAllFlows(
flows: List<StateFlow<NoteState>>,
blockedRelays: Set<NormalizedRelayUrl>,
): Flow<Set<NormalizedRelayUrl>> =
combine(flows) { relayListNotes: Array<NoteState> ->
relayListNotes.mapNotNull {
(it.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()
}
}.map {
it.flatten().toSet()
it.flatten().toSet().minus(blockedRelays)
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Set<NormalizedRelayUrl>> =
kind3Follows.flow
.transformLatest { followList ->
combineTransform(kind3Follows.flow, blockedRelayList.flow) { followList, blockedRelays ->
val flows: List<StateFlow<NoteState>> = allRelayListFlows(followList.authors)
val relayListFlows = combineAllFlows(flows)
val relayListFlows = combineAllFlows(flows, blockedRelays)
emitAll(relayListFlows)
}.onStart {
val blocked = blockedRelayList.flow.value.toSet()
kind3Follows.flow.value.authors
.mapNotNull {
getNIP65RelayList(it)?.writeRelaysNorm()
getNIP65RelayList(it)?.writeRelaysNorm()?.minus(blocked)
}.flatten()
.toSet()
}.flowOn(Dispatchers.Default)
@@ -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,34 +58,40 @@ class FollowsPerOutboxRelay(
fun allRelayListFlows(followList: Set<HexKey>): List<StateFlow<NoteState>> = followList.map { getNIP65RelayListFlow(it) }
fun combineAllFlows(flows: List<StateFlow<NoteState>>): Flow<Map<NormalizedRelayUrl, Set<HexKey>>> =
fun combineAllFlows(
flows: List<StateFlow<NoteState>>,
blockedList: Set<NormalizedRelayUrl>,
): Flow<Map<NormalizedRelayUrl, Set<HexKey>>> =
combine(flows) { relayListNotes: Array<NoteState> ->
mapOfSet {
relayListNotes.forEach { noteState ->
noteState.note.author?.pubkeyHex?.let { authorHex ->
(noteState.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()?.forEach { relay ->
if (relay !in blockedList) {
add(relay, authorHex)
}
}
}
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
val flow: StateFlow<Map<NormalizedRelayUrl, Set<HexKey>>> =
kind3Follows.flow
.transformLatest { followList ->
combineTransform(kind3Follows.flow, blockedRelayList.flow) { followList, blockedList ->
val flows: List<StateFlow<NoteState>> = allRelayListFlows(followList.authors)
val relayListFlows = combineAllFlows(flows)
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)
@@ -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<NoteState> = getBlockedRelayListNote().flow().metadata.stateFlow
fun getBlockedRelayList(): BlockedRelayListEvent? = getBlockedRelayListNote().event as? BlockedRelayListEvent
fun normalizeBlockedRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
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<NormalizedRelayUrl>,
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)
}
}
}
}
}
@@ -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<NoteState> = getTrustedRelayListNote().flow().metadata.stateFlow
fun getTrustedRelayList(): TrustedRelayListEvent? = getTrustedRelayListNote().event as? TrustedRelayListEvent
fun normalizeTrustedRelayListWithBackup(note: Note): Set<NormalizedRelayUrl> {
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<NormalizedRelayUrl>,
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)
}
}
}
}
}
@@ -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<NormalizedRelayUrl>,
private: Set<NormalizedRelayUrl>,
local: Set<NormalizedRelayUrl>,
dm: Set<NormalizedRelayUrl>,
search: Set<NormalizedRelayUrl>,
): Set<NormalizedRelayUrl> = nip65 + private + local + dm + search
fun mergeLists(lists: Array<Set<NormalizedRelayUrl>>): Set<NormalizedRelayUrl> = lists.reduce { acc, set -> acc + set }
val flow: StateFlow<Set<NormalizedRelayUrl>> =
combine(
listOf(
nip65RelayList.allFlow,
privateOutboxRelayList.flow,
localRelayList.flow,
dmRelayList.flow,
searchRelayListState.flow,
trustedRelayList.flow,
),
::mergeLists,
).onStart {
emit(
mergeLists(
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(
arrayOf(
nip65RelayList.allFlow.value,
privateOutboxRelayList.flow.value,
localRelayList.flow.value,
dmRelayList.flow.value,
searchRelayListState.flow.value,
trustedRelayList.flow.value,
),
),
)
}
@@ -50,18 +50,19 @@ class FeedTopNavFilterState(
val allFollows: StateFlow<FollowListState.Kind3Follows>,
val locationFlow: StateFlow<LocationState.LocationResult>,
val followsRelays: StateFlow<Set<NormalizedRelayUrl>>,
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>>,
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)
}
@@ -50,6 +50,7 @@ class AllFollowsByOutboxTopNavFilter(
val geotags: Set<String>? = null,
val communities: Set<String>? = null,
val defaultRelays: StateFlow<Set<NormalizedRelayUrl>>,
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedTopNavFilter {
val geotagScopes: Set<String>? = geotags?.mapTo(mutableSetOf<String>()) { GeohashId.Companion.toScope(it) }
val hashtagScopes: Set<String>? = hashtags?.mapTo(mutableSetOf<String>()) { 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 {
@@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.map
class AllFollowsFeedFlow(
val allFollows: StateFlow<FollowListState.Kind3Follows?>,
val followsRelays: StateFlow<Set<NormalizedRelayUrl>>,
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : 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,
)
}
@@ -48,24 +48,25 @@ class NoteFeedFlow(
val metadataFlow: StateFlow<NoteState?>,
val signer: NostrSigner,
val allFollowRelays: StateFlow<Set<NormalizedRelayUrl>>,
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : 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<IFeedTopNavFilter>.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<IFeedTopNavFilter>) {
val noteEvent = metadataFlow.value?.note?.event
if (noteEvent == null) {
collector.emit(AuthorsByOutboxTopNavFilter(emptySet()))
collector.emit(AuthorsByOutboxTopNavFilter(emptySet(), blockedRelays))
} else {
collector.process(noteEvent)
}
@@ -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<String>,
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : 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<AllCommunitiesTopNavPerRelayFilterSet> =
CommunityRelayLoader.toCommunitiesPerRelayFlow(communities, cache) {
convert(it)
override fun toPerRelayFlow(cache: LocalCache): Flow<AllCommunitiesTopNavPerRelayFilterSet> {
val communitiesPerRelay = CommunityRelayLoader.toCommunitiesPerRelayFlow(communities, cache) { it }
return combine(communitiesPerRelay, blockedRelays) { communitiesPerRelay, blockedRelays ->
convert(communitiesPerRelay.minus(blockedRelays))
}
}
override fun startValue(cache: LocalCache): AllCommunitiesTopNavPerRelayFilterSet =
CommunityRelayLoader.communitiesPerRelaySnapshot(communities, cache) {
convert(it)
override fun startValue(cache: LocalCache): AllCommunitiesTopNavPerRelayFilterSet {
val communitiesPerRelay = CommunityRelayLoader.communitiesPerRelaySnapshot(communities, cache) { it }
return convert(communitiesPerRelay.minus(blockedRelays.value))
}
}
@@ -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<String>,
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : 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<AuthorsByOutboxTopNavPerRelayFilterSet> = OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache, ::convert)
override fun toPerRelayFlow(cache: LocalCache): Flow<AuthorsByOutboxTopNavPerRelayFilterSet> {
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))
}
}
@@ -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<String>?,
val relays: Set<NormalizedRelayUrl>,
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedTopNavFilter {
override fun matchAuthor(pubkey: HexKey) = authors == null || pubkey in authors
@@ -53,20 +56,22 @@ class SingleCommunityTopNavFilter(
override fun toPerRelayFlow(cache: LocalCache): Flow<SingleCommunityTopNavPerRelayFilterSet> {
// 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 {
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)
},
)
@@ -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<String>,
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : 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<MutedAuthorsByOutboxTopNavPerRelayFilterSet> = OutboxRelayLoader.toAuthorsPerRelayFlow(authors, cache, ::convert)
override fun toPerRelayFlow(cache: LocalCache): Flow<MutedAuthorsByOutboxTopNavPerRelayFilterSet> {
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))
}
}
@@ -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 =
@@ -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(
@@ -127,7 +127,7 @@ fun AppNavigation(
composableFromEnd<Route.Settings> { SettingsScreen(sharedPreferencesViewModel, accountViewModel, nav) }
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(it.toAdd, accountViewModel, nav) }
composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ContentDiscovery> { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.Profile> { ProfileScreen(it.id, accountViewModel, nav) }
@@ -455,7 +455,7 @@ fun ListContent(
accountViewModel = accountViewModel,
onClick = {
nav.closeDrawer()
nav.nav(Route.EditRelays())
nav.nav(Route.EditRelays)
},
)
@@ -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,
@@ -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)
@@ -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<Color>,
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<Color>,
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<NormalizedRelayUrl>,
@@ -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)
}
}
}
@@ -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()
}
},
@@ -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()
}
},
@@ -77,7 +77,7 @@ class DiscoveryFollowsDiscoverySubAssembler1(
}
},
key.scope.launch(Dispatchers.Default) {
key.followsPerRelayFlow().sample(5000).collectLatest {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
@@ -75,7 +75,7 @@ class DiscoveryFollowsDiscoverySubAssembler2(
}
},
key.scope.launch(Dispatchers.Default) {
key.followsPerRelayFlow().sample(5000).collectLatest {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
@@ -75,7 +75,7 @@ class DiscoveryFollowsDiscoverySubAssembler3(
}
},
key.scope.launch(Dispatchers.Default) {
key.followsPerRelayFlow().sample(5000).collectLatest {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
@@ -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(
@@ -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),
@@ -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<BasicRelaySetupInfo>,
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)) }
}
}
@@ -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<NormalizedRelayUrl>? = account.blockedRelayList.getBlockedRelayList()?.relays()
override fun saveRelayList(urlList: List<NormalizedRelayUrl>) {
account.saveBlockedRelayList(urlList)
}
}
@@ -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<BasicRelaySetupInfo>,
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)) }
}
}
@@ -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<NormalizedRelayUrl>? = account.trustedRelayList.getTrustedRelayList()?.relays()
override fun saveRelayList(urlList: List<NormalizedRelayUrl>) {
account.saveTrustedRelayList(urlList)
}
}
@@ -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) {
@@ -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()
}
},
+9
View File
@@ -1052,6 +1052,9 @@
<string name="search_relays_not_found_editing">Insert between 13 relays to use when searching for content or tagging users. Make sure your chosen relays implement NIP-50</string>
<string name="search_relays_not_found_examples">Good options are:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com</string>
<string name="blocked_relays_title">Blocked Relays</string>
<string name="trusted_relays_title">Trusted Relays</string>
<string name="dm_upload">DM Upload</string>
<string name="relay_settings">Relay Settings</string>
<string name="public_home_section">Public Outbox/Home Relays</string>
@@ -1076,6 +1079,12 @@
<string name="local_section">Local Relays</string>
<string name="local_section_explainer">List of relays that are running in this device.</string>
<string name="trusted_section">Trusted Relays</string>
<string name="trusted_section_explainer">Relays you trust to not need a Tor connection for</string>
<string name="blocked_section">Blocked Relays</string>
<string name="blocked_section_explainer">Amethyst will never connect to these relays</string>
<string name="zap_the_devs_title">Zap the Devs!</string>
<string name="zap_the_devs_description">Your donation helps us make a difference. Every sat counts!</string>
<string name="donate_now">Donate Now</string>
@@ -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)
@@ -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,7 +71,8 @@ class NostrClient(
reqs + counts + outbox
}.onStart {
activeRequests.relays.value + activeCounts.relays.value + eventOutbox.relays.value
}.onEach {
}.debounce(300)
.onEach {
relayPool.updatePool(it)
}.flowOn(Dispatchers.Default)
.stateIn(
@@ -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<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun relays(): List<NormalizedRelayUrl> = 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<NormalizedRelayUrl>): Array<Array<String>> =
relays
.map {
RelayTag.assemble(it)
}.plusElement(AltTag.assemble("Relay list to use for trusted connections"))
.toTypedArray()
fun updateRelayList(
earlierVersion: BlockedRelayListEvent,
relays: List<NormalizedRelayUrl>,
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<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlockedRelayListEvent) -> Unit,
) {
create(relays, signer, createdAt, onReady)
}
fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (BlockedRelayListEvent) -> Unit,
) {
signer.sign(createdAt, KIND, createTagArray(relays), "", onReady)
}
fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): BlockedRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "")
}
}
@@ -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<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
fun relays(): List<NormalizedRelayUrl> = 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<NormalizedRelayUrl>): Array<Array<String>> =
relays
.map {
RelayTag.assemble(it)
}.plusElement(AltTag.assemble("Relay list to use for trusted connections"))
.toTypedArray()
fun updateRelayList(
earlierVersion: TrustedRelayListEvent,
relays: List<NormalizedRelayUrl>,
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<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (TrustedRelayListEvent) -> Unit,
) {
create(relays, signer, createdAt, onReady)
}
fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (TrustedRelayListEvent) -> Unit,
) {
signer.sign(createdAt, KIND, createTagArray(relays), "", onReady)
}
fun create(
relays: List<NormalizedRelayUrl>,
signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(),
): TrustedRelayListEvent? = signer.sign(createdAt, KIND, createTagArray(relays), "")
}
}