From f1650ad9ce231343e5d908c4ffc123ccec22478d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 08:44:32 +0000 Subject: [PATCH 1/2] feat(privacy): add per-account toggle to disable NIP-89 client tag Adds a "Don't add client tag to my events" switch under Security Filters. NostrSignerWithClientTag now consults a runtime predicate at sign-time, so the toggle takes effect immediately on the live session without rewrapping the signer or rebuilding Account. https://claude.ai/code/session_01KhNVTm8LLdVphqyxVkZtDS --- .../vitorpamplona/amethyst/LocalPreferences.kt | 4 ++++ .../amethyst/model/AccountSettings.kt | 8 ++++++++ .../model/accountsCache/AccountCacheState.kt | 7 ++++++- .../loggedIn/settings/SecurityFiltersScreen.kt | 15 +++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 ++ .../clientTag/NostrSignerWithClientTag.kt | 14 +++++++++++++- 6 files changed, 48 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index ce6324ffc..864a2706a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -95,6 +95,7 @@ private object PrefKeys { const val LOCAL_RELAY_SERVERS = "localRelayServers" const val DEFAULT_FILE_SERVER = "defaultFileServer" const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload" + const val DISABLE_CLIENT_TAG = "disableClientTag" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" @@ -346,6 +347,7 @@ object LocalPreferences { ) putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload) + putBoolean(PrefKeys.DISABLE_CLIENT_TAG, settings.disableClientTag) putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value)) putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value)) @@ -513,6 +515,7 @@ object LocalPreferences { Log.d("LocalPreferences") { "Load account from file $npub - keys ready" } val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true) + val disableClientTag = getBoolean(PrefKeys.DISABLE_CLIENT_TAG, false) val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) @@ -620,6 +623,7 @@ object LocalPreferences { localRelayServers = MutableStateFlow(localRelayServers), defaultFileServer = defaultFileServer.await(), stripLocationOnUpload = stripLocationOnUpload, + disableClientTag = disableClientTag, defaultHomeFollowList = MutableStateFlow(followListPrefs.home), defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories), defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index b6aace912..ea8b1b3c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -149,6 +149,7 @@ class AccountSettings( var localRelayServers: MutableStateFlow> = MutableStateFlow(setOf()), var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0], var stripLocationOnUpload: Boolean = true, + var disableClientTag: Boolean = false, val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), @@ -403,6 +404,13 @@ class AccountSettings( } } + fun changeDisableClientTag(disable: Boolean) { + if (disableClientTag != disable) { + disableClientTag = disable + saveAccountSettings() + } + } + // --- // list names // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index bec0e724d..01d416674 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -135,7 +135,12 @@ class AccountCacheState( val cached = accounts.value[signer.pubKey] if (cached != null) return cached - val signerWithClientTag = NostrSignerWithClientTag(signer, CLIENT_TAG_NAME) + val signerWithClientTag = + NostrSignerWithClientTag( + inner = signer, + clientName = CLIENT_TAG_NAME, + disabled = { accountSettings.disableClientTag }, + ) val accountDir = File(rootFilesDir(), "accounts/${signer.pubKey}").apply { mkdirs() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 0cd081e4d..544788b17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -371,6 +371,21 @@ private fun HeaderOptions(accountViewModel: AccountViewModel) { ) } + SettingsRow( + R.string.disable_client_tag_title, + R.string.disable_client_tag_explainer, + ) { + var disableClientTag by remember { mutableStateOf(accountViewModel.account.settings.disableClientTag) } + + Switch( + checked = disableClientTag, + onCheckedChange = { + disableClientTag = it + accountViewModel.account.settings.changeDisableClientTag(it) + }, + ) + } + SettingsRow( R.string.show_sensitive_content_title, R.string.show_sensitive_content_explainer, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 63e4dac1e..acc44f19e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1172,6 +1172,8 @@ Filter spam Hides posts from strangers that were exactly the same for 5 or more times + Don\'t add client tag to my events + When enabled, Amethyst will not append a NIP-89 client tag to events you publish. Warn on reports Shows a warning message when posts have 5 or more reports from your follows Show sensitive content diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt index 1aa8b4de6..5856a0ebf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/NostrSignerWithClientTag.kt @@ -40,12 +40,19 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent class NostrSignerWithClientTag( val inner: NostrSigner, val clientTag: Array, + val disabled: () -> Boolean = { false }, ) : NostrSigner(inner.pubKey) { constructor( inner: NostrSigner, clientName: String, ) : this(inner, ClientTag.assemble(clientName)) + constructor( + inner: NostrSigner, + clientName: String, + disabled: () -> Boolean, + ) : this(inner, ClientTag.assemble(clientName), disabled) + constructor( inner: NostrSigner, clientName: String, @@ -60,7 +67,12 @@ class NostrSignerWithClientTag( kind: Int, tags: Array>, content: String, - ): T = inner.sign(createdAt, kind, appendClientTag(tags), content) + ): T = + if (disabled()) { + inner.sign(createdAt, kind, tags, content) + } else { + inner.sign(createdAt, kind, appendClientTag(tags), content) + } override suspend fun nip04Encrypt( plaintext: String, From 8559712a55922560e531b6a47b0569ce133a2f8f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 09:23:30 +0000 Subject: [PATCH 2/2] refactor(privacy): move disableClientTag into NIP-78-synced security settings Aligns the toggle with its Security Filters siblings (warnAboutPostsWithReports, filterSpamFromStrangers, etc.): stored in AccountSecurityPreferences{Internal}, synced across devices via the app-specific data event, and updated through account.updateDisableClientTag -> sendNewAppSpecificData. The signer wrapper now reads the live value from synced settings, so changes still apply immediately to the next signed event. https://claude.ai/code/session_01KhNVTm8LLdVphqyxVkZtDS --- .../vitorpamplona/amethyst/LocalPreferences.kt | 4 ---- .../com/vitorpamplona/amethyst/model/Account.kt | 8 ++++++++ .../amethyst/model/AccountSettings.kt | 10 +++++----- .../amethyst/model/AccountSyncedSettings.kt | 15 +++++++++++++++ .../model/AccountSyncedSettingsInternal.kt | 1 + .../model/accountsCache/AccountCacheState.kt | 2 +- .../ui/screen/loggedIn/AccountViewModel.kt | 2 ++ .../loggedIn/settings/SecurityFiltersScreen.kt | 4 ++-- 8 files changed, 34 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 864a2706a..ce6324ffc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -95,7 +95,6 @@ private object PrefKeys { const val LOCAL_RELAY_SERVERS = "localRelayServers" const val DEFAULT_FILE_SERVER = "defaultFileServer" const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload" - const val DISABLE_CLIENT_TAG = "disableClientTag" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" @@ -347,7 +346,6 @@ object LocalPreferences { ) putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload) - putBoolean(PrefKeys.DISABLE_CLIENT_TAG, settings.disableClientTag) putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value)) putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value)) @@ -515,7 +513,6 @@ object LocalPreferences { Log.d("LocalPreferences") { "Load account from file $npub - keys ready" } val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true) - val disableClientTag = getBoolean(PrefKeys.DISABLE_CLIENT_TAG, false) val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) @@ -623,7 +620,6 @@ object LocalPreferences { localRelayServers = MutableStateFlow(localRelayServers), defaultFileServer = defaultFileServer.await(), stripLocationOnUpload = stripLocationOnUpload, - disableClientTag = disableClientTag, defaultHomeFollowList = MutableStateFlow(followListPrefs.home), defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories), defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 10e9d405c..6154f2693 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -529,6 +529,14 @@ class Account( return false } + suspend fun updateDisableClientTag(disable: Boolean): Boolean { + if (settings.updateDisableClientTag(disable)) { + sendNewAppSpecificData() + return true + } + return false + } + suspend fun updateFilterSpam(filterSpam: Boolean): Boolean { if (settings.updateFilterSpam(filterSpam)) { if (!settings.syncedSettings.security.filterSpamFromStrangers.value) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index ea8b1b3c5..568925f1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -149,7 +149,6 @@ class AccountSettings( var localRelayServers: MutableStateFlow> = MutableStateFlow(setOf()), var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0], var stripLocationOnUpload: Boolean = true, - var disableClientTag: Boolean = false, val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), @@ -404,12 +403,13 @@ class AccountSettings( } } - fun changeDisableClientTag(disable: Boolean) { - if (disableClientTag != disable) { - disableClientTag = disable + fun updateDisableClientTag(disable: Boolean): Boolean = + if (syncedSettings.security.updateDisableClientTag(disable)) { saveAccountSettings() + true + } else { + false } - } // --- // list names diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index adc3299ca..9d3721b3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -55,6 +55,7 @@ class AccountSyncedSettings( MutableStateFlow(internalSettings.security.filterSpamFromStrangers), MutableStateFlow(internalSettings.security.maxHashtagLimit), MutableStateFlow(internalSettings.security.sendKind0EventsToLocalRelay), + MutableStateFlow(internalSettings.security.disableClientTag), ) val videoPlayer = AccountVideoPlayerPreferences( @@ -82,6 +83,7 @@ class AccountSyncedSettings( security.filterSpamFromStrangers.value, security.maxHashtagLimit.value, security.sendKind0EventsToLocalRelay.value, + security.disableClientTag.value, ), videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value), ) @@ -138,6 +140,10 @@ class AccountSyncedSettings( security.sendKind0EventsToLocalRelay.tryEmit(syncedSettingsInternal.security.sendKind0EventsToLocalRelay) } + if (security.disableClientTag.value != syncedSettingsInternal.security.disableClientTag) { + security.disableClientTag.tryEmit(syncedSettingsInternal.security.disableClientTag) + } + val newVideoPlayerButtonItems = syncedSettingsInternal.videoPlayer.buttonItems.toImmutableList() if (!equalImmutableLists(videoPlayer.buttonItems.value, newVideoPlayerButtonItems)) { videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems) @@ -233,6 +239,7 @@ class AccountSecurityPreferences( var filterSpamFromStrangers: MutableStateFlow = MutableStateFlow(true), val maxHashtagLimit: MutableStateFlow = MutableStateFlow(5), var sendKind0EventsToLocalRelay: MutableStateFlow = MutableStateFlow(false), + val disableClientTag: MutableStateFlow = MutableStateFlow(false), ) { fun updateShowSensitiveContent(show: Boolean?): Boolean { if (showSensitiveContent.value != show) { @@ -273,4 +280,12 @@ class AccountSecurityPreferences( } else { false } + + fun updateDisableClientTag(disable: Boolean): Boolean = + if (disable != disableClientTag.value) { + disableClientTag.tryEmit(disable) + true + } else { + false + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index efc03475a..a272bfe02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -147,4 +147,5 @@ class AccountSecurityPreferencesInternal( var filterSpamFromStrangers: Boolean = true, val maxHashtagLimit: Int = 5, var sendKind0EventsToLocalRelay: Boolean = false, + var disableClientTag: Boolean = false, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 01d416674..655af6970 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -139,7 +139,7 @@ class AccountCacheState( NostrSignerWithClientTag( inner = signer, clientName = CLIENT_TAG_NAME, - disabled = { accountSettings.disableClientTag }, + disabled = { accountSettings.syncedSettings.security.disableClientTag.value }, ) val accountDir = File(rootFilesDir(), "accounts/${signer.pubKey}").apply { mkdirs() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 60a1d5624..886cf761f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1112,6 +1112,8 @@ class AccountViewModel( fun updateWarnReports(warnReports: Boolean) = launchSigner { account.updateWarnReports(warnReports) } + fun updateDisableClientTag(disable: Boolean) = launchSigner { account.updateDisableClientTag(disable) } + fun updateFilterSpam(filterSpam: Boolean) = launchSigner { if (account.updateFilterSpam(filterSpam)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 544788b17..2dda65234 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -375,13 +375,13 @@ private fun HeaderOptions(accountViewModel: AccountViewModel) { R.string.disable_client_tag_title, R.string.disable_client_tag_explainer, ) { - var disableClientTag by remember { mutableStateOf(accountViewModel.account.settings.disableClientTag) } + var disableClientTag by remember { mutableStateOf(accountViewModel.account.settings.syncedSettings.security.disableClientTag.value) } Switch( checked = disableClientTag, onCheckedChange = { disableClientTag = it - accountViewModel.account.settings.changeDisableClientTag(it) + accountViewModel.updateDisableClientTag(disableClientTag) }, ) }