Refactoring of RelaySetupInfo away from ModelView

This commit is contained in:
Vitor Pamplona
2023-02-09 14:25:58 -05:00
parent ffe5e79474
commit 4cde2fe8e6
6 changed files with 61 additions and 82 deletions
@@ -4,16 +4,10 @@ import android.content.Context
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.ui.navigation.Route
import java.util.Locale import java.util.Locale
import nostr.postr.Persona import nostr.postr.Persona
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.toHex import nostr.postr.toHex
class LocalPreferences(context: Context) { class LocalPreferences(context: Context) {
@@ -52,8 +46,8 @@ class LocalPreferences(context: Context) {
val hiddenUsers = getStringSet("hidden_users", emptySet()) ?: setOf() val hiddenUsers = getStringSet("hidden_users", emptySet()) ?: setOf()
val localRelays = gson.fromJson( val localRelays = gson.fromJson(
getString("relays", "[]"), getString("relays", "[]"),
object : TypeToken<Set<NewRelayListViewModel.Relay>>() {}.type object : TypeToken<Set<RelaySetupInfo>>() {}.type
) ?: setOf<NewRelayListViewModel.Relay>() ) ?: setOf<RelaySetupInfo>()
val dontTranslateFrom = getStringSet("dontTranslateFrom", null) ?: setOf() val dontTranslateFrom = getStringSet("dontTranslateFrom", null) ?: setOf()
val translateTo = getString("translateTo", null) ?: Locale.getDefault().language val translateTo = getString("translateTo", null) ?: Locale.getDefault().language
@@ -1,8 +1,6 @@
package com.vitorpamplona.amethyst.model package com.vitorpamplona.amethyst.model
import android.content.res.Resources import android.content.res.Resources
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Text
import androidx.core.os.ConfigurationCompat import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.relays.Constants import com.vitorpamplona.amethyst.service.relays.Constants
@@ -16,7 +14,6 @@ import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@@ -53,7 +50,7 @@ class Account(
val loggedIn: Persona, val loggedIn: Persona,
var followingChannels: Set<String> = DefaultChannels, var followingChannels: Set<String> = DefaultChannels,
var hiddenUsers: Set<String> = setOf(), var hiddenUsers: Set<String> = setOf(),
var localRelays: Set<NewRelayListViewModel.Relay> = Constants.defaultRelays.toSet(), var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(), var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var translateTo: String = Locale.getDefault().language var translateTo: String = Locale.getDefault().language
) { ) {
@@ -435,7 +432,7 @@ class Account(
innerReports).toSet() innerReports).toSet()
} }
fun saveRelayList(value: List<NewRelayListViewModel.Relay>) { fun saveRelayList(value: List<RelaySetupInfo>) {
localRelays = value.toSet() localRelays = value.toSet()
sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } ) sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
} }
@@ -0,0 +1,13 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.relays.FeedType
data class RelaySetupInfo(
val url: String,
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0,
val feedTypes: Set<FeedType>
)
@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.service.relays package com.vitorpamplona.amethyst.service.relays
import com.vitorpamplona.amethyst.ui.actions.NewRelayListViewModel import com.vitorpamplona.amethyst.model.RelaySetupInfo
object Constants { object Constants {
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS) val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
@@ -14,15 +14,15 @@ object Constants {
val defaultRelays = arrayOf( val defaultRelays = arrayOf(
// Free relays // Free relays
NewRelayListViewModel.Relay("wss://offchain.pub", read = true, write = true, feedTypes = activeTypes), RelaySetupInfo("wss://nostr.bitcoiner.social", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes), RelaySetupInfo("wss://relay.nostr.bg", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes), RelaySetupInfo("wss://relay.snort.social", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes), RelaySetupInfo("wss://relay.damus.io", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes), RelaySetupInfo("wss://nostr.oxtr.dev", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes), RelaySetupInfo("wss://nostr-pub.wellorder.net", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes), RelaySetupInfo("wss://nostr.mom", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes), RelaySetupInfo("wss://no.str.cr", read = true, write = true, feedTypes = activeTypes),
NewRelayListViewModel.Relay("wss://nos.lol", read = true, write = true, feedTypes = activeTypes), RelaySetupInfo("wss://nos.lol", read = true, write = true, feedTypes = activeTypes),
// Less Reliable // Less Reliable
//NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes), //NewRelayListViewModel.Relay("wss://nostr.orangepill.dev", read = true, write = true, feedTypes = activeTypes),
@@ -35,14 +35,14 @@ object Constants {
//NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes), //NewRelayListViewModel.Relay("wss://brb.io", read = true, write = true, feedTypes = activeTypes),
// Paid relays // Paid relays
NewRelayListViewModel.Relay("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats), RelaySetupInfo("wss://relay.nostr.com.au", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats), RelaySetupInfo("wss://eden.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats), RelaySetupInfo("wss://nostr.milou.lol", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats), RelaySetupInfo("wss://puravida.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats), RelaySetupInfo("wss://nostr.wine", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats), RelaySetupInfo("wss://nostr.inosta.cc", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats), RelaySetupInfo("wss://atlas.nostr.land", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats), RelaySetupInfo("wss://relay.orangepill.dev", read = true, write = false, feedTypes = activeTypesGlobalChats),
NewRelayListViewModel.Relay("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats), RelaySetupInfo("wss://relay.nostrati.com", read = true, write = false, feedTypes = activeTypesGlobalChats),
) )
} }
@@ -1,25 +1,19 @@
package com.vitorpamplona.amethyst.ui.actions package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Checkbox
import androidx.compose.material.Colors
import androidx.compose.material.Divider import androidx.compose.material.Divider
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton
@@ -28,18 +22,12 @@ import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.DownloadDone
import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.SyncProblem import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Upload import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.outlined.BarChart
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -48,12 +36,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -62,6 +48,7 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.FeedType
import java.lang.Math.round import java.lang.Math.round
@@ -206,16 +193,16 @@ fun ServerConfigHeader() {
@Composable @Composable
fun ServerConfig( fun ServerConfig(
item: NewRelayListViewModel.Relay, item: RelaySetupInfo,
onToggleDownload: (NewRelayListViewModel.Relay) -> Unit, onToggleDownload: (RelaySetupInfo) -> Unit,
onToggleUpload: (NewRelayListViewModel.Relay) -> Unit, onToggleUpload: (RelaySetupInfo) -> Unit,
onToggleFollows: (NewRelayListViewModel.Relay) -> Unit, onToggleFollows: (RelaySetupInfo) -> Unit,
onTogglePrivateDMs: (NewRelayListViewModel.Relay) -> Unit, onTogglePrivateDMs: (RelaySetupInfo) -> Unit,
onTogglePublicChats: (NewRelayListViewModel.Relay) -> Unit, onTogglePublicChats: (RelaySetupInfo) -> Unit,
onToggleGlobal: (NewRelayListViewModel.Relay) -> Unit, onToggleGlobal: (RelaySetupInfo) -> Unit,
onDelete: (NewRelayListViewModel.Relay) -> Unit) { onDelete: (RelaySetupInfo) -> Unit) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -376,7 +363,7 @@ fun ServerConfig(
} }
@Composable @Composable
fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.Relay) -> Unit) { fun EditableServerConfig(relayToAdd: String, onNewRelay: (RelaySetupInfo) -> Unit) {
var url by remember { mutableStateOf<String>(relayToAdd) } var url by remember { mutableStateOf<String>(relayToAdd) }
var read by remember { mutableStateOf(true) } var read by remember { mutableStateOf(true) }
var write by remember { mutableStateOf(true) } var write by remember { mutableStateOf(true) }
@@ -424,7 +411,7 @@ fun EditableServerConfig(relayToAdd: String, onNewRelay: (NewRelayListViewModel.
if (url.isNotBlank() && url != "/") { if (url.isNotBlank() && url != "/") {
var addedWSS = if (!url.startsWith("wss://")) "wss://$url" else url var addedWSS = if (!url.startsWith("wss://")) "wss://$url" else url
if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1) if (url.endsWith("/")) addedWSS = addedWSS.dropLast(1)
onNewRelay(NewRelayListViewModel.Relay(addedWSS, read, write, feedTypes = FeedType.values().toSet())) onNewRelay(RelaySetupInfo(addedWSS, read, write, feedTypes = FeedType.values().toSet()))
url = "" url = ""
write = true write = true
read = true read = true
@@ -1,32 +1,20 @@
package com.vitorpamplona.amethyst.ui.actions package com.vitorpamplona.amethyst.ui.actions
import android.content.Context import android.content.Context
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relays.Constants import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayPool import com.vitorpamplona.amethyst.service.relays.RelayPool
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import nostr.postr.events.ContactListEvent
class NewRelayListViewModel: ViewModel() { class NewRelayListViewModel: ViewModel() {
private lateinit var account: Account private lateinit var account: Account
data class Relay( private val _relays = MutableStateFlow<List<RelaySetupInfo>>(emptyList())
val url: String,
val read: Boolean,
val write: Boolean,
val errorCount: Int = 0,
val downloadCount: Int = 0,
val uploadCount: Int = 0,
val feedTypes: Set<FeedType>
)
private val _relays = MutableStateFlow<List<Relay>>(emptyList())
val relays = _relays.asStateFlow() val relays = _relays.asStateFlow()
fun load(account: Account, ctx: Context) { fun load(account: Account, ctx: Context) {
@@ -56,7 +44,7 @@ class NewRelayListViewModel: ViewModel() {
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0 val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0 val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
Relay(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, localInfoFeedTypes) RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, localInfoFeedTypes)
}.sortedBy { it.downloadCount }.reversed() }.sortedBy { it.downloadCount }.reversed()
else else
account.localRelays.map { account.localRelays.map {
@@ -66,12 +54,12 @@ class NewRelayListViewModel: ViewModel() {
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0 val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0 val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
Relay(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, it.feedTypes) RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, it.feedTypes)
}.sortedBy { it.downloadCount }.reversed() }.sortedBy { it.downloadCount }.reversed()
} }
} }
fun addRelay(relay: Relay) { fun addRelay(relay: RelaySetupInfo) {
if (relays.value.any { it.url == relay.url }) return if (relays.value.any { it.url == relay.url }) return
_relays.update { _relays.update {
@@ -79,46 +67,46 @@ class NewRelayListViewModel: ViewModel() {
} }
} }
fun deleteRelay(relay: Relay) { fun deleteRelay(relay: RelaySetupInfo) {
_relays.update { _relays.update {
it.minus(relay) it.minus(relay)
} }
} }
fun toggleDownload(relay: Relay) { fun toggleDownload(relay: RelaySetupInfo) {
_relays.update { _relays.update {
it.updated(relay, relay.copy(read = !relay.read)) it.updated(relay, relay.copy(read = !relay.read))
} }
} }
fun toggleUpload(relay: Relay) { fun toggleUpload(relay: RelaySetupInfo) {
_relays.update { _relays.update {
it.updated(relay, relay.copy(write = !relay.write)) it.updated(relay, relay.copy(write = !relay.write))
} }
} }
fun toggleFollows(relay: Relay) { fun toggleFollows(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS) val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.FOLLOWS)
_relays.update { _relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes)) it.updated(relay, relay.copy(feedTypes = newTypes))
} }
} }
fun toggleMessages(relay: Relay) { fun toggleMessages(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS) val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PRIVATE_DMS)
_relays.update { _relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes)) it.updated(relay, relay.copy(feedTypes = newTypes))
} }
} }
fun togglePublicChats(relay: Relay) { fun togglePublicChats(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS) val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.PUBLIC_CHATS)
_relays.update { _relays.update {
it.updated(relay, relay.copy(feedTypes = newTypes)) it.updated(relay, relay.copy(feedTypes = newTypes))
} }
} }
fun toggleGlobal(relay: Relay) { fun toggleGlobal(relay: RelaySetupInfo) {
val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL) val newTypes = togglePresenceInSet(relay.feedTypes, FeedType.GLOBAL)
_relays.update { _relays.update {
it.updated(relay, relay.copy( feedTypes = newTypes )) it.updated(relay, relay.copy( feedTypes = newTypes ))