Updates the User Profile's Relay List to an outbox version

This commit is contained in:
Vitor Pamplona
2025-08-30 13:49:24 -04:00
parent 9d4c690760
commit dd5ea51575
9 changed files with 160 additions and 129 deletions

View File

@@ -309,7 +309,9 @@ object LocalCache : ILocalCache {
require(isValidHex(key = key)) { "$key is not a valid hex" } require(isValidHex(key = key)) { "$key is not a valid hex" }
return users.getOrCreate(key) { return users.getOrCreate(key) {
User(it) val nip65RelayListNote = getOrCreateAddressableNoteInternal(AdvertisedRelayListEvent.createAddress(key))
val dmRelayListNote = getOrCreateAddressableNoteInternal(ChatMessageRelayListEvent.createAddress(key))
User(it, nip65RelayListNote, dmRelayListNote)
} }
} }

View File

@@ -50,6 +50,8 @@ import java.math.BigDecimal
@Stable @Stable
class User( class User(
val pubkeyHex: String, val pubkeyHex: String,
val nip65RelayListNote: Note,
val dmRelayListNote: Note,
) { ) {
var info: UserMetadata? = null var info: UserMetadata? = null
@@ -72,9 +74,9 @@ class User(
fun pubkeyDisplayHex() = pubkeyNpub().toShortDisplay() fun pubkeyDisplayHex() = pubkeyNpub().toShortDisplay()
fun dmInboxRelayList() = (LocalCache.getAddressableNoteIfExists(ChatMessageRelayListEvent.createAddressTag(pubkeyHex))?.event as? ChatMessageRelayListEvent) fun dmInboxRelayList() = dmRelayListNote.event as? ChatMessageRelayListEvent
fun authorRelayList() = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(pubkeyHex))?.event as? AdvertisedRelayListEvent) fun authorRelayList() = nip65RelayListNote.event as? AdvertisedRelayListEvent
fun toNProfile() = NProfile.create(pubkeyHex, relayHints()) fun toNProfile() = NProfile.create(pubkeyHex, relayHints())
@@ -139,8 +141,6 @@ class User(
?.followers ?.followers
?.invalidateData() ?.invalidateData()
} }
flowSet?.relays?.invalidateData()
} }
fun addReport(note: Note) { fun addReport(note: Note) {
@@ -227,7 +227,7 @@ class User(
here.counter++ here.counter++
} }
flowSet?.relayInfo?.invalidateData() flowSet?.usedRelays?.invalidateData()
} }
fun updateUserInfo( fun updateUserInfo(
@@ -339,20 +339,18 @@ class UserFlowSet(
// Observers line up here. // Observers line up here.
val metadata = UserBundledRefresherFlow(u) val metadata = UserBundledRefresherFlow(u)
val follows = UserBundledRefresherFlow(u) val follows = UserBundledRefresherFlow(u)
val relays = UserBundledRefresherFlow(u)
val followers = UserBundledRefresherFlow(u) val followers = UserBundledRefresherFlow(u)
val reports = UserBundledRefresherFlow(u) val reports = UserBundledRefresherFlow(u)
val relayInfo = UserBundledRefresherFlow(u) val usedRelays = UserBundledRefresherFlow(u)
val zaps = UserBundledRefresherFlow(u) val zaps = UserBundledRefresherFlow(u)
val statuses = UserBundledRefresherFlow(u) val statuses = UserBundledRefresherFlow(u)
fun isInUse(): Boolean = fun isInUse(): Boolean =
metadata.hasObservers() || metadata.hasObservers() ||
relays.hasObservers() ||
follows.hasObservers() || follows.hasObservers() ||
followers.hasObservers() || followers.hasObservers() ||
reports.hasObservers() || reports.hasObservers() ||
relayInfo.hasObservers() || usedRelays.hasObservers() ||
zaps.hasObservers() || zaps.hasObservers() ||
statuses.hasObservers() statuses.hasObservers()
} }

View File

@@ -43,7 +43,6 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
@@ -634,61 +633,18 @@ fun observeUserStatuses(
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable @Composable
fun observeUserRelayIntoList( fun observeUserRelayIntoList(
user: User,
relayUrl: NormalizedRelayUrl, relayUrl: NormalizedRelayUrl,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
): State<Boolean> { ): State<Boolean> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device // Subscribe in the LocalCache for changes that arrive in the device
val flow = val flow =
remember(user) { remember(accountViewModel) {
user accountViewModel.account.trustedRelays.flow
.flow() .mapLatest { relays ->
.relayInfo relayUrl in relays
.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.latestContactList
?.relays()
?.none { it.key == relayUrl } == true
}.distinctUntilChanged() }.distinctUntilChanged()
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
} }
return flow.collectAsStateWithLifecycle(false) return flow.collectAsStateWithLifecycle(false)
} }
data class RelayUsage(
val relays: List<NormalizedRelayUrl> = emptyList(),
val userRelayList: List<NormalizedRelayUrl> = emptyList(),
)
@OptIn(FlowPreview::class)
@Composable
fun observeUserRelaysUsing(
user: User,
accountViewModel: AccountViewModel,
): State<RelayUsage> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
combine(user.flow().relays.stateFlow, user.flow().relayInfo.stateFlow) { relays, relayInfo ->
val userRelaysBeingUsed = relays.user.relaysBeingUsed.map { it.key }
val currentUserRelays =
relayInfo.user.latestContactList
?.relays()
?.map { it.key } ?: emptyList()
RelayUsage(userRelaysBeingUsed, currentUserRelays)
}.sample(1000)
.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(RelayUsage())
}

