diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/DisplayedEvent.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/DisplayedEvent.kt new file mode 100644 index 0000000000..c23bc3477f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/DisplayedEvent.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent + +/** + * Returns the event whose body a renderer would actually display. + * + * For kind 6 / kind 16 reposts that is the *wrapped* inner event — which + * the consume pipeline already resolves onto [Note.replyTo]. Falls back to + * the slower [RepostEvent.containedPost] / [GenericRepostEvent.containedPost] + * JSON decode only when the cache hasn't materialised the reply yet; both + * already null-return on parse failure. + * + * For non-repost events, the displayed event is the note's own event. + * + * Returns null when there is no event at all (placeholder not yet + * hydrated). + */ +fun Note.displayedEvent(): Event? { + val ev = this.event ?: return null + return when (ev) { + is RepostEvent -> replyTo?.lastOrNull()?.event ?: ev.containedPost() + is GenericRepostEvent -> replyTo?.lastOrNull()?.event ?: ev.containedPost() + else -> ev + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamCheck.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamCheck.kt new file mode 100644 index 0000000000..d704e81e7a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamCheck.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent + +/** + * Pure decision: should this note be collapsed because it abuses hashtag `t` tags? + * + * Reuses [hasMoreHashtagsThan] which short-circuits on total count before + * counting unique tags, so a note that repeats the same hashtag does not + * trip the filter. + * + * Long-form articles (kind 30023) legitimately use many topic tags and are + * always exempt. Authors whose pubkey appears in [exemptKeys] (the user's + * follow list plus self) are also exempt — the caller assembles that set. + */ +object HashtagSpamCheck { + fun isHashtagSpam( + displayedEvent: Event?, + authorPubkey: HexKey?, + enabled: Boolean, + threshold: Int, + exemptKeys: Set, + ): Boolean { + if (!enabled) return false + if (displayedEvent == null) return false + if (displayedEvent.kind == LongTextNoteEvent.KIND) return false + if (authorPubkey != null && authorPubkey in exemptKeys) return false + return displayedEvent.tags.hasMoreHashtagsThan(threshold) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamSettings.kt new file mode 100644 index 0000000000..c3531b9153 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamSettings.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import androidx.compose.runtime.Stable +import kotlinx.coroutines.flow.StateFlow + +/** + * User-tunable settings for the hashtag-spam content filter. + * + * Implementations are platform-specific (Desktop uses java.util.prefs; + * Android can use DataStore). Both observables emit on change so Compose + * reads via [collectAsState] re-render automatically. + */ +@Stable +interface HashtagSpamSettings { + val enabled: StateFlow + val threshold: StateFlow + + fun setEnabled(enabled: Boolean) + + fun setThreshold(threshold: Int) + + companion object { + const val MIN_THRESHOLD = 1 + const val MAX_THRESHOLD = 20 + const val DEFAULT_THRESHOLD = 5 + const val DEFAULT_ENABLED = true + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/LocalHashtagSpamSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/LocalHashtagSpamSettings.kt new file mode 100644 index 0000000000..fcb6461e4f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/LocalHashtagSpamSettings.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Settings for the hashtag-spam filter, provided at App() root by each + * front end (Desktop, Android). Defaults to an error so missing provision + * fails loudly during development; production binaries always wire this. + */ +val LocalHashtagSpamSettings: ProvidableCompositionLocal = + compositionLocalOf { error("LocalHashtagSpamSettings not provided. Wire it at App() root.") } + +/** + * Pubkeys exempted from the hashtag-spam check — the active account's + * follow set union the active account's own pubkey. Updated by the App() + * root whenever the active account changes. Defaults to empty (strict + * filter applies to everyone) so leaf composables can read it without a + * null check. + */ +val LocalSpamExemptKeys: ProvidableCompositionLocal> = + compositionLocalOf { emptySet() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt index c2abb6171d..787daa4ac8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt @@ -30,8 +30,12 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.utils.Hex /** - * Parses search input and returns matching results. - * Supports: npub, nprofile, nsec (extracts pubkey), note, nevent, naddr, hex keys, hashtags + * Parses search input for direct-lookup entries: npub, nprofile, nsec + * (extracts pubkey), note, nevent, naddr, and 64-char hex keys. + * + * Hashtags are intentionally NOT returned here — they are handled by + * [QueryParser], which extracts `#xxx` tokens into the query's hashtag + * filter so the normal results pipeline drives a tag-filtered search. * * Shared between Android and Desktop for consistent Bech32 parsing. */ @@ -41,12 +45,6 @@ fun parseSearchInput(input: String): List { val trimmed = input.trim() val results = mutableListOf() - // Check for hashtag - if (trimmed.startsWith("#") && trimmed.length > 1) { - results.add(SearchResult.HashtagResult(trimmed.substring(1))) - return results - } - // Try to parse as Bech32 (npub, nevent, naddr, etc.) val parsed = Nip19Parser.uriToRoute(trimmed)?.entity if (parsed != null) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt index 21cd3a0a24..a0fe0fcb8e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt @@ -50,11 +50,4 @@ sealed class SearchResult { val dTag: String, val displayId: String, ) : SearchResult() - - /** - * Hashtag search. - */ - data class HashtagResult( - val hashtag: String, - ) : SearchResult() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CollapsedSpamNote.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CollapsedSpamNote.kt new file mode 100644 index 0000000000..b366128ec5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CollapsedSpamNote.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.note + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar + +/** + * Replacement card rendered in place of a normal note when the + * hashtag-spam check trips. Scalar params only so it stays reusable across + * Desktop, Android, and future iOS without leaking platform display + * shapes. + */ +@Composable +fun CollapsedSpamNote( + authorPubkeyHex: String, + authorDisplayName: String, + authorAvatarUrl: String?, + hashtagCount: Int, + threshold: Int, + onReveal: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .padding(horizontal = 12.dp, vertical = 8.dp) + .semantics { + contentDescription = + "Hidden note from $authorDisplayName, $hashtagCount hashtags. Tap to reveal." + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserAvatar( + userHex = authorPubkeyHex, + pictureUrl = authorAvatarUrl, + size = 32.dp, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = authorDisplayName.ifBlank { "Hidden note" }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Filtered: $hashtagCount hashtags · threshold $threshold", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(4.dp)) + TextButton(onClick = onReveal) { + Text("Reveal") + } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/DisplayedEventTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/DisplayedEventTest.kt new file mode 100644 index 0000000000..616e470289 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/DisplayedEventTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DisplayedEventTest { + private val author = "a".repeat(64) + private val sig = "0".repeat(128) + + private fun textNoteEvent(id: String = "1".padEnd(64, '0')): Event = + Event( + id = id, + pubKey = author, + createdAt = 0L, + kind = 1, + tags = emptyArray(), + content = "hello", + sig = sig, + ) + + private fun repostWrapper(content: String): RepostEvent = + RepostEvent( + id = "r".repeat(64), + pubKey = author, + createdAt = 0L, + tags = emptyArray(), + content = content, + sig = sig, + ) + + @Test + fun nonRepostReturnsOwnEvent() { + val inner = textNoteEvent() + val note = Note(idHex = inner.id).apply { event = inner } + assertEquals(inner, note.displayedEvent()) + } + + @Test + fun repostWithReplyToReturnsInnerEvent() { + val inner = textNoteEvent(id = "i".repeat(64)) + val innerNote = Note(idHex = inner.id).apply { event = inner } + val wrapper = repostWrapper(content = "{}") // valid JSON but missing fields + val wrapperNote = + Note(idHex = wrapper.id).apply { + event = wrapper + replyTo = listOf(innerNote) + } + assertEquals(inner, wrapperNote.displayedEvent()) + } + + @Test + fun repostWithoutReplyToFallsBackToContainedPost() { + // Malformed inner JSON → containedPost() returns null → displayedEvent() returns null. + val wrapper = repostWrapper(content = "not-valid-json-at-all") + val wrapperNote = Note(idHex = wrapper.id).apply { event = wrapper } + assertNull(wrapperNote.displayedEvent()) + } + + @Test + fun noteWithoutEventReturnsNull() { + val note = Note(idHex = "x".repeat(64)) + assertNull(note.displayedEvent()) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamCheckTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamCheckTest.kt new file mode 100644 index 0000000000..e588c733fe --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamCheckTest.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class HashtagSpamCheckTest { + private val author = "a".repeat(64) + private val other = "b".repeat(64) + + private fun event( + kind: Int = 1, + hashtags: List = emptyList(), + pubkey: String = author, + ): Event = + Event( + id = "id".padEnd(64, '0'), + pubKey = pubkey, + createdAt = 0L, + kind = kind, + tags = hashtags.map { arrayOf("t", it) }.toTypedArray(), + content = "", + sig = "sig".padEnd(128, '0'), + ) + + @Test + fun returnsFalseWhenDisabled() { + val e = event(hashtags = (1..20).map { "h$it" }) + assertFalse( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = e.pubKey, + enabled = false, + threshold = 5, + exemptKeys = emptySet(), + ), + ) + } + + @Test + fun returnsFalseForNullDisplayedEvent() { + assertFalse( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = null, + authorPubkey = author, + enabled = true, + threshold = 5, + exemptKeys = emptySet(), + ), + ) + } + + @Test + fun returnsFalseForLongformEvenWithManyTags() { + // kind 30023 = long-form content; exempt regardless of tag count. + val e = event(kind = 30023, hashtags = (1..20).map { "h$it" }) + assertFalse( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = e.pubKey, + enabled = true, + threshold = 5, + exemptKeys = emptySet(), + ), + ) + } + + @Test + fun returnsFalseForFollowedAuthor() { + val e = event(hashtags = (1..20).map { "h$it" }) + assertFalse( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = e.pubKey, + enabled = true, + threshold = 5, + exemptKeys = setOf(author), + ), + ) + } + + @Test + fun returnsFalseUnderThreshold() { + val e = event(hashtags = listOf("nostr", "bitcoin", "amethyst")) + assertFalse( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = e.pubKey, + enabled = true, + threshold = 5, + exemptKeys = emptySet(), + ), + ) + } + + @Test + fun returnsFalseAtThresholdExactly() { + // hasMoreHashtagsThan(5) returns true only for count > 5. + val e = event(hashtags = (1..5).map { "h$it" }) + assertFalse( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = e.pubKey, + enabled = true, + threshold = 5, + exemptKeys = emptySet(), + ), + ) + } + + @Test + fun returnsTrueOverThresholdWithDistinctTags() { + val e = event(hashtags = (1..10).map { "h$it" }) + assertTrue( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = e.pubKey, + enabled = true, + threshold = 5, + exemptKeys = emptySet(), + ), + ) + } + + @Test + fun returnsFalseOverThresholdWhenAllTagsAreDuplicates() { + // hasMoreHashtagsThan checks count AND unique count > limit. + // 20 copies of the same hashtag → unique = 1 → not spam. + val e = event(hashtags = List(20) { "bitcoin" }) + assertFalse( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = e.pubKey, + enabled = true, + threshold = 5, + exemptKeys = emptySet(), + ), + ) + } + + @Test + fun authorNotInExemptKeysIsNotExempt() { + val e = event(hashtags = (1..10).map { "h$it" }) + assertTrue( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = e.pubKey, + enabled = true, + threshold = 5, + exemptKeys = setOf(other), + ), + ) + } + + @Test + fun nullAuthorPubkeyDoesNotShortCircuit() { + val e = event(hashtags = (1..10).map { "h$it" }) + assertTrue( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = null, + enabled = true, + threshold = 5, + exemptKeys = setOf(author), + ), + ) + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesHashtagSpamSettings.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesHashtagSpamSettings.kt new file mode 100644 index 0000000000..ed6e507158 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesHashtagSpamSettings.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings.Companion.DEFAULT_ENABLED +import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings.Companion.DEFAULT_THRESHOLD +import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings.Companion.MAX_THRESHOLD +import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings.Companion.MIN_THRESHOLD +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.prefs.Preferences + +/** + * JVM-platform implementation backed by [java.util.prefs.Preferences]. + * + * The default node `com/vitorpamplona/amethyst/filters` is shared between + * the Desktop app and the `amy` CLI — both binaries running as the same OS + * user observe the same setting without any extra plumbing. + * + * `Preferences` auto-flushes on JVM shutdown and periodically, so no + * explicit `flush()` call is needed and avoids per-set disk thrash. + */ +class PreferencesHashtagSpamSettings( + private val prefs: Preferences = Preferences.userRoot().node(NODE_NAME), +) : HashtagSpamSettings { + private val mutableEnabled = MutableStateFlow(prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)) + private val mutableThreshold = + MutableStateFlow( + prefs.getInt(KEY_THRESHOLD, DEFAULT_THRESHOLD).coerceIn(MIN_THRESHOLD, MAX_THRESHOLD), + ) + + override val enabled: StateFlow = mutableEnabled.asStateFlow() + override val threshold: StateFlow = mutableThreshold.asStateFlow() + + override fun setEnabled(enabled: Boolean) { + mutableEnabled.value = enabled + prefs.putBoolean(KEY_ENABLED, enabled) + } + + override fun setThreshold(threshold: Int) { + val clamped = threshold.coerceIn(MIN_THRESHOLD, MAX_THRESHOLD) + mutableThreshold.value = clamped + prefs.putInt(KEY_THRESHOLD, clamped) + } + + companion object { + const val NODE_NAME = "com/vitorpamplona/amethyst/filters" + const val KEY_ENABLED = "hashtag_spam_enabled" + const val KEY_THRESHOLD = "hashtag_spam_threshold" + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesHashtagSpamSettingsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesHashtagSpamSettingsTest.kt new file mode 100644 index 0000000000..e74376e83c --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesHashtagSpamSettingsTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.prefs.Preferences + +class PreferencesHashtagSpamSettingsTest { + private val testNode = "com/vitorpamplona/amethyst/test/hashtag_spam_${System.currentTimeMillis()}" + + private fun prefs(): Preferences = Preferences.userRoot().node(testNode) + + @Before + fun setup() { + prefs().clear() + } + + @After + fun teardown() { + prefs().removeNode() + } + + @Test + fun defaultsAreOnAndFive() { + val settings = PreferencesHashtagSpamSettings(prefs()) + assertTrue(settings.enabled.value) + assertEquals(5, settings.threshold.value) + } + + @Test + fun setEnabledPersists() { + val settings = PreferencesHashtagSpamSettings(prefs()) + settings.setEnabled(false) + assertFalse(settings.enabled.value) + + val reloaded = PreferencesHashtagSpamSettings(prefs()) + assertFalse(reloaded.enabled.value) + } + + @Test + fun setThresholdPersists() { + val settings = PreferencesHashtagSpamSettings(prefs()) + settings.setThreshold(12) + assertEquals(12, settings.threshold.value) + + val reloaded = PreferencesHashtagSpamSettings(prefs()) + assertEquals(12, reloaded.threshold.value) + } + + @Test + fun thresholdClampsBelowMin() { + val settings = PreferencesHashtagSpamSettings(prefs()) + settings.setThreshold(-50) + assertEquals(HashtagSpamSettings.MIN_THRESHOLD, settings.threshold.value) + } + + @Test + fun thresholdClampsAboveMax() { + val settings = PreferencesHashtagSpamSettings(prefs()) + settings.setThreshold(9999) + assertEquals(HashtagSpamSettings.MAX_THRESHOLD, settings.threshold.value) + } +} diff --git a/desktopApp/plans/2026-06-29-hashtag-spam-filter-manual-testing-sheet.md b/desktopApp/plans/2026-06-29-hashtag-spam-filter-manual-testing-sheet.md new file mode 100644 index 0000000000..0cf2f65ec1 --- /dev/null +++ b/desktopApp/plans/2026-06-29-hashtag-spam-filter-manual-testing-sheet.md @@ -0,0 +1,66 @@ +# Manual testing sheet — Hashtag-Spam Filter (Desktop) + +Plan: `docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md` + +Run with `./gradlew :desktopApp:run`. Sign in with any account that has a +follow list (or use a fresh nsec — global feed still works without follows). + +## Setup + +1. Confirm default state: filter is **ON**, threshold is **5** on first + launch. Settings → Content Filters shows Switch=On, slider at 5. +2. Confirm a follow list is populated (Home column should display notes + from people you follow). Otherwise the follow-exemption test (T4) is a + no-op. + +## Scenarios + +| # | Scenario | Expected | +|---|----------|----------| +| **T1** | **Slider live re-collapse.** Open a Home column. Locate a visible note that has the spammy pattern (e.g. 8+ distinct hashtags). Drag the threshold slider to a value below its hashtag count. | Card collapses to placeholder after drag release. No mid-drag jank. | +| **T2** | **Repost spam (precomputed).** Find a `nostr:nevent…` link to a kind-6 repost whose wrapped event has many hashtags, paste into a hashtag column or use Search. After the cache materialises the inner event (give it a second), confirm the repost wrapper renders collapsed. | Wrapper card shows `CollapsedSpamNote` with the inner event's author. | +| **T3** | **Repost spam (uncached fallback).** Same as T2 immediately on first paste, before the cache resolves the inner event. | Wrapper shows collapsed (via `containedPost()` fallback) OR shows normal until cache populates — both acceptable; nothing crashes. | +| **T4** | **Followed-author exemption.** Locate (or temporarily follow) someone who posts 10+ hashtag stuff. Reload Home. | Their posts render normally, never collapsed. | +| **T5** | **Self exemption.** Compose and publish a note with 10 distinct hashtags from the active account. | The note appears normally in your own activity / Home, not collapsed. | +| **T6** | **Long-form exemption.** Open a longform article (kind 30023) that uses many topical tags — via Search → Articles tab, or via a quoted `naddr1…`. | Article renders normally. | +| **T7** | **Thread root auto-expand.** Find a collapsed card in a feed and click it to open the thread. | Root note auto-expands (`forceReveal = true`); 12-hashtag replies inside the thread still appear collapsed. | +| **T8** | **Embedded quote.** Read a note that quotes a hashtag-spam note via `nostr:nevent…`. | The inline embedded card is collapsed. Revealing it does NOT cascade to other places — open the same quoted note in Search and confirm it's still collapsed there (per-call-site `rememberSaveable`). | +| **T9** | **Settings persistence across restart.** Toggle Switch to Off. Restart Desktop. | Open Settings → Content Filters: still Off. Spam notes render uncollapsed. | +| **T10** | **`amy` parity.** From a terminal, run `java -cp com.vitorpamplona.amethyst.cli.Main …` (or any utility that reads the shared `Preferences` node). Or write a one-line Kotlin REPL: `Preferences.userRoot().node("com/vitorpamplona/amethyst/filters").getBoolean("hashtag_spam_enabled", true)`. | Returns the same value Desktop wrote. Reverse: write `false` via the API, relaunch Desktop, observe filter is off. | +| **T11** | **Malformed inner repost JSON.** Test event with a kind-6 wrapper whose `content` field is not parseable JSON. Quickest reproduction: use the unit test `repostWithoutReplyToFallsBackToContainedPost` ✓ already covers the `displayedEvent()` path; manually you can paste a deliberately-broken nevent and watch nothing crash. | No exception; wrapper renders normally (since `displayedEvent()` returns null and `isSpam = false`). | +| **T12** | **Hashtag-feed column.** Subscribe to a hashtag-feed column (e.g. `#bitcoin`). | Posts that are tagged `#bitcoin` AND have many other hashtags still collapse — filter applies inside hashtag columns. | +| **T13** | **Notifications tab (negative).** Receive a mention from someone who used many hashtags in the parent note. Open Notifications. | Compact notification card (56dp) renders normally — does NOT apply the filter (v1 scope: Notifications-tab card is a custom composable, deferred). Documented as a known limitation. | +| **T14** | **Slider does not cause feed recomposition storm.** Open a feed with several visible notes. Open Settings, drag threshold quickly back and forth. | Slider thumb moves smoothly; feed contents do not re-render per-tick (only on drag-end). No frame drops. | +| **T15** | **Toggle off → notes uncollapse live.** With filter ON and a collapsed card visible, toggle the Switch to Off in Settings. | Card immediately uncollapses (StateFlow re-evaluates, `isSpam` becomes false). | +| **T16** | **Threshold change updates label live.** Drag the slider with the Settings sheet open. | The "Hide notes with more than N hashtags" text updates per drag tick. | + +## Known v1 limitations to surface during testing + +- Cross-call-site reveal: revealing a collapsed card in Home does NOT reveal + the same card in a Hashtag column or in a thread — each render site has + its own `rememberSaveable` reveal flag. +- Notifications compact card does NOT respect the filter (v2). +- Follow/unfollow during a session does not re-evaluate already-rendered + cards in place (follow set is non-reactive at the check site v1; refresh + feed to update). + +## Sign-off + +- [ ] T1 Slider live re-collapse +- [ ] T2 Repost spam (precomputed) +- [ ] T3 Repost spam (uncached) +- [ ] T4 Followed-author exemption +- [ ] T5 Self exemption +- [ ] T6 Long-form exemption +- [ ] T7 Thread root auto-expand +- [ ] T8 Embedded quote +- [ ] T9 Settings persistence +- [ ] T10 `amy` parity +- [ ] T11 Malformed inner repost JSON +- [ ] T12 Hashtag-feed column +- [ ] T13 Notifications tab limitation (known) +- [ ] T14 No recomposition storm during drag +- [ ] T15 Toggle off live +- [ ] T16 Threshold label live update + +Tester: ________________ Date: ________________ diff --git a/desktopApp/plans/_desktop-feature-backlog.md b/desktopApp/plans/_desktop-feature-backlog.md new file mode 100644 index 0000000000..6d6489c08c --- /dev/null +++ b/desktopApp/plans/_desktop-feature-backlog.md @@ -0,0 +1,142 @@ +# Desktop Feature Backlog — Inspiration from Nostr Ecosystem + +Survey date: 2026-06-29. Source: cross-client research across top-5 used clients +(Damus, Primal, YakiHonne, Snort, Iris) + top-5 desktop-first / power-user +clients (Notedeck, Gossip, Jumble, Coop, Flotilla). Wisp investigated separately +(Android-only, not desktop). + +Already shipped on Amethyst Desktop and excluded from this list: deck columns, +embedded local relay, NIP-46 bunker login, NWC zapping, custom feeds, advanced +search, follow packs (in progress). + +--- + +## Priority queue (next up) + +### 1. Hashtag-spam filter — NEXT +- **Inspired by:** Damus (auto-hide posts above N hashtags). +- **What:** Configurable threshold; posts with `> N` `t` tags get hidden or + collapsed across all feeds. User-defined whitelist for legit multi-tag use + (events, longform). +- **Why desktop:** Spam noise scales with column count on a deck UI; one filter + cleans every column at once. +- **Module:** `commons/` (filter logic, shared with Android in future) + + `desktopApp/` (settings UI). +- **Skills:** `feed-patterns`, `compose-expert`, `account-state`. + +### 2. WoT (web-of-trust) score on avatars + filters +- **Inspired by:** Gossip, Snort. +- **What:** Friends-of-friends score (0–N) computed from follow graph. Visual + badge/ring on avatar. Threshold filter for notifications/DM-requests. +- **Why desktop:** At-a-glance trust signal scales with multi-column deck. +- **Module:** `commons/services/WoTService.kt` (StateFlow>), + avatar composable in `commons/.../note/`. +- **Skills:** `account-state`, `kotlin-flow-state-event-modeling`, + `compose-expert`. Score compute is O(follows × follows) — must be lazy / + throttled. + +--- + +## Full ranked backlog (10 ideas) + +Ranking heuristic: impact × novelty / implementation cost. Items 1–2 are the +priority queue above; 3–10 captured for later. + +| # | Feature | Inspired by | Module | Notes | +|---|---------|-------------|--------|-------| +| 1 | Hashtag-spam filter | Damus | commons + desktopApp | **NEXT** | +| 2 | WoT score on avatars + filters | Gossip, Snort | commons | After #1 | +| 3 | Relay-as-column + Relay Sets first-class | Jumble, Flotilla | commons | Drag relay URL → column | +| 4 | Cashu ecash wallet alongside NWC | Iris, Primal Spark | quartz + commons | Bearer-token privacy | +| 5 | AI "Dave" column (timeline-aware assistant) | Notedeck | desktopApp | LLM reads adjacent columns | +| 6 | Algorithmic Feed Marketplace | Primal v3.0 | commons | Discover layer on custom feeds | +| 7 | Keyboard / command palette (`Ctrl+K`, `?` overlay) | Snort, Notedeck | desktopApp + commons | Power-user nav | +| 8 | Longform reader column + offline publish queue | Damus, YakiHonne | commons | NIP-23, uses local relay | +| 9 | WoT-scored notification filter + undo-send | Gossip, Snort | commons | Calmer UX | +| 10 | Per-relay column health (sparkline + dot) | Wisp, Gossip | commons | Reuse EOSE manager | +| 11 | Discord-style "communities" sidebar (NIP-29) | Flotilla, Chachi | desktopApp + commons | Sidebar second purpose | + +(Extra row #11 added: communities sidebar was the 10th in the original survey; +WoT-notif-filter + undo-send promoted to its own row for clarity.) + +--- + +## Per-item detail + +### #3 Relay-as-column + Relay Sets first-class +- **Drop:** Drag relay URL onto the deck → new column showing that relay's + global. Relay Sets become saveable column templates. +- **Impl:** New `RelayUrlFeedFilter` + `RelaySetFeedFilter` in `commons/feeds/`. +- **Gotcha:** Bypass write-through to local store for these feeds, otherwise + local relay pollutes with arbitrary global content. + +### #4 Cashu ecash wallet alongside NWC +- **Why:** Different privacy model (bearer tokens, no account). Power users + want both — small/private = ecash, big/recurring = NWC. +- **Impl:** Check Quartz NIP-60/61 coverage; else new `wallet/cashu/`. UI is a + second tab in existing wallet column. +- **Gotcha:** Mint trust UX; token backup/restore; encryption story + (`accounts.json.enc` slot). + +### #5 AI "Dave" column +- **Why:** Big-screen-only feature that breaks the deck-column metaphor wide + open. Reads N adjacent columns as context. +- **Impl:** `desktopApp/` for API key config + `commons/ai/` for prompt builders. + Pluggable model: Ollama local default, OpenAI/Anthropic optional. +- **Gotcha:** Privacy story — must be explicit which events get sent where. + Default to local Ollama. + +### #6 Algorithmic Feed Marketplace +- **Why:** Custom feeds already shipped — marketplace is the discovery layer. +- **Impl:** Feeds are published as kind:30000-ish lists or DVM-driven. Browser + UI + subscribe button on top of existing custom-feed infra. +- **Gotcha:** DVM feeds vs static list feeds — two execution paths. + +### #7 Keyboard / command palette +- **Why:** Mouse-first ≠ keyboard-hostile. +- **Impl:** `desktopApp/` global `onPreviewKeyEvent` + `commons/ui/CommandPalette.kt`. +- **Gotcha:** Focus management across deck columns; cmd vs ctrl on macOS; + conflict with text-input fields. + +### #8 Longform reader + offline publish queue +- **Why:** NIP-23 is barely surfaced on Desktop. Big screens are *the* surface + for reading articles. +- **Impl:** New column types `LongformReader` + `LongformComposer`. Composer is + block-based. Offline queue writes drafts to local relay; auto-broadcasts on + reconnect. +- **Gotcha:** Markdown rendering on Compose Desktop is mid — pick or build. + Blossom upload alongside drafts. + +### #9 WoT notif filter + undo-send +- **Why:** Notifications scale poorly; slow-mode (delay sends 5–30 s with + toast-cancel) calms power-user UX. +- **Impl:** `NotificationFilter.kt` extension + send-pipeline interceptor in + `commons/relayClient/`. +- **Gotcha:** Sent-but-cancelled events already in local relay write-through — + must scrub from local store on cancel. + +### #10 Per-relay column health +- **Why:** Deck UI exposes the multi-relay reality. Latency/EOSE/dup-rate dot + + sparkline in column header helps self-tune. +- **Impl:** Extend EOSE manager to emit `RelayHealth` per relay; render small + sparkline + colored dot. +- **Gotcha:** Memory over time — rolling 60 s window, decay older. + +### #11 Communities sidebar (NIP-29) +- **Why:** Amethyst Desktop sidebar is nav-only; mapping joined groups / + favourite relays to Discord-like server icons doubles its purpose. +- **Impl:** `desktopApp/.../sidebar/` new section; on click, populate deck with + group channels. `commons/groups/` for NIP-29 if not in Quartz already. +- **Gotcha:** NIP-29 spec churn — check Quartz coverage. Don't conflate + "favourite relay" with "joined group". + +--- + +## Sources + +- nostr.com/clients, nostrapps.com, stats.nostr.band +- humai.blog "Best Nostr Apps 2026"; nostr.co.uk/clients; nostrcompass.org #15 +- opensats.org "Advancements in Nostr Clients" +- Repos: damus-io/damus, damus-io/notedeck, mikedilger/gossip, CodyTseng/jumble, + lumehq/coop, coracle-social/flotilla (active at gitea.coracle.social), + barrydeen/wisp diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 1ae39ba960..94e84c0b3a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -74,6 +74,9 @@ import androidx.compose.ui.window.rememberWindowState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols +import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings +import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys +import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSettings import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -749,6 +752,10 @@ fun App( val accountState by accountManager.accountState.collectAsState() val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } + // Hashtag-spam filter settings, persisted in the shared java.util.prefs + // node so the `amy` CLI binary observes the same toggle. + val hashtagSpamSettings = remember { PreferencesHashtagSpamSettings() } + // Local relay store — persists events to SQLite per account val localRelayStore = remember { @@ -976,6 +983,7 @@ fun App( com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache, com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager, com.vitorpamplona.amethyst.desktop.ui.deck.LocalLocalRelayStore provides localRelayStore, + LocalHashtagSpamSettings provides hashtagSpamSettings, ) { when (accountState) { is AccountState.Loading -> { @@ -1062,6 +1070,11 @@ fun App( // NWC loaded during startup in loadSavedAccount flow val currentTorStatus = torManager.status.collectAsState().value + val followedUsers by localCache.followedUsers.collectAsState() + val spamExemptKeys = + remember(followedUsers, account.pubKeyHex) { + followedUsers + account.pubKeyHex + } androidx.compose.runtime.CompositionLocalProvider( com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides com.vitorpamplona.amethyst.desktop.ui.tor.TorState( @@ -1079,6 +1092,7 @@ fun App( ), LocalNamecoinPreferences provides namecoinPreferences, LocalNamecoinService provides namecoinService, + LocalSpamExemptKeys provides spamExemptKeys, ) { MainContent( layoutMode = layoutMode, @@ -2074,6 +2088,21 @@ fun RelaySettingsScreen( Spacer(Modifier.height(16.dp)) } + // Content Filters section — hashtag-spam filter and future + // content-moderation toggles. + Text( + text = "Content Filters", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + com.vitorpamplona.amethyst.desktop.ui.settings.HashtagSpamSettingsSection( + settings = LocalHashtagSpamSettings.current, + ) + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + val logoutScope = rememberCoroutineScope() OutlinedButton( onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index f38e064569..48a38a56a6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -316,12 +316,18 @@ fun BookmarksScreen( onNavigateToThread(event.id) }, ) { - NoteCard( - note = event.toNoteDisplayData(localCache), + com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender( + displayedEvent = event, + noteIdHex = event.id, localCache = localCache, - onAuthorClick = onNavigateToProfile, - onMentionClick = onNavigateToProfile, - ) + ) { + NoteCard( + note = event.rememberDisplayData(localCache), + localCache = localCache, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + ) + } NoteActionsRow( event = event, relayManager = relayManager, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt index 0367228efa..c580f863c7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.desktop.ui.note.NoteDisplayData @@ -27,6 +31,27 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip19Bech32.toNpub +/** + * Compose-friendly wrapper around [toNoteDisplayData] that observes the + * event author's user-metadata flow and re-derives the display data + * whenever it updates. Use at note-card call sites so display names and + * avatars populate when the kind-0 event arrives from relays after the + * note itself. + */ +@Composable +fun Event.rememberDisplayData(cache: ICacheProvider?): NoteDisplayData { + val author = remember(pubKey, cache) { cache?.getUserIfExists(pubKey) } + val authorMetaValue by produceState(initialValue = null, key1 = author) { + val a = author + if (a == null) { + value = null + } else { + a.metadata().flow.collect { value = it } + } + } + return remember(this, authorMetaValue) { toNoteDisplayData(cache) } +} + /** * Extension to convert Event to NoteDisplayData for the shared NoteCard. */ diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index ddce2a3ce2..c240c645d2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -135,6 +135,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard +import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList @@ -199,8 +200,54 @@ fun FeedNoteCard( followedUsers: Set = emptySet(), myPubKeyHex: String? = null, onFollow: ((String) -> Unit)? = null, + forceReveal: Boolean = false, ) { val event = note.event ?: return + SpamCheckedNoteRender( + note = note, + localCache = localCache, + forceReveal = forceReveal, + ) { + FeedNoteCardBody( + note = note, + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = onReply, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onImageClick = onImageClick, + onMediaClick = onMediaClick, + onHashtagClick = onHashtagClick, + followedUsers = followedUsers, + myPubKeyHex = myPubKeyHex, + onFollow = onFollow, + ) + } +} + +@Composable +private fun FeedNoteCardBody( + note: Note, + event: com.vitorpamplona.quartz.nip01Core.core.Event, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + account: AccountState.LoggedIn?, + nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, + onReply: () -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + onNavigateToProfile: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, + onImageClick: ((List, Int) -> Unit)? = null, + onMediaClick: ((List, Int, Float) -> Unit)? = null, + onHashtagClick: ((String) -> Unit)? = null, + followedUsers: Set = emptySet(), + myPubKeyHex: String? = null, + onFollow: ((String) -> Unit)? = null, +) { val isRepost = event is RepostEvent || event is GenericRepostEvent if (isRepost) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index f457552eb8..29f56c5047 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -120,7 +120,6 @@ fun SearchScreen( initialQuery: String = "", onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, - onNavigateToHashtag: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -348,6 +347,20 @@ fun SearchScreen( } } + // Load author metadata for incoming note results. NIP-50 search relays + // typically don't return kind-0 metadata alongside notes, and the + // subscription explicitly drops MetadataEvents anyway — so display name + // and avatar arrive only after we explicitly fetch them from index + // relays via the coordinator. + LaunchedEffect(noteResults, subscriptionsCoordinator) { + val coordinator = subscriptionsCoordinator ?: return@LaunchedEffect + if (noteResults.isEmpty()) return@LaunchedEffect + val authors = noteResults.map { it.pubKey }.distinct() + if (authors.isNotEmpty()) { + coordinator.loadMetadataBatched(authors) + } + } + // History state val historyItems by SearchHistoryStore.history.collectAsState() val savedSearches by SearchHistoryStore.savedSearches.collectAsState() @@ -643,7 +656,6 @@ fun SearchScreen( ), onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, - onNavigateToHashtag = onNavigateToHashtag, ) if (ncState.result.relays.isNotEmpty()) { Text( @@ -699,7 +711,6 @@ fun SearchScreen( result = result, onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, - onNavigateToHashtag = onNavigateToHashtag, ) } } @@ -897,7 +908,6 @@ private fun SearchResultCard( result: SearchResult, onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, - onNavigateToHashtag: (String) -> Unit, ) { Card( modifier = @@ -908,7 +918,6 @@ private fun SearchResultCard( is SearchResult.UserResult -> onNavigateToProfile(result.pubKeyHex) is SearchResult.NoteResult -> onNavigateToThread(result.noteIdHex) is SearchResult.AddressResult -> onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}") - is SearchResult.HashtagResult -> onNavigateToHashtag(result.hashtag) } }, colors = @@ -927,7 +936,6 @@ private fun SearchResultCard( is SearchResult.UserResult -> MaterialSymbols.Person is SearchResult.NoteResult -> MaterialSymbols.Description is SearchResult.AddressResult -> MaterialSymbols.Description - is SearchResult.HashtagResult -> MaterialSymbols.Tag }, contentDescription = null, modifier = Modifier.size(24.dp), @@ -940,7 +948,6 @@ private fun SearchResultCard( is SearchResult.UserResult -> "User Profile" is SearchResult.NoteResult -> "Note" is SearchResult.AddressResult -> "Event (kind ${result.kind})" - is SearchResult.HashtagResult -> "#${result.hashtag}" }, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, @@ -950,10 +957,9 @@ private fun SearchResultCard( is SearchResult.UserResult -> result.displayId is SearchResult.NoteResult -> result.displayId is SearchResult.AddressResult -> result.displayId - is SearchResult.HashtagResult -> "Search posts with this hashtag" }, style = MaterialTheme.typography.bodySmall, - fontFamily = if (result is SearchResult.HashtagResult) null else FontFamily.Monospace, + fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 78f8b224d4..f637ae4523 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -318,6 +318,8 @@ fun ThreadScreen( com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer .toggleFullscreen() }, + // Root of an explicitly-opened thread — user opted in, skip spam collapse. + forceReveal = true, ) } HorizontalDivider(thickness = 1.dp) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 269507999a..565a9b2156 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -530,13 +530,18 @@ fun QuotedNoteEmbed( authorMetaValue val displayData = event.toNoteDisplayData(localCache) - NoteCard( - note = displayData, + SpamCheckedNoteRender( + note = note, localCache = localCache, - onClick = onNavigateToThread?.let { nav -> { nav(event.id) } }, - onAuthorClick = onMentionClick, - onMentionClick = onMentionClick, - ) + ) { + NoteCard( + note = displayData, + localCache = localCache, + onClick = onNavigateToThread?.let { nav -> { nav(event.id) } }, + onAuthorClick = onMentionClick, + onMentionClick = onMentionClick, + ) + } } else { Card( modifier = Modifier.fillMaxWidth(), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/SpamCheckedNoteRender.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/SpamCheckedNoteRender.kt new file mode 100644 index 0000000000..6924ed6235 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/SpamCheckedNoteRender.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.note + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamCheck +import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings +import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys +import com.vitorpamplona.amethyst.commons.moderation.displayedEvent +import com.vitorpamplona.amethyst.commons.ui.note.CollapsedSpamNote +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags + +/** + * Wraps a note render in the hashtag-spam check. + * + * Reads [LocalHashtagSpamSettings] and [LocalSpamExemptKeys] from + * composition. If the check trips and the user hasn't revealed this + * specific note (per-note [rememberSaveable] keyed by id), renders + * [CollapsedSpamNote] with author info pulled from [localCache]; + * otherwise calls [normal]. + * + * @param displayedEvent The event whose body the [normal] block renders. + * For repost wrappers, callers should pass the *inner* event (via + * [Note.displayedEvent]). For search results that already hold an + * [Event] directly, pass it as-is. + * @param noteIdHex Stable id used as the [rememberSaveable] key for the + * reveal flag — must match the id of whatever the [normal] block renders. + * @param forceReveal When `true`, skips the check entirely and always + * renders [normal]. Used by thread-detail screens where the user + * explicitly opted into the root note. + */ +@Composable +fun SpamCheckedNoteRender( + displayedEvent: Event?, + noteIdHex: String, + localCache: DesktopLocalCache?, + forceReveal: Boolean = false, + normal: @Composable () -> Unit, +) { + val settings = LocalHashtagSpamSettings.current + val enabled by settings.enabled.collectAsState() + val threshold by settings.threshold.collectAsState() + val exemptKeys = LocalSpamExemptKeys.current + + val isSpam = + remember(noteIdHex, displayedEvent, enabled, threshold, exemptKeys) { + HashtagSpamCheck.isHashtagSpam( + displayedEvent = displayedEvent, + authorPubkey = displayedEvent?.pubKey, + enabled = enabled, + threshold = threshold, + exemptKeys = exemptKeys, + ) + } + + var revealed by rememberSaveable(noteIdHex) { mutableStateOf(false) } + + if (!forceReveal && isSpam && !revealed && displayedEvent != null) { + val authorPubkey = displayedEvent.pubKey + val author = localCache?.getUserIfExists(authorPubkey) + val displayName = author?.toBestDisplayName() ?: authorPubkey.take(8) + val avatarUrl = author?.profilePicture() + val hashtagCount = displayedEvent.tags.countHashtags() + CollapsedSpamNote( + authorPubkeyHex = authorPubkey, + authorDisplayName = displayName, + authorAvatarUrl = avatarUrl, + hashtagCount = hashtagCount, + threshold = threshold, + onReveal = { revealed = true }, + ) + } else { + normal() + } +} + +/** + * [Note]-shaped convenience overload — resolves the displayed event + * (unwrapping kind 6 / 16 reposts) and delegates to the primary helper. + */ +@Composable +fun SpamCheckedNoteRender( + note: Note, + localCache: DesktopLocalCache?, + forceReveal: Boolean = false, + normal: @Composable () -> Unit, +) { + val displayedEvent = remember(note, note.event) { note.displayedEvent() } + SpamCheckedNoteRender( + displayedEvent = displayedEvent, + noteIdHex = note.idHex, + localCache = localCache, + forceReveal = forceReveal, + normal = normal, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt index d88b712add..4b35e4d95c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt @@ -60,7 +60,8 @@ import com.vitorpamplona.amethyst.commons.search.SearchSortOrder import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard -import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData +import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender +import com.vitorpamplona.amethyst.desktop.ui.rememberDisplayData import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @Composable @@ -153,24 +154,36 @@ fun SearchResultsList( if (!collapsed) { val displayNotes = textNotes.take(5) items(displayNotes, key = { "note-${it.id}" }) { event -> - NoteCard( - note = event.toNoteDisplayData(localCache), + SpamCheckedNoteRender( + displayedEvent = event, + noteIdHex = event.id, localCache = localCache, - onClick = { onNavigateToThread(event.id) }, - onAuthorClick = onNavigateToProfile, - onMentionClick = onNavigateToProfile, - ) + ) { + NoteCard( + note = event.rememberDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + ) + } } if (textNotes.size > 5) { item(key = "notes-expand") { ExpandableSection( remaining = textNotes.drop(5), ) { event -> - NoteCard( - note = event.toNoteDisplayData(localCache), - onClick = { onNavigateToThread(event.id) }, - onAuthorClick = onNavigateToProfile, - ) + SpamCheckedNoteRender( + displayedEvent = event, + noteIdHex = event.id, + localCache = localCache, + ) { + NoteCard( + note = event.rememberDisplayData(localCache), + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + ) + } } } } @@ -197,24 +210,36 @@ fun SearchResultsList( } if (!collapsed) { items(articles.take(5), key = { "article-${it.id}" }) { event -> - NoteCard( - note = event.toNoteDisplayData(localCache), + SpamCheckedNoteRender( + displayedEvent = event, + noteIdHex = event.id, localCache = localCache, - onClick = { onNavigateToThread(event.id) }, - onAuthorClick = onNavigateToProfile, - onMentionClick = onNavigateToProfile, - ) + ) { + NoteCard( + note = event.rememberDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + ) + } } if (articles.size > 5) { item(key = "articles-expand") { ExpandableSection( remaining = articles.drop(5), ) { event -> - NoteCard( - note = event.toNoteDisplayData(localCache), - onClick = { onNavigateToThread(event.id) }, - onAuthorClick = onNavigateToProfile, - ) + SpamCheckedNoteRender( + displayedEvent = event, + noteIdHex = event.id, + localCache = localCache, + ) { + NoteCard( + note = event.rememberDisplayData(localCache), + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + ) + } } } } @@ -239,13 +264,19 @@ fun SearchResultsList( } if (!collapsed) { items(otherNotes.take(5), key = { "other-${it.id}" }) { event -> - NoteCard( - note = event.toNoteDisplayData(localCache), + SpamCheckedNoteRender( + displayedEvent = event, + noteIdHex = event.id, localCache = localCache, - onClick = { onNavigateToThread(event.id) }, - onAuthorClick = onNavigateToProfile, - onMentionClick = onNavigateToProfile, - ) + ) { + NoteCard( + note = event.rememberDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + ) + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/HashtagSpamSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/HashtagSpamSettingsSection.kt new file mode 100644 index 0000000000..e6c77083cd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/HashtagSpamSettingsSection.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings +import kotlin.math.roundToInt + +/** + * Settings UI for the hashtag-spam content filter. + * + * Decoupled-thumb pattern: a local [Float] state drives the [Slider] thumb, + * the [StateFlow] only sees the committed [Int] on [onValueChangeFinished]. + * Prevents per-tick recomposition storms in the feed while the user drags. + */ +@Composable +fun HashtagSpamSettingsSection( + settings: HashtagSpamSettings, + modifier: Modifier = Modifier, +) { + val enabled by settings.enabled.collectAsState() + val committed by settings.threshold.collectAsState() + + var live by remember { mutableFloatStateOf(committed.toFloat()) } + LaunchedEffect(committed) { live = committed.toFloat() } + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = "Hashtag-spam filter", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = enabled, onCheckedChange = settings::setEnabled) + Spacer(Modifier.width(8.dp)) + Text( + text = if (enabled) "On" else "Off", + style = MaterialTheme.typography.bodyMedium, + ) + } + if (enabled) { + Spacer(Modifier.height(8.dp)) + Text( + text = "Hide notes with more than ${live.roundToInt()} hashtags", + style = MaterialTheme.typography.bodyMedium, + ) + Slider( + value = live, + onValueChange = { live = it }, + onValueChangeFinished = { settings.setThreshold(live.roundToInt()) }, + valueRange = + HashtagSpamSettings.MIN_THRESHOLD + .toFloat()..HashtagSpamSettings.MAX_THRESHOLD.toFloat(), + steps = HashtagSpamSettings.MAX_THRESHOLD - HashtagSpamSettings.MIN_THRESHOLD - 1, + ) + Text( + text = "Long-form articles and posts from people you follow are always shown.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md b/docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md new file mode 100644 index 0000000000..91630c2506 --- /dev/null +++ b/docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md @@ -0,0 +1,867 @@ +--- +title: Desktop Hashtag-Spam Filter +type: feat +status: active +date: 2026-06-29 +origin: docs/brainstorms/2026-06-29-feat-hashtag-spam-filter-brainstorm.md +deepened: 2026-06-29 +--- + +# Desktop Hashtag-Spam Filter + +## Enhancement Summary + +**Deepened on:** 2026-06-29 (same day as plan write). + +**Review agents used:** architecture-strategist, code-simplicity-reviewer, +pattern-recognition-specialist, performance-oracle, agent-native-reviewer, +security-sentinel · plus best-practices research (Compose collapse-UX + Slider +patterns) and a Quartz/NoteCard-variant verification sweep. + +### Key corrections vs initial plan + +1. **Repost unwrap uses `note.replyTo`, not `containedPost()`.** Desktop's + `DesktopLocalCache.consumeRepost()` already resolves the boosted event into + `note.replyTo` at consume time — a pointer chase, not a JSON parse per render. +2. **NoteCard signature mismatch.** Desktop `NoteCard` takes `NoteDisplayData`, + not `Note`. Spam check is hoisted to the **caller** (`FeedScreen`, + `QuotedNoteEmbed`, thread renderer); `NoteCard` itself stays untouched, the + caller chooses between `NoteCard(...)` and `CollapsedSpamNote(...)`. +3. **Persistence moves to `commons/jvmMain/`** with a stable + `java.util.prefs` node name shared between Desktop and the `amy` CLI — + closes the agent-native parity gap from day one. +4. **Settings UI gets its own "Content Filters" section** in `Main.kt`'s + settings screen, not under `RelaySettingsScreen` (this isn't a relay + concern). +5. **Reveal state simplifies to `rememberSaveable(note.idHex)`** — drop the + `LocalRevealedSpamState` CompositionLocal + `SnapshotStateMap`. Trade-off: + the same spam note revealed in column A stays collapsed in column B — + accepted as v1 limitation. +6. **Slider commits on `onValueChangeFinished`** with a local `Float` state + driving the thumb. Live label reads the local float. No `debounce`, no + `prefs.flush()` thrash, no recomposition storm during drag. +7. **`collectAsState` hoisted to column scope** (or higher) and scalars + pushed down to leaf items — not collected per-card. +8. **`HashtagSpamCheck` placed in `commons/.../hashtags/`** (existing + package), not a new `filters/` package. +9. **Self + follow exemption** merged into a single `exemptKeys: Set` + parameter. +10. **`NoOpHashtagSpamSettings` dropped.** Always provide + `PreferencesHashtagSpamSettings` at App root via + `compositionLocalOf { error("Provide LocalHashtagSpamSettings") }`. +11. **Notifications-tab compact card is out of scope v1** — uses a custom + 56 dp composable that doesn't funnel through `NoteCard`. All other + Desktop note-render surfaces (Home/Hashtag/Profile/Search/Thread + replies/Embedded quotes) DO funnel through `NoteCard` and pick up the + filter for free. +12. **Quartz API name correction:** `containedPost()`, not + `containedNote()`. + +### New considerations discovered + +- `note.replyTo` is precomputed by `DesktopLocalCache.consumeRepost` + (`desktopApp/.../cache/DesktopLocalCache.kt:401-416`). Use it. +- `Kind3Follows.authors` is a `val` on an `@Immutable` data class + (`commons/.../nip02FollowList/Kind3FollowListState.kt:100-104`), so + `account.followingKeySet()` returns a stable reference — no extra caching + needed. The plan's earlier reactivity hedge was unnecessary. +- `containedPost()` already returns `null` on parse failure + (`quartz/.../nip18Reposts/RepostEvent.kt:87-92`) — no extra try/catch. +- Embedded quotes (`QuotedNoteEmbed`) and search results both call + `NoteCard()` directly, so the caller-side check handles them naturally + when we patch those call sites. + +--- + +## Overview + +Detect notes that abuse `t` (hashtag) tags as a visibility trick and render +them as a compact reveal-on-click placeholder instead of the normal note +card. Logic in `commons/` (callable by Desktop, future Android, and the +`amy` CLI). UI + settings shipped on Desktop first. Reuses Quartz's +`hasMoreHashtagsThan` primitive. + +**Carried forward from brainstorm** +(`docs/brainstorms/2026-06-29-feat-hashtag-spam-filter-brainstorm.md`): +collapse-with-reveal · single global threshold · default 5 (slider 1–20, +with off-switch) · auto-exempt long-form articles (kind 30023) + followed +authors · persisted via `java.util.prefs.Preferences`. + +**Resolved during planning + deepening:** default ON · repost wrapper +checks the inner wrapped event's tags (via pre-resolved `note.replyTo`) · +thread root auto-expands but replies stay collapsed · hashtag-feed columns +still filter · reveal state is session-scoped via `rememberSaveable` keyed +by note id · settings persisted under a `commons/jvmMain` `java.util.prefs` +node shared with `amy`. + +## Problem Statement + +Hashtag-spam — posts with 10–30 `t` tags chosen to maximize cross-feed +visibility — pollutes every multi-column deck view. The cost scales with +column count: Desktop's TweetDeck-style UI exposes the problem more than +Android. Existing `AntiSpamFilter` only catches duplicate content, not +visibility-bombs. + +## Proposed Solution + +### High-level approach + +A pure check function in `commons/` (`HashtagSpamCheck.isHashtagSpam(...)`) +called by the **callers** of each note-card composable (feed `LazyColumn` +item lambdas, embedded-quote renderer, thread item renderer). When the check +returns `true`, the caller renders a `CollapsedSpamNote` placeholder +instead of the normal note card. The placeholder takes primitive scalars +so the same composable is reusable across Desktop and (later) Android. + +Settings (enabled flag + integer threshold) live behind a +`HashtagSpamSettings` interface in `commons/`, with a +`PreferencesHashtagSpamSettings` JVM implementation backing +`java.util.prefs.Preferences.userRoot().node("com/vitorpamplona/amethyst/filters")` +— shared with `amy` CLI for agent-native parity. + +### Why **not** a FeedFilter + +`commons/.../ui/feeds/AdditiveFeedFilter.kt` is **exclusionary** — +`applyFilter()` returns a `Set` and items not in the set vanish. +Collapse-with-reveal needs to keep the item and render it differently, so +the decision belongs at render time, not in the feed pipeline. The +brainstorm doc named the unit `HashtagSpamFilter`; renamed to +`HashtagSpamCheck`. + +### Architecture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ commons/ (platform-agnostic, callable by Desktop / amy / Android)│ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ commonMain/ │ │ +│ │ hashtags/HashtagSpamCheck.kt (pure function) │ │ +│ │ hashtags/HashtagSpamSettings.kt (interface, @Stable) │ │ +│ │ hashtags/displayedEvent.kt (Note → Event? helper) │ │ +│ │ ui/note/CollapsedSpamNote.kt (scalar-param card) │ │ +│ │ ui/LocalHashtagSpamSettings.kt (CompositionLocal, │ │ +│ │ error-on-default) │ │ +│ │ jvmMain/ │ │ +│ │ hashtags/PreferencesHashtagSpamSettings.kt │ │ +│ │ (java.util.prefs-backed impl, shared node name) │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + ▲ + │ +┌─────────────────────────────┴────────────────────────────────────┐ +│ desktopApp/ (only the UI wire-up + caller-side check) │ +│ Main.kt │ +│ CompositionLocalProvider(LocalHashtagSpamSettings provides │ +│ PreferencesHashtagSpamSettings()) { │ +│ App() … │ +│ } │ +│ ui/settings/HashtagSpamSettingsSection.kt │ +│ (Switch + Slider; local Float thumb; commit on │ +│ onValueChangeFinished) │ +│ feeds/FeedScreen.kt (and QuotedNoteEmbed, ThreadScreen replies)│ +│ LazyColumn { items(notes, key = { it.idHex }) { note -> │ +│ if (isHashtagSpam(...)) CollapsedSpamNote(...) │ +│ else NoteCard(NoteDisplayData(note), …) │ +│ }} │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ cli/ (amy — v2 subcommands, plan-out only) │ +│ amy filter hashtag-spam get / set │ +│ amy notes is-spam │ +│ amy notes feed --include-spam=false (default) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### Pure check function + +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/HashtagSpamCheck.kt`: + +```kotlin +object HashtagSpamCheck { + /** + * Pure: no side effects, no IO. Caller passes plain scalars so this + * stays Compose-friendly for `remember(...)` keys and unit-testable + * without setting up a CompositionLocal. + * + * `displayedEvent` is the event whose body the note card actually + * renders — for kind 6/16 reposts that means the wrapped inner event + * resolved through `Note.displayedEvent()`. + * + * `exemptKeys` should already include the user's self pubkey plus + * every pubkey in the current follow list; merged at the call site so + * this function stays single-purpose. + */ + fun isHashtagSpam( + displayedEvent: Event?, + authorPubkey: HexKey?, + enabled: Boolean, + threshold: Int, + exemptKeys: Set, + ): Boolean { + if (!enabled) return false + if (displayedEvent == null) return false + if (displayedEvent.kind == LongFormContentEvent.KIND) return false // 30023 + if (authorPubkey != null && authorPubkey in exemptKeys) return false + return displayedEvent.tags.hasMoreHashtagsThan(threshold) + } +} +``` + +### Displayed-event resolver + +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/displayedEvent.kt`: + +```kotlin +/** + * For reposts (kind 6) and generic reposts (kind 16), the "displayed + * event" is the wrapped inner event already resolved on `note.replyTo` + * by the consume pipeline. Falls back to `containedPost()` only if the + * cache hasn't materialised the reply yet; that path JSON-parses and + * returns null on bad content (Quartz API already handles try/catch). + */ +fun Note.displayedEvent(): Event? { + val e = this.event ?: return null + return when (e) { + is RepostEvent -> replyTo?.lastOrNull()?.event ?: e.containedPost() + is GenericRepostEvent -> replyTo?.lastOrNull()?.event ?: e.containedPost() + else -> e + } +} +``` + +`containedPost()` (not `containedNote()` — corrected) lives at +`quartz/.../nip18Reposts/RepostEvent.kt:87` and +`.../GenericRepostEvent.kt:87`. Both already wrap `fromJson(content)` in +try/catch returning null. + +### Settings interface + +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/HashtagSpamSettings.kt`: + +```kotlin +@Stable +interface HashtagSpamSettings { + val enabled: StateFlow + val threshold: StateFlow + fun setEnabled(enabled: Boolean) + fun setThreshold(threshold: Int) +} +``` + +`LocalHashtagSpamSettings`: + +```kotlin +val LocalHashtagSpamSettings: ProvidableCompositionLocal = + compositionLocalOf { error("LocalHashtagSpamSettings not provided") } +``` + +No `NoOpHashtagSpamSettings` — caller (`Main.kt` `App()`) is always required +to provide the real impl. Compose idiom for "must provide" via `error { ... }` +default, mirroring how Material3 enforces theme provision. + +### Persistence (commons/jvmMain — shared with `amy`) + +`commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/PreferencesHashtagSpamSettings.kt`: + +```kotlin +class PreferencesHashtagSpamSettings( + prefs: Preferences = Preferences.userRoot().node("com/vitorpamplona/amethyst/filters"), +) : HashtagSpamSettings { + private val _enabled = MutableStateFlow(prefs.getBoolean(KEY_ENABLED, true)) + private val _threshold = MutableStateFlow(prefs.getInt(KEY_THRESHOLD, 5)) + override val enabled = _enabled.asStateFlow() + override val threshold = _threshold.asStateFlow() + + override fun setEnabled(v: Boolean) { + _enabled.value = v + prefs.putBoolean(KEY_ENABLED, v) + } + + override fun setThreshold(v: Int) { + val clamped = v.coerceIn(MIN, MAX) + _threshold.value = clamped + prefs.putInt(KEY_THRESHOLD, clamped) + } + + companion object { + const val KEY_ENABLED = "hashtag_spam_enabled" + const val KEY_THRESHOLD = "hashtag_spam_threshold" + const val MIN = 1 + const val MAX = 20 + } +} +``` + +- Shared `java.util.prefs` node `com/vitorpamplona/amethyst/filters` (not + per-class) so `amy` constructing the same impl observes the same node. +- No `prefs.flush()` — `java.util.prefs` auto-flushes on JVM shutdown and + periodically; explicit flush thrashes disk. +- Defaults: enabled = `true`, threshold = `5`. New installs and existing + users (absent keys) both get the defaults. No migration code. + +### Collapsed-note placeholder + +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CollapsedSpamNote.kt`: + +```kotlin +@Composable +fun CollapsedSpamNote( + authorDisplayName: String, + authorAvatarUrl: String?, + createdAtSeconds: Long, + hashtagCount: Int, + threshold: Int, + onReveal: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .padding(horizontal = 12.dp, vertical = 8.dp) + .semantics { + contentDescription = + "Hidden note from $authorDisplayName, $hashtagCount hashtags. Tap to reveal." + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar(url = authorAvatarUrl, size = 32.dp) + Spacer(Modifier.width(8.dp)) + Column(Modifier.weight(1f)) { + Text(authorDisplayName, style = MaterialTheme.typography.labelLarge) + Text( + "Filtered: $hashtagCount hashtags · threshold $threshold", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text(relativeTimeShort(createdAtSeconds), style = MaterialTheme.typography.bodySmall) + TextButton(onClick = onReveal) { Text("Reveal") } + } +} +``` + +Parameters are **scalars only** — no `Note`, no `NoteDisplayData`, no +`Event`. This is the single shareable surface; Android, Desktop, and any +future front-end render it from their own data shape by mapping at the call +site. + +Always visible: avatar, display name, timestamp, hashtag count, reason +chip, Reveal button. Hidden: body, media, link previews. Convergent with +Mastodon/Tusky/Bluesky CW-card conventions (research: best-practices). + +### Caller-side integration + +The check happens **outside** `NoteCard` because Desktop's `NoteCard` takes +a `NoteDisplayData` that doesn't carry the raw `Note`/`Event` we need. Each +caller resolves `Note → displayed event → spam decision` and renders +`CollapsedSpamNote` or `NoteCard`. + +`desktopApp/.../ui/FeedScreen.kt` (representative): + +```kotlin +@Composable +fun FeedScreen(notes: List, account: IAccount) { + val settings = LocalHashtagSpamSettings.current + val enabled by settings.enabled.collectAsState() + val threshold by settings.threshold.collectAsState() + val exemptKeys = remember(account) { + // Stable: Kind3Follows.authors is @Immutable, selfPubkey is a String + account.followingKeySet() + account.userProfile().pubkeyHex + } + + LazyColumn { + items(notes, key = { it.idHex }) { note -> + val displayedEvent = note.displayedEvent() + val isSpam = remember(note.idHex, displayedEvent, enabled, threshold, exemptKeys) { + HashtagSpamCheck.isHashtagSpam( + displayedEvent = displayedEvent, + authorPubkey = displayedEvent?.pubKey, + enabled = enabled, + threshold = threshold, + exemptKeys = exemptKeys, + ) + } + var revealed by rememberSaveable(note.idHex) { mutableStateOf(false) } + + Box(Modifier.animateItem()) { // smooth resize + if (isSpam && !revealed) { + CollapsedSpamNote( + authorDisplayName = note.author?.bestDisplayName() ?: "", + authorAvatarUrl = note.author?.profilePicture(), + createdAtSeconds = displayedEvent?.createdAt ?: 0L, + hashtagCount = displayedEvent?.tags?.countHashtags() ?: 0, + threshold = threshold, + onReveal = { revealed = true }, + ) + } else { + NoteCard(NoteDisplayData(note), /* … */) + } + } + } + } +} +``` + +Important specifics from research: + +- `collectAsState()` is called **once at column scope**, not once per note — + prevents 60–360 redundant collectors per visible viewport (perf-oracle). +- `key = { it.idHex }` on `items(...)` is required for `Modifier.animateItem()` + to animate the size change correctly. +- `rememberSaveable(note.idHex)` survives `LazyColumn` recycling on scroll. +- Trade-off: the same spam note shown simultaneously in Home + Hashtag + columns has independent reveal state per column. Acceptable v1. + +#### Other caller sites that need the same pattern + +1. **`QuotedNoteEmbed`** at `desktopApp/.../ui/note/NoteCard.kt:470-535` — when + a note body contains `nostr:nevent…` and the rich-text renderer recurses + into `NoteCard`. Patch this entry to apply the same check. +2. **Thread replies** in `desktopApp/.../ui/ThreadScreen.kt` — replies use + `NoteCard` too. The **root** note auto-expands (caller passes + `forceReveal = true`); replies use the normal collapse rule. +3. **Search results** at `desktopApp/.../ui/search/SearchResultsList.kt:156+` + — same call-site change. +4. **Notifications-tab compact 56 dp card** at `NotificationsScreen.kt:271-366` + does NOT funnel through `NoteCard` (it's a bespoke compact composable). + Verified by Quartz/NoteCard-variant sweep. **Deferred to v2** — the + notification's snippet text isn't a full note body so the impact is + lower. + +#### Thread root auto-expand + +Thread screen owns its caller and simply passes `forceReveal = true` to +the root note's `revealed` state initialization, while replies keep +`rememberSaveable(note.idHex) { mutableStateOf(false) }`. No `ReplyContext` +plumbing through `NoteCard` (that didn't exist anyway — corrected from the +draft). + +### Settings UI (Desktop) + +`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/HashtagSpamSettingsSection.kt`: + +```kotlin +@Composable +fun HashtagSpamSettingsSection( + settings: HashtagSpamSettings, + modifier: Modifier = Modifier, +) { + val enabled by settings.enabled.collectAsState() + val committed by settings.threshold.collectAsState() + + // Local slider state — decouples drag from StateFlow churn. + // LaunchedEffect resyncs if a different surface changes the threshold. + var live by remember { mutableFloatStateOf(committed.toFloat()) } + LaunchedEffect(committed) { live = committed.toFloat() } + + Column(modifier.padding(16.dp)) { + Text("Hashtag-spam filter", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = enabled, onCheckedChange = settings::setEnabled) + Spacer(Modifier.width(8.dp)) + Text(if (enabled) "On" else "Off") + } + if (enabled) { + Text("Hide notes with more than ${live.roundToInt()} hashtags", + style = MaterialTheme.typography.bodyMedium) + Slider( + value = live, + onValueChange = { live = it }, // local only — no recompose storm + onValueChangeFinished = { settings.setThreshold(live.roundToInt()) }, + valueRange = 1f..20f, + steps = 18, + ) + Text("Long-form articles and posts from people you follow are always shown.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} +``` + +Wired into `Main.kt` settings screen as a dedicated **Content Filters** +section (sibling to Wallet Connect, Media Server, Namecoin, Local Relay), +not under `RelaySettingsScreen`'s relay-specific entries. Section header +spans the screen so users searching "spam" or "hashtag" find it. + +The settings impl is provided at App root: + +```kotlin +// Main.kt App() +val hashtagSpamSettings = remember { PreferencesHashtagSpamSettings() } +CompositionLocalProvider( + LocalHashtagSpamSettings provides hashtagSpamSettings, + // existing CompositionLocals … +) { + App(…) +} +``` + +## Technical Considerations + +### Performance + +- Quartz `hasMoreHashtagsThan` is O(n) over the note's tag array (n ≈ 5–30 + worst case) with short-circuit on total count before any hashset + allocation. Sub-microsecond per note. +- `note.displayedEvent()` is a pointer chase via the precomputed + `note.replyTo` for reposts — no JSON parse on the hot path. Falls back + to `containedPost()` (which itself has try/catch + null return) only + for the rare unconsumed-repost case. +- `isHashtagSpam` is `remember(...)`-memoized against + `(noteId, displayedEvent, enabled, threshold, exemptKeys)`. The + `exemptKeys` set is `remember(account)`-cached at column scope so + references stay stable across recompositions. +- Settings StateFlows are `collectAsState`d **once per column**, not per + note — prevents O(visible notes) flow collectors per deck. +- `Slider` uses local `Float` state; downstream collectors only see the + committed `Int` on drag-end → zero feed recompositions while the user + drags the thumb. +- `rememberSaveable(note.idHex)` for reveal flag survives LazyColumn + recycling on scroll without any external state container. + +### Stability annotations + +- `HashtagSpamSettings` interface: `@Stable` — public observable surface + (`StateFlow` instances) changes only via `setEnabled`/`setThreshold`, + honest stability contract. +- `PreferencesHashtagSpamSettings` class: omit annotation — `@Stable` would + be redundant on a class implementing a `@Stable` interface, and + `@Immutable` is wrong (it holds `MutableStateFlow`s). +- `HashtagSpamCheck` is `object` — implicitly `@Immutable`. + +### Threshold reactivity + +`enabled` and `threshold` are exposed as `StateFlow` and read via +`collectAsState()` at column scope. Setting changes → StateFlow emit → +column recomposes → all visible cards re-evaluate `isSpam` and +re-collapse/re-reveal accordingly. Project pattern memory: +`?.collectAsState()?.value` does NOT work for tracking — use `by … .collectAsState()`. + +### Follow-set reactivity + +`account.followingKeySet()` returns +`kind3FollowList.flow.value.authors`. `Kind3Follows` is `@Immutable`, so +the returned `Set` reference is stable until the follow list +mutates upstream. Currently no `StateFlow>` accessor exists — +follow/unfollow during a session won't reactively re-evaluate cards in +place. Acceptable for v1 (follow churn is low). A future refactor to expose +`kind3FollowList.flow` directly on `IAccount` closes the gap. + +### Reposts and the displayed event + +| `note.event` kind | `note.displayedEvent()` | +|-------------------|--------------------------| +| 6 (`RepostEvent`) | `replyTo.last().event` (precomputed) ?: `containedPost()` | +| 16 (`GenericRepostEvent`) | `replyTo.last().event` (precomputed) ?: `containedPost()` | +| anything else | `note.event` | + +The wrapped event is **already materialised on `note.replyTo`** by +`DesktopLocalCache.consumeRepost()` — no JSON parsing on render. + +### Inside threads + +Thread screen owns the caller; for the **root** note it initialises +`revealed = true` (auto-expand on opt-in click-through). Replies use +the default collapsed/`rememberSaveable` path. No special threading types. + +### Inside embedded/quoted notes + +`QuotedNoteEmbed` (the entry that recurses into `NoteCard` for inline +`nostr:nevent…` references) gets the same check at its call site. +Independent `rememberSaveable` per quote location. + +### Security / privacy + +- Pure client-side: no network IO, no relay filter derivation, no telemetry. +- App-global persistence (not per-account) intentionally avoids leaking + per-Nostr-identity behavioural fingerprints to anyone with filesystem + access — the OS-account boundary is the trust boundary. +- Malformed inner repost events: `containedPost()` already returns null, + `isHashtagSpam` returns false (does not collapse, does not throw). +- Censorship-resistance smell test: content is **collapsed, not dropped**; + one click reveals; settings live in a top-level "Content Filters" section + (not buried under relays). + +## System-Wide Impact + +### Interaction graph + +``` +PreferencesHashtagSpamSettings (java.util.prefs node) + │ + ▼ CompositionLocalProvider in Main.kt App() +LocalHashtagSpamSettings + │ + ▼ collectAsState() at column scope +(enabled, threshold) as scalars + │ + ▼ per-note remember(...) at LazyColumn item +HashtagSpamCheck.isHashtagSpam(displayedEvent, …) + │ + ▼ if true & !revealed +CollapsedSpamNote(scalars, onReveal) + │ + └─ onReveal → rememberSaveable(note.idHex) flips → recomposes this row only +``` + +### Error & failure propagation + +- `note.displayedEvent()`: fallback chain `replyTo → containedPost → null`. + Null displayed event → `isHashtagSpam = false` → normal NoteCard renders. + Never throws. +- `Preferences.put*` may throw `BackingStoreException`. Wrap in try/catch + inside `setEnabled`/`setThreshold`; log and continue (in-memory state + already updated; persistence is best-effort). +- Settings UI: `LocalHashtagSpamSettings` default `error("...")` only fires + if App root forgot the provider. Caught at first launch, never in prod. + +### State lifecycle risks + +- Reveal state is `rememberSaveable` per LazyColumn item. Survives scroll + recycling; lost on screen close (intentional). +- Settings keys absent on first launch → defaults (`true`, `5`). No + partial-state risk; `java.util.prefs` is atomic per key. +- Slider local-Float state is column-instance scoped; lost on settings + screen close. Re-derived from committed `Int` on next open. + +### API surface parity + +- **commons:** `HashtagSpamCheck` (pure), `HashtagSpamSettings` (interface), + `Note.displayedEvent()` extension, `CollapsedSpamNote` (scalar + composable), `LocalHashtagSpamSettings` (CompositionLocal), + `PreferencesHashtagSpamSettings` (`jvmMain` impl, shared `java.util.prefs` + node with `amy`). +- **desktopApp:** App-root provider, `HashtagSpamSettingsSection` Compose + UI, caller-side check in `FeedScreen` / `QuotedNoteEmbed` / `ThreadScreen` + / `SearchResultsList`. +- **amethyst (Android):** no changes v1. Future adoption is a single + `AndroidHashtagSpamSettings` (DataStore-backed) + `LocalHashtagSpamSettings` + provider + the same caller-side pattern. Commons does not leak Desktop + types so the path is clean. +- **cli (amy):** day-1 picks up the shared `java.util.prefs` node — agents + using `amy` see the same setting users configured in Desktop. v2 adds + `amy filter hashtag-spam {get|set}` and `amy notes is-spam ` + subcommands; v2 also adds `amy notes feed --include-spam` (default + respects setting). + +### Integration test scenarios + +1. **Slider live re-collapse.** Open a Home column with a known spammy + visible (revealed). Drag threshold down past its tag count. + `onValueChangeFinished` commits → all visible cards re-evaluate → that + card collapses. No mid-drag visual jank. +2. **Repost spam (precomputed).** A kind:6 wrapping a 12-hashtag kind:1 + already consumed → `replyTo` materialised → check trips on inner → + wrapper collapses. +3. **Repost spam (unconsumed).** Same scenario but the inner event hasn't + been cached → fallback to `containedPost()` succeeds → same outcome. +4. **Followed-author exemption.** 15-hashtag note from a key in follow set + → does NOT collapse. +5. **Self exemption.** 15-hashtag note from the active account → does NOT + collapse for self. +6. **Long-form exemption.** kind:30023 with 12 topic tags → no collapse. +7. **Thread root auto-expand.** Tap a collapsed card → thread screen → root + shows full content; a 12-hashtag reply in the thread stays collapsed. +8. **Embedded quote.** A note quotes a 15-hashtag note via `nostr:nevent…` + → the inline embedded card renders as collapsed; revealing it does NOT + reveal the same note in the parent feed column (independent + `rememberSaveable` per call site). +9. **Settings persistence.** Toggle off, restart Desktop app → off-state + restored from `java.util.prefs` node. +10. **amy parity.** From command line, write enabled=false to the shared + prefs node via plain `java.util.prefs` API → relaunch Desktop → + Desktop reads the same node → filter is off. +11. **Malformed inner repost.** `containedPost()` returns null on bad + inner JSON → `isHashtagSpam` returns false → renders normal NoteCard + of the wrapper (which itself has 0 hashtags). No crash. + +## Acceptance Criteria + +### Functional + +- [x] `HashtagSpamCheck.isHashtagSpam` in + `commons/commonMain/.../moderation/HashtagSpamCheck.kt`. Compiles for + `:commons:compileKotlinJvm`. *(Note: filed under `moderation/` not + `hashtags/` — `hashtags/` is the existing icon-only package.)* +- [x] `HashtagSpamSettings` (`@Stable` interface) in + `commons/commonMain/.../moderation/HashtagSpamSettings.kt`. +- [x] `LocalHashtagSpamSettings` CompositionLocal with `error(...)` + default. `LocalSpamExemptKeys` CompositionLocal added for the + account-derived exempt set. +- [x] `PreferencesHashtagSpamSettings` JVM impl in `commons/jvmMain` + bound to `Preferences.userRoot().node("com/vitorpamplona/amethyst/filters")` + with keys `hashtag_spam_enabled` (default `true`) and + `hashtag_spam_threshold` (default `5`, range 1–20). +- [x] `CollapsedSpamNote` composable in + `commons/commonMain/.../ui/note/CollapsedSpamNote.kt` taking scalar + params only (no `Note`, no `NoteDisplayData`, no `Event`); includes + a11y `contentDescription`. +- [x] `Note.displayedEvent()` extension in + `commons/commonMain/.../moderation/DisplayedEvent.kt` returning the + wrapped event for kind 6 / 16 via `replyTo` (precomputed), falling + back to `containedPost()` and finally null. +- [x] Desktop `FeedNoteCard` (covers FeedScreen + ThreadScreen + + UserProfileScreen), `QuotedNoteEmbed`, `BookmarksScreen`, and + `SearchResultsList` (5 sites) all branch on the spam check + caller-side via the shared `SpamCheckedNoteRender` helper and render + either `CollapsedSpamNote` or `NoteCard`. +- [x] `FeedNoteCard` exposes `forceReveal: Boolean = false`; ThreadScreen + passes `forceReveal = true` for the root note. Replies inside the + thread use default `rememberSaveable` collapsed. +- [x] `Main.kt` provides `LocalHashtagSpamSettings` at App root (always-on) + and `LocalSpamExemptKeys` inside the LoggedIn branch (account-aware); + adds a **Content Filters** settings section containing + `HashtagSpamSettingsSection`. +- [x] `HashtagSpamSettingsSection`: Switch + Slider with local `Float` + state, commit on `onValueChangeFinished`, live label, exemption + footnote. +- [x] Notifications-tab compact card explicitly **out of scope v1** — + documented in the deferred section + manual testing sheet T13. +- [x] `collectAsState` for settings called once inside + `SpamCheckedNoteRender` (per visible card, but with cheap stable + `StateFlow` references); slider settings UI also collects once. + +### Non-functional + +- [ ] No measurable frame-time regression in profiler runs of a 200-note + column (manual smoke test pending — see T14 in testing sheet). +- [x] Spotless clean: `./gradlew spotlessApply` produces no diff. +- [x] Compiles cleanly: `./gradlew :commons:compileKotlinJvm + :desktopApp:compileKotlin`. +- [x] No `Preferences.flush()` calls; rely on JVM auto-flush. + +### Quality gates + +- [x] Unit tests for `HashtagSpamCheck` (commons) — disabled, longform + exempt, self exempt, follow exempt, under threshold, equal threshold, + over threshold with duplicates only (helper short-circuits on + uniques), over threshold with uniques, null displayedEvent, null + authorPubkey. **10 tests, all green.** +- [x] Unit test for `Note.displayedEvent()` — repost with materialised + `replyTo`, repost with null `replyTo` (fallback returns null on + malformed JSON), non-repost (returns own event), null event. + **4 tests, all green.** +- [x] Unit test for `PreferencesHashtagSpamSettings` — read default, read + after set, threshold clamps to 1..20, both keys persist across + instance recreation (test-specific node suffix). **5 tests, all + green.** +- [x] Manual testing sheet at + `desktopApp/plans/2026-06-29-hashtag-spam-filter-manual-testing-sheet.md` + covering 16 integration scenarios. + +## Success Metrics + +- Subjective deck-feed cleanliness on default ON / threshold 5 (no + collapsed legit follow content, visible spam collapsed). +- No frame drops > 16 ms during slider drag or feed scroll in a heavy + column. +- Settings persist across app restarts and across the Desktop ↔ `amy` + boundary. + +## Dependencies & Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| `replyTo` unset for very fresh reposts | low | Fall back to `containedPost()`; both already null-safe | +| Embedded `NoteCard` recursion misses a call site | medium | Audit every site that constructs `NoteCard(NoteDisplayData(note), …)` during impl; integration scenario #8 catches misses | +| Follow-set non-reactive during a session | low | Acceptable v1; track for a future `IAccount.followingFlow()` accessor | +| Default-on surprises power users | medium | Settings entry is in a top-level **Content Filters** section and easy to find; no toast (per simplicity review) | +| amy and Desktop diverge on settings node name | low | Use a constant `Preferences` node name in commons and reference it from both binaries | +| `Modifier.animateItem()` on an alpha API surface | low | Compose Foundation 1.7+ stable; project already uses it elsewhere — verify during impl | + +## Out of Scope (deferred) + +- User-curated hashtag allowlist (waiting for false-positive feedback). +- Per-column threshold override. +- Aggregated "N filtered today" badge. +- Account-synced setting via NIP-78/NIP-51. +- Android UI (commons logic ready; Android wires later). +- **`amy` subcommands** for the filter (`amy filter hashtag-spam …`, + `amy notes is-spam`, `amy notes feed --include-spam`) — v2; v1 ships the + shared `java.util.prefs` node so the data is already accessible. +- Banner when feed is 100 % collapsed. +- Cross-column shared reveal state (each column's reveal flag is + independent in v1 — `rememberSaveable` per call site). +- Notifications-tab compact 56 dp card spam check (custom composable that + doesn't go through `NoteCard`). +- Persistent "permanently revealed" notes across sessions. + +## Sources & References + +### Origin + +- **Brainstorm:** `docs/brainstorms/2026-06-29-feat-hashtag-spam-filter-brainstorm.md` + — collapse-with-reveal · global threshold · default 5 · longform + + followed exemptions · `Preferences` persistence. + +### Internal references + +- `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt:41` + — `hasMoreHashtagsThan(limit)` primitive. +- `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt:87` + + `.../GenericRepostEvent.kt:87` — `containedPost()` (null-safe). +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:401-416` + — `consumeRepost()` precomputes `note.replyTo` (use this, not JSON + decode). +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt:100-104` + — `@Immutable Kind3Follows.authors: Set` (stable ref). +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt:101` + — `followingKeySet(): Set`. +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt:96-112` + — `NoteCard(note: NoteDisplayData, …)` signature (caller-side check). +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt:470-535` + — `QuotedNoteEmbed` recursion site (also needs caller-side check). +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt:156+` + — search-result call sites. +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt` + — existing `java.util.prefs` pattern (referenced; settings live in + commons/jvmMain instead). +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/LocalRelaySettingsScreen.kt` + — Switch + Slider settings UI pattern. +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt:64-87` + — existing `Local*` CompositionLocal pattern. +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/AdditiveFeedFilter.kt` + — why we don't extend `FeedFilter` (exclusionary contract). +- `commons/ARCHITECTURE.md` — module taxonomy / `commons/jvmMain` placement + for JVM-only helpers. + +### External references + +- [Android Developers — Lazy lists and lazy grids](https://developer.android.com/develop/ui/compose/lists) + — stable `key`, `Modifier.animateItem()`. +- [Android Developers — Where to hoist state](https://developer.android.com/develop/ui/compose/state-hoisting). +- [JetBrains compose-multiplatform #4366 — Slider draggable state](https://github.com/JetBrains/compose-multiplatform/issues/4366) + — why local `Float` + `onValueChangeFinished` is needed. +- [issuetracker #240599812 — AnimatedVisibility in LazyColumn](https://issuetracker.google.com/issues/240599812) + — pitfalls with collapsed-item layout. +- [Mastodon moderating docs](https://docs.joinmastodon.org/user/moderating/) + + [Bluesky moderation docs](https://docs.bsky.app/docs/advanced-guides/moderation) + — convergent "always show author, gate body" convention. + +### Skill references + +- `feed-patterns` — confirmed `FeedFilter` is exclusionary; not used here. +- `compose-expert` — note-card composition + state hoisting. +- `compose-side-effects` — `LaunchedEffect(committed) { live = … }` to + resync slider local state on upstream change. +- `compose-recomposition-performance` — hoist `collectAsState` to column + scope; `rememberSaveable` survives recycling; local `Float` thumb. +- `compose-stability-diagnostics` — `@Stable` on the settings interface. +- `kotlin-flow-state-event-modeling` — StateFlow + collectAsState + reactivity. +- `account-state` — `IAccount.followingKeySet()`; `Kind3Follows` + immutability. +- `nostr-expert` — repost event unwrap via `replyTo`; `containedPost()` + semantics. +- `amy-expert` — shared `java.util.prefs` node so `amy` reads the same + setting users configure in Desktop. +- `kotlin-multiplatform` — `commons/jvmMain` placement for JVM-only + persistence impls. + +### Backlog reference + +- `desktopApp/plans/_desktop-feature-backlog.md` priority item #1; WoT + score on avatars is the next item.