feat(desktop): hashtag-spam filter with collapse-with-reveal

Damus-inspired content filter that collapses notes abusing `t` hashtag
tags into a compact reveal-on-click placeholder. Ships default ON with a
threshold of 5 (adjustable 1–20 in Settings → Content Filters, or off).

Scope
- Pure check (`HashtagSpamCheck`) + settings interface
  (`HashtagSpamSettings`) live in `commons/moderation/`, callable by
  Desktop, `amy` CLI, and (future) Android.
- JVM-backed `PreferencesHashtagSpamSettings` writes to the shared
  `java.util.prefs` node `com/vitorpamplona/amethyst/filters`, so `amy`
  and Desktop observe the same value automatically.
- `CollapsedSpamNote` placeholder in `commons/ui/note/` takes only
  primitive scalars so Android can adopt it without touching commons.
- Desktop wraps every `NoteCard` call site (FeedNoteCard, QuotedNoteEmbed,
  BookmarksScreen, 5 SearchResultsList sites) with a shared
  `SpamCheckedNoteRender` helper. Thread root notes auto-expand via
  `forceReveal=true`; replies still respect the filter.

Exemptions
- Long-form articles (kind 30023)
- Authors in the follow list plus self
- Repost wrappers check the inner event's tags via precomputed
  `note.replyTo`, falling back to `containedPost()`

Search UX fixes bundled in
- Removed the `#hashtag` → "Direct lookup" card. `QueryParser` already
  extracts `#xxx` into the query's hashtag filter, so typing `#bitcoin`
  now goes straight to filtered results.
- Search-result rows now trigger metadata loading via
  `subscriptionsCoordinator.loadMetadataBatched(authors)` and observe
  each user's metadata flow via a new `rememberDisplayData` helper, so
  display names + avatars refresh when kind-0 arrives from index
  relays. Same helper reused in Bookmarks.

Tests + docs
- 19 unit tests (check × 10, displayed-event unwrap × 4, prefs × 5),
  all green.
- Manual testing sheet with 16 scenarios at
  `desktopApp/plans/2026-06-29-hashtag-spam-filter-manual-testing-sheet.md`.
- Plan at `docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md`.
- Cross-client desktop feature backlog reference at
  `desktopApp/plans/_desktop-feature-backlog.md`.
This commit is contained in:
nrobi144
2026-07-01 09:46:43 +03:00
parent f05500792c
commit 7a18e30fc4
24 changed files with 2223 additions and 64 deletions
@@ -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
}
}
@@ -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<HexKey>,
): 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)
}
}
@@ -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<Boolean>
val threshold: StateFlow<Int>
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
}
}
@@ -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<HashtagSpamSettings> =
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<Set<HexKey>> =
compositionLocalOf { emptySet() }
@@ -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<SearchResult> {
val trimmed = input.trim()
val results = mutableListOf<SearchResult>()
// 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) {
@@ -50,11 +50,4 @@ sealed class SearchResult {
val dTag: String,
val displayId: String,
) : SearchResult()
/**
* Hashtag search.
*/
data class HashtagResult(
val hashtag: String,
) : SearchResult()
}
@@ -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")
}
}
}
@@ -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())
}
}
@@ -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<String> = 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),
),
)
}
}
@@ -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<Boolean> = mutableEnabled.asStateFlow()
override val threshold: StateFlow<Int> = 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"
}
}
@@ -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)
}
}
@@ -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 <path> 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: ________________
@@ -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<Map<HexKey, Int>>),
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
@@ -76,6 +76,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
@@ -770,6 +773,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 {
@@ -980,6 +987,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 -> {
@@ -1074,6 +1082,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(
@@ -1091,6 +1104,7 @@ fun App(
),
LocalNamecoinPreferences provides namecoinPreferences,
LocalNamecoinService provides namecoinService,
LocalSpamExemptKeys provides spamExemptKeys,
) {
MainContent(
layoutMode = layoutMode,
@@ -1978,6 +1992,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) } },
@@ -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,
@@ -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<Any?>(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.
*/
@@ -132,6 +132,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
@@ -194,8 +195,54 @@ fun FeedNoteCard(
followedUsers: Set<String> = 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<String>, Int) -> Unit)? = null,
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
onHashtagClick: ((String) -> Unit)? = null,
followedUsers: Set<String> = emptySet(),
myPubKeyHex: String? = null,
onFollow: ((String) -> Unit)? = null,
) {
val isRepost = event is RepostEvent || event is GenericRepostEvent
if (isRepost) {
@@ -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,
)
}
@@ -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)
@@ -509,13 +509,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(),
@@ -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,
)
}
@@ -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,
)
}
}
}
}
@@ -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,
)
}
}
}
@@ -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<HexKey>`
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<Note>` 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 <neventid|hex> │
│ 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<HexKey>,
): 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<Boolean>
val threshold: StateFlow<Int>
fun setEnabled(enabled: Boolean)
fun setThreshold(threshold: Int)
}
```
`LocalHashtagSpamSettings`:
```kotlin
val LocalHashtagSpamSettings: ProvidableCompositionLocal<HashtagSpamSettings> =
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<Note>, 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<HexKey>` reference is stable until the follow list
mutates upstream. Currently no `StateFlow<Set<HexKey>>` 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 <ref>`
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<HexKey>` (stable ref).
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt:101`
— `followingKeySet(): Set<String>`.
- `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.