From 2a24b711cd7afc807e9d1d35bc56bdeec86b0915 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 12:34:32 +0000 Subject: [PATCH] feat: add configurable max hashtag limit filter for spam prevention Adds a security setting to hide posts with more than X hashtags (t tags), defaulting to 5. Applied at the feed filter level via FilterByListParams.match() and Account.isAcceptable() to cover all feeds. Configurable in Security Filters settings, with 0 to disable. https://claude.ai/code/session_01NMVypgGSU9EjQA7hZ9uvjD --- .../vitorpamplona/amethyst/model/Account.kt | 13 +++++++++ .../amethyst/model/AccountSettings.kt | 8 +++++ .../amethyst/model/AccountSyncedSettings.kt | 15 ++++++++++ .../model/AccountSyncedSettingsInternal.kt | 1 + .../model/nip51Lists/HiddenUsersState.kt | 8 +++-- .../amethyst/ui/dal/FilterByListParams.kt | 6 +++- .../ui/screen/loggedIn/AccountViewModel.kt | 2 ++ .../settings/SecurityFiltersScreen.kt | 29 +++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 ++ .../amethyst/commons/model/IAccount.kt | 1 + .../nip01Core/tags/hashtags/EventExt.kt | 2 ++ 11 files changed, 84 insertions(+), 3 deletions(-) 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 0f30f4259..09bff4c1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -143,6 +143,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.references.references @@ -450,6 +451,12 @@ class Account( } } + suspend fun updateMaxHashtagLimit(limit: Int) { + if (settings.updateMaxHashtagLimit(limit)) { + sendNewAppSpecificData() + } + } + suspend fun changeReactionTypes(reactionSet: List) { if (settings.changeReactionTypes(reactionSet)) { sendNewAppSpecificData() @@ -2024,10 +2031,16 @@ class Account( fun isKnown(user: HexKey): Boolean = user in allFollows.flow.value.authors + private fun hasExcessiveHashtags(note: Note): Boolean { + val limit = settings.syncedSettings.security.maxHashtagLimit.value + return limit > 0 && (note.event?.countHashtags() ?: 0) > limit + } + override fun isAcceptable(note: Note): Boolean { return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author isAcceptableDirect(note) && + !hasExcessiveHashtags(note) && ( (note.event !is RepostEvent && note.event !is GenericRepostEvent) || ( 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 093a9a1cf..1d931de19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -753,4 +753,12 @@ class AccountSettings( } else { false } + + fun updateMaxHashtagLimit(limit: Int): Boolean = + if (syncedSettings.security.updateMaxHashtagLimit(limit)) { + saveAccountSettings() + true + } else { + false + } } 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 a1ab6665b..ed5e58ca0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -53,6 +53,7 @@ class AccountSyncedSettings( MutableStateFlow(internalSettings.security.showSensitiveContent), internalSettings.security.warnAboutPostsWithReports, MutableStateFlow(internalSettings.security.filterSpamFromStrangers), + MutableStateFlow(internalSettings.security.maxHashtagLimit), ) fun toInternal(): AccountSyncedSettingsInternal = @@ -74,6 +75,7 @@ class AccountSyncedSettings( security.showSensitiveContent.value, security.warnAboutPostsWithReports, security.filterSpamFromStrangers.value, + security.maxHashtagLimit.value, ), ) @@ -120,6 +122,10 @@ class AccountSyncedSettings( if (security.warnAboutPostsWithReports != syncedSettingsInternal.security.warnAboutPostsWithReports) { security.warnAboutPostsWithReports = syncedSettingsInternal.security.warnAboutPostsWithReports } + + if (security.maxHashtagLimit.value != syncedSettingsInternal.security.maxHashtagLimit) { + security.maxHashtagLimit.tryEmit(syncedSettingsInternal.security.maxHashtagLimit) + } } fun dontTranslateFromFilteredBySpokenLanguages(): Set = languages.dontTranslateFrom.value - getLanguagesSpokenByUser() @@ -204,6 +210,7 @@ class AccountSecurityPreferences( val showSensitiveContent: MutableStateFlow = MutableStateFlow(null), var warnAboutPostsWithReports: Boolean = true, var filterSpamFromStrangers: MutableStateFlow = MutableStateFlow(true), + val maxHashtagLimit: MutableStateFlow = MutableStateFlow(5), ) { fun updateShowSensitiveContent(show: Boolean?): Boolean { if (showSensitiveContent.value != show) { @@ -228,4 +235,12 @@ class AccountSecurityPreferences( } else { false } + + fun updateMaxHashtagLimit(limit: Int): Boolean = + if (maxHashtagLimit.value != limit) { + maxHashtagLimit.update { limit } + 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 5dbb8727b..d10a027f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -105,4 +105,5 @@ class AccountSecurityPreferencesInternal( val showSensitiveContent: Boolean? = null, var warnAboutPostsWithReports: Boolean = true, var filterSpamFromStrangers: Boolean = true, + val maxHashtagLimit: Int = 5, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt index 8f45d8534..ef18b8b4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/HiddenUsersState.kt @@ -52,6 +52,7 @@ class HiddenUsersState( muteList: List, transientHiddenUsers: Set, showSensitiveContent: Boolean?, + maxHashtagLimit: Int, ): LiveHiddenUsers { val hiddenUsers = blockList.mapNotNullTo(mutableSetOf()) { if (it is UserTag) it.pubKey else null } + muteList.mapNotNull { if (it is UserTag) it.pubKey else null } val hiddenWords = blockList.mapNotNullTo(mutableSetOf()) { if (it is WordTag) it.word else null } + muteList.mapNotNull { if (it is WordTag) it.word else null } @@ -64,6 +65,7 @@ class HiddenUsersState( hiddenUsers = hiddenUsers, spammers = transientHiddenUsers, hiddenWords = hiddenWords, + maxHashtagLimit = maxHashtagLimit, ) } @@ -73,9 +75,10 @@ class HiddenUsersState( muteList, transientHiddenUsers, settings.syncedSettings.security.showSensitiveContent, - ) { blockList, muteList, transientHiddenUsers, showSensitiveContent -> + settings.syncedSettings.security.maxHashtagLimit, + ) { blockList, muteList, transientHiddenUsers, showSensitiveContent, maxHashtagLimit -> checkNotInMainThread() - emit(assembleLiveHiddenUsers(blockList, muteList, transientHiddenUsers, showSensitiveContent)) + emit(assembleLiveHiddenUsers(blockList, muteList, transientHiddenUsers, showSensitiveContent, maxHashtagLimit)) }.onStart { emit( assembleLiveHiddenUsers( @@ -83,6 +86,7 @@ class HiddenUsersState( muteList.value, transientHiddenUsers.value, settings.syncedSettings.security.showSensitiveContent.value, + settings.syncedSettings.security.maxHashtagLimit.value, ), ) }.flowOn(Dispatchers.IO) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt index 31f4c9c13..397484393 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FilterByListParams.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -43,6 +44,8 @@ class FilterByListParams( fun isNotInTheFuture(noteEvent: Event) = noteEvent.createdAt <= now + fun hasExcessiveHashtags(noteEvent: Event) = hiddenLists.maxHashtagLimit > 0 && noteEvent.countHashtags() > hiddenLists.maxHashtagLimit + fun isEventInList(noteEvent: Event): Boolean { if (followLists == null) return false @@ -70,7 +73,8 @@ class FilterByListParams( comingFrom: List, ) = ((isGlobal(comingFrom)) || isEventInList(noteEvent)) && (isHiddenList || isNotHidden(noteEvent.pubKey)) && - isNotInTheFuture(noteEvent) + isNotInTheFuture(noteEvent) && + !hasExcessiveHashtags(noteEvent) fun match( address: Address?, 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 07d432511..9c57aae63 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 @@ -1152,6 +1152,8 @@ class AccountViewModel( fun updateShowSensitiveContent(show: Boolean?) = launchSigner { account.updateShowSensitiveContent(show) } + fun updateMaxHashtagLimit(limit: Int) = launchSigner { account.updateMaxHashtagLimit(limit) } + fun changeReactionTypes( reactionSet: List, onDone: () -> Unit, 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 1752a6022..68f83b91d 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 @@ -58,6 +58,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle @@ -266,6 +267,34 @@ private fun HeaderOptions(accountViewModel: AccountViewModel) { }, ) } + + SettingsRow( + R.string.max_hashtag_limit_title, + R.string.max_hashtag_limit_explainer, + ) { + var maxHashtags by remember { + mutableStateOf( + accountViewModel.account.settings.syncedSettings.security.maxHashtagLimit.value.let { + if (it == 0) "" else it.toString() + }, + ) + } + + OutlinedTextField( + value = maxHashtags, + onValueChange = { + maxHashtags = it + accountViewModel.updateMaxHashtagLimit(it.toIntOrNull() ?: 0) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + singleLine = true, + modifier = Modifier.padding(start = 8.dp), + ) + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index fe8bc0049..1080389ef 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -834,6 +834,8 @@ Shows a warning message when posts have 5 or more reports from your follows Show sensitive content Shows a warning message when the author of the post marked it as sensitive + Max hashtags per post + Hides posts with more hashtags than this limit. Set to 0 to disable New Reaction Symbol No reaction types pre-selected for this user. Long press on the heart button to change diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt index d41216733..3b1e42d4b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt @@ -59,6 +59,7 @@ data class LiveHiddenUsers( val hiddenUsers: Set = emptySet(), val spammers: Set = emptySet(), val hiddenWords: Set = emptySet(), + val maxHashtagLimit: Int = 5, ) { fun isUserHidden(userHex: String) = hiddenUsers.contains(userHex) || spammers.contains(userHex) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt index 9c8500a3a..b48102b53 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/EventExt.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Event fun Event.anyHashTag(onEach: (str: String) -> Boolean) = tags.anyHashTag(onEach) +fun Event.countHashtags() = tags.countHashtags() + fun Event.hasHashtags() = tags.hasHashtags() fun Event.hashtags() = tags.hashtags()