View File

@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
@@ -67,7 +68,7 @@ fun RelayCompose(
) { ) {
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.spacedBy(Size5dp),
) { ) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text( Text(

View File

@@ -388,7 +388,7 @@ private fun RelayOptionsAction(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val isCurrentlyOnTheUsersList by observeUserRelayIntoList(accountViewModel.userProfile(), relay, accountViewModel) val isCurrentlyOnTheUsersList by observeUserRelayIntoList(relay, accountViewModel)
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
if (isCurrentlyOnTheUsersList) { if (isCurrentlyOnTheUsersList) {

View File

@@ -20,23 +20,29 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.relays
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
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.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.RelayInfo import com.vitorpamplona.amethyst.model.RelayInfo
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.RelayCompose import com.vitorpamplona.amethyst.ui.note.RelayCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@Composable @Composable
fun RelayFeedView( fun RelayFeedView(
@@ -45,16 +51,45 @@ fun RelayFeedView(
nav: INav, nav: INav,
enablePullRefresh: Boolean = true, enablePullRefresh: Boolean = true,
) { ) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle() val outboxListState by viewModel.nip65OutboxFlow.collectAsStateWithLifecycle()
val inboxListState by viewModel.nip65InboxFlow.collectAsStateWithLifecycle()
val dmListState by viewModel.dmInboxFlow.collectAsStateWithLifecycle()
RefresheableBox(viewModel, enablePullRefresh) { RefresheableBox(viewModel, enablePullRefresh) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
LazyColumn( LazyColumn(
contentPadding = FeedPadding, contentPadding = PaddingValues(top = 10.dp, bottom = 20.dp),
state = listState, state = listState,
) { ) {
itemsIndexed(feedState, key = { _, item -> item.url.url }) { _, item -> item {
SettingsCategory(
stringRes(R.string.public_home_section),
stringRes(R.string.public_home_section_explainer_profile),
Modifier.padding(top = 10.dp, bottom = 8.dp, start = 10.dp, end = 10.dp),
)
}
itemsIndexed(outboxListState, key = { _, item -> "outbox" + item.url.url }) { _, item ->
RenderRelayRow(item, accountViewModel, nav)
}
item {
SettingsCategory(
stringRes(R.string.public_notif_section),
stringRes(R.string.public_notif_section_explainer_profile),
Modifier.padding(top = 24.dp, bottom = 8.dp, start = 10.dp, end = 10.dp),
)
}
itemsIndexed(inboxListState, key = { _, item -> "inbox" + item.url.url }) { _, item ->
RenderRelayRow(item, accountViewModel, nav)
}
item {
SettingsCategory(
stringRes(R.string.private_inbox_section),
stringRes(R.string.private_inbox_section_explainer_profile),
Modifier.padding(top = 24.dp, bottom = 8.dp, start = 10.dp, end = 10.dp),
)
}
itemsIndexed(dmListState, key = { _, item -> "dminbox" + item.url.url }) { _, item ->
RenderRelayRow(item, accountViewModel, nav) RenderRelayRow(item, accountViewModel, nav)
} }
} }

View File

@@ -29,18 +29,24 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.RelayInfo import com.vitorpamplona.amethyst.model.RelayInfo
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent
import com.vitorpamplona.ammolite.relays.BundledUpdate
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip02FollowList.ReadWrite import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.update 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 kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.collections.map
@Stable @Stable
class RelayFeedViewModel : class RelayFeedViewModel :
@@ -51,11 +57,90 @@ class RelayFeedViewModel :
.thenByDescending { it.counter } .thenByDescending { it.counter }
.thenBy { it.url.url } .thenBy { it.url.url }
private val _feedContent = MutableStateFlow<List<RelayInfo>>(emptyList()) var currentUser: MutableStateFlow<User?> = MutableStateFlow(null)
val feedContent = _feedContent.asStateFlow()
var currentUser: User? = null fun convert(
var currentJob: Job? = null relays: Set<NormalizedRelayUrl>?,
user: User?,
): List<RelayInfo> {
if (relays == null || user == null) return emptyList()
return relays
.map { relay ->
user.relaysBeingUsed[relay] ?: RelayInfo(relay, 0, 0)
}.sortedWith(order)
}
@OptIn(ExperimentalCoroutinesApi::class)
val nip65OutboxFlow =
currentUser
.transformLatest { user ->
if (user != null) {
emitAll(
combine(
user.nip65RelayListNote
.flow()
.metadata.stateFlow,
user.flow().usedRelays.stateFlow,
) { nip65, userState ->
val relays = (nip65.note.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()?.toSet() ?: emptySet()
convert(relays, userState.user)
},
)
} else {
emit(emptyList<RelayInfo>())
}
}.onStart {
emit(convert((currentUser.value?.nip65RelayListNote?.event as? AdvertisedRelayListEvent)?.writeRelaysNorm()?.toSet(), currentUser.value))
}.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
@OptIn(ExperimentalCoroutinesApi::class)
val nip65InboxFlow =
currentUser
.transformLatest { user ->
if (user != null) {
emitAll(
combine(
user.nip65RelayListNote
.flow()
.metadata.stateFlow,
user.flow().usedRelays.stateFlow,
) { nip65, userState ->
val relays = (nip65.note.event as? AdvertisedRelayListEvent)?.readRelaysNorm()?.toSet() ?: emptySet()
convert(relays, userState.user)
},
)
} else {
emit(emptyList<RelayInfo>())
}
}.onStart {
emit(convert((currentUser.value?.nip65RelayListNote?.event as? AdvertisedRelayListEvent)?.readRelaysNorm()?.toSet(), currentUser.value))
}.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
@OptIn(ExperimentalCoroutinesApi::class)
val dmInboxFlow =
currentUser
.transformLatest { user ->
if (user != null) {
emitAll(
combine(
user.dmRelayListNote
.flow()
.metadata.stateFlow,
user.flow().usedRelays.stateFlow,
) { nip65, userState ->
val relays = (nip65.note.event as? ChatMessageRelayListEvent)?.relays()?.toSet() ?: emptySet()
convert(relays, userState.user)
},
)
} else {
emit(emptyList<RelayInfo>())
}
}.onStart {
emit(convert((currentUser.value?.nip65RelayListNote?.event as? ChatMessageRelayListEvent)?.relays()?.toSet(), currentUser.value))
}.flowOn(Dispatchers.Default)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
override val isRefreshing: MutableState<Boolean> = mutableStateOf(false) override val isRefreshing: MutableState<Boolean> = mutableStateOf(false)
@@ -65,80 +150,36 @@ class RelayFeedViewModel :
} }
} }
fun refreshSuspended() { suspend fun refreshSuspended() {
try { try {
isRefreshing.value = true isRefreshing.value = true
currentUser?.let { delay(1000)
val newList = mergeRelays(it.relaysBeingUsed, it.latestContactList?.relays())
_feedContent.update { newList }
}
} finally { } finally {
isRefreshing.value = false isRefreshing.value = false
} }
} }
fun mergeRelays(
relaysBeingUsed: Map<NormalizedRelayUrl, RelayInfo>,
relays: Map<NormalizedRelayUrl, ReadWrite>?,
): List<RelayInfo> {
val userRelaysBeingUsed = relaysBeingUsed.map { it.value }
val currentUserRelays =
relays?.mapNotNull {
val url = it.key
if (url !in relaysBeingUsed) {
RelayInfo(url, 0, 0)
} else {
null
}
} ?: emptyList()
return (userRelaysBeingUsed + currentUserRelays).sortedWith(order)
}
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
fun subscribeTo(user: User) { fun subscribeTo(user: User) {
if (currentUser != user) { if (currentUser != user) {
currentUser = user currentUser.tryEmit(user)
currentJob?.cancel()
currentJob =
viewModelScope.launch {
combine(currentUser!!.flow().relays.stateFlow, currentUser!!.flow().relayInfo.stateFlow) { relays, relayInfo ->
mergeRelays(relays.user.relaysBeingUsed, relayInfo.user.latestContactList?.relays())
}.debounce(1000)
.collect { newList ->
_feedContent.update { newList }
}
}
invalidateData()
} }
} }
fun unsubscribeTo(user: User) { fun unsubscribeTo(user: User) {
if (currentUser == user) { if (currentUser == user) {
currentUser = null currentUser.tryEmit(null)
currentJob?.cancel()
invalidateData() invalidateData()
} }
} }
private val bundler = BundledUpdate(250, Dispatchers.IO)
override fun invalidateData(ignoreIfDoing: Boolean) { override fun invalidateData(ignoreIfDoing: Boolean) {
bundler.invalidate(ignoreIfDoing) { currentUser.tryEmit(currentUser.value)
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
} }
override fun onCleared() { override fun onCleared() {
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")
bundler.cancel()
currentJob?.cancel()
super.onCleared() super.onCleared()
} }
} }

View File

@@ -25,7 +25,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserRelaysUsing
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -34,9 +33,5 @@ fun RelaysTabHeader(
baseUser: User, baseUser: User,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val userState by observeUserRelaysUsing(baseUser, accountViewModel) Text(text = stringRes(R.string.relays))
Text(text = "${sizeAsString(userState.userRelayList.size)} / ${sizeAsString(userState.relays.size)} ${stringRes(R.string.relays)}")
} }
private fun sizeAsString(count: Int) = if (count > 0) count.toString() else "--"

View File

@@ -1084,10 +1084,13 @@
<string name="dm_upload">DM Upload</string> <string name="dm_upload">DM Upload</string>
<string name="relay_settings">Relay Settings</string> <string name="relay_settings">Relay Settings</string>
<string name="public_home_section">Public Outbox/Home Relays</string> <string name="public_home_section">Public Outbox/Home Relays</string>
<string name="public_home_section_explainer_profile">User is posting his content to these 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 13 relays. They can be personal relays, paid relays or public 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 13 relays. They can be personal relays, paid relays or public relays.</string>
<string name="public_notif_section">Public Inbox Relays</string> <string name="public_notif_section">Public Inbox Relays</string>
<string name="public_notif_section_explainer_profile">User is receiving notifications on these relays</string>
<string name="public_notif_section_explainer">This relay type receives all replies, comments, likes and zaps to your posts. They can be paid or free relays. Limits set by the relay operator can limit the notifications you receive for the good and for the bad. For example, if you are being attacked by comment spam, paid relays can filter the spam out. Insert between 13 relays.</string> <string name="public_notif_section_explainer">This relay type receives all replies, comments, likes and zaps to your posts. They can be paid or free relays. Limits set by the relay operator can limit the notifications you receive for the good and for the bad. For example, if you are being attacked by comment spam, paid relays can filter the spam out. Insert between 13 relays.</string>
<string name="private_inbox_section">DM Inbox Relays</string> <string name="private_inbox_section">DM Inbox Relays</string>
<string name="private_inbox_section_explainer_profile">User receives DMs on these relays</string>
<string name="private_inbox_section_explainer">Insert between 13 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 - auth.nostr1.com (free)\n - you.nostr1.com (personal relays - paid)</string> <string name="private_inbox_section_explainer">Insert between 13 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 - auth.nostr1.com (free)\n - you.nostr1.com (personal relays - paid)</string>
<string name="private_outbox_section">Private Home Relays</string> <string name="private_outbox_section">Private Home Relays</string>
<string name="private_outbox_section_explainer">Insert between 13 relays to store events no one else can see, like your Drafts and/or app settings. Ideally, these relays are either local or require authentication before downloading each user\'s content.</string> <string name="private_outbox_section_explainer">Insert between 13 relays to store events no one else can see, like your Drafts and/or app settings. Ideally, these relays are either local or require authentication before downloading each user\'s content.</string>