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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -105,4 +105,5 @@ class AccountSecurityPreferencesInternal(
|
||||
val showSensitiveContent: Boolean? = null,
|
||||
var warnAboutPostsWithReports: Boolean = true,
|
||||
var filterSpamFromStrangers: Boolean = true,
|
||||
val maxHashtagLimit: Int = 5,
|
||||
)
|
||||
|
||||
+6
-2
@@ -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?,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+29
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+2
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user