Adds NIP-65 relay settings

This commit is contained in:
Vitor Pamplona 2024-05-29 10:39:08 -04:00
parent 51a6eb4974
commit aa97c7ee9b
5 changed files with 740 additions and 2 deletions

View File

@ -2651,6 +2651,20 @@ class Account(
}
}
fun getNIP65RelayListNote(): AddressableNote {
return LocalCache.getOrCreateAddressableNote(
AdvertisedRelayListEvent.createAddressATag(signer.pubKey),
)
}
fun getNIP65RelayListFlow(): StateFlow<NoteState> {
return getNIP65RelayListNote().flow().metadata.stateFlow
}
fun getNIP65RelayList(): AdvertisedRelayListEvent? {
return getNIP65RelayListNote().event as? AdvertisedRelayListEvent
}
fun sendNip65RelayList(relays: List<AdvertisedRelayListEvent.AdvertisedRelayInfo>) {
if (!isWriteable()) return

View File

@ -64,13 +64,19 @@ fun AllRelayListView(
nav: (String) -> Unit,
) {
val kind3ViewModel: Kind3RelayListViewModel = viewModel()
val dmViewModel: DMRelayListViewModel = viewModel()
val kind3FeedState by kind3ViewModel.relays.collectAsStateWithLifecycle()
val dmViewModel: DMRelayListViewModel = viewModel()
val dmFeedState by dmViewModel.relays.collectAsStateWithLifecycle()
val nip65ViewModel: Nip65RelayListViewModel = viewModel()
val homeFeedState by nip65ViewModel.homeRelays.collectAsStateWithLifecycle()
val notifFeedState by nip65ViewModel.notificationRelays.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
kind3ViewModel.load(accountViewModel.account)
dmViewModel.load(accountViewModel.account)
nip65ViewModel.load(accountViewModel.account)
}
Dialog(
@ -90,6 +96,7 @@ fun AllRelayListView(
onPost = {
kind3ViewModel.create()
dmViewModel.create()
nip65ViewModel.create()
onClose()
},
true,
@ -125,11 +132,27 @@ fun AllRelayListView(
verticalArrangement = Arrangement.SpaceAround,
) {
LazyColumn(contentPadding = FeedPadding) {
item {
SettingsCategory(
stringResource(R.string.public_home_section),
stringResource(R.string.public_home_section_explainer),
Modifier.padding(bottom = 8.dp),
)
}
renderNip65HomeItems(homeFeedState, nip65ViewModel, accountViewModel, onClose, nav)
item {
SettingsCategory(
stringResource(R.string.public_notif_section),
stringResource(R.string.public_notif_section_explainer),
)
}
renderNip65NotifItems(notifFeedState, nip65ViewModel, accountViewModel, onClose, nav)
item {
SettingsCategory(
stringResource(R.string.private_inbox_section),
stringResource(R.string.private_inbox_section_explainer),
Modifier.padding(bottom = 8.dp),
)
}
renderDMItems(dmFeedState, dmViewModel, accountViewModel, onClose, nav)

View File

@ -0,0 +1,502 @@
/**
* Copyright (c) 2024 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.actions.relays
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Paid
import androidx.compose.material.icons.filled.SyncProblem
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.ui.actions.RelayInfoDialog
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.HalfHorzPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.WarningColor
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.largeRelayIconModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.warningColor
import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
import kotlinx.coroutines.launch
@Composable
fun Nip65RelayList(
postViewModel: Nip65RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
val homeFeedState by postViewModel.homeRelays.collectAsStateWithLifecycle()
val notifFeedState by postViewModel.notificationRelays.collectAsStateWithLifecycle()
Row(verticalAlignment = Alignment.CenterVertically) {
LazyColumn(
contentPadding = FeedPadding,
) {
renderNip65HomeItems(homeFeedState, postViewModel, accountViewModel, onClose, nav)
renderNip65NotifItems(notifFeedState, postViewModel, accountViewModel, onClose, nav)
}
}
}
fun LazyListScope.renderNip65HomeItems(
feedState: List<Nip65RelayListViewModel.Nip65RelaySetupInfo>,
postViewModel: Nip65RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
itemsIndexed(feedState, key = { _, item -> "Nip65Home" + item.url }) { index, item ->
Nip65ServerConfig(
item,
onDelete = { postViewModel.deleteHomeRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
item {
Spacer(modifier = StdVertSpacer)
Nip65EditableServerConfig { postViewModel.addHomeRelay(it) }
}
}
fun LazyListScope.renderNip65NotifItems(
feedState: List<Nip65RelayListViewModel.Nip65RelaySetupInfo>,
postViewModel: Nip65RelayListViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: (String) -> Unit,
) {
itemsIndexed(feedState, key = { _, item -> "Nip65Notif" + item.url }) { index, item ->
Nip65ServerConfig(
item,
onDelete = { postViewModel.deleteNotifRelay(item) },
accountViewModel = accountViewModel,
) {
onClose()
nav(it)
}
}
item {
Spacer(modifier = StdVertSpacer)
Nip65EditableServerConfig { postViewModel.addNotifRelay(it) }
}
}
@Composable
fun Nip65ServerConfig(
item: Nip65RelayListViewModel.Nip65RelaySetupInfo,
onDelete: (Nip65RelayListViewModel.Nip65RelaySetupInfo) -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
var relayInfo: RelayInfoDialog? by remember { mutableStateOf(null) }
val context = LocalContext.current
relayInfo?.let {
RelayInformationDialog(
onClose = { relayInfo = null },
relayInfo = it.relayInfo,
relayBriefInfo = it.relayBriefInfo,
accountViewModel = accountViewModel,
nav = nav,
)
}
val automaticallyShowProfilePicture =
remember {
accountViewModel.settings.showProfilePictures.value
}
Nip65ServerConfigClickableLine(
item = item,
loadProfilePicture = automaticallyShowProfilePicture,
onDelete = onDelete,
accountViewModel = accountViewModel,
onClick = {
accountViewModel.retrieveRelayDocument(
item.url,
onInfo = { relayInfo = RelayInfoDialog(RelayBriefInfoCache.RelayBriefInfo(item.url), it) },
onError = { url, errorCode, exceptionMessage ->
val msg =
when (errorCode) {
Nip11Retriever.ErrorCode.FAIL_TO_ASSEMBLE_URL ->
context.getString(
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_REACH_SERVER ->
context.getString(
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_TO_PARSE_RESULT ->
context.getString(
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
Nip11Retriever.ErrorCode.FAIL_WITH_HTTP_STATUS ->
context.getString(
R.string.relay_information_document_error_assemble_url,
url,
exceptionMessage,
)
}
accountViewModel.toast(
context.getString(R.string.unable_to_download_relay_document),
msg,
)
},
)
},
)
}
@Composable
fun Nip65ServerConfigClickableLine(
item: Nip65RelayListViewModel.Nip65RelaySetupInfo,
loadProfilePicture: Boolean,
onDelete: (Nip65RelayListViewModel.Nip65RelaySetupInfo) -> Unit,
onClick: () -> Unit,
accountViewModel: AccountViewModel,
) {
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp),
) {
Column(Modifier.clickable(onClick = onClick)) {
val iconUrlFromRelayInfoDoc =
remember(item) {
Nip11CachedRetriever.getFromCache(item.url)?.icon
}
RenderRelayIcon(
item.briefInfo.displayUrl,
iconUrlFromRelayInfoDoc ?: item.briefInfo.favIcon,
loadProfilePicture,
MaterialTheme.colorScheme.largeRelayIconModifier,
)
}
Spacer(modifier = HalfHorzPadding)
Column(Modifier.weight(1f)) {
FirstLine(item, onClick, onDelete, ReactionRowHeightChat.fillMaxWidth())
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat.fillMaxWidth(),
) {
StatusRow(
item = item,
modifier = HalfStartPadding.weight(1f),
accountViewModel = accountViewModel,
)
}
}
}
HorizontalDivider(thickness = DividerThickness)
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun StatusRow(
item: Nip65RelayListViewModel.Nip65RelaySetupInfo,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
Icon(
imageVector = Icons.Default.Download,
contentDescription = stringResource(R.string.read_from_relay),
modifier =
Modifier
.size(15.dp)
.combinedClickable(
onClick = {},
onLongClick = {
accountViewModel.toast(
R.string.read_from_relay,
R.string.read_from_relay_description,
)
},
),
tint = MaterialTheme.colorScheme.allGoodColor,
)
Text(
text = countToHumanReadableBytes(item.downloadCountInBytes),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colorScheme.placeholderText,
)
Icon(
imageVector = Icons.Default.Upload,
stringResource(R.string.write_to_relay),
modifier =
Modifier
.size(15.dp)
.combinedClickable(
onClick = { },
onLongClick = {
accountViewModel.toast(
R.string.write_to_relay,
R.string.write_to_relay_description,
)
},
),
tint = MaterialTheme.colorScheme.allGoodColor,
)
Text(
text = countToHumanReadableBytes(item.uploadCountInBytes),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colorScheme.placeholderText,
)
Icon(
imageVector = Icons.Default.SyncProblem,
stringResource(R.string.errors),
modifier =
Modifier
.size(15.dp)
.combinedClickable(
onClick = {},
onLongClick = {
accountViewModel.toast(
R.string.errors,
R.string.errors_description,
)
},
),
tint =
if (item.errorCount > 0) {
MaterialTheme.colorScheme.warningColor
} else {
MaterialTheme.colorScheme.allGoodColor
},
)
Text(
text = countToHumanReadable(item.errorCount, "errors"),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colorScheme.placeholderText,
)
Icon(
imageVector = Icons.Default.DeleteSweep,
stringResource(R.string.spam),
modifier =
Modifier
.size(15.dp)
.combinedClickable(
onClick = {},
onLongClick = {
accountViewModel.toast(
R.string.spam,
R.string.spam_description,
)
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.spam),
Toast.LENGTH_SHORT,
)
.show()
}
},
),
tint =
if (item.spamCount > 0) {
MaterialTheme.colorScheme.warningColor
} else {
MaterialTheme.colorScheme.allGoodColor
},
)
Text(
text = countToHumanReadable(item.spamCount, "spam"),
maxLines = 1,
fontSize = 12.sp,
modifier = modifier,
color = MaterialTheme.colorScheme.placeholderText,
)
}
@Composable
private fun FirstLine(
item: Nip65RelayListViewModel.Nip65RelaySetupInfo,
onClick: () -> Unit,
onDelete: (Nip65RelayListViewModel.Nip65RelaySetupInfo) -> Unit,
modifier: Modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(
text = item.briefInfo.displayUrl,
modifier = Modifier.clickable(onClick = onClick),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (item.paidRelay) {
Icon(
imageVector = Icons.Default.Paid,
null,
modifier =
Modifier
.padding(start = 5.dp, top = 1.dp)
.size(14.dp),
tint = MaterialTheme.colorScheme.allGoodColor,
)
}
}
IconButton(
modifier = Modifier.size(30.dp),
onClick = { onDelete(item) },
) {
Icon(
imageVector = Icons.Default.Cancel,
contentDescription = stringResource(id = R.string.remove),
modifier =
Modifier
.padding(start = 10.dp)
.size(15.dp),
tint = WarningColor,
)
}
}
}
@Composable
fun Nip65EditableServerConfig(onNewRelay: (Nip65RelayListViewModel.Nip65RelaySetupInfo) -> Unit) {
var url by remember { mutableStateOf("") }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Size10dp)) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.add_a_relay)) },
modifier = Modifier.weight(1f),
value = url,
onValueChange = { url = it },
placeholder = {
Text(
text = "server.com",
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
},
singleLine = true,
)
Button(
onClick = {
if (url.isNotBlank() && url != "/") {
val addedWSS = RelayUrlFormatter.normalize(url)
onNewRelay(Nip65RelayListViewModel.Nip65RelaySetupInfo(addedWSS))
url = ""
}
},
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor =
if (url.isNotBlank()) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.placeholderText
},
),
) {
Text(text = stringResource(id = R.string.add), color = Color.White)
}
}
}

View File

@ -0,0 +1,195 @@
/**
* Copyright (c) 2024 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.actions.relays
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelayBriefInfoCache
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.quartz.events.AdvertisedRelayListEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class Nip65RelayListViewModel : ViewModel() {
private lateinit var account: Account
private val _homeRelays = MutableStateFlow<List<Nip65RelaySetupInfo>>(emptyList())
val homeRelays = _homeRelays.asStateFlow()
private val _notificationRelays = MutableStateFlow<List<Nip65RelaySetupInfo>>(emptyList())
val notificationRelays = _notificationRelays.asStateFlow()
fun load(account: Account) {
this.account = account
clear()
loadRelayDocuments()
}
fun create() {
viewModelScope.launch(Dispatchers.IO) {
val writes = _homeRelays.value.map { it.url }.toSet()
val reads = _notificationRelays.value.map { it.url }.toSet()
val urls = writes.union(reads)
account.sendNip65RelayList(
urls.map {
val type =
if (writes.contains(it) && reads.contains(it)) {
AdvertisedRelayListEvent.AdvertisedRelayType.BOTH
} else if (writes.contains(it)) {
AdvertisedRelayListEvent.AdvertisedRelayType.WRITE
} else {
AdvertisedRelayListEvent.AdvertisedRelayType.READ
}
AdvertisedRelayListEvent.AdvertisedRelayInfo(it, type)
},
)
clear()
}
}
fun loadRelayDocuments() {
viewModelScope.launch(Dispatchers.IO) {
_homeRelays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
dirtyUrl = item.url,
onInfo = {
toggleHomePaidRelay(item, it.limitation?.payment_required ?: false)
},
onError = { url, errorCode, exceptionMessage -> },
)
}
_notificationRelays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
dirtyUrl = item.url,
onInfo = {
toggleNotifPaidRelay(item, it.limitation?.payment_required ?: false)
},
onError = { url, errorCode, exceptionMessage -> },
)
}
}
}
@Immutable
data class Nip65RelaySetupInfo(
val url: String,
val errorCount: Int = 0,
val downloadCountInBytes: Int = 0,
val uploadCountInBytes: Int = 0,
val spamCount: Int = 0,
val paidRelay: Boolean = false,
) {
val briefInfo: RelayBriefInfoCache.RelayBriefInfo = RelayBriefInfoCache.RelayBriefInfo(url)
}
fun clear() {
_homeRelays.update {
val relayList = account.getNIP65RelayList()?.relays() ?: emptyList()
relayList.filter { it.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || it.type == AdvertisedRelayListEvent.AdvertisedRelayType.WRITE }.map { relayUrl ->
val liveRelay = RelayPool.getRelay(relayUrl.relayUrl)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
Nip65RelaySetupInfo(
relayUrl.relayUrl,
errorCounter,
eventDownloadCounter,
eventUploadCounter,
spamCounter,
)
}.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed()
}
_notificationRelays.update {
val relayList = account.getNIP65RelayList()?.relays() ?: emptyList()
relayList.filter { it.type == AdvertisedRelayListEvent.AdvertisedRelayType.BOTH || it.type == AdvertisedRelayListEvent.AdvertisedRelayType.READ }.map { relayUrl ->
val liveRelay = RelayPool.getRelay(relayUrl.relayUrl)
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
val spamCounter = liveRelay?.spamCounter ?: 0
Nip65RelaySetupInfo(
relayUrl.relayUrl,
errorCounter,
eventDownloadCounter,
eventUploadCounter,
spamCounter,
)
}.distinctBy { it.url }.sortedBy { it.downloadCountInBytes }.reversed()
}
}
fun addHomeRelay(relay: Nip65RelaySetupInfo) {
if (_homeRelays.value.any { it.url == relay.url }) return
_homeRelays.update { it.plus(relay) }
}
fun deleteHomeRelay(relay: Nip65RelaySetupInfo) {
_homeRelays.update { it.minus(relay) }
}
fun deleteHomeAll() {
_homeRelays.update { relays -> emptyList() }
}
fun toggleHomePaidRelay(
relay: Nip65RelaySetupInfo,
paid: Boolean,
) {
_homeRelays.update { it.updated(relay, relay.copy(paidRelay = paid)) }
}
fun addNotifRelay(relay: Nip65RelaySetupInfo) {
if (_notificationRelays.value.any { it.url == relay.url }) return
_notificationRelays.update { it.plus(relay) }
}
fun deleteNotifRelay(relay: Nip65RelaySetupInfo) {
_notificationRelays.update { it.minus(relay) }
}
fun deleteNotifAll() {
_notificationRelays.update { relays -> emptyList() }
}
fun toggleNotifPaidRelay(
relay: Nip65RelaySetupInfo,
paid: Boolean,
) {
_notificationRelays.update { it.updated(relay, relay.copy(paidRelay = paid)) }
}
}

View File

@ -812,6 +812,10 @@
<string name="dm_relays_not_found_editing">Insert between 1-3 relays to serve as your private inbox. DM Inbox relays should accept any message from anyone, but only allow you to download them.</string>
<string name="dm_relays_not_found_create_now">Set up now</string>
<string name="public_home_section">Public Home Relays</string>
<string name="public_home_section_explainer">This relay type stores all your content. Amethyst will send your posts here and others will use these relays to find your content. Insert between 1-3 relays. They can be personal relays or paid relays.</string>
<string name="public_notif_section">Public Inbox Relays</string>
<string name="public_notif_section_explainer">This relay type receives events that others are sending to you, like replies, comments, likes, zaps, etc. Insert between 1-3 relays that accept posts from anyone.</string>
<string name="private_inbox_section">DM Inbox Relays</string>
<string name="private_inbox_section_explainer">Insert between 1-3 relays to serve as your private inbox. Others will use these relays to send DMs to you. DM Inbox relays should accept any message from anyone, but only allow you to download them. Good options are:\n - inbox.nostr.wine (paid)\n - you.nostr1.com (personal relays - paid)</string>
<string name="kind_3_section">General Relays</string>