feat: add account setting to forward kind 0 events to local relay

Adds a toggle in the Local Relays settings section that, when enabled,
forwards all received kind 0 (profile/metadata) events to the user's
configured local relays.

- AccountSettings: add sendKind0EventsToLocalRelay MutableStateFlow + toggle method
- LocalPreferences: persist the new setting
- ForwardKind0ToLocalRelayState: uses EventCollector to intercept kind 0
  events from any relay and publish them to local relays when enabled
- Account: instantiate ForwardKind0ToLocalRelayState
- AccountViewModel: expose toggleSendKind0ToLocalRelay
- LocalRelayListView: add Switch toggle above the relay list
- strings.xml: add label and description strings

https://claude.ai/code/session_01Er9VV7nHyDb5pihrunb81F
This commit is contained in:
Claude
2026-04-03 10:05:51 +00:00
committed by greenart7c3
parent 843f557fee
commit e82e1ee226
7 changed files with 93 additions and 0 deletions
@@ -91,6 +91,7 @@ private object PrefKeys {
const val NOSTR_PRIVKEY = "nostr_privkey" const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey" const val NOSTR_PUBKEY = "nostr_pubkey"
const val LOCAL_RELAY_SERVERS = "localRelayServers" const val LOCAL_RELAY_SERVERS = "localRelayServers"
const val SEND_KIND0_TO_LOCAL_RELAY = "sendKind0ToLocalRelay"
const val DEFAULT_FILE_SERVER = "defaultFileServer" const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload" const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
@@ -359,6 +360,8 @@ object LocalPreferences {
remove(PrefKeys.LOCAL_RELAY_SERVERS) remove(PrefKeys.LOCAL_RELAY_SERVERS)
} }
putBoolean(PrefKeys.SEND_KIND0_TO_LOCAL_RELAY, settings.sendKind0EventsToLocalRelay.value)
putOrRemove(PrefKeys.LATEST_MUTE_LIST, settings.backupMuteList) putOrRemove(PrefKeys.LATEST_MUTE_LIST, settings.backupMuteList)
putOrRemove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST, settings.backupPrivateHomeRelayList) putOrRemove(PrefKeys.LATEST_PRIVATE_HOME_RELAY_LIST, settings.backupPrivateHomeRelayList)
putOrRemove(PrefKeys.LATEST_APP_SPECIFIC_DATA, settings.backupAppSpecificData) putOrRemove(PrefKeys.LATEST_APP_SPECIFIC_DATA, settings.backupAppSpecificData)
@@ -474,6 +477,7 @@ object LocalPreferences {
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf() val localRelayServers = getStringSet(PrefKeys.LOCAL_RELAY_SERVERS, null) ?: setOf()
val sendKind0ToLocalRelay = getBoolean(PrefKeys.SEND_KIND0_TO_LOCAL_RELAY, false)
val defaultHomeFollowListStr = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) val defaultHomeFollowListStr = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null)
val defaultStoriesFollowListStr = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) val defaultStoriesFollowListStr = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null)
@@ -560,6 +564,7 @@ object LocalPreferences {
transientAccount = false, transientAccount = false,
externalSignerPackageName = externalSignerPackageName, externalSignerPackageName = externalSignerPackageName,
localRelayServers = MutableStateFlow(localRelayServers), localRelayServers = MutableStateFlow(localRelayServers),
sendKind0EventsToLocalRelay = MutableStateFlow(sendKind0ToLocalRelay),
defaultFileServer = defaultFileServer.await(), defaultFileServer = defaultFileServer.await(),
stripLocationOnUpload = stripLocationOnUpload, stripLocationOnUpload = stripLocationOnUpload,
defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList.await()), defaultHomeFollowList = MutableStateFlow(defaultHomeFollowList.await()),
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.logTime
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayState
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountHomeRelayState import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountHomeRelayState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayState import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayState
@@ -262,6 +263,8 @@ class Account(
val userMetadata = UserMetadataState(signer, cache, scope, settings) val userMetadata = UserMetadataState(signer, cache, scope, settings)
val forwardKind0ToLocalRelay = ForwardKind0ToLocalRelayState(client, localRelayList, settings)
override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings.zapPaymentRequest) override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings.zapPaymentRequest)
val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings) val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings)
@@ -159,6 +159,7 @@ class AccountSettings(
val transientAccount: Boolean = false, val transientAccount: Boolean = false,
var externalSignerPackageName: String? = null, var externalSignerPackageName: String? = null,
var localRelayServers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()), var localRelayServers: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val sendKind0EventsToLocalRelay: MutableStateFlow<Boolean> = MutableStateFlow(false),
var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0], var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0],
var stripLocationOnUpload: Boolean = true, var stripLocationOnUpload: Boolean = true,
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows), val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
@@ -424,6 +425,13 @@ class AccountSettings(
} }
} }
fun changeSendKind0EventsToLocalRelay(send: Boolean) {
if (sendKind0EventsToLocalRelay.value != send) {
sendKind0EventsToLocalRelay.tryEmit(send)
saveAccountSettings()
}
}
fun updateUserMetadata(newMetadata: MetadataEvent?) { fun updateUserMetadata(newMetadata: MetadataEvent?) {
if (newMetadata == null) return if (newMetadata == null) return
@@ -0,0 +1,46 @@
/*
* 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.localRelays
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.EventCollector
class ForwardKind0ToLocalRelayState(
val client: INostrClient,
val localRelayList: LocalRelayListState,
val settings: AccountSettings,
) {
private val eventCollector =
EventCollector(client) { event, _ ->
if (event is MetadataEvent && settings.sendKind0EventsToLocalRelay.value) {
val localRelays = localRelayList.flow.value
if (localRelays.isNotEmpty()) {
client.publish(event, localRelays)
}
}
}
fun destroy() {
eventCollector.destroy()
}
}
@@ -1144,6 +1144,10 @@ class AccountViewModel(
fun filterSpamFromStrangers() = account.settings.syncedSettings.security.filterSpamFromStrangers fun filterSpamFromStrangers() = account.settings.syncedSettings.security.filterSpamFromStrangers
fun toggleSendKind0ToLocalRelay(enabled: Boolean) {
account.settings.changeSendKind0EventsToLocalRelay(enabled)
}
fun updateWarnReports(warnReports: Boolean) = launchSigner { account.updateWarnReports(warnReports) } fun updateWarnReports(warnReports: Boolean) = launchSigner { account.updateWarnReports(warnReports) }
fun updateFilterSpam(filterSpam: Boolean) = fun updateFilterSpam(filterSpam: Boolean) =
@@ -25,11 +25,16 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -37,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySet
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog 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.RelayUrlEditField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@@ -65,6 +71,25 @@ fun LazyListScope.renderLocalItems(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
item {
val sendKind0 by accountViewModel.account.settings.sendKind0EventsToLocalRelay
.collectAsStateWithLifecycle()
var checked by remember(sendKind0) { mutableStateOf(sendKind0) }
SettingsRow(
R.string.send_kind0_to_local_relay_title,
R.string.send_kind0_to_local_relay_description,
) {
Switch(
checked = checked,
onCheckedChange = {
checked = it
accountViewModel.toggleSendKind0ToLocalRelay(it)
},
)
}
}
itemsIndexed(feedState, key = { _, item -> "Local" + item.relay.url }) { index, item -> itemsIndexed(feedState, key = { _, item -> "Local" + item.relay.url }) { index, item ->
BasicRelaySetupInfoDialog( BasicRelaySetupInfoDialog(
item, item,
+2
View File
@@ -1517,6 +1517,8 @@
<string name="search_section_explainer">List of relays to use when searching content or users. Tagging and search will not work if no options are available. Make sure they implement NIP-50.</string> <string name="search_section_explainer">List of relays to use when searching content or users. Tagging and search will not work if no options are available. Make sure they implement NIP-50.</string>
<string name="local_section">Local Relays</string> <string name="local_section">Local Relays</string>
<string name="local_section_explainer">List of relays that are running in this device.</string> <string name="local_section_explainer">List of relays that are running in this device.</string>
<string name="send_kind0_to_local_relay_title">Forward profiles to local relay</string>
<string name="send_kind0_to_local_relay_description">Send all received profile (kind 0) events to your local relay</string>
<string name="trusted_relays_title">Trusted Relays</string> <string name="trusted_relays_title">Trusted Relays</string>
<string name="trusted_section">Trusted Relays</string> <string name="trusted_section">Trusted Relays</string>