From 32b9a0661209277b005b972c402bd7c71e92d7f1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sat, 16 May 2026 11:34:11 -0400 Subject: [PATCH] Ignores duplicated hashtags in different char cases when processing hashtag spam --- .../vitorpamplona/amethyst/model/Account.kt | 4 ++-- .../amethyst/ui/dal/FilterByListParams.kt | 4 ++-- .../nip01Core/tags/hashtags/EventExt.kt | 2 ++ .../nip01Core/tags/hashtags/HashtagTag.kt | 7 +++++++ .../nip01Core/tags/hashtags/TagArrayExt.kt | 21 +++++++++++++++++++ 5 files changed, 34 insertions(+), 4 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 971da63e2..260a2fa0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -158,7 +158,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds @@ -3035,7 +3035,7 @@ class Account( private fun hasExcessiveHashtags(note: Note): Boolean { val limit = settings.syncedSettings.security.maxHashtagLimit.value - return limit > 0 && (note.event?.countHashtags() ?: 0) > limit + return limit > 0 && note.event?.hasMoreHashtagsThan(limit) == true } override fun isAcceptable(note: Note): Boolean { 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 c1ef9f4c4..1d7e025cd 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 @@ -30,7 +30,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.nip01Core.tags.hashtags.hasMoreHashtagsThan import com.vitorpamplona.quartz.nip10Notes.threadRootIdOrSelf import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent @@ -51,7 +51,7 @@ class FilterByListParams( fun isNotInTheFuture(noteEvent: Event) = noteEvent.createdAt <= now - fun hasExcessiveHashtags(noteEvent: Event) = hiddenLists.maxHashtagLimit > 0 && noteEvent.countHashtags() > hiddenLists.maxHashtagLimit + fun hasExcessiveHashtags(noteEvent: Event) = hiddenLists.maxHashtagLimit > 0 && noteEvent.hasMoreHashtagsThan(hiddenLists.maxHashtagLimit) fun isAuthorInFollows(author: HexKey): Boolean { if (followLists == null) return false 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 b48102b53..2f6108fd0 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 @@ -26,6 +26,8 @@ fun Event.anyHashTag(onEach: (str: String) -> Boolean) = tags.anyHashTag(onEach) fun Event.countHashtags() = tags.countHashtags() +fun Event.hasMoreHashtagsThan(limit: Int) = tags.hasMoreHashtagsThan(limit) + fun Event.hasHashtags() = tags.hasHashtags() fun Event.hashtags() = tags.hashtags() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt index 6ad153af7..f77b15e7b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/HashtagTag.kt @@ -46,6 +46,13 @@ class HashtagTag { return tag[1] } + fun parseLowercase(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].lowercase() + } + fun assemble(name: String) = arrayOf(TAG_NAME, name) fun assembleDualCase(name: String): List> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt index 3d07717a5..8d9a7b458 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt @@ -38,8 +38,29 @@ fun TagArray.hashtags() = this.mapNotNull(HashtagTag::parse) fun TagArray.countHashtags() = this.count(HashtagTag::isTagged) +fun TagArray.hasMoreHashtagsThan(limit: Int): Boolean { + val count = this.count(HashtagTag::isTagged) + return count > limit && this.countUnique(count, HashtagTag::parseLowercase) > limit +} + fun TagArray.isTaggedHash(hashtag: String) = this.isTagged(HashtagTag.TAG_NAME, hashtag, true) fun TagArray.isTaggedHashes(hashtags: Set) = this.isAnyLowercaseTagged(HashtagTag.TAG_NAME, hashtags) fun TagArray.firstIsTaggedHashes(hashtags: Set) = this.firstAnyLowercaseTaggedValue(HashtagTag.TAG_NAME, hashtags) + +public inline fun Array.countUnique( + size: Int, + transform: (T) -> U?, +): Int { + // Pre-allocate hash set to avoid frequent resizing for larger arrays + val seen = HashSet(size) + var count = 0 + for (element in this) { + val value = transform(element) + if (value != null && seen.add(value)) { + ++count + } + } + return count +}