Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd

This commit is contained in:
Claude
2026-07-01 18:39:59 +00:00
96 changed files with 4655 additions and 669 deletions
+49
View File
@@ -345,6 +345,55 @@ object Nip04 {
**Note**: Use NIP-44 (`Nip44`) for new implementations. NIP-04 has security issues.
## Hex Encoding (HexKey ↔ ByteArray)
Pubkeys, event ids and signatures are lower-case hex. Quartz uses the `HexKey`
typealias (`= String`) plus extensions in `nip01Core/core/HexKey.kt`, backed by
the `Hex` object in `utils/Hex.kt`. **Use these — never hand-roll a byte loop or
import a third-party hex codec.**
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.Hex
val hex: HexKey = bytes.toHexKey() // ByteArray -> lower-case hex
val back: ByteArray = hex.hexToByteArray() // hex -> ByteArray (throws on odd length)
val safe: ByteArray? = input.hexToByteArrayOrNull() // null on invalid hex
Hex.isHex(input) // valid hex, any length
Hex.isHex64(input) // ~30% faster fast-path for a 32-byte key/id
hex.isValid() // 64 chars + valid hex (pubkey / event-id shape)
Hex.isEqual(hex, bytes) // compare hex to bytes without decoding
```
Constants `PUBKEY_LENGTH` / `EVENT_ID_LENGTH` (both 64) live in `nip01Core.core`.
## Core Utilities (time, random, event id)
Reuse these instead of hand-rolling — each avoids a common mistake:
```kotlin
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.sha256.sha256
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
TimeUtils.now() // Unix SECONDS for created_at — not currentTimeMillis()/1000
TimeUtils.oneHourAgo() // relative filter bounds (…Ago / …FromNow); all in seconds
RandomInstance.bytes(32) // secure random (SecureRandom) — for nonces/keys, not kotlin.random.Random
RandomInstance.randomChars() // 16-char subscription id
sha256(bytes) // raw hash primitive
EventHasher.hashId(pubKey, createdAt, kind, tags, content) // canonical event id
EventHasher.hashIdCheck(id, pubKey, createdAt, kind, tags, content) // verify untrusted events
```
`EventHasher` serializes `[0, pubkey, created_at, kind, tags, content]` in the
exact form NIP-01 requires — prefer it over calling `sha256` on your own JSON.
## Bech32 Encoding (NIP-19)
Encoding uses extension functions on `ByteArray` (`nip19Bech32/ByteArrayExt.kt`);
+129 -1
View File
@@ -134,13 +134,14 @@ val privKeyHex: String? = keyPair.privKey?.toHexKey()
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
// ByteArray → hex
val hex = byteArray.toHexKey()
// hex → ByteArray
val bytes = HexKey.decodeHex(hex)
val bytes = hex.hexToByteArray()
// Bech32 import (npub, nsec)
val parsed = Nip19Parser.uriToRoute("npub1abc...")
@@ -148,6 +149,126 @@ val parsed = Nip19Parser.uriToRoute("npub1abc...")
val parsed = Nip19Parser.uriToRoute("nsec1abc...")
```
> Hex ↔ ByteArray is a first-class utility in Quartz — see **§3.1 Hex utilities** below.
---
### 3.1 Hex utilities (HexKey ↔ ByteArray)
Nostr keys, event ids and signatures travel as lower-case hex strings. Quartz
models this with the `HexKey` typealias (just a `String`) plus extension
functions — **do not** write your own byte loop or pull in a third-party codec.
**Packages:** `com.vitorpamplona.quartz.nip01Core.core` (the extensions) and
`com.vitorpamplona.quartz.utils` (the underlying `Hex` object).
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.HexKey // typealias = String
import com.vitorpamplona.quartz.nip01Core.core.toHexKey // ByteArray → hex
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray // hex → ByteArray
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.Hex
// Encode / decode
val hex: HexKey = pubKeyBytes.toHexKey() // lower-case, 2 chars per byte
val bytes: ByteArray = hex.hexToByteArray() // throws on odd length
// Untrusted input → decode safely
val maybe: ByteArray? = userInput.hexToByteArrayOrNull() // null if not valid hex
// Validate without decoding (no allocation)
Hex.isHex(userInput) // even-length, all hex digits (any length)
Hex.isHex64(userInput) // fast path for a 32-byte key/id (checks first 64 chars)
hex.isValid() // 64 chars AND valid hex (pubkey / event-id shape)
// Compare a hex string to raw bytes without decoding
Hex.isEqual(incomingHexId, myIdBytes)
```
| Need | Call | Notes |
|------|------|-------|
| ByteArray → hex | `bytes.toHexKey()` | lower-case output |
| hex → ByteArray (strict) | `hex.hexToByteArray()` | throws on odd length |
| hex → ByteArray (safe) | `hex.hexToByteArrayOrNull()` | `null` on invalid hex |
| is this valid hex? | `Hex.isHex(s)` / `Hex.isHex64(s)` | `isHex64` ~30% faster for keys/ids |
| is this a pubkey/id shape? | `hex.isValid()` | 64 chars + valid hex |
| hex == bytes? | `Hex.isEqual(hex, bytes)` | no decode allocation |
Constants `PUBKEY_LENGTH` and `EVENT_ID_LENGTH` (both `64`) live in the same
`nip01Core.core` package.
---
### 3.2 Everyday utilities (time, random, hashing, bech32, base64)
These small helpers exist so you don't reinvent them — and several have a
footgun the built-in avoids. **Prefer them over stdlib/hand-rolled equivalents.**
**Time — `TimeUtils` (`com.vitorpamplona.quartz.utils`).** Everything is in Unix
**seconds** (what `created_at` and filter `since`/`until` use), *not* millis.
```kotlin
import com.vitorpamplona.quartz.utils.TimeUtils
val createdAt = TimeUtils.now() // seconds — for created_at. NOT currentTimeMillis()/1000
val since = TimeUtils.oneDayAgo() // relative filter bounds: oneHourAgo(), fiveMinutesAgo()…
val fresh = TimeUtils.withinTenMinutes(event.createdAt) // NIP-42/NIP-98 freshness
// TimeUtils.nowMillis() is the only millisecond helper — non-protocol use only.
```
**Secure random — `RandomInstance` (`utils`).** Backed by `SecureRandom`; use it
for anything security-sensitive instead of `kotlin.random.Random`.
```kotlin
import com.vitorpamplona.quartz.utils.RandomInstance
val nonce = RandomInstance.bytes(32) // nonces, salts, keys
val subId = RandomInstance.randomChars() // 16-char [a-zA-Z0-9] subscription id
```
**Hashing — `sha256(...)` + `EventHasher`.** `sha256` is the raw primitive; to
compute/verify an **event id** use `EventHasher`, which canonically serializes
`[0, pubkey, created_at, kind, tags, content]` before hashing (getting this wrong
is what makes relays reject an event). Typed builders already do this for you.
```kotlin
import com.vitorpamplona.quartz.utils.sha256.sha256
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
val digest = sha256(bytes) // raw 32-byte hash
val id = EventHasher.hashId(pubKey, createdAt, kind, tags, content)
val valid = EventHasher.hashIdCheck(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content)
```
**Bech32.** For `npub`/`nsec`/`note`/… prefer the NIP-19 layer (`ByteArray.toNpub()`,
`Nip19Parser.uriToRoute(...)` — see §10). Drop to the low-level
`Bech32` object (`nip19Bech32.bech32`) only for a custom prefix:
```kotlin
import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
val addr = Bech32.encodeBytes("npub", pubKeyBytes, Bech32.Encoding.Bech32)
val bytes = "npub1...".bechToBytes("npub") // decode + assert the prefix
```
**Base64.** Quartz has no wrapper — use the Kotlin stdlib `kotlin.io.encoding.Base64`
directly, and match the variant the spec wants: NIP-44/NIP-04 payloads use
`Base64.Default` (standard, padded); url-safe contexts use `Base64.UrlSafe`
(configure padding via `.withPadding(...)`).
| Need | Call |
|------|------|
| Now (event `created_at`) | `TimeUtils.now()` (seconds) |
| Relative filter bound | `TimeUtils.oneDayAgo()` / `oneHourAgo()` / … |
| Secure random bytes | `RandomInstance.bytes(n)` |
| Subscription id | `RandomInstance.randomChars()` |
| Raw hash | `sha256(bytes)` |
| Event id / verify | `EventHasher.hashId(...)` / `hashIdCheck(...)` |
| Bech32 custom prefix | `Bech32.encodeBytes(hrp, bytes, enc)` / `s.bechToBytes(hrp)` |
| Base64 | `kotlin.io.encoding.Base64` (`.Default` / `.UrlSafe`) |
---
## 4. Signing Events
@@ -644,6 +765,13 @@ val results = store.query<Event>(Filter(search = "bitcoin"))
| Sign event | `signer.sign(template)` | `nip01Core.signers` |
| Serialize | `event.toJson()` | `nip01Core.core` |
| Parse | `Event.fromJson(json)` | `nip01Core.core` |
| ByteArray → hex | `bytes.toHexKey()` | `nip01Core.core` |
| hex → ByteArray | `hex.hexToByteArray()` / `hex.hexToByteArrayOrNull()` | `nip01Core.core` |
| Validate hex | `Hex.isHex(s)` / `Hex.isHex64(s)` / `hex.isValid()` | `utils`, `nip01Core.core` |
| Now (seconds) | `TimeUtils.now()` | `utils` |
| Relative time | `TimeUtils.oneDayAgo()` / `oneHourAgo()` | `utils` |
| Secure random | `RandomInstance.bytes(n)` / `randomChars()` | `utils` |
| Hash / event id | `sha256(bytes)` / `EventHasher.hashId(...)` | `utils.sha256`, `nip01Core.crypto` |
| Normalize relay URL | `RelayUrlNormalizer.normalize("wss://...")` | `nip01Core.relay.normalizer` |
| Setup relay client | `NostrClient(BasicOkHttpWebSocket.Builder { okhttp })` | `nip01Core.relay.client` |
| Subscribe | `client.openReqSubscription(subId, mapOf(relay to filters), listener)` | `nip01Core.relay.client` |
+4
View File
@@ -0,0 +1,4 @@
# Gzip-compressed test corpora (e.g. nostr_vitor_startup_data.json.gz): treat as
# binary so git never applies CRLF/text normalization or textual diff/merge, which
# would corrupt the compressed stream (important on Windows checkouts).
*.gz binary
+4 -4
View File
@@ -19,7 +19,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -66,7 +66,7 @@ jobs:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -122,7 +122,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -181,7 +181,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
+7 -7
View File
@@ -50,7 +50,7 @@ jobs:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -200,7 +200,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -249,7 +249,7 @@ jobs:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -451,7 +451,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -479,7 +479,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -488,7 +488,7 @@ jobs:
java-version: 21
- name: Cache gradle
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: |
~/.gradle/caches
@@ -626,7 +626,7 @@ jobs:
fi
- name: Upload Android assets to GH Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: dist/*
tag_name: ${{ github.ref_name }}
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
# Need tags so scripts/translators.sh can resolve the last v* release tag
# for the "since last tag" window.
@@ -54,7 +54,7 @@ jobs:
- name: Open or update the combined Crowdin PR
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
# linked into any shipped artifact). It no-ops when there is no diff.
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
base: main
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -55,7 +55,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayStat
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.marmot.KeyPackageRelayListState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountHomeRelayState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountMineRelayState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.NotificationInboxRelayState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.UserMetadataState
@@ -416,6 +417,7 @@ class Account(
// Relay settings
val homeRelays = AccountHomeRelayState(nip65RelayList, privateStorageRelayList, localRelayList, scope)
val outboxRelays = AccountOutboxRelayState(nip65RelayList, privateStorageRelayList, localRelayList, broadcastRelayList, scope)
val mineRelays = AccountMineRelayState(nip65RelayList, privateStorageRelayList, localRelayList, proxyRelayList, scope)
val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope)
val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope)
@@ -508,6 +510,7 @@ class Account(
followsRelays = defaultGlobalRelays.flow,
blockedRelays = blockedRelayList.flow,
proxyRelays = proxyRelayList.flow,
mineRelays = mineRelays.flow,
relayFeeds = relayFeedsList.flow,
caches = feedDecryptionCaches,
signer = signer,
@@ -0,0 +1,65 @@
/*
* 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.model.nip01UserMetadata
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
/**
* The set of relays to read the user's *own* events from for the "Mine" top-nav selection:
* the user's NIP-65 outbox, their private-storage relays, their local relays and their proxy
* relays. Deliberately mirrors [AccountOutboxRelayState] **minus broadcast**: broadcast relays are
* write-only blast targets, so reading the user's own content back from them is wrong — they don't
* serve reads and would only waste a subscription.
*/
class AccountMineRelayState(
nip65: Nip65RelayListState,
privateStorage: PrivateStorageRelayListState,
local: LocalRelayListState,
proxy: ProxyRelayListState,
scope: CoroutineScope,
) {
val flow =
combine(
nip65.outboxFlow,
privateStorage.flow,
local.flow,
proxy.flow,
) { nip65Outbox, privateOutBox, localRelays, proxyRelays ->
nip65Outbox + privateOutBox + localRelays + proxyRelays
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
nip65.outboxFlow.value +
privateStorage.flow.value +
local.flow.value +
proxy.flow.value,
)
}
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAl
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.MultiHashtagFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.mine.MineFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayFeedFlow
import com.vitorpamplona.amethyst.service.location.LocationState
@@ -62,6 +63,7 @@ class FeedTopNavFilterState(
val followsRelays: StateFlow<Set<NormalizedRelayUrl>>,
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>>,
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
val mineRelays: StateFlow<Set<NormalizedRelayUrl>>,
val relayFeeds: StateFlow<Set<NormalizedRelayUrl>>,
val caches: FeedDecryptionCaches,
val signer: NostrSigner,
@@ -93,7 +95,7 @@ class FeedTopNavFilterState(
}
TopFilter.Mine -> {
AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays)
MineFeedFlow(signer.pubKey, mineRelays)
}
is TopFilter.Community, is TopFilter.PeopleList, is TopFilter.MuteList -> {
@@ -0,0 +1,66 @@
/*
* 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.model.topNavFeeds.mine
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
/**
* Resolves the "Mine" top-nav selection to an author filter scoped to the logged-in user's own
* pubkey, pinned to the user's own relays — their outbox, private-storage, local and proxy relays
* (see [com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountMineRelayState]). Unlike the
* follow filters, "Mine" doesn't need per-author outbox resolution from cache: the only author is
* the user, and the user's relays are already known from their own account state — so we pin the
* fixed set directly via [AuthorsByProxyTopNavFilter] (it associates each given relay with the
* authors, which is exactly "query my relays for my events").
*
* This is the single source of truth for "Mine". Both the relay sub-assemblers (through each
* screen's `liveXFollowListsPerRelay`) and the local DAL filters (through `liveXFollowLists`)
* consume the produced [AuthorsByProxyTopNavFilter], so screens no longer need a dedicated
* `TopFilter.Mine` branch: the generic author path already narrows to the user. It also makes
* relay-set changes re-invalidate automatically — `liveXFollowListsPerRelay` re-emits whenever
* [mineRelays] changes, through the sub-assemblers' existing `followsPerRelayFlow` collector.
*/
class MineFeedFlow(
val myPubkey: HexKey,
val mineRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedFlowsType {
fun convert(relays: Set<NormalizedRelayUrl>): IFeedTopNavFilter =
AuthorsByProxyTopNavFilter(
authors = setOf(myPubkey),
proxyRelays = relays,
)
override fun flow(): Flow<IFeedTopNavFilter> = mineRelays.map(::convert)
override fun startValue(): IFeedTopNavFilter = convert(mineRelays.value)
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
collector.emit(startValue())
}
}
@@ -145,6 +145,8 @@ class MainActivity : AppCompatActivity() {
}
}
private const val NOSTR_URI_PREFIX = "nostr:"
fun isNotificationRoute(uri: String) = uri.startsWith("notifications", true) || uri.startsWith("nostr:notifications", true)
fun isHashtagRoute(uri: String) = uri.startsWith("hashtag?id=") || uri.startsWith("nostr:hashtag?id=")
@@ -161,7 +163,7 @@ fun isConnectedAppRoute(uri: String) = uri.startsWith("connectedapp?coordinate="
fun connectedAppRoute(uri: String): Route.ConnectedAppDetail? {
val coordinate =
runCatching {
val raw = java.net.URI(uri.removePrefix("nostr:")).findParameterValue("coordinate") ?: return null
val raw = java.net.URI(uri.removePrefix(NOSTR_URI_PREFIX)).findParameterValue("coordinate") ?: return null
URLDecoder.decode(raw, Charsets.UTF_8.name())
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return null
@@ -171,7 +173,7 @@ fun connectedAppRoute(uri: String): Route.ConnectedAppDetail? {
fun urlRoute(uri: String): Route.Url? {
val url =
runCatching {
val rawUrl = java.net.URI(uri.removePrefix("nostr:")).findParameterValue("id") ?: return null
val rawUrl = java.net.URI(uri.removePrefix(NOSTR_URI_PREFIX)).findParameterValue("id") ?: return null
URLDecoder.decode(rawUrl, Charsets.UTF_8.name())
}.getOrNull() ?: return null
@@ -189,11 +191,11 @@ fun uriToRoute(
account: Account,
): Route? {
if (isNotificationRoute(uri)) {
val scrollTo = runCatching { java.net.URI(uri.removePrefix("nostr:")).findParameterValue("scrollTo") }.getOrNull()
val scrollTo = runCatching { java.net.URI(uri.removePrefix(NOSTR_URI_PREFIX)).findParameterValue("scrollTo") }.getOrNull()
return Route.Notification(scrollToEventId = scrollTo)
}
if (isHashtagRoute(uri)) {
return Route.Hashtag(uri.removePrefix("nostr:").removePrefix("hashtag?id=").lowercase())
return Route.Hashtag(uri.removePrefix(NOSTR_URI_PREFIX).removePrefix("hashtag?id=").lowercase())
}
if (isUrlRoute(uri)) {
return urlRoute(uri)
@@ -44,6 +44,7 @@ import okio.buffer
import okio.sink
import okio.source
import java.io.File
import java.io.IOException
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@@ -53,6 +54,7 @@ object MediaSaverToDisk {
okHttpClient: (String) -> OkHttpClient,
mimeType: String?,
localContext: Context,
resolveBlossom: suspend (String) -> String? = { null },
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) = withContext(Dispatchers.IO) {
@@ -77,6 +79,7 @@ object MediaSaverToDisk {
mimeType = mimeType,
okHttpClient = okHttpClient,
context = localContext,
resolveBlossom = resolveBlossom,
onSuccess = onSuccess,
onError = onError,
)
@@ -87,6 +90,11 @@ object MediaSaverToDisk {
/**
* Saves the image to the gallery. May require a storage permission.
*
* `blossom:` (BUD-10) URIs are resolved to a concrete `http(s)` server URL
* via [resolveBlossom] before downloading, since OkHttp only speaks
* http/https. When resolution fails the save reports an error instead of
* crashing with "expected scheme http or https but was blossom".
*
* @see AMETHYST_SUBDIRECTORY
*/
suspend fun downloadAndSave(
@@ -94,26 +102,35 @@ object MediaSaverToDisk {
mimeType: String?,
okHttpClient: (String) -> OkHttpClient,
context: Context,
resolveBlossom: suspend (String) -> String? = { null },
onSuccess: () -> Any?,
onError: (Throwable) -> Any?,
) {
try {
val client = okHttpClient(url)
val downloadUrl =
if (url.startsWith(BLOSSOM_SCHEME, ignoreCase = true)) {
resolveBlossom(url)
?: throw IOException("Could not find a Blossom server that hosts $url")
} else {
url
}
val client = okHttpClient(downloadUrl)
val request =
Request
.Builder()
.get()
.url(url)
.url(downloadUrl)
.build()
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
check(response.isSuccessful) {
"Failed to download $url: HTTP ${response.code} ${response.message}"
"Failed to download $downloadUrl: HTTP ${response.code} ${response.message}"
}
val trimmedUrl = trimInlineMetaData(url)
val trimmedUrl = trimInlineMetaData(downloadUrl)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val headerType =
response
@@ -292,4 +309,5 @@ object MediaSaverToDisk {
private const val AMETHYST_SUBDIRECTORY = "Amethyst"
private const val PDF_MIME_TYPE = "application/pdf"
private const val BLOSSOM_SCHEME = "blossom:"
}
@@ -83,6 +83,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -532,6 +533,11 @@ internal suspend fun saveMediaToGallery(
}
},
localContext,
resolveBlossom = {
Amethyst.instance.blossomResolver
.findServers(it)
?.serverUrl
},
onSuccess = {
showToastOnMain(localContext, success)
},
@@ -2367,6 +2367,11 @@ class AccountViewModel(
okHttpClient = httpClientBuilder::okHttpClientForVideo,
mimeType = mimeType,
localContext = localContext,
resolveBlossom = {
Amethyst.instance.blossomResolver
.findServers(it)
?.serverUrl
},
onSuccess = {
Handler(Looper.getMainLooper()).post {
Toast
@@ -47,35 +47,17 @@ class BadgesFeedFilter(
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
private fun myPubkey(): String = account.userProfile().pubkeyHex
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
if (followList() == TopFilter.Mine) {
val me = myPubkey()
LocalCache.addressables.filterIntoSet(BadgeDefinitionEvent.KIND) { _, it ->
val noteEvent = it.event
noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me
}
} else {
val params = buildFilterParams(account)
LocalCache.addressables.filterIntoSet(BadgeDefinitionEvent.KIND) { _, it ->
val noteEvent = it.event
noteEvent is BadgeDefinitionEvent && params.match(noteEvent, it.relays)
}
LocalCache.addressables.filterIntoSet(BadgeDefinitionEvent.KIND) { _, it ->
val noteEvent = it.event
noteEvent is BadgeDefinitionEvent && params.match(noteEvent, it.relays)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> {
if (followList() == TopFilter.Mine) {
val me = myPubkey()
return newItems.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is BadgeDefinitionEvent && noteEvent.pubKey == me
}
}
val params = buildFilterParams(account)
return newItems.filterTo(HashSet()) {
val noteEvent = it.event
@@ -42,15 +42,11 @@ class BadgesSubAssembler(
key: BadgesQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val listName = key.listName()
// "Mine" needs no special-case: the shared TopFilter.Mine flow now resolves to an author
// filter scoped to the user, so followsPerRelay() already carries authors=[me].
val defaultSince = key.feedStates.badgesFeed.lastNoteCreatedAtIfFilled()
return if (listName == TopFilter.Mine) {
val outbox = key.account.outboxRelays.flow.value
filterBadgesMine(key.account.userProfile().pubkeyHex, outbox, since)
} else {
makeBadgesFilter(key.followsPerRelay(), since, defaultSince)
}
return makeBadgesFilter(key.followsPerRelay(), since, defaultSince)
}
override fun user(key: BadgesQueryState) = key.account.userProfile()
@@ -48,27 +48,6 @@ fun makeBadgesFilter(
else -> emptyList()
}
fun filterBadgesMine(
pubkey: HexKey,
relays: Set<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (relays.isEmpty() || pubkey.isEmpty()) return emptyList()
val authors = listOf(pubkey)
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(BadgeDefinitionEvent.KIND),
authors = authors,
limit = BADGE_FEED_LIMIT,
since = since?.get(relay)?.time,
),
)
}
}
private fun filterBadgesByAuthorsOnRelay(
relay: NormalizedRelayUrl,
authors: Set<HexKey>,
@@ -90,7 +90,6 @@ import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps
import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -189,22 +188,17 @@ private fun BrowserLauncher(
}.collectAsStateWithLifecycle(emptyList())
val nsiteFollows by accountViewModel.account.liveNsitesFollowLists.collectAsStateWithLifecycle()
val nsiteListName by accountViewModel.account.settings.defaultNsitesFollowList
.collectAsStateWithLifecycle()
val nappletFollows by accountViewModel.account.liveNappletsFollowLists.collectAsStateWithLifecycle()
val nappletListName by accountViewModel.account.settings.defaultNappletsFollowList
.collectAsStateWithLifecycle()
val myPubkey = accountViewModel.account.userProfile().pubkeyHex
// Drop ones already pinned — they show under Favorites, not twice.
val favoriteCoordinates = remember(apps) { apps.filterIsInstance<FavoriteApp.NostrApp>().mapTo(HashSet()) { it.coordinate } }
val followedNsites =
remember(nsiteNotes, nsiteFollows, nsiteListName, myPubkey, favoriteCoordinates) {
nsiteNotes.toDiscoverApps(nsiteListName == TopFilter.Mine, myPubkey, nsiteFollows::matchAuthor, favoriteCoordinates)
remember(nsiteNotes, nsiteFollows, favoriteCoordinates) {
nsiteNotes.toDiscoverApps(nsiteFollows::matchAuthor, favoriteCoordinates)
}
val followedNapplets =
remember(nappletNotes, nappletFollows, nappletListName, myPubkey, favoriteCoordinates) {
nappletNotes.toDiscoverApps(nappletListName == TopFilter.Mine, myPubkey, nappletFollows::matchAuthor, favoriteCoordinates)
remember(nappletNotes, nappletFollows, favoriteCoordinates) {
nappletNotes.toDiscoverApps(nappletFollows::matchAuthor, favoriteCoordinates)
}
// What the user actually typed, excluding any selected ghost-completion suffix (selection.min is the
@@ -606,20 +600,18 @@ private data class DiscoverNostrApp(
private const val DISCOVER_NOSTR_LIMIT = 12
/**
* Keeps the [Note]s authored by the followed set (or by the user, in the "Mine" case — the shared
* matcher resolves Mine to all-follows, so it can't serve that case), drops ones already pinned, maps
* each to its launchable [DiscoverNostrApp], and caps the result.
* Keeps the [Note]s authored by the followed set, drops ones already pinned, maps each to its
* launchable [DiscoverNostrApp], and caps the result. Covers the "Mine" selection too: the shared
* matcher now resolves Mine to the user's own pubkey, so [matchAuthor] already narrows to the user.
*/
private fun List<Note>.toDiscoverApps(
mine: Boolean,
myPubkey: String,
matchAuthor: (String) -> Boolean,
excludeCoordinates: Set<String>,
): List<DiscoverNostrApp> =
asSequence()
.filter { note ->
val author = note.event?.pubKey ?: return@filter false
if (mine) author == myPubkey else matchAuthor(author)
matchAuthor(author)
}.mapNotNull { it.toDiscoverNostrApp() }
.filter { it.app.coordinate !in excludeCoordinates }
.take(DISCOVER_NOSTR_LIMIT)
@@ -24,7 +24,6 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.model.mapNotNullIntoSet
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.DiscoverCommunityFeedFilter
@@ -40,19 +39,7 @@ class CommunitiesFeedFilter(
override fun followList(): TopFilter = account.settings.defaultCommunitiesFollowList.value
private fun myPubkey(): String = account.userProfile().pubkeyHex
override fun feed(): List<Note> {
if (followList() == TopFilter.Mine) {
val me = myPubkey()
val notes =
LocalCache.addressables.filterIntoSet(CommunityDefinitionEvent.KIND) { _, note ->
val noteEvent = note.event
noteEvent is CommunityDefinitionEvent && noteEvent.pubKey == me
}
return sort(notes)
}
val filterParams =
FilterByListParams.create(
followLists = account.liveCommunitiesFollowLists.value,
@@ -77,15 +64,6 @@ class CommunitiesFeedFilter(
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
override fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
if (followList() == TopFilter.Mine) {
val me = myPubkey()
return collection
.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is CommunityDefinitionEvent && noteEvent.pubKey == me
}
}
val filterParams =
FilterByListParams.create(
followLists = account.liveCommunitiesFollowLists.value,
@@ -43,15 +43,11 @@ class CommunitiesListSubAssembler(
key: CommunitiesListQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val listName = key.listName()
// "Mine" needs no special-case: the shared TopFilter.Mine flow now resolves to an author
// filter scoped to the user, so followsPerRelay() already carries authors=[me].
val defaultSince = key.feedStates.communitiesList.lastNoteCreatedAtIfFilled()
return if (listName == TopFilter.Mine) {
val outbox = key.account.outboxRelays.flow.value
filterCommunitiesMine(key.account.userProfile().pubkeyHex, outbox, since)
} else {
makeCommunitiesFilter(key.followsPerRelay(), since, defaultSince)
}
return makeCommunitiesFilter(key.followsPerRelay(), since, defaultSince)
}
override fun user(key: CommunitiesListQueryState) = key.account.userProfile()
@@ -59,7 +59,7 @@ data class MagnifierFrame(
val captureMs: Double,
val requestStampNanos: Long,
) {
override fun equals(other: Any?) = this === other
override fun equals(other: Any?) = other is MagnifierFrame && this === other
override fun hashCode() = System.identityHashCode(this)
}
@@ -39,10 +39,14 @@ interface EmbeddedSurfaceController {
fun attachView(view: SandboxedSdkView)
/** The session became the visible tab. */
fun onShown() {}
fun onShown() {
// Optional hook: default no-op. Controllers that don't pause/resume applet JS need no action.
}
/** The session is warm but off-screen; a controller may pause its applet here. */
fun onHidden() {}
fun onHidden() {
// Optional hook: default no-op. Controllers that don't pause/resume applet JS need no action.
}
/** Permanently close the session (unbind the service); used on eviction. */
fun teardown()
@@ -59,5 +63,7 @@ interface EmbeddedSurfaceController {
var onLoadStatusChanged: ((EmbeddedLoadStatus) -> Unit)?
/** Re-attempt the load from scratch (the overlay's Retry); default no-op. */
fun retry() {}
fun retry() {
// Default no-op: only controllers that report a real load state (and thus can fail) override this.
}
}
@@ -85,14 +85,18 @@ class RemoteImeView(
start: Int,
count: Int,
after: Int,
) {}
) {
// No-op: TextWatcher requires this override, but only afterTextChanged drives our flush.
}
override fun onTextChanged(
s: CharSequence?,
start: Int,
before: Int,
count: Int,
) {}
) {
// No-op: TextWatcher requires this override, but only afterTextChanged drives our flush.
}
override fun afterTextChanged(s: Editable?) {
if (!applyingRemote) onEdited?.invoke()
@@ -48,36 +48,17 @@ class GitRepositoriesFeedFilter(
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
if (followList() == TopFilter.Mine) {
val me = account.userProfile().pubkeyHex
LocalCache.addressables.filterIntoSet(GitRepositoryEvent.KIND) { _, it -> isMine(it, me) }
} else {
val params = buildFilterParams(account)
LocalCache.addressables.filterIntoSet(GitRepositoryEvent.KIND) { _, it ->
val noteEvent = it.event
noteEvent is GitRepositoryEvent && params.match(noteEvent, it.relays)
}
LocalCache.addressables.filterIntoSet(GitRepositoryEvent.KIND) { _, it ->
val noteEvent = it.event
noteEvent is GitRepositoryEvent && params.match(noteEvent, it.relays)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> {
if (followList() == TopFilter.Mine) {
val me = account.userProfile().pubkeyHex
return newItems.filterTo(HashSet()) { isMine(it, me) }
}
return innerApplyFilter(newItems)
}
private fun isMine(
note: Note,
me: String,
): Boolean {
val noteEvent = note.event
return noteEvent is GitRepositoryEvent && noteEvent.pubKey == me
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
@@ -24,7 +24,6 @@ import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepositories.datasource.subassemblies.filterGitRepositoriesMine
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
@@ -43,13 +42,9 @@ class GitRepositoriesSubAssembler(
key: GitRepositoriesQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// "Mine" bypasses the follow-list machinery: query the user's own repositories by author
// against their outbox relays (same pattern as music/badges), because the shared
// TopFilter.Mine flow falls back to all-follows.
if (key.listName() == TopFilter.Mine) {
val outbox = key.account.outboxRelays.flow.value
return filterGitRepositoriesMine(key.account.userProfile().pubkeyHex, outbox, since)
}
// "Mine" needs no special-case: the shared TopFilter.Mine flow now resolves to an author
// filter scoped to the user, so followsPerRelay() already carries authors=[me] against the
// user's own outbox.
val feedSettings = key.followsPerRelay()
return makeGitRepositoriesFilter(feedSettings, since, key.feedStates.gitRepositoriesFeed.lastNoteCreatedAtIfFilled())
@@ -1,55 +0,0 @@
/*
* 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.ui.screen.loggedIn.gitRepositories.datasource.subassemblies
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
/**
* Builds the relay filters for the "Mine" repository selector: the user's own repository
* announcements, queried by author against their own outbox relays. Mirrors
* `filterNappletsMine` / `filterMusicEventsMine` — the only correct source for "my own"
* content, since the shared `TopFilter.Mine` flow falls back to all-follows.
*/
fun filterGitRepositoriesMine(
pubkey: HexKey,
relays: Set<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (relays.isEmpty() || pubkey.isEmpty()) return emptyList()
val authors = listOf(pubkey)
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(GitRepositoryEvent.KIND),
authors = authors,
limit = 200,
since = since?.get(relay)?.time,
),
)
}
}
@@ -54,32 +54,12 @@ class MusicPlaylistsFeedFilter(
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val notes =
if (followList() == TopFilter.Mine) {
val me = account.userProfile().pubkeyHex
LocalCache.addressables.filterIntoSet(MusicPlaylistEvent.KIND) { _, it -> isMine(it, me) }
} else {
val params = buildFilterParams(account)
LocalCache.addressables.filterIntoSet(MusicPlaylistEvent.KIND) { _, it -> accept(it, params) }
}
val params = buildFilterParams(account)
val notes = LocalCache.addressables.filterIntoSet(MusicPlaylistEvent.KIND) { _, it -> accept(it, params) }
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> {
if (followList() == TopFilter.Mine) {
val me = account.userProfile().pubkeyHex
return newItems.filterTo(HashSet()) { isMine(it, me) }
}
return innerApplyFilter(newItems)
}
private fun isMine(
note: Note,
me: String,
): Boolean {
val noteEvent = note.event
return noteEvent is MusicPlaylistEvent && noteEvent.pubKey == me
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
@@ -55,32 +55,12 @@ class MusicTracksFeedFilter(
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val notes =
if (followList() == TopFilter.Mine) {
val me = account.userProfile().pubkeyHex
LocalCache.addressables.filterIntoSet(MusicTrackEvent.KIND) { _, it -> isMine(it, me) }
} else {
val params = buildFilterParams(account)
LocalCache.addressables.filterIntoSet(MusicTrackEvent.KIND) { _, it -> accept(it, params) }
}
val params = buildFilterParams(account)
val notes = LocalCache.addressables.filterIntoSet(MusicTrackEvent.KIND) { _, it -> accept(it, params) }
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> {
if (followList() == TopFilter.Mine) {
val me = account.userProfile().pubkeyHex
return newItems.filterTo(HashSet()) { isMine(it, me) }
}
return innerApplyFilter(newItems)
}
private fun isMine(
note: Note,
me: String,
): Boolean {
val noteEvent = note.event
return noteEvent is MusicTrackEvent && noteEvent.pubKey == me
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
@@ -24,8 +24,6 @@ import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.MUSIC_PLAYLIST_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsMine
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
@@ -54,12 +52,8 @@ class MusicPlaylistsSubAssembler(
key: MusicPlaylistsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// "Mine" bypasses the follow-list machinery: query the user's own playlists by author
// against their outbox relays (same pattern as badges/communities).
if (key.listName() == TopFilter.Mine) {
val outbox = key.account.outboxRelays.flow.value
return filterMusicEventsMine(key.account.userProfile().pubkeyHex, MUSIC_PLAYLIST_KINDS, outbox, since)
}
// "Mine" needs no special-case: the shared TopFilter.Mine flow now resolves to an author
// filter scoped to the user, so followsPerRelay() already carries authors=[me].
val feedSettings = key.followsPerRelay()
// REQ now only asks for kind 34139 (playlists), keyed to this screen's follow
// list selector — no cross-feed cursor min needed.
@@ -24,8 +24,6 @@ import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.MUSIC_TRACK_KINDS
import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.subassemblies.filterMusicEventsMine
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
@@ -44,12 +42,8 @@ class MusicTracksSubAssembler(
key: MusicTracksQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// "Mine" bypasses the follow-list machinery: query the user's own tracks by author
// against their outbox relays (same pattern as badges/communities).
if (key.listName() == TopFilter.Mine) {
val outbox = key.account.outboxRelays.flow.value
return filterMusicEventsMine(key.account.userProfile().pubkeyHex, MUSIC_TRACK_KINDS, outbox, since)
}
// "Mine" needs no special-case: the shared TopFilter.Mine flow now resolves to an author
// filter scoped to the user, so followsPerRelay() already carries authors=[me].
val feedSettings = key.followsPerRelay()
// REQ now only asks for kind 36787 (tracks), so the `since` cursor lines up with
// the tracks feed alone — no cross-feed min needed.
@@ -1,54 +0,0 @@
/*
* 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.ui.screen.loggedIn.music.datasource.subassemblies
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Builds the relay filters for the "Mine" music selector: the user's own tracks/playlists,
* queried by author against their outbox relays. Mirrors `filterBadgesMine` /
* `filterCommunitiesMine`. The `kinds` list scopes it to tracks (36787) or playlists (34139).
*/
fun filterMusicEventsMine(
pubkey: HexKey,
kinds: List<Int>,
relays: Set<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (relays.isEmpty() || pubkey.isEmpty()) return emptyList()
val authors = listOf(pubkey)
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = kinds,
authors = authors,
limit = 200,
since = since?.get(relay)?.time,
),
)
}
}
@@ -39,7 +39,6 @@ import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -77,17 +76,14 @@ fun NappletsScreen(
val followFilter by accountViewModel.account.liveNappletsFollowLists
.collectAsStateWithLifecycle()
val listName by accountViewModel.account.settings.defaultNappletsFollowList
.collectAsStateWithLifecycle()
val myPubkey = accountViewModel.account.userProfile().pubkeyHex
val visible =
remember(napplets, followFilter, listName, myPubkey) {
remember(napplets, followFilter) {
napplets.filter { note ->
val author = note.event?.pubKey ?: return@filter false
// "Mine" matches the user's own napplets; the shared author-matcher resolves Mine to
// all-follows (see the SubAssembler), so it can't be used for the Mine case here.
if (listName == TopFilter.Mine) author == myPubkey else followFilter.matchAuthor(author)
// Covers "Mine" too: the shared author-matcher now resolves Mine to the user's own
// pubkey, so matchAuthor already narrows to the user.
followFilter.matchAuthor(author)
}
}
@@ -24,7 +24,6 @@ import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.subassemblies.filterNappletsMine
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
@@ -51,12 +50,8 @@ class NappletsFilterSubAssembler(
key: NappletsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// "Mine" bypasses the follow-list machinery: query the user's own napplets by author against
// their outbox relays (same pattern as badges/music). The shared TopFilter.Mine flow falls
// back to all-follows, so it can't be used here.
if (key.listName() == TopFilter.Mine) {
return filterNappletsMine(key.account.userProfile().pubkeyHex, key.account.outboxRelays.flow.value, since)
}
// "Mine" needs no special-case: the shared TopFilter.Mine flow now resolves to an author
// filter scoped to the user, so followsPerRelay() already carries authors=[me].
return makeNappletsFilter(key.followsPerRelay(), since)
}
@@ -1,57 +0,0 @@
/*
* 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.ui.screen.loggedIn.napplets.datasource.subassemblies
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NAPPLET_PAGE_LIMIT
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
/**
* Builds the relay filters for the "Mine" napplet selector: the user's own napplet manifests,
* queried by author against their own outbox relays. Mirrors `filterBadgesMine` /
* `filterMusicEventsMine` — the only correct source for "my own" content, since the shared
* `TopFilter.Mine` flow falls back to all-follows.
*/
fun filterNappletsMine(
pubkey: HexKey,
relays: Set<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (relays.isEmpty() || pubkey.isEmpty()) return emptyList()
val authors = listOf(pubkey)
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND),
authors = authors,
limit = NAPPLET_PAGE_LIMIT,
since = since?.get(relay)?.time,
),
)
}
}
@@ -39,7 +39,6 @@ import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -75,17 +74,14 @@ fun NsitesScreen(
val followFilter by accountViewModel.account.liveNsitesFollowLists
.collectAsStateWithLifecycle()
val listName by accountViewModel.account.settings.defaultNsitesFollowList
.collectAsStateWithLifecycle()
val myPubkey = accountViewModel.account.userProfile().pubkeyHex
val visible =
remember(nsites, followFilter, listName, myPubkey) {
remember(nsites, followFilter) {
nsites.filter { note ->
val author = note.event?.pubKey ?: return@filter false
// "Mine" matches the user's own sites; the shared author-matcher resolves Mine to
// all-follows (see the SubAssembler), so it can't be used for the Mine case here.
if (listName == TopFilter.Mine) author == myPubkey else followFilter.matchAuthor(author)
// Covers "Mine" too: the shared author-matcher now resolves Mine to the user's own
// pubkey, so matchAuthor already narrows to the user.
followFilter.matchAuthor(author)
}
}
@@ -24,7 +24,6 @@ import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.subassemblies.filterNsitesMine
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
@@ -51,12 +50,8 @@ class NsitesFilterSubAssembler(
key: NsitesQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// "Mine" bypasses the follow-list machinery: query the user's own nSites by author against
// their outbox relays (same pattern as badges/music). The shared TopFilter.Mine flow falls
// back to all-follows, so it can't be used here.
if (key.listName() == TopFilter.Mine) {
return filterNsitesMine(key.account.userProfile().pubkeyHex, key.account.outboxRelays.flow.value, since)
}
// "Mine" needs no special-case: the shared TopFilter.Mine flow now resolves to an author
// filter scoped to the user, so followsPerRelay() already carries authors=[me].
return makeNsitesFilter(key.followsPerRelay(), since)
}
@@ -1,57 +0,0 @@
/*
* 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.ui.screen.loggedIn.nsites.datasource.subassemblies
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NSITE_PAGE_LIMIT
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
/**
* Builds the relay filters for the "Mine" nSite selector: the user's own static-site manifests,
* queried by author against their own outbox relays. Mirrors `filterBadgesMine` /
* `filterMusicEventsMine` — the only correct source for "my own" content, since the shared
* `TopFilter.Mine` flow falls back to all-follows.
*/
fun filterNsitesMine(
pubkey: HexKey,
relays: Set<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (relays.isEmpty() || pubkey.isEmpty()) return emptyList()
val authors = listOf(pubkey)
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(RootSiteEvent.KIND, NamedSiteEvent.KIND),
authors = authors,
limit = NSITE_PAGE_LIMIT,
since = since?.get(relay)?.time,
),
)
}
}
+128 -1
View File
@@ -611,6 +611,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="app_definition_handles">Obdeluje</string>
<string name="app_definition_by">z</string>
<string name="app_definition_available_on">Na voljo na</string>
<string name="app_definition_categories">Kategorije</string>
<string name="app_definition_related">Sorodno</string>
<string name="app_definition_implements">Implementacije</string>
<string name="app_definition_supported_nips">Podprti NIPi</string>
<string name="app_definition_via">prek %1$s</string>
<string name="app_definition_nip">NIP-%1$s</string>
<string name="app_definition_kind_app">Aplikacija</string>
<string name="platform_web">Splet</string>
<string name="platform_android">Android</string>
<string name="platform_ios">iOS</string>
@@ -671,6 +678,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="browser_go">Odpri</string>
<string name="browser_clear">Počisti</string>
<string name="browser_favorites">Priljubljene</string>
<string name="browser_suggested">Odkrijte spletne aplikacije</string>
<string name="browser_discover_nsites">Spletna mesta ljudi, ki jim sledite</string>
<string name="browser_discover_napplets">Aplikacije ljudi, ki jim sledite</string>
<string name="browser_recent_options">Možnosti</string>
<string name="browser_recent_remove">Odstrani iz zgodovine</string>
<string name="favorite_apps">Priljubljene aplikacije</string>
@@ -696,6 +706,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="napplet_permissions_forget">Pozabi ta nApplet</string>
<string name="napplet_permissions_blocked">Blokirano</string>
<string name="napplet_permissions_revoke">Prekliči</string>
<string name="napplet_permissions_ask_each_time">Vsakič me vprašaj</string>
<string name="napplet_none_found">Še ni najdenih nobenih nAppletov.</string>
<string name="napplet_fallback_title">nApplet%1$s…</string>
<!-- Napplet sandbox chrome (host top bar + live action notices) -->
@@ -747,13 +758,69 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<item quantity="other">Ta nApplet želi plačati Lightning račun za %1$d satov.</item>
</plurals>
<!-- Signer permissions: first-connect dialog -->
<string name="napplet_connect_title">Povezava z Nostrom</string>
<string name="napplet_connect_subtitle">se želi povezati z vašim nostr računom</string>
<string name="napplet_connect_how_handle">Kako naj obravnavam zahteve te aplikacije?</string>
<string name="napplet_connect_button">Poveži</string>
<string name="napplet_connect_block">Blokiraj in ignoriraj %1$s</string>
<!-- Signer trust levels -->
<string name="napplet_policy_full_trust">Popolnama zaupam</string>
<string name="napplet_policy_full_trust_desc">Samodejno podpiši vse zahteve (razen plačil)</string>
<string name="napplet_policy_reasonable">Bodimo razumni</string>
<string name="napplet_policy_reasonable_desc">Samodejno odobri običajne zahteve</string>
<string name="napplet_policy_paranoid">Sem malce paranoičen</string>
<string name="napplet_policy_paranoid_desc">Nič ne podpisuj brez mojega soglasja!</string>
<!-- Signer per-op consent dialog -->
<string name="napplet_consent_wants_to">želi %1$s</string>
<string name="napplet_consent_show_event">Pokaži dogodek</string>
<string name="napplet_consent_hide_event">Skrij dogodek</string>
<string name="napplet_consent_more_options">Več možnosti</string>
<string name="napplet_consent_fewer_options">Manj možnosti</string>
<string name="napplet_signer_allow_once">dovoli enkrat</string>
<string name="napplet_signer_allow_session">Dovoli v tej seji</string>
<string name="napplet_signer_allow_24h">Dovoli za 24h</string>
<string name="napplet_signer_allow_30d">dovoli do 30 dni</string>
<string name="napplet_signer_allow_op">Ne vprašaj me več, da %1$s</string>
<string name="napplet_signer_allow_all">Ne sprašuj več za zahteve Nostr</string>
<string name="napplet_signer_deny_once">Zavrni</string>
<string name="napplet_signer_deny_op">Vedno zavrni %1$s</string>
<string name="napplet_signer_last_used">Nazadnje uporabljeno</string>
<string name="napplet_signer_expires">Preteče</string>
<!-- Signer op labels -->
<string name="napplet_op_sign_kind">podpiši tip (kind) %1$d dogodek</string>
<string name="napplet_op_sign_kind_named">podpiši za %1$s (kind: %2$d)</string>
<string name="napplet_op_encrypt">Šifriraj sporočilo</string>
<!-- Decrypt: the message is already decrypted by Amethyst; the permission is to expose it to the app -->
<string name="napplet_op_decrypt">berem tvoja zasebna sporočila</string>
<!-- Permissions management screen -->
<string name="napplet_permissions_title">Povezane aplikacije</string>
<string name="napplet_permissions_revoke_all">Prekliči vsa dovoljenja</string>
<string name="napplet_permissions_overrides">Preglasitve operacij</string>
<string name="napplet_signer_permissions_empty">Nobena aplikacija se še ni povezala.</string>
<string name="napplet_signer_permissions_revoke_all">Prekliči vsa dovoljenja</string>
<string name="napplet_decision_allow">Dovoli</string>
<string name="napplet_decision_ask">Vprašaj</string>
<string name="napplet_decision_deny">Zavrni</string>
<string name="napplet_connected_apps_search_keywords">Aplikacije, dovoljenja, podpisovalnik, napplet, nsite, spletna aplikacije zaupnik povezan.</string>
<string name="napplet_connected_app_trust_level">Podpisujem stopnjo zaupanja</string>
<string name="napplet_connected_app_capabilities">Zmogljivosti</string>
<string name="napplet_connected_app_forget">Pozabi to aplikacijo</string>
<string name="napplet_connected_app_op_overrides">Preglasitve operacij podpisovanja</string>
<string name="napplet_connected_app_empty">Nobena aplikacija se še ni povezala..\n\nKo se spletna aplikacija poveže z vašim ključem Nostr, bo prikazana tukaj.</string>
<!-- Relay Authentication (NIP-42) settings -->
<string name="relay_auth_search_keywords"></string>
<string name="relay_auth_settings_title">Avtentikacija releja</string>
<string name="relay_auth_search_keywords">Preverjanje pristnosti (Auth), rele (Relay), podpis (Sign), preverjanje (Verify), NIP-42, identiteta</string>
<string name="relay_auth_global_policy">Globalna pravila</string>
<string name="relay_auth_policy_always">Vedno zahtevaj avtentikacijo</string>
<string name="relay_auth_policy_always_desc">Podpiši avtentikacijske izzive za vsak relej, ki to zahteva</string>
<string name="relay_auth_policy_never">Nikoli ne avtenticiraj</string>
<string name="relay_auth_policy_never_desc">Prezri avtentikacijske izzive vseh relejev</string>
<string name="relay_auth_policy_if_in_my_list">Samo moji releji</string>
<string name="relay_auth_policy_if_in_my_list_desc">Avtenticiraj samo z releji s seznama</string>
<string name="relay_auth_per_relay_overrides">Prilagoditve po posameznem releju</string>
<string name="relay_auth_decision_allow">Dovoli</string>
<string name="relay_auth_decision_deny">Zavrni</string>
<string name="relay_auth_remove_override">Odstrani prilagoditev</string>
<string name="nip82_repository_label">Vir: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Prenos</string>
@@ -952,6 +1019,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
</plurals>
<string name="private_bookmarks">Privatni zaznamki</string>
<string name="public_bookmarks">Javni zaznamki</string>
<string name="repository_bookmarks">Repozitoriji</string>
<string name="repository_bookmarks_explainer">Vaši zaznamovani repozitoriji Git</string>
<string name="add_to_private_bookmarks">Dodaj v privatne zaznamke</string>
<string name="add_to_public_bookmarks">Dodaj v javne zaznamke</string>
<string name="remove_from_private_bookmarks">Odstrani iz privatnih zaznamkov</string>
@@ -1501,6 +1570,26 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="wallet_connect">Wallet Connect</string>
<string name="language">Jezik</string>
<string name="theme">Tema</string>
<string name="accent_color">Poudarjena barva</string>
<string name="accent_color_description">Poudarjena barva: Glavna barva za gumbe in povezave</string>
<string name="accent_color_purple">Vijolična</string>
<string name="accent_color_blue">Modra</string>
<string name="accent_color_green">Zelena</string>
<string name="accent_color_orange">Oranžna</string>
<string name="accent_color_red">Rdeča</string>
<string name="accent_color_pink">Roza</string>
<string name="font_family">Pisava</string>
<string name="font_family_description">Pisava v aplikaciji</string>
<string name="font_family_system">Sistemske prednastavitve</string>
<string name="font_family_sans_serif">Sans Serif</string>
<string name="font_family_serif">Serif</string>
<string name="font_family_monospace">Monospace</string>
<string name="font_size">Velikost pisave</string>
<string name="font_size_description">Prilagoditev velikosti besedila v aplikaciji</string>
<string name="font_size_small">Majhno</string>
<string name="font_size_normal">Normalno</string>
<string name="font_size_large">Veliko</string>
<string name="font_size_huge">Ogromno</string>
<string name="automatically_load_images_gifs">Predogled slike</string>
<string name="automatically_play_videos">Predvajaj Video</string>
<string name="autoplay_videos">Video predvajaj samodejno</string>
@@ -1817,6 +1906,21 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem</string>
<string name="cashu_no_wallet_description">Ustvari Cashu denarnico za shranjevanje žetonov ecash in prejemanje nutzapov.</string>
<string name="cashu_discovering">Povezovanje z denarnico…</string>
<string name="cashu_discovering_description">Denarnice NIP-60 se sinhronizirajo med odjemalci. Če si jo ustvaril v drugi aplikaciji pod tem ključem Nostr, se bo prikazala v nekaj sekundah.</string>
<string name="cashu_wizard_title">Nastavi lastno denarnico</string>
<string name="cashu_wizard_searching">Iščem vašo denarnico…</string>
<string name="cashu_wizard_searching_description">Iskanje denarnice Cashu, objavljene pod vašim ključem Nostr, na vseh relejih.</string>
<string name="cashu_wizard_searching_progress">Preiskovanje relejev… %1$d / %2$d</string>
<string name="cashu_wizard_analyzing">Preverjanje najdenih denarnic…</string>
<string name="cashu_wizard_none_title">Denarnica ni najdena</string>
<string name="cashu_wizard_none_description">Pod vašim ključem Nostr na nobenem releju nismo našli obstoječe denarnice Cashu. Da začnete, ustvarite novo.</string>
<string name="cashu_wizard_create">Ustvari novo denarnico</string>
<string name="cashu_wizard_single_title">Vaša denarnica je bila najdena</string>
<string name="cashu_wizard_single_description">Ta denarnica Cashu je objavljena v omrežju Nostr pod vašim ključem. Uporabite jo na tej napravi — ponovno jo bomo objavili na vaših relejih, da jo boste naslednjič lažje našli.</string>
<string name="cashu_wizard_use_wallet">Uporabi to denarnico</string>
<string name="cashu_wizard_multiple_title">Našli smo nekaj denarnic</string>
<string name="cashu_wizard_multiple_description">V omrežju imate več denarnic Cashu, ki so jih verjetno ustvarile različne aplikacije. Najnovejša postane vaša glavna denarnica; sredstva s starejših prenesite nanjo.</string>
<string name="cashu_wizard_main_label">Najnovejše — Vaša glavna denarnica</string>
<string name="cashu_wizard_old_label">Starejša denarnica</string>
<string name="cashu_balance">Stanje</string>
<string name="cashu_mints">Kovnice</string>
<string name="cashu_mint_url">URL kovnice</string>
@@ -2350,6 +2454,29 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
<string name="git_repo_section_maintainers">Vzdrževalci</string>
<string name="git_repo_section_topics">Teme</string>
<string name="git_repo_personal_fork">Osebni fork</string>
<string name="git_new_issue_subject">Naslov</string>
<string name="git_new_issue_body">Opis</string>
<string name="git_new_issue_create">Ustvari</string>
<string name="git_new_issue_cancel">Prekliči</string>
<string name="git_new_issue_labels">Oznake</string>
<string name="git_new_issue_labels_hint">Hrošč, izboljšava…</string>
<string name="git_issue_close">Zapri težavo</string>
<string name="git_issue_reopen">Ponovno odpri</string>
<string name="git_status_close">Zapri</string>
<string name="git_status_reopen">Ponovno odpri</string>
<string name="git_status_mark_merged">Označi kot združeno</string>
<string name="git_pr_view_changes">Poglej spremembe</string>
<string name="git_pr_loading_changes">Nalagam spremembe…</string>
<string name="git_pr_no_changes">Ni najdenih sprememb datotek.</string>
<string name="git_pr_changes_retry">Ponovno naloži spremembe</string>
<string name="git_pr_revised">Popravljeno</string>
<string name="git_repo_settings_title">Uredi repozitorij</string>
<string name="git_repo_settings_name">Ime</string>
<string name="git_repo_settings_description">Opis</string>
<string name="git_repo_settings_clone_urls">Podvoji URL-je (eden v vrsti)</string>
<string name="git_repo_settings_web_urls">Web URL-ji (eden v vrsti)</string>
<string name="git_repo_settings_topics">Teme (ločene z vejicami)</string>
<string name="git_repo_settings_save">Shrani</string>
<string name="git_repositories">Git repozitoriji</string>
<string name="nsite_title">Statična spletna stran: %1$s</string>
<string name="napplet_card_title">nApplet: %1$s</string>
@@ -0,0 +1,87 @@
/*
* 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.model.topNavFeeds.mine
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class MineFeedFlowTest {
private val me = "00".repeat(32)
private val someoneIFollow = "11".repeat(32)
private val outbox = NormalizedRelayUrl("wss://outbox.example.com/")
private val local = NormalizedRelayUrl("ws://127.0.0.1:4869/")
private val proxy = NormalizedRelayUrl("wss://proxy.example.com/")
@Test
fun resolvesToAuthorFilterScopedToSelf_pinnedToTheGivenRelays() {
val relays = setOf(outbox, local, proxy)
val flow = MineFeedFlow(myPubkey = me, mineRelays = MutableStateFlow(relays))
val filter = flow.startValue()
assertTrue(filter is AuthorsByProxyTopNavFilter)
filter as AuthorsByProxyTopNavFilter
// Author scoped to the user; rejects a follow's posts.
assertEquals(setOf(me), filter.authors)
assertTrue(filter.matchAuthor(me))
assertFalse(filter.matchAuthor(someoneIFollow))
// Every "mine" relay is queried for the user's own events (no broadcast in this set).
assertEquals(relays, filter.proxyRelays)
}
@Test
fun emptyRelaySet_stillScopesAuthorButQueriesNothing() {
val flow = MineFeedFlow(myPubkey = me, mineRelays = MutableStateFlow(emptySet()))
val filter = flow.startValue() as AuthorsByProxyTopNavFilter
// DAL still narrows to the user from cache even when no relays are configured.
assertTrue(filter.matchAuthor(me))
assertFalse(filter.matchAuthor(someoneIFollow))
assertTrue(filter.proxyRelays.isEmpty())
}
@Test
fun flow_reEmits_whenMineRelaysChange() =
runTest {
val relays = MutableStateFlow(setOf(outbox))
val flow = MineFeedFlow(myPubkey = me, mineRelays = relays)
val before = flow.flow().first() as AuthorsByProxyTopNavFilter
assertEquals(setOf(outbox), before.proxyRelays)
relays.value = setOf(outbox, local, proxy)
val after = flow.flow().first() as AuthorsByProxyTopNavFilter
assertEquals(setOf(outbox, local, proxy), after.proxyRelays)
assertTrue(after.matchAuthor(me))
assertFalse(after.matchAuthor(someoneIFollow))
}
}
@@ -33,7 +33,7 @@ open class BaseLargeCacheBenchmark {
companion object {
fun getEventDB(): List<Event> {
// This file includes duplicates
val fullDBInputStream = javaClass.classLoader!!.getResourceAsStream("nostr_vitor_startup_data.json")
val fullDBInputStream = javaClass.classLoader!!.getResourceAsStream("nostr_vitor_startup_data.json.gz")
return JacksonMapper.mapper.readValue<ArrayList<Event>>(
GZIPInputStream(fullDBInputStream),
@@ -41,7 +41,7 @@ import java.util.zip.GZIPInputStream
open class BaseCacheBenchmark {
fun getEventDB(): List<Event> {
// This file includes duplicates
val fullDBInputStream = javaClass.classLoader?.getResourceAsStream("nostr_vitor_startup_data.json")
val fullDBInputStream = javaClass.classLoader?.getResourceAsStream("nostr_vitor_startup_data.json.gz")
return JacksonMapper.mapper.readValue<ArrayList<Event>>(
GZIPInputStream(fullDBInputStream),
@@ -51,6 +51,8 @@ import okhttp3.OkHttpClient
* which only parses). The signer side lives in [BunkerCommand].
*/
object NostrConnect {
private const val NOSTRCONNECT_SCHEME = "nostrconnect://"
data class Offer(
val clientPubkey: String,
val relays: Set<NormalizedRelayUrl>,
@@ -60,8 +62,8 @@ object NostrConnect {
/** Parse `nostrconnect://<client-pubkey>?relay=…&secret=…&name=…` (percent-decoded). */
fun parseOffer(uri: String): Offer? {
if (!uri.startsWith("nostrconnect://")) return null
val parts = uri.removePrefix("nostrconnect://").split("?", limit = 2)
if (!uri.startsWith(NOSTRCONNECT_SCHEME)) return null
val parts = uri.removePrefix(NOSTRCONNECT_SCHEME).split("?", limit = 2)
val clientPubkey = parts[0].lowercase()
if (clientPubkey.length != 64 || clientPubkey.any { it !in "0123456789abcdef" }) return null
val relays = mutableSetOf<NormalizedRelayUrl>()
@@ -89,7 +91,7 @@ object NostrConnect {
): String {
val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") }
return buildString {
append("nostrconnect://").append(clientPubkey)
append(NOSTRCONNECT_SCHEME).append(clientPubkey)
append("?").append(relays.joinToString("&") { "relay=${enc(it.url)}" })
append("&secret=").append(enc(secret))
if (name != null) append("&name=").append(enc(name))
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.moderation
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
/**
* Returns the event whose body a renderer would actually display.
*
* For kind 6 / kind 16 reposts that is the *wrapped* inner event — which
* the consume pipeline already resolves onto [Note.replyTo]. Falls back to
* the slower [RepostEvent.containedPost] / [GenericRepostEvent.containedPost]
* JSON decode only when the cache hasn't materialised the reply yet; both
* already null-return on parse failure.
*
* For non-repost events, the displayed event is the note's own event.
*
* Returns null when there is no event at all (placeholder not yet
* hydrated).
*/
fun Note.displayedEvent(): Event? {
val ev = this.event ?: return null
return when (ev) {
is RepostEvent -> replyTo?.lastOrNull()?.event ?: ev.containedPost()
is GenericRepostEvent -> replyTo?.lastOrNull()?.event ?: ev.containedPost()
else -> ev
}
}
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.moderation
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
/**
* Pure decision: should this note be collapsed because it abuses hashtag `t` tags?
*
* Reuses [hasMoreHashtagsThan] which short-circuits on total count before
* counting unique tags, so a note that repeats the same hashtag does not
* trip the filter.
*
* Long-form articles (kind 30023) legitimately use many topic tags and are
* always exempt. Authors whose pubkey appears in [exemptKeys] (the user's
* follow list plus self) are also exempt — the caller assembles that set.
*/
object HashtagSpamCheck {
fun isHashtagSpam(
displayedEvent: Event?,
authorPubkey: HexKey?,
enabled: Boolean,
threshold: Int,
exemptKeys: Set<HexKey>,
): Boolean {
if (!enabled) return false
if (displayedEvent == null) return false
if (displayedEvent.kind == LongTextNoteEvent.KIND) return false
if (authorPubkey != null && authorPubkey in exemptKeys) return false
return displayedEvent.tags.hasMoreHashtagsThan(threshold)
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.moderation
import androidx.compose.runtime.Stable
import kotlinx.coroutines.flow.StateFlow
/**
* User-tunable settings for the hashtag-spam content filter.
*
* Implementations are platform-specific (Desktop uses java.util.prefs;
* Android can use DataStore). Both observables emit on change so Compose
* reads via [collectAsState] re-render automatically.
*/
@Stable
interface HashtagSpamSettings {
val enabled: StateFlow<Boolean>
val threshold: StateFlow<Int>
fun setEnabled(enabled: Boolean)
fun setThreshold(threshold: Int)
companion object {
const val MIN_THRESHOLD = 1
const val MAX_THRESHOLD = 20
const val DEFAULT_THRESHOLD = 5
const val DEFAULT_ENABLED = true
}
}
@@ -18,34 +18,26 @@
* 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.ui.screen.loggedIn.communities.list.datasource
package com.vitorpamplona.amethyst.commons.moderation
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import androidx.compose.runtime.ProvidableCompositionLocal
import androidx.compose.runtime.compositionLocalOf
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
private const val COMMUNITIES_MINE_LIMIT = 300
/**
* 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.") }
fun filterCommunitiesMine(
pubkey: HexKey,
relays: Set<NormalizedRelayUrl>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (relays.isEmpty() || pubkey.isEmpty()) return emptyList()
val authors = listOf(pubkey)
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(CommunityDefinitionEvent.KIND),
authors = authors,
limit = COMMUNITIES_MINE_LIMIT,
since = since?.get(relay)?.time,
),
)
}
}
/**
* Pubkeys exempted from the hashtag-spam check — the active account's
* follow set union the active account's own pubkey. Updated by the App()
* root whenever the active account changes. Defaults to empty (strict
* filter applies to everyone) so leaf composables can read it without a
* null check.
*/
val LocalSpamExemptKeys: ProvidableCompositionLocal<Set<HexKey>> =
compositionLocalOf { emptySet() }
@@ -30,8 +30,12 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.utils.Hex
/**
* Parses search input and returns matching results.
* Supports: npub, nprofile, nsec (extracts pubkey), note, nevent, naddr, hex keys, hashtags
* Parses search input for direct-lookup entries: npub, nprofile, nsec
* (extracts pubkey), note, nevent, naddr, and 64-char hex keys.
*
* Hashtags are intentionally NOT returned here — they are handled by
* [QueryParser], which extracts `#xxx` tokens into the query's hashtag
* filter so the normal results pipeline drives a tag-filtered search.
*
* Shared between Android and Desktop for consistent Bech32 parsing.
*/
@@ -41,12 +45,6 @@ fun parseSearchInput(input: String): List<SearchResult> {
val trimmed = input.trim()
val results = mutableListOf<SearchResult>()
// Check for hashtag
if (trimmed.startsWith("#") && trimmed.length > 1) {
results.add(SearchResult.HashtagResult(trimmed.substring(1)))
return results
}
// Try to parse as Bech32 (npub, nevent, naddr, etc.)
val parsed = Nip19Parser.uriToRoute(trimmed)?.entity
if (parsed != null) {
@@ -50,11 +50,4 @@ sealed class SearchResult {
val dTag: String,
val displayId: String,
) : SearchResult()
/**
* Hashtag search.
*/
data class HashtagResult(
val hashtag: String,
) : SearchResult()
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
/**
* Replacement card rendered in place of a normal note when the
* hashtag-spam check trips. Scalar params only so it stays reusable across
* Desktop, Android, and future iOS without leaking platform display
* shapes.
*/
@Composable
fun CollapsedSpamNote(
authorPubkeyHex: String,
authorDisplayName: String,
authorAvatarUrl: String?,
hashtagCount: Int,
threshold: Int,
onReveal: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(horizontal = 12.dp, vertical = 8.dp)
.semantics {
contentDescription =
"Hidden note from $authorDisplayName, $hashtagCount hashtags. Tap to reveal."
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
UserAvatar(
userHex = authorPubkeyHex,
pictureUrl = authorAvatarUrl,
size = 32.dp,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = authorDisplayName.ifBlank { "Hidden note" },
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Filtered: $hashtagCount hashtags · threshold $threshold",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(4.dp))
TextButton(onClick = onReveal) {
Text("Reveal")
}
}
}
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.moderation
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class DisplayedEventTest {
private val author = "a".repeat(64)
private val sig = "0".repeat(128)
private fun textNoteEvent(id: String = "1".padEnd(64, '0')): Event =
Event(
id = id,
pubKey = author,
createdAt = 0L,
kind = 1,
tags = emptyArray(),
content = "hello",
sig = sig,
)
private fun repostWrapper(content: String): RepostEvent =
RepostEvent(
id = "r".repeat(64),
pubKey = author,
createdAt = 0L,
tags = emptyArray(),
content = content,
sig = sig,
)
@Test
fun nonRepostReturnsOwnEvent() {
val inner = textNoteEvent()
val note = Note(idHex = inner.id).apply { event = inner }
assertEquals(inner, note.displayedEvent())
}
@Test
fun repostWithReplyToReturnsInnerEvent() {
val inner = textNoteEvent(id = "i".repeat(64))
val innerNote = Note(idHex = inner.id).apply { event = inner }
val wrapper = repostWrapper(content = "{}") // valid JSON but missing fields
val wrapperNote =
Note(idHex = wrapper.id).apply {
event = wrapper
replyTo = listOf(innerNote)
}
assertEquals(inner, wrapperNote.displayedEvent())
}
@Test
fun repostWithoutReplyToFallsBackToContainedPost() {
// Malformed inner JSON → containedPost() returns null → displayedEvent() returns null.
val wrapper = repostWrapper(content = "not-valid-json-at-all")
val wrapperNote = Note(idHex = wrapper.id).apply { event = wrapper }
assertNull(wrapperNote.displayedEvent())
}
@Test
fun noteWithoutEventReturnsNull() {
val note = Note(idHex = "x".repeat(64))
assertNull(note.displayedEvent())
}
}
@@ -0,0 +1,189 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.moderation
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class HashtagSpamCheckTest {
private val author = "a".repeat(64)
private val other = "b".repeat(64)
private fun event(
kind: Int = 1,
hashtags: List<String> = emptyList(),
pubkey: String = author,
): Event =
Event(
id = "id".padEnd(64, '0'),
pubKey = pubkey,
createdAt = 0L,
kind = kind,
tags = hashtags.map { arrayOf("t", it) }.toTypedArray(),
content = "",
sig = "sig".padEnd(128, '0'),
)
@Test
fun returnsFalseWhenDisabled() {
val e = event(hashtags = (1..20).map { "h$it" })
assertFalse(
HashtagSpamCheck.isHashtagSpam(
displayedEvent = e,
authorPubkey = e.pubKey,
enabled = false,
threshold = 5,
exemptKeys = emptySet(),
),
)
}
@Test
fun returnsFalseForNullDisplayedEvent() {
assertFalse(
HashtagSpamCheck.isHashtagSpam(
displayedEvent = null,
authorPubkey = author,
enabled = true,
threshold = 5,
exemptKeys = emptySet(),
),
)
}
@Test
fun returnsFalseForLongformEvenWithManyTags() {
// kind 30023 = long-form content; exempt regardless of tag count.
val e = event(kind = 30023, hashtags = (1..20).map { "h$it" })
assertFalse(
HashtagSpamCheck.isHashtagSpam(
displayedEvent = e,
authorPubkey = e.pubKey,
enabled = true,
threshold = 5,
exemptKeys = emptySet(),
),
)
}
@Test
fun returnsFalseForFollowedAuthor() {
val e = event(hashtags = (1..20).map { "h$it" })
assertFalse(
HashtagSpamCheck.isHashtagSpam(
displayedEvent = e,
authorPubkey = e.pubKey,
enabled = true,
threshold = 5,
exemptKeys = setOf(author),
),
)
}
@Test
fun returnsFalseUnderThreshold() {
val e = event(hashtags = listOf("nostr", "bitcoin", "amethyst"))
assertFalse(
HashtagSpamCheck.isHashtagSpam(
displayedEvent = e,
authorPubkey = e.pubKey,
enabled = true,
threshold = 5,
exemptKeys = emptySet(),
),
)
}
@Test
fun returnsFalseAtThresholdExactly() {
// hasMoreHashtagsThan(5) returns true only for count > 5.
val e = event(hashtags = (1..5).map { "h$it" })
assertFalse(
HashtagSpamCheck.isHashtagSpam(
displayedEvent = e,
authorPubkey = e.pubKey,
enabled = true,
threshold = 5,
exemptKeys = emptySet(),
),
)
}
@Test
fun returnsTrueOverThresholdWithDistinctTags() {
val e = event(hashtags = (1..10).map { "h$it" })
assertTrue(
HashtagSpamCheck.isHashtagSpam(
displayedEvent = e,
authorPubkey = e.pubKey,
enabled = true,
threshold = 5,
exemptKeys = emptySet(),
),
)
}
@Test
fun returnsFalseOverThresholdWhenAllTagsAreDuplicates() {
// hasMoreHashtagsThan checks count AND unique count > limit.
// 20 copies of the same hashtag → unique = 1 → not spam.
val e = event(hashtags = List(20) { "bitcoin" })
assertFalse(
HashtagSpamCheck.isHashtagSpam(
displayedEvent = e,
authorPubkey = e.pubKey,
enabled = true,
threshold = 5,
exemptKeys = emptySet(),
),
)
}
@Test
fun authorNotInExemptKeysIsNotExempt() {
val e = event(hashtags = (1..10).map { "h$it" })
assertTrue(
HashtagSpamCheck.isHashtagSpam(
displayedEvent = e,
authorPubkey = e.pubKey,
enabled = true,
threshold = 5,
exemptKeys = setOf(other),
),
)
}
@Test
fun nullAuthorPubkeyDoesNotShortCircuit() {
val e = event(hashtags = (1..10).map { "h$it" })
assertTrue(
HashtagSpamCheck.isHashtagSpam(
displayedEvent = e,
authorPubkey = null,
enabled = true,
threshold = 5,
exemptKeys = setOf(author),
),
)
}
}
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.moderation
import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings.Companion.DEFAULT_ENABLED
import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings.Companion.DEFAULT_THRESHOLD
import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings.Companion.MAX_THRESHOLD
import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings.Companion.MIN_THRESHOLD
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.prefs.Preferences
/**
* JVM-platform implementation backed by [java.util.prefs.Preferences].
*
* The default node `com/vitorpamplona/amethyst/filters` is shared between
* the Desktop app and the `amy` CLI — both binaries running as the same OS
* user observe the same setting without any extra plumbing.
*
* `Preferences` auto-flushes on JVM shutdown and periodically, so no
* explicit `flush()` call is needed and avoids per-set disk thrash.
*/
class PreferencesHashtagSpamSettings(
private val prefs: Preferences = Preferences.userRoot().node(NODE_NAME),
) : HashtagSpamSettings {
private val mutableEnabled = MutableStateFlow(prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED))
private val mutableThreshold =
MutableStateFlow(
prefs.getInt(KEY_THRESHOLD, DEFAULT_THRESHOLD).coerceIn(MIN_THRESHOLD, MAX_THRESHOLD),
)
override val enabled: StateFlow<Boolean> = mutableEnabled.asStateFlow()
override val threshold: StateFlow<Int> = mutableThreshold.asStateFlow()
override fun setEnabled(enabled: Boolean) {
mutableEnabled.value = enabled
prefs.putBoolean(KEY_ENABLED, enabled)
}
override fun setThreshold(threshold: Int) {
val clamped = threshold.coerceIn(MIN_THRESHOLD, MAX_THRESHOLD)
mutableThreshold.value = clamped
prefs.putInt(KEY_THRESHOLD, clamped)
}
companion object {
const val NODE_NAME = "com/vitorpamplona/amethyst/filters"
const val KEY_ENABLED = "hashtag_spam_enabled"
const val KEY_THRESHOLD = "hashtag_spam_threshold"
}
}
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.moderation
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.prefs.Preferences
class PreferencesHashtagSpamSettingsTest {
private val testNode = "com/vitorpamplona/amethyst/test/hashtag_spam_${System.currentTimeMillis()}"
private fun prefs(): Preferences = Preferences.userRoot().node(testNode)
@Before
fun setup() {
prefs().clear()
}
@After
fun teardown() {
prefs().removeNode()
}
@Test
fun defaultsAreOnAndFive() {
val settings = PreferencesHashtagSpamSettings(prefs())
assertTrue(settings.enabled.value)
assertEquals(5, settings.threshold.value)
}
@Test
fun setEnabledPersists() {
val settings = PreferencesHashtagSpamSettings(prefs())
settings.setEnabled(false)
assertFalse(settings.enabled.value)
val reloaded = PreferencesHashtagSpamSettings(prefs())
assertFalse(reloaded.enabled.value)
}
@Test
fun setThresholdPersists() {
val settings = PreferencesHashtagSpamSettings(prefs())
settings.setThreshold(12)
assertEquals(12, settings.threshold.value)
val reloaded = PreferencesHashtagSpamSettings(prefs())
assertEquals(12, reloaded.threshold.value)
}
@Test
fun thresholdClampsBelowMin() {
val settings = PreferencesHashtagSpamSettings(prefs())
settings.setThreshold(-50)
assertEquals(HashtagSpamSettings.MIN_THRESHOLD, settings.threshold.value)
}
@Test
fun thresholdClampsAboveMax() {
val settings = PreferencesHashtagSpamSettings(prefs())
settings.setThreshold(9999)
assertEquals(HashtagSpamSettings.MAX_THRESHOLD, settings.threshold.value)
}
}
@@ -0,0 +1,66 @@
# Manual testing sheet — Hashtag-Spam Filter (Desktop)
Plan: `docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md`
Run with `./gradlew :desktopApp:run`. Sign in with any account that has a
follow list (or use a fresh nsec — global feed still works without follows).
## Setup
1. Confirm default state: filter is **ON**, threshold is **5** on first
launch. Settings → Content Filters shows Switch=On, slider at 5.
2. Confirm a follow list is populated (Home column should display notes
from people you follow). Otherwise the follow-exemption test (T4) is a
no-op.
## Scenarios
| # | Scenario | Expected |
|---|----------|----------|
| **T1** | **Slider live re-collapse.** Open a Home column. Locate a visible note that has the spammy pattern (e.g. 8+ distinct hashtags). Drag the threshold slider to a value below its hashtag count. | Card collapses to placeholder after drag release. No mid-drag jank. |
| **T2** | **Repost spam (precomputed).** Find a `nostr:nevent…` link to a kind-6 repost whose wrapped event has many hashtags, paste into a hashtag column or use Search. After the cache materialises the inner event (give it a second), confirm the repost wrapper renders collapsed. | Wrapper card shows `CollapsedSpamNote` with the inner event's author. |
| **T3** | **Repost spam (uncached fallback).** Same as T2 immediately on first paste, before the cache resolves the inner event. | Wrapper shows collapsed (via `containedPost()` fallback) OR shows normal until cache populates — both acceptable; nothing crashes. |
| **T4** | **Followed-author exemption.** Locate (or temporarily follow) someone who posts 10+ hashtag stuff. Reload Home. | Their posts render normally, never collapsed. |
| **T5** | **Self exemption.** Compose and publish a note with 10 distinct hashtags from the active account. | The note appears normally in your own activity / Home, not collapsed. |
| **T6** | **Long-form exemption.** Open a longform article (kind 30023) that uses many topical tags — via Search → Articles tab, or via a quoted `naddr1…`. | Article renders normally. |
| **T7** | **Thread root auto-expand.** Find a collapsed card in a feed and click it to open the thread. | Root note auto-expands (`forceReveal = true`); 12-hashtag replies inside the thread still appear collapsed. |
| **T8** | **Embedded quote.** Read a note that quotes a hashtag-spam note via `nostr:nevent…`. | The inline embedded card is collapsed. Revealing it does NOT cascade to other places — open the same quoted note in Search and confirm it's still collapsed there (per-call-site `rememberSaveable`). |
| **T9** | **Settings persistence across restart.** Toggle Switch to Off. Restart Desktop. | Open Settings → Content Filters: still Off. Spam notes render uncollapsed. |
| **T10** | **`amy` parity.** From a terminal, run `java -cp <path> com.vitorpamplona.amethyst.cli.Main …` (or any utility that reads the shared `Preferences` node). Or write a one-line Kotlin REPL: `Preferences.userRoot().node("com/vitorpamplona/amethyst/filters").getBoolean("hashtag_spam_enabled", true)`. | Returns the same value Desktop wrote. Reverse: write `false` via the API, relaunch Desktop, observe filter is off. |
| **T11** | **Malformed inner repost JSON.** Test event with a kind-6 wrapper whose `content` field is not parseable JSON. Quickest reproduction: use the unit test `repostWithoutReplyToFallsBackToContainedPost` ✓ already covers the `displayedEvent()` path; manually you can paste a deliberately-broken nevent and watch nothing crash. | No exception; wrapper renders normally (since `displayedEvent()` returns null and `isSpam = false`). |
| **T12** | **Hashtag-feed column.** Subscribe to a hashtag-feed column (e.g. `#bitcoin`). | Posts that are tagged `#bitcoin` AND have many other hashtags still collapse — filter applies inside hashtag columns. |
| **T13** | **Notifications tab (negative).** Receive a mention from someone who used many hashtags in the parent note. Open Notifications. | Compact notification card (56dp) renders normally — does NOT apply the filter (v1 scope: Notifications-tab card is a custom composable, deferred). Documented as a known limitation. |
| **T14** | **Slider does not cause feed recomposition storm.** Open a feed with several visible notes. Open Settings, drag threshold quickly back and forth. | Slider thumb moves smoothly; feed contents do not re-render per-tick (only on drag-end). No frame drops. |
| **T15** | **Toggle off → notes uncollapse live.** With filter ON and a collapsed card visible, toggle the Switch to Off in Settings. | Card immediately uncollapses (StateFlow re-evaluates, `isSpam` becomes false). |
| **T16** | **Threshold change updates label live.** Drag the slider with the Settings sheet open. | The "Hide notes with more than N hashtags" text updates per drag tick. |
## Known v1 limitations to surface during testing
- Cross-call-site reveal: revealing a collapsed card in Home does NOT reveal
the same card in a Hashtag column or in a thread — each render site has
its own `rememberSaveable` reveal flag.
- Notifications compact card does NOT respect the filter (v2).
- Follow/unfollow during a session does not re-evaluate already-rendered
cards in place (follow set is non-reactive at the check site v1; refresh
feed to update).
## Sign-off
- [ ] T1 Slider live re-collapse
- [ ] T2 Repost spam (precomputed)
- [ ] T3 Repost spam (uncached)
- [ ] T4 Followed-author exemption
- [ ] T5 Self exemption
- [ ] T6 Long-form exemption
- [ ] T7 Thread root auto-expand
- [ ] T8 Embedded quote
- [ ] T9 Settings persistence
- [ ] T10 `amy` parity
- [ ] T11 Malformed inner repost JSON
- [ ] T12 Hashtag-feed column
- [ ] T13 Notifications tab limitation (known)
- [ ] T14 No recomposition storm during drag
- [ ] T15 Toggle off live
- [ ] T16 Threshold label live update
Tester: ________________ Date: ________________
@@ -0,0 +1,142 @@
# Desktop Feature Backlog — Inspiration from Nostr Ecosystem
Survey date: 2026-06-29. Source: cross-client research across top-5 used clients
(Damus, Primal, YakiHonne, Snort, Iris) + top-5 desktop-first / power-user
clients (Notedeck, Gossip, Jumble, Coop, Flotilla). Wisp investigated separately
(Android-only, not desktop).
Already shipped on Amethyst Desktop and excluded from this list: deck columns,
embedded local relay, NIP-46 bunker login, NWC zapping, custom feeds, advanced
search, follow packs (in progress).
---
## Priority queue (next up)
### 1. Hashtag-spam filter — NEXT
- **Inspired by:** Damus (auto-hide posts above N hashtags).
- **What:** Configurable threshold; posts with `> N` `t` tags get hidden or
collapsed across all feeds. User-defined whitelist for legit multi-tag use
(events, longform).
- **Why desktop:** Spam noise scales with column count on a deck UI; one filter
cleans every column at once.
- **Module:** `commons/` (filter logic, shared with Android in future) +
`desktopApp/` (settings UI).
- **Skills:** `feed-patterns`, `compose-expert`, `account-state`.
### 2. WoT (web-of-trust) score on avatars + filters
- **Inspired by:** Gossip, Snort.
- **What:** Friends-of-friends score (0–N) computed from follow graph. Visual
badge/ring on avatar. Threshold filter for notifications/DM-requests.
- **Why desktop:** At-a-glance trust signal scales with multi-column deck.
- **Module:** `commons/services/WoTService.kt` (StateFlow<Map<HexKey, Int>>),
avatar composable in `commons/.../note/`.
- **Skills:** `account-state`, `kotlin-flow-state-event-modeling`,
`compose-expert`. Score compute is O(follows × follows) — must be lazy /
throttled.
---
## Full ranked backlog (10 ideas)
Ranking heuristic: impact × novelty / implementation cost. Items 1–2 are the
priority queue above; 3–10 captured for later.
| # | Feature | Inspired by | Module | Notes |
|---|---------|-------------|--------|-------|
| 1 | Hashtag-spam filter | Damus | commons + desktopApp | **NEXT** |
| 2 | WoT score on avatars + filters | Gossip, Snort | commons | After #1 |
| 3 | Relay-as-column + Relay Sets first-class | Jumble, Flotilla | commons | Drag relay URL → column |
| 4 | Cashu ecash wallet alongside NWC | Iris, Primal Spark | quartz + commons | Bearer-token privacy |
| 5 | AI "Dave" column (timeline-aware assistant) | Notedeck | desktopApp | LLM reads adjacent columns |
| 6 | Algorithmic Feed Marketplace | Primal v3.0 | commons | Discover layer on custom feeds |
| 7 | Keyboard / command palette (`Ctrl+K`, `?` overlay) | Snort, Notedeck | desktopApp + commons | Power-user nav |
| 8 | Longform reader column + offline publish queue | Damus, YakiHonne | commons | NIP-23, uses local relay |
| 9 | WoT-scored notification filter + undo-send | Gossip, Snort | commons | Calmer UX |
| 10 | Per-relay column health (sparkline + dot) | Wisp, Gossip | commons | Reuse EOSE manager |
| 11 | Discord-style "communities" sidebar (NIP-29) | Flotilla, Chachi | desktopApp + commons | Sidebar second purpose |
(Extra row #11 added: communities sidebar was the 10th in the original survey;
WoT-notif-filter + undo-send promoted to its own row for clarity.)
---
## Per-item detail
### #3 Relay-as-column + Relay Sets first-class
- **Drop:** Drag relay URL onto the deck → new column showing that relay's
global. Relay Sets become saveable column templates.
- **Impl:** New `RelayUrlFeedFilter` + `RelaySetFeedFilter` in `commons/feeds/`.
- **Gotcha:** Bypass write-through to local store for these feeds, otherwise
local relay pollutes with arbitrary global content.
### #4 Cashu ecash wallet alongside NWC
- **Why:** Different privacy model (bearer tokens, no account). Power users
want both — small/private = ecash, big/recurring = NWC.
- **Impl:** Check Quartz NIP-60/61 coverage; else new `wallet/cashu/`. UI is a
second tab in existing wallet column.
- **Gotcha:** Mint trust UX; token backup/restore; encryption story
(`accounts.json.enc` slot).
### #5 AI "Dave" column
- **Why:** Big-screen-only feature that breaks the deck-column metaphor wide
open. Reads N adjacent columns as context.
- **Impl:** `desktopApp/` for API key config + `commons/ai/` for prompt builders.
Pluggable model: Ollama local default, OpenAI/Anthropic optional.
- **Gotcha:** Privacy story — must be explicit which events get sent where.
Default to local Ollama.
### #6 Algorithmic Feed Marketplace
- **Why:** Custom feeds already shipped — marketplace is the discovery layer.
- **Impl:** Feeds are published as kind:30000-ish lists or DVM-driven. Browser
UI + subscribe button on top of existing custom-feed infra.
- **Gotcha:** DVM feeds vs static list feeds — two execution paths.
### #7 Keyboard / command palette
- **Why:** Mouse-first ≠ keyboard-hostile.
- **Impl:** `desktopApp/` global `onPreviewKeyEvent` + `commons/ui/CommandPalette.kt`.
- **Gotcha:** Focus management across deck columns; cmd vs ctrl on macOS;
conflict with text-input fields.
### #8 Longform reader + offline publish queue
- **Why:** NIP-23 is barely surfaced on Desktop. Big screens are *the* surface
for reading articles.
- **Impl:** New column types `LongformReader` + `LongformComposer`. Composer is
block-based. Offline queue writes drafts to local relay; auto-broadcasts on
reconnect.
- **Gotcha:** Markdown rendering on Compose Desktop is mid — pick or build.
Blossom upload alongside drafts.
### #9 WoT notif filter + undo-send
- **Why:** Notifications scale poorly; slow-mode (delay sends 5–30 s with
toast-cancel) calms power-user UX.
- **Impl:** `NotificationFilter.kt` extension + send-pipeline interceptor in
`commons/relayClient/`.
- **Gotcha:** Sent-but-cancelled events already in local relay write-through —
must scrub from local store on cancel.
### #10 Per-relay column health
- **Why:** Deck UI exposes the multi-relay reality. Latency/EOSE/dup-rate dot +
sparkline in column header helps self-tune.
- **Impl:** Extend EOSE manager to emit `RelayHealth` per relay; render small
sparkline + colored dot.
- **Gotcha:** Memory over time — rolling 60 s window, decay older.
### #11 Communities sidebar (NIP-29)
- **Why:** Amethyst Desktop sidebar is nav-only; mapping joined groups /
favourite relays to Discord-like server icons doubles its purpose.
- **Impl:** `desktopApp/.../sidebar/` new section; on click, populate deck with
group channels. `commons/groups/` for NIP-29 if not in Quartz already.
- **Gotcha:** NIP-29 spec churn — check Quartz coverage. Don't conflate
"favourite relay" with "joined group".
---
## Sources
- nostr.com/clients, nostrapps.com, stats.nostr.band
- humai.blog "Best Nostr Apps 2026"; nostr.co.uk/clients; nostrcompass.org #15
- opensats.org "Advancements in Nostr Clients"
- Repos: damus-io/damus, damus-io/notedeck, mikedilger/gossip, CodyTseng/jumble,
lumehq/coop, coracle-social/flotilla (active at gitea.coracle.social),
barrydeen/wisp
@@ -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) } },
@@ -316,12 +316,18 @@ fun BookmarksScreen(
onNavigateToThread(event.id)
},
) {
NoteCard(
note = event.toNoteDisplayData(localCache),
com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender(
displayedEvent = event,
noteIdHex = event.id,
localCache = localCache,
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
) {
NoteCard(
note = event.rememberDisplayData(localCache),
localCache = localCache,
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
}
NoteActionsRow(
event = event,
relayManager = relayManager,
@@ -20,6 +20,10 @@
*/
package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.desktop.ui.note.NoteDisplayData
@@ -27,6 +31,27 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip19Bech32.toNpub
/**
* Compose-friendly wrapper around [toNoteDisplayData] that observes the
* event author's user-metadata flow and re-derives the display data
* whenever it updates. Use at note-card call sites so display names and
* avatars populate when the kind-0 event arrives from relays after the
* note itself.
*/
@Composable
fun Event.rememberDisplayData(cache: ICacheProvider?): NoteDisplayData {
val author = remember(pubKey, cache) { cache?.getUserIfExists(pubKey) }
val authorMetaValue by produceState<Any?>(initialValue = null, key1 = author) {
val a = author
if (a == null) {
value = null
} else {
a.metadata().flow.collect { value = it }
}
}
return remember(this, authorMetaValue) { toNoteDisplayData(cache) }
}
/**
* Extension to convert Event to NoteDisplayData for the shared NoteCard.
*/
@@ -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) {
@@ -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)
@@ -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(),
@@ -0,0 +1,123 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.note
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamCheck
import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings
import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
import com.vitorpamplona.amethyst.commons.moderation.displayedEvent
import com.vitorpamplona.amethyst.commons.ui.note.CollapsedSpamNote
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags
/**
* Wraps a note render in the hashtag-spam check.
*
* Reads [LocalHashtagSpamSettings] and [LocalSpamExemptKeys] from
* composition. If the check trips and the user hasn't revealed this
* specific note (per-note [rememberSaveable] keyed by id), renders
* [CollapsedSpamNote] with author info pulled from [localCache];
* otherwise calls [normal].
*
* @param displayedEvent The event whose body the [normal] block renders.
* For repost wrappers, callers should pass the *inner* event (via
* [Note.displayedEvent]). For search results that already hold an
* [Event] directly, pass it as-is.
* @param noteIdHex Stable id used as the [rememberSaveable] key for the
* reveal flag — must match the id of whatever the [normal] block renders.
* @param forceReveal When `true`, skips the check entirely and always
* renders [normal]. Used by thread-detail screens where the user
* explicitly opted into the root note.
*/
@Composable
fun SpamCheckedNoteRender(
displayedEvent: Event?,
noteIdHex: String,
localCache: DesktopLocalCache?,
forceReveal: Boolean = false,
normal: @Composable () -> Unit,
) {
val settings = LocalHashtagSpamSettings.current
val enabled by settings.enabled.collectAsState()
val threshold by settings.threshold.collectAsState()
val exemptKeys = LocalSpamExemptKeys.current
val isSpam =
remember(noteIdHex, displayedEvent, enabled, threshold, exemptKeys) {
HashtagSpamCheck.isHashtagSpam(
displayedEvent = displayedEvent,
authorPubkey = displayedEvent?.pubKey,
enabled = enabled,
threshold = threshold,
exemptKeys = exemptKeys,
)
}
var revealed by rememberSaveable(noteIdHex) { mutableStateOf(false) }
if (!forceReveal && isSpam && !revealed && displayedEvent != null) {
val authorPubkey = displayedEvent.pubKey
val author = localCache?.getUserIfExists(authorPubkey)
val displayName = author?.toBestDisplayName() ?: authorPubkey.take(8)
val avatarUrl = author?.profilePicture()
val hashtagCount = displayedEvent.tags.countHashtags()
CollapsedSpamNote(
authorPubkeyHex = authorPubkey,
authorDisplayName = displayName,
authorAvatarUrl = avatarUrl,
hashtagCount = hashtagCount,
threshold = threshold,
onReveal = { revealed = true },
)
} else {
normal()
}
}
/**
* [Note]-shaped convenience overload — resolves the displayed event
* (unwrapping kind 6 / 16 reposts) and delegates to the primary helper.
*/
@Composable
fun SpamCheckedNoteRender(
note: Note,
localCache: DesktopLocalCache?,
forceReveal: Boolean = false,
normal: @Composable () -> Unit,
) {
val displayedEvent = remember(note, note.event) { note.displayedEvent() }
SpamCheckedNoteRender(
displayedEvent = displayedEvent,
noteIdHex = note.idHex,
localCache = localCache,
forceReveal = forceReveal,
normal = normal,
)
}
@@ -60,7 +60,8 @@ import com.vitorpamplona.amethyst.commons.search.SearchSortOrder
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData
import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender
import com.vitorpamplona.amethyst.desktop.ui.rememberDisplayData
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
@Composable
@@ -153,24 +154,36 @@ fun SearchResultsList(
if (!collapsed) {
val displayNotes = textNotes.take(5)
items(displayNotes, key = { "note-${it.id}" }) { event ->
NoteCard(
note = event.toNoteDisplayData(localCache),
SpamCheckedNoteRender(
displayedEvent = event,
noteIdHex = event.id,
localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
) {
NoteCard(
note = event.rememberDisplayData(localCache),
localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
}
}
if (textNotes.size > 5) {
item(key = "notes-expand") {
ExpandableSection(
remaining = textNotes.drop(5),
) { event ->
NoteCard(
note = event.toNoteDisplayData(localCache),
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
)
SpamCheckedNoteRender(
displayedEvent = event,
noteIdHex = event.id,
localCache = localCache,
) {
NoteCard(
note = event.rememberDisplayData(localCache),
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
)
}
}
}
}
@@ -197,24 +210,36 @@ fun SearchResultsList(
}
if (!collapsed) {
items(articles.take(5), key = { "article-${it.id}" }) { event ->
NoteCard(
note = event.toNoteDisplayData(localCache),
SpamCheckedNoteRender(
displayedEvent = event,
noteIdHex = event.id,
localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
) {
NoteCard(
note = event.rememberDisplayData(localCache),
localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
}
}
if (articles.size > 5) {
item(key = "articles-expand") {
ExpandableSection(
remaining = articles.drop(5),
) { event ->
NoteCard(
note = event.toNoteDisplayData(localCache),
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
)
SpamCheckedNoteRender(
displayedEvent = event,
noteIdHex = event.id,
localCache = localCache,
) {
NoteCard(
note = event.rememberDisplayData(localCache),
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
)
}
}
}
}
@@ -239,13 +264,19 @@ fun SearchResultsList(
}
if (!collapsed) {
items(otherNotes.take(5), key = { "other-${it.id}" }) { event ->
NoteCard(
note = event.toNoteDisplayData(localCache),
SpamCheckedNoteRender(
displayedEvent = event,
noteIdHex = event.id,
localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
) {
NoteCard(
note = event.rememberDisplayData(localCache),
localCache = localCache,
onClick = { onNavigateToThread(event.id) },
onAuthorClick = onNavigateToProfile,
onMentionClick = onNavigateToProfile,
)
}
}
}
}
@@ -0,0 +1,101 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings
import kotlin.math.roundToInt
/**
* Settings UI for the hashtag-spam content filter.
*
* Decoupled-thumb pattern: a local [Float] state drives the [Slider] thumb,
* the [StateFlow] only sees the committed [Int] on [onValueChangeFinished].
* Prevents per-tick recomposition storms in the feed while the user drags.
*/
@Composable
fun HashtagSpamSettingsSection(
settings: HashtagSpamSettings,
modifier: Modifier = Modifier,
) {
val enabled by settings.enabled.collectAsState()
val committed by settings.threshold.collectAsState()
var live by remember { mutableFloatStateOf(committed.toFloat()) }
LaunchedEffect(committed) { live = committed.toFloat() }
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = "Hashtag-spam filter",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = enabled, onCheckedChange = settings::setEnabled)
Spacer(Modifier.width(8.dp))
Text(
text = if (enabled) "On" else "Off",
style = MaterialTheme.typography.bodyMedium,
)
}
if (enabled) {
Spacer(Modifier.height(8.dp))
Text(
text = "Hide notes with more than ${live.roundToInt()} hashtags",
style = MaterialTheme.typography.bodyMedium,
)
Slider(
value = live,
onValueChange = { live = it },
onValueChangeFinished = { settings.setThreshold(live.roundToInt()) },
valueRange =
HashtagSpamSettings.MIN_THRESHOLD
.toFloat()..HashtagSpamSettings.MAX_THRESHOLD.toFloat(),
steps = HashtagSpamSettings.MAX_THRESHOLD - HashtagSpamSettings.MIN_THRESHOLD - 1,
)
Text(
text = "Long-form articles and posts from people you follow are always shown.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
+6 -6
View File
@@ -139,6 +139,12 @@
"Chinese Simplified"
]
},
{
"user": "StellarStoic",
"languages": [
"Slovenian"
]
},
{
"user": "anthony-robin",
"languages": [
@@ -153,12 +159,6 @@
"Spanish, United States"
]
},
{
"user": "StellarStoic",
"languages": [
"Slovenian"
]
},
{
"user": "summoner001",
"languages": [
@@ -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.
@@ -67,5 +67,5 @@ object RelayFixtures {
fun vitorShort(): List<Event> = OptimizedJsonMapper.fromJsonToEventList(loadString("nostr_vitor_short.json"))
/** Loads `nostr_vitor_startup_data.json.gz` — the larger Vitor startup corpus. */
fun vitorStartup(): List<Event> = OptimizedJsonMapper.fromJsonToEventList(loadGzipString("nostr_vitor_startup_data.json"))
fun vitorStartup(): List<Event> = OptimizedJsonMapper.fromJsonToEventList(loadGzipString("nostr_vitor_startup_data.json.gz"))
}
@@ -153,7 +153,7 @@ class NappletBrowserService : Service() {
BrowserTab(
sessionId = sessionId,
clientMessenger = msg.replyTo,
url = data.getString(NappletBrowserContract.KEY_URL)?.ifBlank { "about:blank" } ?: "about:blank",
url = data.getString(NappletBrowserContract.KEY_URL)?.ifBlank { ABOUT_BLANK } ?: ABOUT_BLANK,
proxyPort = data.getInt(NappletBrowserContract.KEY_PROXY_PORT, -1),
useTor = data.getBoolean(NappletBrowserContract.KEY_USE_TOR, false),
bgColor = data.getInt(NappletBrowserContract.KEY_BG_COLOR, android.graphics.Color.WHITE),
@@ -590,9 +590,10 @@ class NappletBrowserService : Service() {
private fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() }
/** Address-bar text → URL via the shared [OmniboxInput] rules (bare domain → https, else search). */
private fun normalizeUrl(input: String): String = OmniboxInput.resolve(input)?.url ?: "about:blank"
private fun normalizeUrl(input: String): String = OmniboxInput.resolve(input)?.url ?: ABOUT_BLANK
private companion object {
private const val TAG = "NappletBrowserService"
private const val ABOUT_BLANK = "about:blank"
}
}
@@ -81,7 +81,9 @@ private class BrowserSession(
override val signalOptions: Set<String> = emptySet()
override fun notifySessionRendered(supportedSignalOptions: Set<String>) {}
override fun notifySessionRendered(supportedSignalOptions: Set<String>) {
// No-op: signalOptions is empty, so there are no supported signals to report back on.
}
override fun notifyResized(
width: Int,
@@ -91,11 +93,17 @@ private class BrowserSession(
webView.requestLayout()
}
override fun notifyZOrderChanged(isZOrderOnTop: Boolean) {}
override fun notifyZOrderChanged(isZOrderOnTop: Boolean) {
// No-op: the WebView's z-order within the SurfaceControlViewHost surface is fixed.
}
override fun notifyConfigurationChanged(configuration: Configuration) {}
override fun notifyConfigurationChanged(configuration: Configuration) {
// No-op: the WebView handles configuration changes itself; the session needs no extra action.
}
override fun notifyUiChanged(uiContainerInfo: Bundle) {}
override fun notifyUiChanged(uiContainerInfo: Bundle) {
// No-op: no host-side reaction is needed to UI-container geometry updates.
}
override fun close() {
// The library may call close() off the main thread; WebView.destroy() (and the tabs mutation)
@@ -121,7 +121,7 @@ class NappletContentServer(
if (url == NappletWebContract.SHELL_URL) return serveShell()
if (url == appOrigin || url.startsWith("$appOrigin/")) {
// A document navigation accepts text/html; a sub-resource (js/css/img) does not.
val acceptsHtml = request.requestHeaders["Accept"]?.contains("text/html", ignoreCase = true) == true
val acceptsHtml = request.requestHeaders["Accept"]?.contains(MIME_HTML, ignoreCase = true) == true
return serveAppResource(url, acceptsHtml)
}
// Off-origin: a locked napplet 404s (connect-src 'none' means it shouldn't ask). An nSite in
@@ -134,7 +134,7 @@ class NappletContentServer(
// origin so the shell frames exactly this applet (and the CSP frame-src is pinned to it too).
val html = shellHtmlBytes.decodeToString().replace(NappletWebContract.APP_ORIGIN_PLACEHOLDER, appOrigin).encodeToByteArray()
return WebResourceResponse(
"text/html",
MIME_HTML,
"utf-8",
200,
"OK",
@@ -167,7 +167,7 @@ class NappletContentServer(
if (resolution !is StaticSiteResolution.Resolved) return notFound()
val (mime, charset) = splitContentType(resolution.contentType)
val isHtml = mime.equals("text/html", ignoreCase = true)
val isHtml = mime.equals(MIME_HTML, ignoreCase = true)
val bytes = if (isHtml) injectShim(resolution.bytes) else resolution.bytes
// Locked napplets get the strict app CSP (connect-src 'none', etc.). An nSite in website mode
@@ -222,5 +222,6 @@ class NappletContentServer(
companion object {
// Marker "server" for a Resolved served from the local content-addressed cache.
private const val CACHE_SERVER = "cache"
private const val MIME_HTML = "text/html"
}
}
@@ -82,7 +82,9 @@ private class HostSession(
override val signalOptions: Set<String> = emptySet()
override fun notifySessionRendered(supportedSignalOptions: Set<String>) {}
override fun notifySessionRendered(supportedSignalOptions: Set<String>) {
// No-op: signalOptions is empty, so there are no supported signals to report back on.
}
override fun notifyResized(
width: Int,
@@ -92,11 +94,17 @@ private class HostSession(
webView.requestLayout()
}
override fun notifyZOrderChanged(isZOrderOnTop: Boolean) {}
override fun notifyZOrderChanged(isZOrderOnTop: Boolean) {
// No-op: the WebView's z-order within the SurfaceControlViewHost surface is fixed.
}
override fun notifyConfigurationChanged(configuration: Configuration) {}
override fun notifyConfigurationChanged(configuration: Configuration) {
// No-op: the WebView handles configuration changes itself; the session needs no extra action.
}
override fun notifyUiChanged(uiContainerInfo: Bundle) {}
override fun notifyUiChanged(uiContainerInfo: Bundle) {
// No-op: no host-side reaction is needed to UI-container geometry updates.
}
override fun close() {
// The library may call close() off the main thread; WebView.destroy() (and the tabs mutation)
@@ -58,7 +58,7 @@ class LargeDBSignatureCheck {
fun insertStartupDatabase() =
runBlocking {
// This file includes duplicates
val fullDBInputStream = javaClass.classLoader?.getResourceAsStream("nostr_vitor_startup_data.json")
val fullDBInputStream = javaClass.classLoader?.getResourceAsStream("nostr_vitor_startup_data.json.gz")
val eventArray =
JacksonMapper.mapper.readValue<ArrayList<Event>>(
@@ -22,17 +22,49 @@ package com.vitorpamplona.quartz.nip01Core.core
import com.vitorpamplona.quartz.utils.Hex
/** Makes the distinction between String and Hex * */
/**
* A lower-case hexadecimal string. This is the canonical wire format for the
* 32-byte values Nostr deals with everywhere: public keys, event ids, and the
* 64-byte Schnorr signature. It is only a [String] alias — it documents intent
* and does no validation on its own — so pair it with [isValid] (or [Hex.isHex])
* whenever the value comes from an untrusted source.
*
* Convert with the extension functions below rather than reaching for a byte
* loop or a third-party codec:
*
* ```kotlin
* import com.vitorpamplona.quartz.nip01Core.core.toHexKey
* import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
*
* val hex: HexKey = pubKeyBytes.toHexKey() // ByteArray -> hex
* val bytes: ByteArray = hex.hexToByteArray() // hex -> ByteArray
* ```
*
* For byte-array-free, allocation-light checks use [Hex.isHex64] (32-byte keys
* and ids) or [Hex.isEqual] (compare a hex string to raw bytes without decoding).
*/
typealias HexKey = String
/** Encodes these bytes as a lower-case [HexKey]. Inverse of [hexToByteArray]. */
fun ByteArray.toHexKey(): HexKey = Hex.encode(this)
/**
* Decodes this hex string into its bytes. Inverse of [toHexKey].
*
* Accepts upper- or lower-case input but requires an even length; throws
* [IllegalArgumentException] on odd-length input. Use [hexToByteArrayOrNull]
* when the string may contain non-hex characters.
*/
fun HexKey.hexToByteArray(): ByteArray = Hex.decode(this)
/** Like [hexToByteArray] but returns null instead of throwing when this is not valid hex. */
fun HexKey.hexToByteArrayOrNull(): ByteArray? = if (Hex.isHex(this)) Hex.decode(this) else null
/** True when this is a 64-char (32-byte) hex string — the shape of a pubkey or event id. */
fun HexKey.isValid(): Boolean = length == PUBKEY_LENGTH && Hex.isHex(this)
/** Length in hex chars of a 32-byte public key. */
const val PUBKEY_LENGTH = 64
/** Length in hex chars of a 32-byte event id. */
const val EVENT_ID_LENGTH = 64
@@ -24,7 +24,23 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256
/**
* Computes and verifies Nostr **event ids** (NIP-01).
*
* An id is the SHA-256 of the canonically serialized array
* `[0, pubkey, created_at, kind, tags, content]` — UTF-8, no whitespace, in that
* exact order. Getting that serialization right by hand is easy to botch (it is
* what makes relays reject an event), so route through here rather than calling
* `sha256` on your own JSON. Signing/building via the typed event builders already
* does this for you; reach for `EventHasher` when validating events from an
* untrusted source or hashing a not-yet-wrapped template.
*
* ```kotlin
* val ok = EventHasher.hashIdCheck(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content)
* ```
*/
object EventHasher {
/** Raw 32-byte id digest for the given event fields. See [hashId] for the hex form. */
fun hashIdBytes(
pubKey: HexKey,
createdAt: Long,
@@ -33,6 +49,7 @@ object EventHasher {
content: String,
): ByteArray = sha256(EventHasherSerializer.fastMakeJsonForId(pubKey, createdAt, kind, tags, content))
/** The event id as a lower-case 64-char hex string. */
fun hashId(
pubKey: HexKey,
createdAt: Long,
@@ -41,6 +58,7 @@ object EventHasher {
content: String,
): String = hashIdBytes(pubKey, createdAt, kind, tags, content).toHexKey()
/** True when [id] matches the id computed from the other fields — use to validate untrusted events. */
fun hashIdCheck(
id: HexKey,
pubKey: HexKey,
@@ -83,6 +83,18 @@ interface INostrClient : AutoCloseable {
fun removeConnectionListener(listener: RelayConnectionListener)
/**
* Returns the [IRelayClient] for [url], creating and registering it in the
* connection pool if it is not there yet.
*
* Most callers should never need this — [subscribe]/[count]/[publish] manage
* the pool for you. It exists for accessories that must drive a single relay
* directly, such as NIP-77 negentropy (which sends `NEG-OPEN` and walks the
* reconciliation rounds on one connection). The default implementation throws;
* only a real pool-backed client can hand out relay clients.
*/
fun getOrCreateRelay(url: NormalizedRelayUrl): IRelayClient = throw UnsupportedOperationException("This INostrClient does not expose relay clients")
fun getReqFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>?
fun getCountFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>?
@@ -342,6 +342,8 @@ class NostrClient(
listeners.forEach { it.onCannotConnect(relay, errorMessage) }
}
override fun getOrCreateRelay(url: NormalizedRelayUrl): IRelayClient = relayPool.getOrCreateRelay(url)
override fun addConnectionListener(listener: RelayConnectionListener) {
listeners = listeners.plus(listener)
}
@@ -0,0 +1,69 @@
/*
* 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.quartz.nip01Core.relay.client.accessories
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Thrown by [negentropySync] when a relay's matched set cannot be reconciled
* through NIP-77 for a given [window].
*
* The accessory does NOT silently fall back to plain paging — that is a heavier,
* non-delta transport and the choice belongs to the caller. Catch this and decide:
* page the filter yourself with [fetchAllPages], try another relay, narrow the
* filter, or give up. For the common "try negentropy, else page" shape use
* [negentropySyncOrFetch], which does exactly that (with id-dedup) for you.
*
* [window] is the specific `created_at` slice that failed. When negentropy fails
* on the very first reconcile (e.g. the relay does not speak NIP-77) it equals the
* filter you passed; after windowing it is a sub-range. Note that events from
* windows that DID reconcile before this failure may already have been delivered
* to your `onEvent`, so dedupe by event id if you then page the whole filter.
*
* @property relay the relay that could not reconcile.
* @property window the filter slice that failed.
* @property reason machine-readable category — branch on this to recover.
* @property detail the underlying specifics (a relay's `NEG-ERR` text, `timeout`, …).
*/
class NegentropySyncException(
val relay: NormalizedRelayUrl,
val window: Filter,
val reason: Reason,
val detail: String,
) : Exception("NIP-77 sync of $relay failed ($reason): $detail") {
enum class Reason {
/**
* The relay caps negentropy below the matched set and even a minimal
* `created_at` window still exceeds that cap (strfry's `max_sync_events`),
* so negentropy cannot enumerate the window at all. Paging is the only way
* to get these events.
*/
OVER_MAX_SYNC_EVENTS,
/**
* The relay did not complete reconciliation: no NIP-77 support, a
* non-overflow `NEG-ERR`, a disconnect, or a timeout. [detail] carries the
* specifics.
*/
UNAVAILABLE,
}
}
@@ -64,6 +64,19 @@ suspend fun INostrClient.fetchAllPages(
// Track how many matching events each filter has received so far.
val matchCountPerFilter = IntArray(filters.size)
// One subscription id reused for every page. Each page opens it (with the
// page's `until`), waits for EOSE, then closes it before the next page opens
// it again — so at most one subscription is ever live and the whole download
// occupies a single subscription slot on the connection (relays cap the
// number of concurrent subscriptions per connection, so churning through a
// fresh id per page is wasteful).
//
// Reusing the id is safe because the pool serializes the "send a REQ"
// decision: after each page's EOSE, the pool's auto-resend and this loop's
// unsubscribe+resubscribe can no longer both fire a REQ for the same id (see
// PoolRequests.decideCommandLocked / PoolRequestsConcurrencyTest). Without
// that fix the two raced and produced a duplicate REQ — two EOSEs, or an
// empty page that silently truncated large results.
val subId = newSubId()
while (true) {
@@ -0,0 +1,141 @@
/*
* 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.quartz.nip01Core.relay.client.accessories
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow
/**
* Streaming form of [negentropySync]: emits each event **individually** as it
* arrives, then completes when the sync finishes. Nothing is accumulated, so it
* stays O(1) in memory regardless of how many events the relay holds.
*
* Events are buffered with [Channel.UNLIMITED] because [negentropySync] delivers
* them through a non-suspending callback on the relay reader thread: a bounded
* buffer would force the producer to drop events when the collector lags. A slow
* collector therefore lets the buffer grow — apply your own
* [kotlinx.coroutines.flow.buffer]/`conflate`/`collectLatest` downstream if you
* need a different policy. Cancelling the collector cancels the sync and tears
* down its subscriptions via [awaitClose].
*
* See [negentropySync] for the meaning of every parameter.
*/
fun INostrClient.negentropySyncEvents(
relay: NormalizedRelayUrl,
filter: Filter,
maxEvents: Int = 0,
maxConcurrentReqs: Int = 8,
fetchBatch: Int = 500,
idleTimeoutMs: Long = 120_000L,
): Flow<Event> =
callbackFlow {
negentropySync(
relay = relay,
filter = filter,
maxEvents = maxEvents,
maxConcurrentReqs = maxConcurrentReqs,
fetchBatch = fetchBatch,
idleTimeoutMs = idleTimeoutMs,
) { event ->
trySend(event)
}
close()
awaitClose { }
}.buffer(Channel.UNLIMITED)
fun INostrClient.negentropySyncEvents(
relay: String,
filter: Filter,
maxEvents: Int = 0,
maxConcurrentReqs: Int = 8,
fetchBatch: Int = 500,
idleTimeoutMs: Long = 120_000L,
): Flow<Event> =
negentropySyncEvents(
relay = RelayUrlNormalizer.normalize(relay),
filter = filter,
maxEvents = maxEvents,
maxConcurrentReqs = maxConcurrentReqs,
fetchBatch = fetchBatch,
idleTimeoutMs = idleTimeoutMs,
)
/**
* Streaming "try negentropy, else page" — the [Flow] form of
* [negentropySyncOrFetch]. Emits each event individually as it arrives from
* whichever transport delivered it, deduped by id across both phases, then
* completes. Unlike [negentropySyncEvents] it never throws on a relay that can't
* reconcile; it pages instead.
*
* See [negentropySyncEvents] for the buffering/backpressure note and
* [negentropySync] for the meaning of every parameter.
*/
fun INostrClient.negentropySyncOrFetchEvents(
relay: NormalizedRelayUrl,
filter: Filter,
maxEvents: Int = 0,
maxConcurrentReqs: Int = 8,
fetchBatch: Int = 500,
idleTimeoutMs: Long = 120_000L,
): Flow<Event> =
callbackFlow {
negentropySyncOrFetch(
relay = relay,
filter = filter,
maxEvents = maxEvents,
maxConcurrentReqs = maxConcurrentReqs,
fetchBatch = fetchBatch,
idleTimeoutMs = idleTimeoutMs,
) { event ->
trySend(event)
}
close()
awaitClose { }
}.buffer(Channel.UNLIMITED)
fun INostrClient.negentropySyncOrFetchEvents(
relay: String,
filter: Filter,
maxEvents: Int = 0,
maxConcurrentReqs: Int = 8,
fetchBatch: Int = 500,
idleTimeoutMs: Long = 120_000L,
): Flow<Event> =
negentropySyncOrFetchEvents(
relay = RelayUrlNormalizer.normalize(relay),
filter = filter,
maxEvents = maxEvents,
maxConcurrentReqs = maxConcurrentReqs,
fetchBatch = fetchBatch,
idleTimeoutMs = idleTimeoutMs,
)
@@ -0,0 +1,726 @@
/*
* 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.quartz.nip01Core.relay.client.accessories
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip77Negentropy.NegErrMessage
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgMessage
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySession
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.concurrent.Volatile
import kotlin.coroutines.coroutineContext
import kotlin.math.min
import kotlin.time.TimeSource
/**
* Outcome of a successful [negentropySync] run.
*
* @property needCount ids the relay had that we lacked (i.e. everything that
* matched [Filter] on the relay — this sync always reconciles against an empty
* local set, so it downloads the full matched set).
* @property haveCount ids we had that the relay lacked. Always `0` here because
* the local set is empty; kept so the result mirrors a full NIP-77 reconcile.
* @property downloaded distinct events actually delivered through `onEvent`.
* @property windows number of `created_at` windows the matched set was split
* into (`1` when the relay reconciled the whole filter in one shot).
*/
class NegentropySyncResult(
val needCount: Int,
val haveCount: Int,
val downloaded: Int,
val windows: Int,
)
/**
* Downloads every event a single [relay] holds matching [filter], delivering each
* one (deduped by id) through [onEvent]. A high-level wrapper over NIP-77
* negentropy that hides the parts that make the raw protocol painful to use:
*
* 1. Reconciles the relay's matched set against an empty local set, **streaming**
* the ids the relay has straight into the download pipeline as each NIP-77
* round arrives — the full id list is never materialised.
* 2. Downloads those ids through at most [maxConcurrentReqs] concurrent `REQ`
* subscriptions of [fetchBatch] ids each, refilling as each `EOSE` arrives. The
* reconciliation, the id queue and event delivery are all back-pressured, so a
* slow consumer throttles the whole chain and **peak memory is bounded by the
* pipeline depth, not by the window size** — a multi-million-event window
* streams through in roughly constant memory. No id/event dedup set is held:
* NIP-77 yields a distinct id set, so each event is requested (and returned)
* exactly once.
* 3. Handles the relay-side cap on negentropy (strfry's `max_sync_events`,
* observed as `NEG-ERR … "blocked: too many query results"`): the [filter] is
* split by `created_at` windows and each window reconciled on its own, a
* window that still overflows being halved and retried.
*
* This method is negentropy-only. It does NOT silently fall back to plain paging:
* if a window genuinely cannot be reconciled — a minimal `created_at` window still
* over the relay's cap, or a relay that does not speak NIP-77 / drops the session /
* times out — it throws [NegentropySyncException] so the caller chooses what to do.
* For the common "try negentropy, else page" shape, use [negentropySyncOrFetch].
*
* Scope is controlled entirely by [filter] — narrow it (kinds, authors, `since`,
* tags, …) to download a slice instead of everything. [maxEvents] additionally caps
* the delivered set.
*
* Coroutine-cancellable: on completion, cancel, reaching [maxEvents], or a thrown
* [NegentropySyncException], all `REQ` subscriptions are unsubscribed and the
* negentropy session is closed and its listener removed, so nothing leaks.
*
* @throws NegentropySyncException when a window cannot be reconciled via NIP-77.
*
* @param relay the relay to sync from.
* @param filter what to download. A single filter (NEG-OPEN is single-filter).
* @param maxEvents stop after delivering this many distinct events. `0` = unlimited.
* @param maxConcurrentReqs upper bound on simultaneously-open download `REQ`s. Keep
* it at or below the relay's per-connection subscription cap.
* @param fetchBatch ids per download `REQ`.
* @param idleTimeoutMs the idle watchdog: the maximum time the relay may go
* **completely silent** before the sync gives up. It is NOT a per-round deadline —
* it **resets on every message the relay sends** (each NIP-77 round, every download
* `EOSE`/event) and on connect. So a genuinely slow but progressing sync runs for as
* long as it needs: only true silence trips it. This matters because the relay
* builds its whole negentropy snapshot before the FIRST round responds — O(matched
* set), a minute or more for a multi-million-event filter — and that first wait is a
* real silence, so keep this comfortably above the largest expected first-round build.
* A dead/half-open socket does NOT depend on this: the WebSocket keep-alive detects
* it and the disconnect is turned into a clean abort. Pass `0` to disable the
* watchdog entirely and run until the socket drops (download batches keep a finite
* internal idle bound regardless, so a single stuck batch can't hang the pipeline).
* @param onProgress optional `(needSoFar, downloaded)` ticks as work proceeds.
* @param onEvent called once per distinct event, on the relay reader thread.
*/
suspend fun INostrClient.negentropySync(
relay: NormalizedRelayUrl,
filter: Filter,
maxEvents: Int = 0,
maxConcurrentReqs: Int = 8,
fetchBatch: Int = 500,
idleTimeoutMs: Long = 120_000L,
onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null,
onEvent: (Event) -> Unit,
): NegentropySyncResult {
var need = 0
var windows = 0
var downloaded = 0
// Pin the relay in the pool's "desired" set for the whole sync. A NEG-OPEN is not
// a REQ, so during a reconcile round (before that window's first download REQ
// exists) the relay would otherwise look unwanted and the pool would disconnect
// it — fatal mid-sync, and frequent when many small windows each have such a gap.
// A never-matching keep-alive subscription holds the connection open without
// delivering anything.
val keepAliveSubId = newSubId()
subscribe(keepAliveSubId, mapOf(relay to listOf(Filter(ids = listOf(KEEP_ALIVE_ID)))), null)
try {
coroutineScope {
// Bounded funnel: every delivered event passes through this one consumer
// (so onEvent + the maxEvents cap run single-threaded) and the bound
// back-pressures the download workers when the consumer can't keep up.
val events = Channel<Event>(DELIVERY_BUFFER)
val producer =
launch {
try {
syncWindow(
relay = relay,
filter = filter,
idleTimeoutMs = idleTimeoutMs,
fetchBatch = fetchBatch,
maxConcurrentReqs = maxConcurrentReqs,
onWindow = { windows++ },
// Only accumulate here; progress is reported from the
// single consumer loop below so the user callback is never
// invoked from two coroutines at once.
onNeed = { need += it },
deliver = { events.send(it) },
)
} finally {
events.close()
}
}
for (event in events) {
downloaded++
onEvent(event)
onProgress?.invoke(need, downloaded)
if (maxEvents in 1..downloaded) break
}
// If we broke out early (cap reached) the producer may still be working —
// stop it. If the producer finished normally this is a no-op.
producer.cancel()
}
} finally {
unsubscribe(keepAliveSubId)
}
return NegentropySyncResult(
needCount = need,
haveCount = 0,
downloaded = downloaded,
windows = windows,
)
}
suspend fun INostrClient.negentropySync(
relay: String,
filter: Filter,
maxEvents: Int = 0,
maxConcurrentReqs: Int = 8,
fetchBatch: Int = 500,
idleTimeoutMs: Long = 120_000L,
onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null,
onEvent: (Event) -> Unit,
): NegentropySyncResult =
negentropySync(
relay = RelayUrlNormalizer.normalize(relay),
filter = filter,
maxEvents = maxEvents,
maxConcurrentReqs = maxConcurrentReqs,
fetchBatch = fetchBatch,
idleTimeoutMs = idleTimeoutMs,
onProgress = onProgress,
onEvent = onEvent,
)
/**
* Result of [negentropySyncOrFetch].
*
* @property downloaded distinct events delivered through `onEvent` (across whichever
* path ran).
* @property pagedFallback `true` if negentropy could not reconcile and the events
* came from [fetchAllPages] instead.
* @property negentropy the negentropy outcome when it succeeded; `null` on fallback.
* @property fallbackCause why negentropy was abandoned; `null` when it succeeded.
*/
class NegentropyOrFetchResult(
val downloaded: Int,
val pagedFallback: Boolean,
val negentropy: NegentropySyncResult?,
val fallbackCause: NegentropySyncException?,
)
/**
* "Try negentropy, else page." Runs [negentropySync] and, if it throws
* [NegentropySyncException] (relay can't reconcile the set — no NIP-77 support, an
* over-cap minimal window, a disconnect, …), transparently falls back to
* [fetchAllPages] over the same [filter].
*
* This is the convenience combinator for the common case where you just want the
* events and don't care which transport delivered them. Events are deduped by id
* across both phases, so anything the negentropy attempt already delivered before
* failing is not delivered again by the paging phase. [maxEvents] is honored across
* both phases.
*
* Use [negentropySync] directly if you want to decide the fallback yourself (try
* another relay, narrow the filter, abort, …) instead of always paging.
*/
suspend fun INostrClient.negentropySyncOrFetch(
relay: NormalizedRelayUrl,
filter: Filter,
maxEvents: Int = 0,
maxConcurrentReqs: Int = 8,
fetchBatch: Int = 500,
idleTimeoutMs: Long = 120_000L,
onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null,
onEvent: (Event) -> Unit,
): NegentropyOrFetchResult {
val seen = HashSet<HexKey>()
var delivered = 0
// Shared dedup + cap across both phases. Returns true if the event was new and
// delivered. Both phases run sequentially, so no concurrent access.
fun accept(event: Event): Boolean {
if ((maxEvents <= 0 || delivered < maxEvents) && seen.add(event.id)) {
delivered++
onEvent(event)
return true
}
return false
}
return try {
val result =
negentropySync(
relay = relay,
filter = filter,
maxEvents = maxEvents,
maxConcurrentReqs = maxConcurrentReqs,
fetchBatch = fetchBatch,
idleTimeoutMs = idleTimeoutMs,
onProgress = onProgress,
) { accept(it) }
NegentropyOrFetchResult(delivered, pagedFallback = false, negentropy = result, fallbackCause = null)
} catch (e: NegentropySyncException) {
// Negentropy couldn't enumerate the set — page the whole filter instead,
// skipping anything the negentropy attempt already delivered. fetchAllPages
// has no "no timeout" mode, so a disabled watchdog maps to a finite page bound.
val pageFilter = if (maxEvents > 0) filter.copy(limit = maxEvents) else filter
val pageTimeoutMs = if (idleTimeoutMs > 0) idleTimeoutMs else DEFAULT_DOWNLOAD_IDLE_MS
fetchAllPages(relay, listOf(pageFilter), pageTimeoutMs) { event ->
if (accept(event)) onProgress?.invoke(delivered, delivered)
}
NegentropyOrFetchResult(delivered, pagedFallback = true, negentropy = null, fallbackCause = e)
}
}
suspend fun INostrClient.negentropySyncOrFetch(
relay: String,
filter: Filter,
maxEvents: Int = 0,
maxConcurrentReqs: Int = 8,
fetchBatch: Int = 500,
idleTimeoutMs: Long = 120_000L,
onProgress: ((needSoFar: Int, downloaded: Int) -> Unit)? = null,
onEvent: (Event) -> Unit,
): NegentropyOrFetchResult =
negentropySyncOrFetch(
relay = RelayUrlNormalizer.normalize(relay),
filter = filter,
maxEvents = maxEvents,
maxConcurrentReqs = maxConcurrentReqs,
fetchBatch = fetchBatch,
idleTimeoutMs = idleTimeoutMs,
onProgress = onProgress,
onEvent = onEvent,
)
/**
* Recursively reconciles [filter] for [relay], splitting by `created_at` windows
* whenever the relay rejects the set as too large, and downloading the ids of each
* window as it resolves. Runs on a single coroutine; [deliver] funnels events out.
*
* Throws [NegentropySyncException] for any window negentropy cannot reconcile (a
* minimal window still over the cap, or an unavailable/erroring relay).
*/
private suspend fun INostrClient.syncWindow(
relay: NormalizedRelayUrl,
filter: Filter,
idleTimeoutMs: Long,
fetchBatch: Int,
maxConcurrentReqs: Int,
onWindow: () -> Unit,
onNeed: (Int) -> Unit,
deliver: suspend (Event) -> Unit,
) {
coroutineContext.ensureActive()
when (val outcome = downloadWindow(relay, filter, idleTimeoutMs, fetchBatch, maxConcurrentReqs, onNeed, deliver)) {
is ReconcileOutcome.Complete -> onWindow()
is ReconcileOutcome.Overflow -> {
val lo = filter.since ?: 0L
val hi = filter.until ?: TimeUtils.now()
if (hi - lo <= MIN_WINDOW_SECONDS) {
// A minimal window that still overflows: negentropy genuinely can't
// enumerate this slice. Surface it — paging is the caller's call.
throw NegentropySyncException(
relay = relay,
window = filter,
reason = NegentropySyncException.Reason.OVER_MAX_SYNC_EVENTS,
detail = "created_at window [$lo, $hi] still exceeds the relay's max_sync_events",
)
} else {
val mid = lo + (hi - lo) / 2
syncWindow(relay, filter.copy(since = lo, until = mid), idleTimeoutMs, fetchBatch, maxConcurrentReqs, onWindow, onNeed, deliver)
syncWindow(relay, filter.copy(since = mid + 1, until = hi), idleTimeoutMs, fetchBatch, maxConcurrentReqs, onWindow, onNeed, deliver)
}
}
is ReconcileOutcome.Failed ->
throw NegentropySyncException(
relay = relay,
window = filter,
reason = NegentropySyncException.Reason.UNAVAILABLE,
detail = outcome.detail,
)
}
}
private sealed interface ReconcileOutcome {
/** Reconciliation completed; every id was streamed to the downloader. */
object Complete : ReconcileOutcome
/** Relay rejected the set as too large (strfry `max_sync_events`). */
object Overflow : ReconcileOutcome
/** Reconciliation could not complete; [detail] says why. */
class Failed(
val detail: String,
) : ReconcileOutcome
}
/**
* Drives one NIP-77 reconciliation of [filter] against an EMPTY local set, sending
* `NEG-OPEN` and walking the rounds itself (rather than via [NegentropyManager]) so
* it can apply back-pressure: each round's `needIds` are handed to [sendBatch] —
* which suspends while the download queue is full — *before* the next round is
* acked, so the relay's id stream is paced to the downloader and never piles up.
*
* The ids are streamed, not returned; the result is only the terminal outcome.
* Always sends `NEG-CLOSE` and removes the listener on the way out.
*/
private suspend fun INostrClient.reconcileStreaming(
relay: NormalizedRelayUrl,
filter: Filter,
idleTimeoutMs: Long,
fetchBatch: Int,
onNeed: (Int) -> Unit,
sendBatch: suspend (List<HexKey>) -> Unit,
): ReconcileOutcome {
val targetUrl = relay
val relayClient = getOrCreateRelay(relay)
val subId = newSubId()
val session = NegentropySession(subId, filter, localEvents = emptyList())
// Reader-thread → driver hand-off. Holds at most one frame: the relay only sends
// the next one once we ack, and we ack only after this round's ids are queued.
val incoming = Channel<NegFrame>(Channel.UNLIMITED)
// Idle watchdog. Bumped on connect and on EVERY message this relay sends —
// including the download REQs' events, since this is a connection-level listener
// that sees all of them — so any progress anywhere in the pipeline pushes the
// reconcile deadline out. Only true silence trips it.
val clock = IdleClock()
val listener =
object : RelayConnectionListener {
override fun onConnected(
relay: IRelayClient,
pingMillis: Int,
compressed: Boolean,
) {
if (relay.url == targetUrl) clock.bump()
}
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
if (relay.url == targetUrl) clock.bump()
when (msg) {
is NegMsgMessage -> if (msg.subId == subId) incoming.trySend(NegFrame.Msg(msg.message))
is NegErrMessage -> if (msg.subId == subId) incoming.trySend(NegFrame.Err(msg.reason))
else -> Unit
}
}
override fun onDisconnected(relay: IRelayClient) {
if (relay.url == targetUrl) incoming.trySend(NegFrame.Err("closed: relay disconnected"))
}
}
addConnectionListener(listener)
try {
// NEG-OPEN is a one-shot command. Unlike a REQ — which the client replays
// from its active-request state every time a relay (re)connects — a dropped
// NEG-OPEN is never resent, so we must connect and wait until the relay is
// ready before sending it. The connect itself keeps a finite bound even when
// the watchdog is disabled, so an unreachable relay can't hang here forever.
relayClient.connect()
val connectBound = if (idleTimeoutMs > 0) idleTimeoutMs else DEFAULT_CONNECT_TIMEOUT_MS
val connected =
withTimeoutOrNull(connectBound) {
connectedRelaysFlow().first { targetUrl in it }
}
if (connected == null) return ReconcileOutcome.Failed("could not connect within ${connectBound}ms")
relayClient.sendIfConnected(session.open())
while (true) {
// Wait for the relay's next frame, giving up only after idleTimeoutMs of
// total silence (the wait resets whenever the relay sends anything —
// another round, or an event on a download REQ). A disconnect arrives as
// an Err frame, so a dead socket ends this promptly regardless.
val frame =
incoming.receiveWithinIdle(clock, idleTimeoutMs)
?: return ReconcileOutcome.Failed(
if (idleTimeoutMs > 0) {
"relay went silent for ${idleTimeoutMs}ms mid-reconcile"
} else {
"connection closed before reconcile completed"
},
)
when (frame) {
is NegFrame.Err ->
return if (isOverflow(frame.reason)) ReconcileOutcome.Overflow else ReconcileOutcome.Failed(frame.reason)
is NegFrame.Msg -> {
val result = session.processMessage(frame.payload)
val needIds = result.needIds
if (needIds.isNotEmpty()) {
onNeed(needIds.size)
var i = 0
while (i < needIds.size) {
val end = min(i + fetchBatch, needIds.size)
// Copy each batch so the frame's full id list can be freed
// as soon as it is chunked; suspends under back-pressure.
sendBatch(ArrayList(needIds.subList(i, end)))
i = end
}
}
val next = result.nextCmd
if (next != null) {
relayClient.sendIfConnected(next)
} else {
return ReconcileOutcome.Complete
}
}
}
}
} finally {
relayClient.sendIfConnected(session.close())
removeConnectionListener(listener)
incoming.close()
}
}
private sealed interface NegFrame {
class Msg(
val payload: String,
) : NegFrame
class Err(
val reason: String,
) : NegFrame
}
/**
* strfry sends `["NEG-ERR", subId, "blocked: too many query results"]` when a
* NEG-OPEN matches more than `relay__negentropy__maxSyncEvents`. Match that
* verbatim, plus a looser contains-check so equivalent wording from other relays
* still triggers the window split rather than aborting.
*/
private fun isOverflow(reason: String): Boolean =
reason == "blocked: too many query results" ||
reason.contains("too many", ignoreCase = true) ||
reason.startsWith("blocked", ignoreCase = true)
/**
* Reconciles [filter] and streams its ids straight into a bounded download pool, so
* reconciliation and download overlap and peak memory stays independent of the
* window's size. At most [maxConcurrentReqs] `REQ`s of [fetchBatch] ids are open at
* once; the id queue is bounded so a slow download back-pressures reconciliation.
* Returns the terminal [ReconcileOutcome]; events go out through [deliver].
*/
private suspend fun INostrClient.downloadWindow(
relay: NormalizedRelayUrl,
filter: Filter,
idleTimeoutMs: Long,
fetchBatch: Int,
maxConcurrentReqs: Int,
onNeed: (Int) -> Unit,
deliver: suspend (Event) -> Unit,
): ReconcileOutcome =
coroutineScope {
val workerCount = maxConcurrentReqs.coerceAtLeast(1)
// Bounded: when full, reconcileStreaming suspends instead of letting the
// relay's id stream accumulate. This is what keeps memory O(pipeline), not
// O(window).
val idBatches = Channel<List<HexKey>>(workerCount)
val workers =
List(workerCount) {
launch {
for (batch in idBatches) {
coroutineContext.ensureActive()
for (event in fetchByIds(relay, batch, idleTimeoutMs)) {
deliver(event)
}
}
}
}
val outcome =
reconcileStreaming(relay, filter, idleTimeoutMs, fetchBatch, onNeed) { batch ->
idBatches.send(batch)
}
idBatches.close()
workers.joinAll()
outcome
}
/**
* One `REQ` for [batch] ids; collects the matching events and returns them on
* `EOSE`/close/timeout. All events for a single relay arrive on its one reader
* thread, so collecting here needs no synchronisation.
*
* Events are deduped *within this batch* (a [HashSet] bounded by the batch size, so
* still O(pipeline) memory). A REQ-by-ids should return each id once, but the client
* may re-send the REQ on a reconnect/filter-sync mid-flight, which makes the relay
* replay the batch; without this the same event would be delivered twice. We rely on
* NIP-77 yielding a distinct id set across batches, so no global dedup is needed.
*/
private suspend fun INostrClient.fetchByIds(
relay: NormalizedRelayUrl,
batch: List<HexKey>,
idleTimeoutMs: Long,
): List<Event> {
val subId = newSubId()
val done = Channel<Unit>(Channel.CONFLATED)
val collected = ArrayList<Event>(batch.size)
val seen = HashSet<HexKey>(batch.size)
// Per-batch idle clock: each event resets it, so a batch that keeps streaming is
// never cut off, but a batch that stalls (relay stops mid-flight) unblocks after
// the idle bound instead of hanging a worker. A download batch always keeps a
// finite bound even when the caller disabled the whole-sync watchdog.
val clock = IdleClock()
val batchIdleMs = if (idleTimeoutMs > 0) idleTimeoutMs else DEFAULT_DOWNLOAD_IDLE_MS
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
clock.bump()
if (seen.add(event.id)) collected.add(event)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
done.trySend(Unit)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
done.trySend(Unit)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
done.trySend(Unit)
}
}
try {
subscribe(subId, mapOf(relay to listOf(Filter(ids = batch))), listener)
done.receiveWithinIdle(clock, batchIdleMs)
} finally {
unsubscribe(subId)
done.close()
}
return collected
}
/** Seconds: a window this small that still overflows can't be split further. */
private const val MIN_WINDOW_SECONDS = 1L
/** Bounded buffer between the download workers and the single delivery consumer. */
private const val DELIVERY_BUFFER = 256
/**
* A 32-byte id that no real event can have (all `f`s), used only to hold a
* never-matching keep-alive subscription that keeps the relay connected for the
* duration of a sync. Synthetic/real event ids are SHA-256 digests, so this never
* collides with an actual event.
*/
private val KEEP_ALIVE_ID = "f".repeat(64)
/**
* Finite fallback bounds (ms) for the two waits that must stay bounded even when the
* whole-sync idle watchdog is disabled (`idleTimeoutMs = 0`): the initial connect,
* and each individual download batch. Keeping these finite means an unreachable relay
* or a single stuck batch can never hang the pipeline, while the reconcile rounds
* still honor "run until the socket drops".
*/
private const val DEFAULT_CONNECT_TIMEOUT_MS = 30_000L
private const val DEFAULT_DOWNLOAD_IDLE_MS = 60_000L
/**
* Monotonic "last activity" marker for the idle watchdog. [bump] on every sign of
* life from the relay; [elapsedMs] reports the silence since the last bump.
*
* [bump] is on the per-event hot path (the connection listener bumps for every
* message the relay sends — millions during a large download), so it must not
* allocate: a single [start] mark is taken once (unboxed field) and each bump only
* writes a `Long` of nanos-since-start into a `@Volatile` field. Reader threads
* write, the driver coroutine reads — visibility is all we need, so a plain volatile
* Long beats boxing a `ValueTimeMark` into an `AtomicReference` on every event.
*/
private class IdleClock {
private val start = TimeSource.Monotonic.markNow()
@Volatile
private var lastNanos = 0L
fun bump() {
lastNanos = start.elapsedNow().inWholeNanoseconds
}
fun elapsedMs(): Long = (start.elapsedNow().inWholeNanoseconds - lastNanos) / 1_000_000
}
/**
* Receives the next item, giving up (returning `null`) only after [idleMs] elapse with
* no activity on [clock]. Because [clock] is bumped by *any* relay message — not just
* items on this channel — unrelated progress (e.g. download events arriving during a
* reconcile wait) keeps pushing the deadline out. [idleMs] `<= 0` disables the
* watchdog: it waits until an item arrives (a disconnect is delivered as an item, so
* a dead socket still unblocks it).
*/
private suspend fun <T> Channel<T>.receiveWithinIdle(
clock: IdleClock,
idleMs: Long,
): T? {
if (idleMs <= 0) return receive()
while (true) {
val remaining = idleMs - clock.elapsedMs()
if (remaining <= 0) return null
val item = withTimeoutOrNull(remaining) { receive() }
if (item != null) return item
// Timed out with nothing on this channel. If other activity bumped the clock
// meanwhile, the next `remaining` is positive and we wait again; otherwise it
// is <= 0 on the next iteration and we give up.
}
}
@@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
/**
* Manages relay subscriptions for the entire pool in a way that only
@@ -43,6 +45,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
* This code also awaits a subscription to come to EOSE since many relays
* have through switching subs while they are processing the past.
*/
@OptIn(ExperimentalAtomicApi::class)
class PoolRequests {
/**
* Desired subs and listeners
@@ -64,6 +67,42 @@ class PoolRequests {
fun subState(subId: String): RequestSubscriptionState<NormalizedRelayUrl> = relayState.getOrCreate(subId) { RequestSubscriptionState() }
/**
* Serializes every access to the subscription state machine
* ([RequestSubscriptionState]) and the "should I send a REQ?" decision.
*
* A single subscription can span many relays, and each relay's
* socket-reader thread delivers messages into this class concurrently while
* the app thread adds/removes subscriptions — so the plain maps inside
* [RequestSubscriptionState] are written from several threads at once. That
* is both a memory hazard (concurrent map mutation) and, more importantly,
* a logic hazard: the check-then-send in [decideCommandLocked] must be
* atomic, otherwise two threads can both observe "no REQ in flight" and both
* send a REQ for the same sub id.
*
* This is a tiny non-reentrant spin lock (the same [AtomicBoolean] primitive
* used by BasicRelayClient's connecting mutex): the critical sections are a
* handful of map operations, never any I/O. Listener callbacks and the
* actual socket sends are ALWAYS performed outside the lock — they re-enter
* this class through [onSent], so holding the lock across them would
* self-deadlock.
*/
private val stateLock = AtomicBoolean(false)
private inline fun <R> withStateLock(block: () -> R): R {
while (stateLock.exchange(true)) {
// Another thread holds the lock. Spin-read until it looks free
// (test-and-test-and-set: cheaper on the cache line than hammering
// exchange) then retry the acquisition above.
while (stateLock.load()) { }
}
try {
return block()
} finally {
stateLock.store(false)
}
}
/**
* This is called when a sub is added or removed from this class and
* should update the desired relay list to get the pool to connect
@@ -150,8 +189,10 @@ class PoolRequests {
*/
fun onConnecting(url: NormalizedRelayUrl) {
// Change states to connecting.
relayState.forEach { subId, state ->
state.connecting(url)
withStateLock {
relayState.forEach { subId, state ->
state.connecting(url)
}
}
}
@@ -164,7 +205,9 @@ class PoolRequests {
) {
when (cmd) {
is ReqCmd -> {
subState(cmd.subId).onOpenReq(relay, cmd.filters)
withStateLock {
subState(cmd.subId).onOpenReq(relay, cmd.filters)
}
desiredSubListeners.get(cmd.subId)?.onSubscriptionStarted(
relay = relay.url,
forFilters = cmd.filters,
@@ -172,7 +215,9 @@ class PoolRequests {
}
is CloseCmd -> {
subState(cmd.subId).onSubscriptionClosed(relay)
withStateLock {
subState(cmd.subId).onSubscriptionClosed(relay)
}
desiredSubListeners.get(cmd.subId)?.onSubscriptionClosed(
relay = relay.url,
)
@@ -189,46 +234,63 @@ class PoolRequests {
) {
when (msg) {
is EventMessage -> {
val state = relayState.get(msg.subId)
state?.onNewEvent(relay.url)
var isLive = false
var forFilters: List<Filter>? = null
withStateLock {
val state = relayState.get(msg.subId)
state?.onNewEvent(relay.url)
isLive = state?.currentState(relay.url) == ReqSubStatus.LIVE
forFilters = state?.lastKnownFilterStates(relay.url)
}
desiredSubListeners.get(msg.subId)?.onEvent(
event = msg.event,
isLive = state?.currentState(relay.url) == ReqSubStatus.LIVE,
isLive = isLive,
relay = relay.url,
forFilters = state?.lastKnownFilterStates(relay.url),
forFilters = forFilters,
)
}
is EoseMessage -> {
val state = relayState.get(msg.subId)
state?.onEose(relay.url)
var forFilters: List<Filter>? = null
val cmd =
withStateLock {
val state = relayState.get(msg.subId)
state?.onEose(relay.url)
forFilters = state?.lastKnownFilterStates(relay.url)
// Decide (and pre-mark) the resend while still holding the
// lock, so a concurrent subscribe/unsubscribe on the app
// thread can't also decide to send a REQ for this sub.
decideCommandLocked(msg.subId, relay.url)
}
desiredSubListeners.get(msg.subId)?.onEose(
relay = relay.url,
forFilters = state?.lastKnownFilterStates(relay.url),
forFilters = forFilters,
)
// send a newer version when done
sendToRelayIfChanged(msg.subId, relay.url) { cmd ->
if (cmd != null) {
relay.sendOrConnectAndSync(cmd)
}
}
is ClosedMessage -> {
val state = relayState.get(msg.subId)
state?.onClosed(relay.url)
var forFilters: List<Filter>? = null
val cmd =
withStateLock {
val state = relayState.get(msg.subId)
state?.onClosed(relay.url)
forFilters = state?.lastKnownFilterStates(relay.url)
decideCommandLocked(msg.subId, relay.url)
}
desiredSubListeners.get(msg.subId)?.onClosed(
message = msg.message,
relay = relay.url,
forFilters = state?.lastKnownFilterStates(relay.url),
forFilters = forFilters,
)
// send a newer version when done
sendToRelayIfChanged(msg.subId, relay.url) { cmd ->
// don't send a close if just closed
if (cmd !is CloseCmd) {
relay.sendOrConnectAndSync(cmd)
}
// send a newer version when done, but don't send a close if just closed
if (cmd != null && cmd !is CloseCmd) {
relay.sendOrConnectAndSync(cmd)
}
}
}
@@ -238,8 +300,10 @@ class PoolRequests {
* When the relay disconnects
*/
fun onDisconnected(url: NormalizedRelayUrl) {
relayState.forEach { subId, state ->
state.disconnected(url)
withStateLock {
relayState.forEach { subId, state ->
state.disconnected(url)
}
}
}
@@ -262,16 +326,27 @@ class PoolRequests {
url: NormalizedRelayUrl,
errorMessage: String,
) {
relayState.forEach { subId, state ->
// These are all my subs.. need to figure out which relays have them
val subs = desiredSubs.get(subId)
if (subs != null && url in subs.keys) {
desiredSubListeners.get(subId)?.onCannotConnect(
relay = url,
message = errorMessage,
forFilters = state.lastKnownFilterStates(url),
)
// Snapshot the affected subs (and their last-known filters) under the
// lock, then notify listeners outside it.
val toNotify =
withStateLock {
val list = mutableListOf<Pair<String, List<Filter>?>>()
relayState.forEach { subId, state ->
// These are all my subs.. need to figure out which relays have them
val subs = desiredSubs.get(subId)
if (subs != null && url in subs.keys) {
list.add(subId to state.lastKnownFilterStates(url))
}
}
list
}
toNotify.forEach { (subId, forFilters) ->
desiredSubListeners.get(subId)?.onCannotConnect(
relay = url,
message = errorMessage,
forFilters = forFilters,
)
}
}
@@ -281,54 +356,65 @@ class PoolRequests {
sync: (NormalizedRelayUrl, Command) -> Unit,
) {
relaysToUpdate.forEach { relay ->
sendToRelayIfChanged(subId, relay) { cmd ->
if (cmd is ReqCmd) {
val currentState = relayState.get(subId)?.currentState(relay)
if (currentState == ReqSubStatus.SENT || currentState == ReqSubStatus.QUERYING_PAST) {
// sending multiple REQs triggers multiple EOSEs back and we then don't know which
// one is which.
} else {
sync(relay, cmd)
}
} else {
sync(relay, cmd)
}
// Decide + pre-mark atomically under the lock, then send outside it.
val cmd = withStateLock { decideCommandLocked(subId, relay) }
if (cmd != null) {
sync(relay, cmd)
}
}
}
fun sendToRelayIfChanged(
/**
* Decides which command (if any) must be sent to [relay] to bring it in line
* with the desired filters for [subId], and — for a REQ — pre-marks the
* subscription state as SENT before returning.
*
* Pre-marking is what makes the check-then-send atomic: a second thread that
* runs this method for the same sub sees the SENT state (or the
* already-updated filters) and declines to send a duplicate REQ. Two REQs on
* one sub id race on the wire and produce duplicate EOSEs/events (or, if a
* CLOSE interleaves, an empty result that silently truncates a paged
* download) — that is the bug this guards against.
*
* MUST be called while holding [withStateLock].
*/
private fun decideCommandLocked(
subId: String,
relay: NormalizedRelayUrl,
sync: (Command) -> Unit,
) {
): Command? {
val state = relayState.get(subId)
val oldFilters = state?.currentFilters(relay)
val newFilters = desiredSubs.get(subId)?.get(relay)
sendToRelayIfChanged(subId, oldFilters, newFilters, sync)
}
fun sendToRelayIfChanged(
subId: String,
oldFilters: List<Filter>?,
newFilters: List<Filter>?,
sync: (Command) -> Unit,
) {
if (newFilters.isNullOrEmpty()) {
// some relays are not in this sub anymore. Stop their subscriptions
if (!oldFilters.isNullOrEmpty()) {
// only update if the old filters are not already closed.
sync(CloseCmd(subId))
return when {
newFilters.isNullOrEmpty() -> {
// some relays are not in this sub anymore. Stop their subscriptions
// only if the old filters are not already closed.
if (!oldFilters.isNullOrEmpty()) CloseCmd(subId) else null
}
oldFilters.isNullOrEmpty() || FiltersChanged.needsToResendRequest(oldFilters, newFilters) -> {
// A REQ is warranted: a brand new sub, or the filters changed
// enough (not just a `since` bump) to need a resend. But if a REQ
// is already in flight, don't send another — multiple REQs on one
// sub id trigger multiple EOSEs and we can no longer tell which
// reply belongs to which REQ. The pending change is picked up
// later by the EOSE handler, which runs this method again once the
// sub reaches LIVE.
val current = state?.currentState(relay)
if (current == ReqSubStatus.SENT || current == ReqSubStatus.QUERYING_PAST) {
null
} else {
// Pre-mark SENT + filters so a concurrent decider skips.
subState(subId).onOpenReq(relay, newFilters)
ReqCmd(subId, newFilters)
}
}
else -> {
// Filters are effectively the same; nothing to do.
null
}
} else if (oldFilters.isNullOrEmpty()) {
// new relays were added. Start a new sub in them
sync(ReqCmd(subId, newFilters))
} else if (FiltersChanged.needsToResendRequest(oldFilters, newFilters)) {
// filters were changed enough (not only an update in since) to warn a new update
sync(ReqCmd(subId, newFilters))
} else {
// They are the same don't do anything.
}
}
@@ -43,8 +43,21 @@ package com.vitorpamplona.quartz.nip19Bech32.bech32
private typealias Int5 = Byte
/**
* Bech32 and Bech32m address formats. See
* https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki and
* Low-level Bech32 / Bech32m codec (BIP-173 / BIP-350).
*
* For the common Nostr entities (`npub`, `nsec`, `note`, `nevent`, `nprofile`,
* `naddr`) you usually **don't** need this directly — prefer the NIP-19 helpers:
* the `ByteArray.toNpub()/toNsec()/toNote()` extensions to encode and
* `Nip19Parser.uriToRoute(...)` to decode. Reach for `Bech32` when you need a
* custom human-readable prefix or raw 5-bit/8-bit access:
*
* ```kotlin
* val addr = Bech32.encodeBytes("npub", pubKeyBytes, Bech32.Encoding.Bech32)
* val (hrp, data, _) = Bech32.decodeBytes(addr) // hrp = "npub", data = 32 bytes
* val bytes = "npub1...".bechToBytes("npub") // decode + assert the prefix
* ```
*
* See https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki and
* https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki.
*/
object Bech32 {
@@ -284,6 +297,11 @@ object Bech32 {
}
}
/**
* Decodes this Bech32 string to its raw data bytes. If [hrp] is given, throws
* [IllegalArgumentException] when the decoded human-readable prefix doesn't match
* (e.g. pass `"npub"` to reject anything that isn't a public key).
*/
fun String.bechToBytes(hrp: String? = null): ByteArray {
val decodedForm = Bech32.decodeBytes(this)
hrp?.also {
@@ -20,6 +20,24 @@
*/
package com.vitorpamplona.quartz.utils
/**
* Fast, allocation-conscious hex codec used throughout Quartz for keys, event
* ids and signatures. Backed by pre-computed lookup tables and benchmarked
* against the secp256k1 codec and Kotlin's stdlib `HexFormat` (see
* `benchmark/.../HexBenchmark.kt`).
*
* Most call sites should prefer the extension functions in
* [com.vitorpamplona.quartz.nip01Core.core] — `ByteArray.toHexKey()` and
* `HexKey.hexToByteArray()` — which delegate here. Reach for this object
* directly when you want to validate without decoding ([isHex] / [isHex64]) or
* compare a hex string to raw bytes without allocating ([isEqual]).
*
* ```kotlin
* val hex = Hex.encode(bytes) // ByteArray -> lower-case hex
* val bytes = Hex.decode(hex) // hex (any case) -> ByteArray
* if (Hex.isHex64(id)) { ... } // is this a valid 32-byte hex id?
* ```
*/
object Hex {
private const val LOWER_CASE_HEX = "0123456789abcdef"
private const val UPPER_CASE_HEX = "0123456789ABCDEF"
@@ -36,7 +54,12 @@ object Hex {
(LOWER_CASE_HEX[(it shr 4)].code shl 8) or LOWER_CASE_HEX[(it and 0xF)].code
}
// 47ns in debug on the Emulator
/**
* True when [hex] is a non-null, even-length string of only hex digits
* (upper or lower case). Rejects odd lengths and stray non-hex chars (e.g.
* emoji in `p` tags) instead of throwing. ~47ns in debug on the Emulator;
* use [isHex64] when the length is known to be 64.
*/
fun isHex(hex: String?): Boolean {
if (hex == null) return false
if (hex.length and 1 != 0) return false
@@ -63,7 +86,12 @@ object Hex {
return true
}
// 30% faster than isHex
/**
* Validates the first 64 chars of [hex] as hex digits — the fast path for
* checking a 32-byte pubkey or event id. ~30% faster than [isHex] because
* the length is fixed and the checks are unrolled. Assumes [hex] is at least
* 64 chars long; it does not verify the total length.
*/
fun isHex64(hex: String): Boolean =
try {
hexToByte[hex[0].code] >= 0 &&
@@ -144,6 +172,12 @@ object Hex {
false
}
/**
* Decodes [hex] (upper or lower case) into bytes. Requires an even length —
* throws [IllegalArgumentException] otherwise. Does not itself validate the
* characters, so guard untrusted input with [isHex] first (or use
* `HexKey.hexToByteArrayOrNull()`).
*/
fun decode(hex: String): ByteArray {
// faster version of hex decoder
require(hex.length and 1 == 0) {
@@ -154,6 +188,7 @@ object Hex {
}
}
/** Encodes [input] as a lower-case hex string (two chars per byte). */
fun encode(input: ByteArray): String {
val out = CharArray(input.size * 2)
var outIdx = 0
@@ -165,6 +200,12 @@ object Hex {
return out.concatToString()
}
/**
* True when the hex string [id] encodes exactly the bytes [ourId], compared
* without allocating a decode buffer. Handy for matching an incoming hex id
* against bytes you already hold. Assumes [id] is at least `2 * ourId.size`
* chars and lower-case (as produced by [encode]).
*/
fun isEqual(
id: String,
ourId: ByteArray,
@@ -20,22 +20,41 @@
*/
package com.vitorpamplona.quartz.utils
/**
* Cryptographically secure random source shared across Quartz, backed by the
* platform [SecureRandom]. **Use this — not `kotlin.random.Random`** — for
* anything security-sensitive: NIP-44 nonces, private keys, gift-wrap timestamps,
* random `d` tags, subscription ids.
*
* ```kotlin
* val nonce = RandomInstance.bytes(32) // 32 secure random bytes
* val dTag = RandomInstance.bytes(16).toHexKey()
* val subId = RandomInstance.randomChars() // 16-char [a-zA-Z0-9] id
* ```
*/
object RandomInstance {
val randomizer = SecureRandom()
/** A secure random [Int] across the full 32-bit range (may be negative). */
fun int() = randomizer.nextInt()
/** A secure random [Long] across the full 64-bit range (may be negative). */
fun long() = randomizer.nextLong()
/** A secure random [Int] in `0 until bound`. */
fun int(bound: Int) = randomizer.nextInt(bound)
/** A secure random [Long] in `0 until bound`. */
fun long(bound: Long) = randomizer.nextLong(bound)
/** [size] secure random bytes — the go-to for nonces, keys and salts. */
fun bytes(size: Int) = ByteArray(size).also { randomizer.nextBytes(it) }
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
/** A single secure random alphanumeric char from [charPool]. */
fun randomChar() = charPool[randomizer.nextInt(charPool.size)]
/** A secure random alphanumeric string of [size] chars — handy for subscription ids. */
fun randomChars(size: Int = 16) = CharArray(size) { randomChar() }.concatToString()
}
@@ -32,6 +32,13 @@ fun Int.bytesUsedInMemory(): Int = 4
fun Boolean.bytesUsedInMemory(): Int = 8
/**
* Case-insensitive [contains] that does **not** allocate a lowercased copy of the
* receiver — it scans in place comparing each char against both cases of [term].
* Preferred over `this.lowercase().contains(term.lowercase())` on hot paths (feed
* search, tag matching). When testing the same term against many strings, precompute
* a [DualCase] and use the two-arg overload / [containsAny] to hoist the casing work.
*/
fun String.containsIgnoreCase(term: String): Boolean {
if (term.isEmpty()) return true // Empty string is contained
@@ -41,6 +48,7 @@ fun String.containsIgnoreCase(term: String): Boolean {
return containsIgnoreCase(whatLowercase, whatUppercase)
}
/** [containsIgnoreCase] variant taking the term's cases precomputed — see [DualCase]. */
fun String.containsIgnoreCase(
whatLowercase: String,
whatUppercase: String,
@@ -68,6 +76,7 @@ fun String.containsIgnoreCase(
return false
}
/** Case-insensitive [startsWith], allocation-free, taking the prefix's cases precomputed. */
fun String.startsWithIgnoreCase(
whatLowercase: String,
whatUppercase: String,
@@ -89,6 +98,7 @@ fun String.startsWithIgnoreCase(
return true
}
/** True when this contains any of [terms] (case-insensitive). Empty list ⇒ true. */
fun String.containsAny(terms: List<DualCase>): Boolean {
if (terms.isEmpty()) return true // Empty string is contained
@@ -99,6 +109,7 @@ fun String.containsAny(terms: List<DualCase>): Boolean {
return terms.any { containsIgnoreCase(it.lowercase, it.uppercase) }
}
/** True when this starts with any of [terms] (case-insensitive). Empty list ⇒ true. */
fun String.startsWithAny(terms: List<DualCase>): Boolean {
if (terms.isEmpty()) return true // Empty string is contained
@@ -111,6 +122,12 @@ fun String.startsWithAny(terms: List<DualCase>): Boolean {
fun String.startsWith(prefix: DualCase): Boolean = startsWithIgnoreCase(prefix.lowercase, prefix.uppercase)
/**
* A search term with its lower- and upper-case forms computed once, so the
* allocation-free case-insensitive matchers ([containsIgnoreCase], [startsWithIgnoreCase],
* [containsAny], [startsWithAny]) can be run against many strings without re-casing
* the term each time. Build once, reuse across a feed scan.
*/
class DualCase(
val lowercase: String,
val uppercase: String,
@@ -20,6 +20,25 @@
*/
package com.vitorpamplona.quartz.utils
/**
* Clock and duration helpers for Nostr timestamps.
*
* **Everything here is in Unix _seconds_, not milliseconds** — that is the unit
* Nostr uses for an event's `created_at` and for filter `since`/`until` bounds.
* Use [now] to stamp an event and the `...Ago` / `...FromNow` helpers to build
* relative filter windows; do **not** hand-roll `currentTimeMillis() / 1000`.
* The `const val` durations ([ONE_MINUTE], [ONE_HOUR], [ONE_DAY], …) are also in
* seconds and can be added/subtracted directly.
*
* ```kotlin
* val createdAt = TimeUtils.now() // seconds, for created_at
* val since = TimeUtils.oneDayAgo() // filter: last 24h
* val fresh = TimeUtils.withinTenMinutes(event.createdAt)
* ```
*
* [nowMillis] is the one exception: it returns milliseconds, for the rare
* non-protocol case (UI timers, latency measurements) that needs finer detail.
*/
object TimeUtils {
const val TEN_SECONDS = 10
const val ONE_MINUTE = 60
@@ -34,8 +53,10 @@ object TimeUtils {
const val ONE_MONTH = 30 * ONE_DAY
const val ONE_YEAR = 365 * ONE_DAY
/** Current time in Unix **seconds** — the value for an event's `created_at`. */
fun now() = currentTimeSeconds()
/** Current time in Unix **milliseconds**. For non-protocol use only; `created_at` wants [now]. */
fun nowMillis() = currentTimeMillis()
fun tenSecondsFromNow() = now() + TEN_SECONDS
@@ -74,6 +95,7 @@ object TimeUtils {
fun oneYearAgo() = now() - ONE_YEAR
/** True when [time] (Unix seconds) is within ±10 minutes of [now] — e.g. a fresh NIP-98/NIP-42 stamp. */
fun withinTenMinutes(time: Long): Boolean {
val now = now()
return time > now - TEN_MINUTES && time < now + TEN_MINUTES
@@ -20,6 +20,15 @@
*/
package com.vitorpamplona.quartz.utils.sha256
/**
* SHA-256 of [data], returning a fresh 32-byte digest.
*
* This is the raw primitive. To compute or verify a Nostr **event id** don't hash
* by hand — use `EventHasher.hashId(...)` / `hashIdCheck(...)`, which serialize the
* `[0, pubkey, created_at, kind, tags, content]` array in the canonical form the
* protocol requires before hashing. Use [sha256Into] on hot paths to avoid
* allocating a new array per call.
*/
expect fun sha256(data: ByteArray): ByteArray
/**
@@ -35,7 +35,7 @@ class LargeDBTests {
companion object {
fun getEventDB(): List<Event> =
OptimizedJsonMapper.fromJsonToEventList(
TestResourceLoader().loadDecompressString("nostr_vitor_startup_data.json"),
TestResourceLoader().loadDecompressString("nostr_vitor_startup_data.json.gz"),
)
val events by
@@ -0,0 +1,309 @@
/*
* 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.quartz.nip01Core.relay
import com.vitorpamplona.geode.InProcessRelays
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.geode.testing.preload
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropySyncException
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySync
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySyncEvents
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySyncOrFetch
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip77Negentropy.NegentropySettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NostrClientNegentropySyncTest : RelayClientTest() {
@Test
fun fullDownloadDeliversEveryEvent() =
runBlocking {
defaultRelay.preload(SyntheticEvents.batch(20, kind = 1))
val got = mutableListOf<Event>()
val result =
withTimeout(20_000) {
client.negentropySync(
relay = defaultRelayUrl,
filter = Filter(kinds = listOf(1)),
) { got.add(it) }
}
assertEquals(20, got.size, "every event should be delivered")
assertEquals(20, got.map { it.id }.toSet().size, "no duplicates")
assertEquals(20, result.needCount)
assertEquals(0, result.haveCount)
assertEquals(20, result.downloaded)
assertEquals(1, result.windows, "small set reconciles in a single window")
}
@Test
fun maxEventsCapsDelivery() =
runBlocking {
defaultRelay.preload(SyntheticEvents.batch(20, kind = 1))
val got = mutableListOf<Event>()
val result =
withTimeout(20_000) {
client.negentropySync(
relay = defaultRelayUrl,
filter = Filter(kinds = listOf(1)),
maxEvents = 10,
fetchBatch = 5,
) { got.add(it) }
}
assertEquals(10, got.size, "delivery stops at maxEvents")
assertEquals(10, result.downloaded)
}
@Test
fun cleanTeardownLeavesNoSubscriptions() =
runBlocking {
defaultRelay.preload(SyntheticEvents.batch(15, kind = 1))
withTimeout(20_000) {
client.negentropySync(
relay = defaultRelayUrl,
filter = Filter(kinds = listOf(1)),
fetchBatch = 4,
) { }
}
assertTrue(
client.activeRequests(defaultRelayUrl).isEmpty(),
"all download subscriptions must be closed after the sync",
)
}
@Test
fun flowVariantStreamsEachEvent() =
runBlocking {
defaultRelay.preload(SyntheticEvents.batch(12, kind = 1))
val events =
withTimeout(20_000) {
client
.negentropySyncEvents(
relay = defaultRelayUrl,
filter = Filter(kinds = listOf(1)),
).toList()
}
assertEquals(12, events.size)
assertEquals(12, events.map { it.id }.toSet().size)
}
/**
* Forces the relay to split its NEG-MSG responses into many small frames
* (`frameSizeLimit` at the library floor) so reconciliation spans many rounds,
* and downloads through a small, bounded pipeline (`fetchBatch`/`maxConcurrentReqs`).
* Exercises the streaming + back-pressure path end to end: every event must still
* be delivered exactly once with nothing accumulated.
*/
@Test
fun multiRoundReconcileStreamsEveryEventThrough() =
runBlocking {
val hub = InProcessRelays(negentropySettings = NegentropySettings(frameSizeLimit = 4096))
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(hub, scope)
try {
val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7784/")
hub.getOrCreate(url).preload(SyntheticEvents.batch(1500, kind = 1))
val got = mutableListOf<Event>()
val result =
withTimeout(60_000) {
client.negentropySync(
relay = url,
filter = Filter(kinds = listOf(1)),
fetchBatch = 50,
maxConcurrentReqs = 4,
) { got.add(it) }
}
assertEquals(1500, got.size, "every event delivered across many reconcile rounds")
assertEquals(1500, got.map { it.id }.toSet().size, "each exactly once")
assertEquals(1500, result.downloaded)
assertEquals(1500, result.needCount)
} finally {
client.disconnect()
scope.cancel()
hub.close()
}
}
/**
* A relay that caps negentropy below the matched-set size (strfry's
* `max_sync_events`) but whose events are spread across distinct `created_at`
* values. Windowing alone resolves the cap — each window ends up under it — so
* the sync completes purely via negentropy, no exception, no paging.
*/
@Test
fun overCapWithSpreadTimestampsSucceedsViaWindowing() =
runBlocking {
val hub = InProcessRelays(negentropySettings = NegentropySettings(maxSyncEvents = 3))
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(hub, scope)
try {
val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7781/")
// 12 events at distinct created_at: windowing can split until each
// window holds <= the cap.
hub.getOrCreate(url).preload(SyntheticEvents.batch(12, kind = 1))
val got = mutableListOf<Event>()
val result =
withTimeout(60_000) {
client.negentropySync(
relay = url,
filter = Filter(kinds = listOf(1)),
) { got.add(it) }
}
assertEquals(12, got.map { it.id }.toSet().size, "all events reconciled via windowing")
assertEquals(12, result.downloaded)
assertTrue(result.windows > 1, "the set must be split into multiple created_at windows")
} finally {
client.disconnect()
scope.cancel()
hub.close()
}
}
/**
* A relay that caps negentropy below the matched-set size AND whose events all
* share one `created_at`, so no `created_at` window can separate them. Even the
* minimal window stays over the cap, so [negentropySync] cannot reconcile it and
* throws [NegentropySyncException] (reason OVER_MAX_SYNC_EVENTS) rather than
* silently paging — the fallback is the caller's call.
*/
@Test
fun overCapMinimalWindowThrowsInsteadOfPaging() =
runBlocking {
val hub = InProcessRelays(negentropySettings = NegentropySettings(maxSyncEvents = 3))
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(hub, scope)
try {
val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7782/")
val events = (1..10).map { SyntheticEvents.fakeEvent(idSeed = it, kind = 1, createdAt = 1000L) }
hub.getOrCreate(url).preload(events)
val thrown =
assertFailsWith<NegentropySyncException> {
withTimeout(60_000) {
client.negentropySync(
relay = url,
filter = Filter(kinds = listOf(1)),
) { }
}
}
assertEquals(NegentropySyncException.Reason.OVER_MAX_SYNC_EVENTS, thrown.reason)
// And the caller can recover by paging it themselves.
val paged = mutableListOf<Event>()
withTimeout(60_000) {
client.fetchAllPages(url, listOf(Filter(kinds = listOf(1)))) { paged.add(it) }
}
assertEquals(10, paged.map { it.id }.toSet().size)
} finally {
client.disconnect()
scope.cancel()
hub.close()
}
}
/**
* The "try negentropy, else page" combinator: against the same over-cap relay
* where raw [negentropySync] throws, [negentropySyncOrFetch] transparently pages
* and delivers every event, reporting that it fell back.
*/
@Test
fun orFetchPagesWhenNegentropyCannotReconcile() =
runBlocking {
val hub = InProcessRelays(negentropySettings = NegentropySettings(maxSyncEvents = 3))
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val client = NostrClient(hub, scope)
try {
val url = RelayUrlNormalizer.normalize("ws://127.0.0.1:7783/")
val events = (1..10).map { SyntheticEvents.fakeEvent(idSeed = it, kind = 1, createdAt = 1000L) }
hub.getOrCreate(url).preload(events)
val got = mutableListOf<Event>()
val result =
withTimeout(60_000) {
client.negentropySyncOrFetch(
relay = url,
filter = Filter(kinds = listOf(1)),
) { got.add(it) }
}
assertEquals(10, got.map { it.id }.toSet().size, "all events delivered via the paging fallback")
assertEquals(10, result.downloaded)
assertTrue(result.pagedFallback, "it should have fallen back to paging")
assertEquals(
NegentropySyncException.Reason.OVER_MAX_SYNC_EVENTS,
result.fallbackCause?.reason,
)
} finally {
client.disconnect()
scope.cancel()
hub.close()
}
}
/**
* On a relay that reconciles fine, [negentropySyncOrFetch] uses negentropy and
* does not page.
*/
@Test
fun orFetchUsesNegentropyWhenItWorks() =
runBlocking {
defaultRelay.preload(SyntheticEvents.batch(8, kind = 1))
val got = mutableListOf<Event>()
val result =
withTimeout(20_000) {
client.negentropySyncOrFetch(
relay = defaultRelayUrl,
filter = Filter(kinds = listOf(1)),
) { got.add(it) }
}
assertEquals(8, got.size)
assertFalse(result.pagedFallback, "negentropy should have handled it")
assertEquals(8, result.negentropy?.downloaded)
}
}
@@ -0,0 +1,146 @@
/*
* 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.quartz.nip01Core.relay
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.PoolRequests
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicInteger
import kotlin.concurrent.thread
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Regression guard for the shared-sub-id double-REQ race in [PoolRequests].
*
* A single subscription id is driven from two threads at once: the app thread
* (the subscribe path, [PoolRequests.sendToRelayIfChanged]) and the relay reader
* thread (an EOSE that triggers an auto-resend, [PoolRequests.onIncomingMessage]).
* The subscription is already LIVE and its desired filters have just changed, so
* both threads independently conclude "the filters changed, send a REQ".
*
* The bug: the decision (read state) and the send (mark state SENT via onSent)
* were not atomic, so the reader could read the pre-send state (filters still on
* the previous value) while the app had already moved the desired filters
* forward — and both would send a REQ for the same sub id. Two REQs on one id
* race on the wire: the relay answers with two EOSEs and duplicate events, or —
* if a CLOSE interleaves — an empty result that silently truncates a paged
* download (this is what broke `fetchAllPages` on large sets).
*
* The fix makes the "should I send a REQ?" decision pre-mark the state
* atomically, so exactly one REQ is ever produced. This test pins the exact
* interleaving the bug needs (app has produced its REQ but not yet run onSent)
* open and asserts only one REQ comes out.
*/
class PoolRequestsConcurrencyTest {
private class FakeRelay(
override val url: NormalizedRelayUrl,
val onCmd: (Command) -> Unit,
) : IRelayClient {
override fun connect() {}
override fun needsToReconnect() = false
override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) {}
override fun isConnected() = true
override fun sendOrConnectAndSync(cmd: Command) = onCmd(cmd)
override fun sendIfConnected(cmd: Command) = onCmd(cmd)
override fun disconnect() {}
}
@Test
fun concurrentEoseResendAndSubscribeSendExactlyOneReq() {
val url = RelayUrlNormalizer.normalize("ws://race/")
val subId = "shared-sub"
val filtersA = listOf(Filter(kinds = listOf(1)))
val filtersB = listOf(Filter(kinds = listOf(2)))
val listener = object : SubscriptionListener {}
// Many episodes so a regression that only sometimes doubles still trips.
repeat(300) { episode ->
val pool = PoolRequests()
val reqBCount = AtomicInteger(0)
fun countReqB(cmd: Command) {
if (cmd is ReqCmd && cmd.filters == filtersB) reqBCount.incrementAndGet()
}
val fakeRelay =
FakeRelay(url) { cmd ->
// relay-reader auto-resend send path
countReqB(cmd)
pool.onSent(url, cmd)
}
// Bring the sub to LIVE with filters A.
val setupRelays = pool.addOrUpdate(subId, mapOf(url to filtersA), listener)
pool.sendToRelayIfChanged(subId, setupRelays) { _, cmd -> pool.onSent(url, cmd) }
pool.onIncomingMessage(fakeRelay, EoseMessage(subId))
// The desired filters change to B (e.g. the next page of a paged download).
pool.addOrUpdate(subId, mapOf(url to filtersB), listener)
val appProducedReq = CountDownLatch(1)
val readerDone = CountDownLatch(1)
val appThread =
thread {
pool.sendToRelayIfChanged(subId, setOf(url)) { _, cmd ->
countReqB(cmd)
// App has produced its REQ(B); park before onSent so the
// subscription state is not yet advanced — the exact window
// the race needs.
appProducedReq.countDown()
readerDone.await()
pool.onSent(url, cmd)
}
}
val readerThread =
thread {
appProducedReq.await()
pool.onIncomingMessage(fakeRelay, EoseMessage(subId))
readerDone.countDown()
}
appThread.join()
readerThread.join()
assertEquals(
1,
reqBCount.get(),
"episode $episode: exactly one REQ must be sent for the changed filters, " +
"never a duplicate from the app + reader race",
)
}
}
}