mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #3431 from nrobi144/feat/desktop-hashtag-spam-filter
feat(desktop): hashtag-spam filter with collapse-with-reveal
This commit is contained in:
+49
@@ -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
|
||||
}
|
||||
}
|
||||
+53
@@ -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)
|
||||
}
|
||||
}
|
||||
+48
@@ -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
|
||||
}
|
||||
}
|
||||
+43
@@ -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() }
|
||||
+6
-8
@@ -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) {
|
||||
|
||||
-7
@@ -50,11 +50,4 @@ sealed class SearchResult {
|
||||
val dTag: String,
|
||||
val displayId: String,
|
||||
) : SearchResult()
|
||||
|
||||
/**
|
||||
* Hashtag search.
|
||||
*/
|
||||
data class HashtagResult(
|
||||
val hashtag: String,
|
||||
) : SearchResult()
|
||||
}
|
||||
|
||||
+92
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -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())
|
||||
}
|
||||
}
|
||||
+189
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+70
@@ -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"
|
||||
}
|
||||
}
|
||||
+86
@@ -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
|
||||
@@ -74,6 +74,9 @@ import androidx.compose.ui.window.rememberWindowState
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings
|
||||
import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
|
||||
import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSettings
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
@@ -749,6 +752,10 @@ fun App(
|
||||
val accountState by accountManager.accountState.collectAsState()
|
||||
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
|
||||
|
||||
// Hashtag-spam filter settings, persisted in the shared java.util.prefs
|
||||
// node so the `amy` CLI binary observes the same toggle.
|
||||
val hashtagSpamSettings = remember { PreferencesHashtagSpamSettings() }
|
||||
|
||||
// Local relay store — persists events to SQLite per account
|
||||
val localRelayStore =
|
||||
remember {
|
||||
@@ -976,6 +983,7 @@ fun App(
|
||||
com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache,
|
||||
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager,
|
||||
com.vitorpamplona.amethyst.desktop.ui.deck.LocalLocalRelayStore provides localRelayStore,
|
||||
LocalHashtagSpamSettings provides hashtagSpamSettings,
|
||||
) {
|
||||
when (accountState) {
|
||||
is AccountState.Loading -> {
|
||||
@@ -1062,6 +1070,11 @@ fun App(
|
||||
// NWC loaded during startup in loadSavedAccount flow
|
||||
|
||||
val currentTorStatus = torManager.status.collectAsState().value
|
||||
val followedUsers by localCache.followedUsers.collectAsState()
|
||||
val spamExemptKeys =
|
||||
remember(followedUsers, account.pubKeyHex) {
|
||||
followedUsers + account.pubKeyHex
|
||||
}
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides
|
||||
com.vitorpamplona.amethyst.desktop.ui.tor.TorState(
|
||||
@@ -1079,6 +1092,7 @@ fun App(
|
||||
),
|
||||
LocalNamecoinPreferences provides namecoinPreferences,
|
||||
LocalNamecoinService provides namecoinService,
|
||||
LocalSpamExemptKeys provides spamExemptKeys,
|
||||
) {
|
||||
MainContent(
|
||||
layoutMode = layoutMode,
|
||||
@@ -2074,6 +2088,21 @@ fun RelaySettingsScreen(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Content Filters section — hashtag-spam filter and future
|
||||
// content-moderation toggles.
|
||||
Text(
|
||||
text = "Content Filters",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
com.vitorpamplona.amethyst.desktop.ui.settings.HashtagSpamSettingsSection(
|
||||
settings = LocalHashtagSpamSettings.current,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
val logoutScope = rememberCoroutineScope()
|
||||
OutlinedButton(
|
||||
onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } },
|
||||
|
||||
+11
-5
@@ -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,
|
||||
|
||||
+25
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -135,6 +135,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
|
||||
@@ -199,8 +200,54 @@ fun FeedNoteCard(
|
||||
followedUsers: Set<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) {
|
||||
|
||||
+15
-9
@@ -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)
|
||||
|
||||
+11
-6
@@ -530,13 +530,18 @@ fun QuotedNoteEmbed(
|
||||
authorMetaValue
|
||||
val displayData = event.toNoteDisplayData(localCache)
|
||||
|
||||
NoteCard(
|
||||
note = displayData,
|
||||
SpamCheckedNoteRender(
|
||||
note = note,
|
||||
localCache = localCache,
|
||||
onClick = onNavigateToThread?.let { nav -> { nav(event.id) } },
|
||||
onAuthorClick = onMentionClick,
|
||||
onMentionClick = onMentionClick,
|
||||
)
|
||||
) {
|
||||
NoteCard(
|
||||
note = displayData,
|
||||
localCache = localCache,
|
||||
onClick = onNavigateToThread?.let { nav -> { nav(event.id) } },
|
||||
onAuthorClick = onMentionClick,
|
||||
onMentionClick = onMentionClick,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
+123
@@ -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
-29
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+101
@@ -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.
|
||||
Reference in New Issue
Block a user