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
This commit is contained in:
Claude
2026-04-03 12:34:32 +00:00
parent be6a6346ae
commit 2a24b711cd
11 changed files with 84 additions and 3 deletions
@@ -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<String>) {
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) ||
(
@@ -753,4 +753,12 @@ class AccountSettings(
} else {
false
}
fun updateMaxHashtagLimit(limit: Int): Boolean =
if (syncedSettings.security.updateMaxHashtagLimit(limit)) {
saveAccountSettings()
true
} else {
false
}
}
@@ -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<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
@@ -204,6 +210,7 @@ class AccountSecurityPreferences(
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: MutableStateFlow<Boolean> = MutableStateFlow(true),
val maxHashtagLimit: MutableStateFlow<Int> = 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
}
}
@@ -105,4 +105,5 @@ class AccountSecurityPreferencesInternal(
val showSensitiveContent: Boolean? = null,
var warnAboutPostsWithReports: Boolean = true,
var filterSpamFromStrangers: Boolean = true,
val maxHashtagLimit: Int = 5,
)
@@ -52,6 +52,7 @@ class HiddenUsersState(
muteList: List<MuteTag>,
transientHiddenUsers: Set<String>,
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)
@@ -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<NormalizedRelayUrl>,
) = ((isGlobal(comingFrom)) || isEventInList(noteEvent)) &&
(isHiddenList || isNotHidden(noteEvent.pubKey)) &&
isNotInTheFuture(noteEvent)
isNotInTheFuture(noteEvent) &&
!hasExcessiveHashtags(noteEvent)
fun match(
address: Address?,
@@ -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<String>,
onDone: () -> Unit,
@@ -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),
)
}
}
}
+2
View File
@@ -834,6 +834,8 @@
<string name="warn_when_posts_have_reports_from_your_follows_explainer">Shows a warning message when posts have 5 or more reports from your follows</string>
<string name="show_sensitive_content_title">Show sensitive content</string>
<string name="show_sensitive_content_explainer">Shows a warning message when the author of the post marked it as sensitive</string>
<string name="max_hashtag_limit_title">Max hashtags per post</string>
<string name="max_hashtag_limit_explainer">Hides posts with more hashtags than this limit. Set to 0 to disable</string>
<string name="new_reaction_symbol">New Reaction Symbol</string>
<string name="no_reaction_type_setup_long_press_to_change">No reaction types pre-selected for this user. Long press on the heart button to change</string>
@@ -59,6 +59,7 @@ data class LiveHiddenUsers(
val hiddenUsers: Set<String> = emptySet(),
val spammers: Set<String> = emptySet(),
val hiddenWords: Set<String> = emptySet(),
val maxHashtagLimit: Int = 5,
) {
fun isUserHidden(userHex: String) = hiddenUsers.contains(userHex) || spammers.contains(userHex)
}
@@ -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()