mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 19:53:08 +00:00
Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd
This commit is contained in:
@@ -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`);
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+65
@@ -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,
|
||||
)
|
||||
}
|
||||
+3
-1
@@ -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 -> {
|
||||
|
||||
+66
@@ -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:"
|
||||
}
|
||||
|
||||
+6
@@ -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)
|
||||
},
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
+4
-22
@@ -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
|
||||
|
||||
+3
-7
@@ -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()
|
||||
|
||||
-21
@@ -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>,
|
||||
|
||||
+8
-16
@@ -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)
|
||||
|
||||
-22
@@ -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,
|
||||
|
||||
+3
-7
@@ -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()
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
+9
-3
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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()
|
||||
|
||||
+5
-24
@@ -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(
|
||||
|
||||
+3
-8
@@ -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())
|
||||
|
||||
-55
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+3
-23
@@ -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(
|
||||
|
||||
+3
-23
@@ -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(
|
||||
|
||||
+2
-8
@@ -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.
|
||||
|
||||
+2
-8
@@ -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.
|
||||
|
||||
-54
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+4
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-7
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
-57
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+4
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-7
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
-57
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+87
@@ -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))
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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))
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.moderation
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
|
||||
/**
|
||||
* Returns the event whose body a renderer would actually display.
|
||||
*
|
||||
* For kind 6 / kind 16 reposts that is the *wrapped* inner event — which
|
||||
* the consume pipeline already resolves onto [Note.replyTo]. Falls back to
|
||||
* the slower [RepostEvent.containedPost] / [GenericRepostEvent.containedPost]
|
||||
* JSON decode only when the cache hasn't materialised the reply yet; both
|
||||
* already null-return on parse failure.
|
||||
*
|
||||
* For non-repost events, the displayed event is the note's own event.
|
||||
*
|
||||
* Returns null when there is no event at all (placeholder not yet
|
||||
* hydrated).
|
||||
*/
|
||||
fun Note.displayedEvent(): Event? {
|
||||
val ev = this.event ?: return null
|
||||
return when (ev) {
|
||||
is RepostEvent -> replyTo?.lastOrNull()?.event ?: ev.containedPost()
|
||||
is GenericRepostEvent -> replyTo?.lastOrNull()?.event ?: ev.containedPost()
|
||||
else -> ev
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.moderation
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
|
||||
/**
|
||||
* Pure decision: should this note be collapsed because it abuses hashtag `t` tags?
|
||||
*
|
||||
* Reuses [hasMoreHashtagsThan] which short-circuits on total count before
|
||||
* counting unique tags, so a note that repeats the same hashtag does not
|
||||
* trip the filter.
|
||||
*
|
||||
* Long-form articles (kind 30023) legitimately use many topic tags and are
|
||||
* always exempt. Authors whose pubkey appears in [exemptKeys] (the user's
|
||||
* follow list plus self) are also exempt — the caller assembles that set.
|
||||
*/
|
||||
object HashtagSpamCheck {
|
||||
fun isHashtagSpam(
|
||||
displayedEvent: Event?,
|
||||
authorPubkey: HexKey?,
|
||||
enabled: Boolean,
|
||||
threshold: Int,
|
||||
exemptKeys: Set<HexKey>,
|
||||
): Boolean {
|
||||
if (!enabled) return false
|
||||
if (displayedEvent == null) return false
|
||||
if (displayedEvent.kind == LongTextNoteEvent.KIND) return false
|
||||
if (authorPubkey != null && authorPubkey in exemptKeys) return false
|
||||
return displayedEvent.tags.hasMoreHashtagsThan(threshold)
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.moderation
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* User-tunable settings for the hashtag-spam content filter.
|
||||
*
|
||||
* Implementations are platform-specific (Desktop uses java.util.prefs;
|
||||
* Android can use DataStore). Both observables emit on change so Compose
|
||||
* reads via [collectAsState] re-render automatically.
|
||||
*/
|
||||
@Stable
|
||||
interface HashtagSpamSettings {
|
||||
val enabled: StateFlow<Boolean>
|
||||
val threshold: StateFlow<Int>
|
||||
|
||||
fun setEnabled(enabled: Boolean)
|
||||
|
||||
fun setThreshold(threshold: Int)
|
||||
|
||||
companion object {
|
||||
const val MIN_THRESHOLD = 1
|
||||
const val MAX_THRESHOLD = 20
|
||||
const val DEFAULT_THRESHOLD = 5
|
||||
const val DEFAULT_ENABLED = true
|
||||
}
|
||||
}
|
||||
+19
-27
@@ -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() }
|
||||
+6
-8
@@ -30,8 +30,12 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
|
||||
/**
|
||||
* Parses search input and returns matching results.
|
||||
* Supports: npub, nprofile, nsec (extracts pubkey), note, nevent, naddr, hex keys, hashtags
|
||||
* Parses search input for direct-lookup entries: npub, nprofile, nsec
|
||||
* (extracts pubkey), note, nevent, naddr, and 64-char hex keys.
|
||||
*
|
||||
* Hashtags are intentionally NOT returned here — they are handled by
|
||||
* [QueryParser], which extracts `#xxx` tokens into the query's hashtag
|
||||
* filter so the normal results pipeline drives a tag-filtered search.
|
||||
*
|
||||
* Shared between Android and Desktop for consistent Bech32 parsing.
|
||||
*/
|
||||
@@ -41,12 +45,6 @@ fun parseSearchInput(input: String): List<SearchResult> {
|
||||
val trimmed = input.trim()
|
||||
val results = mutableListOf<SearchResult>()
|
||||
|
||||
// Check for hashtag
|
||||
if (trimmed.startsWith("#") && trimmed.length > 1) {
|
||||
results.add(SearchResult.HashtagResult(trimmed.substring(1)))
|
||||
return results
|
||||
}
|
||||
|
||||
// Try to parse as Bech32 (npub, nevent, naddr, etc.)
|
||||
val parsed = Nip19Parser.uriToRoute(trimmed)?.entity
|
||||
if (parsed != null) {
|
||||
|
||||
-7
@@ -50,11 +50,4 @@ sealed class SearchResult {
|
||||
val dTag: String,
|
||||
val displayId: String,
|
||||
) : SearchResult()
|
||||
|
||||
/**
|
||||
* Hashtag search.
|
||||
*/
|
||||
data class HashtagResult(
|
||||
val hashtag: String,
|
||||
) : SearchResult()
|
||||
}
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.note
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
|
||||
|
||||
/**
|
||||
* Replacement card rendered in place of a normal note when the
|
||||
* hashtag-spam check trips. Scalar params only so it stays reusable across
|
||||
* Desktop, Android, and future iOS without leaking platform display
|
||||
* shapes.
|
||||
*/
|
||||
@Composable
|
||||
fun CollapsedSpamNote(
|
||||
authorPubkeyHex: String,
|
||||
authorDisplayName: String,
|
||||
authorAvatarUrl: String?,
|
||||
hashtagCount: Int,
|
||||
threshold: Int,
|
||||
onReveal: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 56.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
.semantics {
|
||||
contentDescription =
|
||||
"Hidden note from $authorDisplayName, $hashtagCount hashtags. Tap to reveal."
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
UserAvatar(
|
||||
userHex = authorPubkeyHex,
|
||||
pictureUrl = authorAvatarUrl,
|
||||
size = 32.dp,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = authorDisplayName.ifBlank { "Hidden note" },
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "Filtered: $hashtagCount hashtags · threshold $threshold",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
TextButton(onClick = onReveal) {
|
||||
Text("Reveal")
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.moderation
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class DisplayedEventTest {
|
||||
private val author = "a".repeat(64)
|
||||
private val sig = "0".repeat(128)
|
||||
|
||||
private fun textNoteEvent(id: String = "1".padEnd(64, '0')): Event =
|
||||
Event(
|
||||
id = id,
|
||||
pubKey = author,
|
||||
createdAt = 0L,
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "hello",
|
||||
sig = sig,
|
||||
)
|
||||
|
||||
private fun repostWrapper(content: String): RepostEvent =
|
||||
RepostEvent(
|
||||
id = "r".repeat(64),
|
||||
pubKey = author,
|
||||
createdAt = 0L,
|
||||
tags = emptyArray(),
|
||||
content = content,
|
||||
sig = sig,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun nonRepostReturnsOwnEvent() {
|
||||
val inner = textNoteEvent()
|
||||
val note = Note(idHex = inner.id).apply { event = inner }
|
||||
assertEquals(inner, note.displayedEvent())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repostWithReplyToReturnsInnerEvent() {
|
||||
val inner = textNoteEvent(id = "i".repeat(64))
|
||||
val innerNote = Note(idHex = inner.id).apply { event = inner }
|
||||
val wrapper = repostWrapper(content = "{}") // valid JSON but missing fields
|
||||
val wrapperNote =
|
||||
Note(idHex = wrapper.id).apply {
|
||||
event = wrapper
|
||||
replyTo = listOf(innerNote)
|
||||
}
|
||||
assertEquals(inner, wrapperNote.displayedEvent())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repostWithoutReplyToFallsBackToContainedPost() {
|
||||
// Malformed inner JSON → containedPost() returns null → displayedEvent() returns null.
|
||||
val wrapper = repostWrapper(content = "not-valid-json-at-all")
|
||||
val wrapperNote = Note(idHex = wrapper.id).apply { event = wrapper }
|
||||
assertNull(wrapperNote.displayedEvent())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noteWithoutEventReturnsNull() {
|
||||
val note = Note(idHex = "x".repeat(64))
|
||||
assertNull(note.displayedEvent())
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.moderation
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class HashtagSpamCheckTest {
|
||||
private val author = "a".repeat(64)
|
||||
private val other = "b".repeat(64)
|
||||
|
||||
private fun event(
|
||||
kind: Int = 1,
|
||||
hashtags: List<String> = emptyList(),
|
||||
pubkey: String = author,
|
||||
): Event =
|
||||
Event(
|
||||
id = "id".padEnd(64, '0'),
|
||||
pubKey = pubkey,
|
||||
createdAt = 0L,
|
||||
kind = kind,
|
||||
tags = hashtags.map { arrayOf("t", it) }.toTypedArray(),
|
||||
content = "",
|
||||
sig = "sig".padEnd(128, '0'),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun returnsFalseWhenDisabled() {
|
||||
val e = event(hashtags = (1..20).map { "h$it" })
|
||||
assertFalse(
|
||||
HashtagSpamCheck.isHashtagSpam(
|
||||
displayedEvent = e,
|
||||
authorPubkey = e.pubKey,
|
||||
enabled = false,
|
||||
threshold = 5,
|
||||
exemptKeys = emptySet(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returnsFalseForNullDisplayedEvent() {
|
||||
assertFalse(
|
||||
HashtagSpamCheck.isHashtagSpam(
|
||||
displayedEvent = null,
|
||||
authorPubkey = author,
|
||||
enabled = true,
|
||||
threshold = 5,
|
||||
exemptKeys = emptySet(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returnsFalseForLongformEvenWithManyTags() {
|
||||
// kind 30023 = long-form content; exempt regardless of tag count.
|
||||
val e = event(kind = 30023, hashtags = (1..20).map { "h$it" })
|
||||
assertFalse(
|
||||
HashtagSpamCheck.isHashtagSpam(
|
||||
displayedEvent = e,
|
||||
authorPubkey = e.pubKey,
|
||||
enabled = true,
|
||||
threshold = 5,
|
||||
exemptKeys = emptySet(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returnsFalseForFollowedAuthor() {
|
||||
val e = event(hashtags = (1..20).map { "h$it" })
|
||||
assertFalse(
|
||||
HashtagSpamCheck.isHashtagSpam(
|
||||
displayedEvent = e,
|
||||
authorPubkey = e.pubKey,
|
||||
enabled = true,
|
||||
threshold = 5,
|
||||
exemptKeys = setOf(author),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returnsFalseUnderThreshold() {
|
||||
val e = event(hashtags = listOf("nostr", "bitcoin", "amethyst"))
|
||||
assertFalse(
|
||||
HashtagSpamCheck.isHashtagSpam(
|
||||
displayedEvent = e,
|
||||
authorPubkey = e.pubKey,
|
||||
enabled = true,
|
||||
threshold = 5,
|
||||
exemptKeys = emptySet(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returnsFalseAtThresholdExactly() {
|
||||
// hasMoreHashtagsThan(5) returns true only for count > 5.
|
||||
val e = event(hashtags = (1..5).map { "h$it" })
|
||||
assertFalse(
|
||||
HashtagSpamCheck.isHashtagSpam(
|
||||
displayedEvent = e,
|
||||
authorPubkey = e.pubKey,
|
||||
enabled = true,
|
||||
threshold = 5,
|
||||
exemptKeys = emptySet(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returnsTrueOverThresholdWithDistinctTags() {
|
||||
val e = event(hashtags = (1..10).map { "h$it" })
|
||||
assertTrue(
|
||||
HashtagSpamCheck.isHashtagSpam(
|
||||
displayedEvent = e,
|
||||
authorPubkey = e.pubKey,
|
||||
enabled = true,
|
||||
threshold = 5,
|
||||
exemptKeys = emptySet(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun returnsFalseOverThresholdWhenAllTagsAreDuplicates() {
|
||||
// hasMoreHashtagsThan checks count AND unique count > limit.
|
||||
// 20 copies of the same hashtag → unique = 1 → not spam.
|
||||
val e = event(hashtags = List(20) { "bitcoin" })
|
||||
assertFalse(
|
||||
HashtagSpamCheck.isHashtagSpam(
|
||||
displayedEvent = e,
|
||||
authorPubkey = e.pubKey,
|
||||
enabled = true,
|
||||
threshold = 5,
|
||||
exemptKeys = emptySet(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authorNotInExemptKeysIsNotExempt() {
|
||||
val e = event(hashtags = (1..10).map { "h$it" })
|
||||
assertTrue(
|
||||
HashtagSpamCheck.isHashtagSpam(
|
||||
displayedEvent = e,
|
||||
authorPubkey = e.pubKey,
|
||||
enabled = true,
|
||||
threshold = 5,
|
||||
exemptKeys = setOf(other),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullAuthorPubkeyDoesNotShortCircuit() {
|
||||
val e = event(hashtags = (1..10).map { "h$it" })
|
||||
assertTrue(
|
||||
HashtagSpamCheck.isHashtagSpam(
|
||||
displayedEvent = e,
|
||||
authorPubkey = null,
|
||||
enabled = true,
|
||||
threshold = 5,
|
||||
exemptKeys = setOf(author),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.moderation
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings.Companion.DEFAULT_ENABLED
|
||||
import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings.Companion.DEFAULT_THRESHOLD
|
||||
import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings.Companion.MAX_THRESHOLD
|
||||
import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings.Companion.MIN_THRESHOLD
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
/**
|
||||
* JVM-platform implementation backed by [java.util.prefs.Preferences].
|
||||
*
|
||||
* The default node `com/vitorpamplona/amethyst/filters` is shared between
|
||||
* the Desktop app and the `amy` CLI — both binaries running as the same OS
|
||||
* user observe the same setting without any extra plumbing.
|
||||
*
|
||||
* `Preferences` auto-flushes on JVM shutdown and periodically, so no
|
||||
* explicit `flush()` call is needed and avoids per-set disk thrash.
|
||||
*/
|
||||
class PreferencesHashtagSpamSettings(
|
||||
private val prefs: Preferences = Preferences.userRoot().node(NODE_NAME),
|
||||
) : HashtagSpamSettings {
|
||||
private val mutableEnabled = MutableStateFlow(prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED))
|
||||
private val mutableThreshold =
|
||||
MutableStateFlow(
|
||||
prefs.getInt(KEY_THRESHOLD, DEFAULT_THRESHOLD).coerceIn(MIN_THRESHOLD, MAX_THRESHOLD),
|
||||
)
|
||||
|
||||
override val enabled: StateFlow<Boolean> = mutableEnabled.asStateFlow()
|
||||
override val threshold: StateFlow<Int> = mutableThreshold.asStateFlow()
|
||||
|
||||
override fun setEnabled(enabled: Boolean) {
|
||||
mutableEnabled.value = enabled
|
||||
prefs.putBoolean(KEY_ENABLED, enabled)
|
||||
}
|
||||
|
||||
override fun setThreshold(threshold: Int) {
|
||||
val clamped = threshold.coerceIn(MIN_THRESHOLD, MAX_THRESHOLD)
|
||||
mutableThreshold.value = clamped
|
||||
prefs.putInt(KEY_THRESHOLD, clamped)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NODE_NAME = "com/vitorpamplona/amethyst/filters"
|
||||
const val KEY_ENABLED = "hashtag_spam_enabled"
|
||||
const val KEY_THRESHOLD = "hashtag_spam_threshold"
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.moderation
|
||||
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.prefs.Preferences
|
||||
|
||||
class PreferencesHashtagSpamSettingsTest {
|
||||
private val testNode = "com/vitorpamplona/amethyst/test/hashtag_spam_${System.currentTimeMillis()}"
|
||||
|
||||
private fun prefs(): Preferences = Preferences.userRoot().node(testNode)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
prefs().clear()
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
prefs().removeNode()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsAreOnAndFive() {
|
||||
val settings = PreferencesHashtagSpamSettings(prefs())
|
||||
assertTrue(settings.enabled.value)
|
||||
assertEquals(5, settings.threshold.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setEnabledPersists() {
|
||||
val settings = PreferencesHashtagSpamSettings(prefs())
|
||||
settings.setEnabled(false)
|
||||
assertFalse(settings.enabled.value)
|
||||
|
||||
val reloaded = PreferencesHashtagSpamSettings(prefs())
|
||||
assertFalse(reloaded.enabled.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setThresholdPersists() {
|
||||
val settings = PreferencesHashtagSpamSettings(prefs())
|
||||
settings.setThreshold(12)
|
||||
assertEquals(12, settings.threshold.value)
|
||||
|
||||
val reloaded = PreferencesHashtagSpamSettings(prefs())
|
||||
assertEquals(12, reloaded.threshold.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thresholdClampsBelowMin() {
|
||||
val settings = PreferencesHashtagSpamSettings(prefs())
|
||||
settings.setThreshold(-50)
|
||||
assertEquals(HashtagSpamSettings.MIN_THRESHOLD, settings.threshold.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thresholdClampsAboveMax() {
|
||||
val settings = PreferencesHashtagSpamSettings(prefs())
|
||||
settings.setThreshold(9999)
|
||||
assertEquals(HashtagSpamSettings.MAX_THRESHOLD, settings.threshold.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# Manual testing sheet — Hashtag-Spam Filter (Desktop)
|
||||
|
||||
Plan: `docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md`
|
||||
|
||||
Run with `./gradlew :desktopApp:run`. Sign in with any account that has a
|
||||
follow list (or use a fresh nsec — global feed still works without follows).
|
||||
|
||||
## Setup
|
||||
|
||||
1. Confirm default state: filter is **ON**, threshold is **5** on first
|
||||
launch. Settings → Content Filters shows Switch=On, slider at 5.
|
||||
2. Confirm a follow list is populated (Home column should display notes
|
||||
from people you follow). Otherwise the follow-exemption test (T4) is a
|
||||
no-op.
|
||||
|
||||
## Scenarios
|
||||
|
||||
| # | Scenario | Expected |
|
||||
|---|----------|----------|
|
||||
| **T1** | **Slider live re-collapse.** Open a Home column. Locate a visible note that has the spammy pattern (e.g. 8+ distinct hashtags). Drag the threshold slider to a value below its hashtag count. | Card collapses to placeholder after drag release. No mid-drag jank. |
|
||||
| **T2** | **Repost spam (precomputed).** Find a `nostr:nevent…` link to a kind-6 repost whose wrapped event has many hashtags, paste into a hashtag column or use Search. After the cache materialises the inner event (give it a second), confirm the repost wrapper renders collapsed. | Wrapper card shows `CollapsedSpamNote` with the inner event's author. |
|
||||
| **T3** | **Repost spam (uncached fallback).** Same as T2 immediately on first paste, before the cache resolves the inner event. | Wrapper shows collapsed (via `containedPost()` fallback) OR shows normal until cache populates — both acceptable; nothing crashes. |
|
||||
| **T4** | **Followed-author exemption.** Locate (or temporarily follow) someone who posts 10+ hashtag stuff. Reload Home. | Their posts render normally, never collapsed. |
|
||||
| **T5** | **Self exemption.** Compose and publish a note with 10 distinct hashtags from the active account. | The note appears normally in your own activity / Home, not collapsed. |
|
||||
| **T6** | **Long-form exemption.** Open a longform article (kind 30023) that uses many topical tags — via Search → Articles tab, or via a quoted `naddr1…`. | Article renders normally. |
|
||||
| **T7** | **Thread root auto-expand.** Find a collapsed card in a feed and click it to open the thread. | Root note auto-expands (`forceReveal = true`); 12-hashtag replies inside the thread still appear collapsed. |
|
||||
| **T8** | **Embedded quote.** Read a note that quotes a hashtag-spam note via `nostr:nevent…`. | The inline embedded card is collapsed. Revealing it does NOT cascade to other places — open the same quoted note in Search and confirm it's still collapsed there (per-call-site `rememberSaveable`). |
|
||||
| **T9** | **Settings persistence across restart.** Toggle Switch to Off. Restart Desktop. | Open Settings → Content Filters: still Off. Spam notes render uncollapsed. |
|
||||
| **T10** | **`amy` parity.** From a terminal, run `java -cp <path> com.vitorpamplona.amethyst.cli.Main …` (or any utility that reads the shared `Preferences` node). Or write a one-line Kotlin REPL: `Preferences.userRoot().node("com/vitorpamplona/amethyst/filters").getBoolean("hashtag_spam_enabled", true)`. | Returns the same value Desktop wrote. Reverse: write `false` via the API, relaunch Desktop, observe filter is off. |
|
||||
| **T11** | **Malformed inner repost JSON.** Test event with a kind-6 wrapper whose `content` field is not parseable JSON. Quickest reproduction: use the unit test `repostWithoutReplyToFallsBackToContainedPost` ✓ already covers the `displayedEvent()` path; manually you can paste a deliberately-broken nevent and watch nothing crash. | No exception; wrapper renders normally (since `displayedEvent()` returns null and `isSpam = false`). |
|
||||
| **T12** | **Hashtag-feed column.** Subscribe to a hashtag-feed column (e.g. `#bitcoin`). | Posts that are tagged `#bitcoin` AND have many other hashtags still collapse — filter applies inside hashtag columns. |
|
||||
| **T13** | **Notifications tab (negative).** Receive a mention from someone who used many hashtags in the parent note. Open Notifications. | Compact notification card (56dp) renders normally — does NOT apply the filter (v1 scope: Notifications-tab card is a custom composable, deferred). Documented as a known limitation. |
|
||||
| **T14** | **Slider does not cause feed recomposition storm.** Open a feed with several visible notes. Open Settings, drag threshold quickly back and forth. | Slider thumb moves smoothly; feed contents do not re-render per-tick (only on drag-end). No frame drops. |
|
||||
| **T15** | **Toggle off → notes uncollapse live.** With filter ON and a collapsed card visible, toggle the Switch to Off in Settings. | Card immediately uncollapses (StateFlow re-evaluates, `isSpam` becomes false). |
|
||||
| **T16** | **Threshold change updates label live.** Drag the slider with the Settings sheet open. | The "Hide notes with more than N hashtags" text updates per drag tick. |
|
||||
|
||||
## Known v1 limitations to surface during testing
|
||||
|
||||
- Cross-call-site reveal: revealing a collapsed card in Home does NOT reveal
|
||||
the same card in a Hashtag column or in a thread — each render site has
|
||||
its own `rememberSaveable` reveal flag.
|
||||
- Notifications compact card does NOT respect the filter (v2).
|
||||
- Follow/unfollow during a session does not re-evaluate already-rendered
|
||||
cards in place (follow set is non-reactive at the check site v1; refresh
|
||||
feed to update).
|
||||
|
||||
## Sign-off
|
||||
|
||||
- [ ] T1 Slider live re-collapse
|
||||
- [ ] T2 Repost spam (precomputed)
|
||||
- [ ] T3 Repost spam (uncached)
|
||||
- [ ] T4 Followed-author exemption
|
||||
- [ ] T5 Self exemption
|
||||
- [ ] T6 Long-form exemption
|
||||
- [ ] T7 Thread root auto-expand
|
||||
- [ ] T8 Embedded quote
|
||||
- [ ] T9 Settings persistence
|
||||
- [ ] T10 `amy` parity
|
||||
- [ ] T11 Malformed inner repost JSON
|
||||
- [ ] T12 Hashtag-feed column
|
||||
- [ ] T13 Notifications tab limitation (known)
|
||||
- [ ] T14 No recomposition storm during drag
|
||||
- [ ] T15 Toggle off live
|
||||
- [ ] T16 Threshold label live update
|
||||
|
||||
Tester: ________________ Date: ________________
|
||||
@@ -0,0 +1,142 @@
|
||||
# Desktop Feature Backlog — Inspiration from Nostr Ecosystem
|
||||
|
||||
Survey date: 2026-06-29. Source: cross-client research across top-5 used clients
|
||||
(Damus, Primal, YakiHonne, Snort, Iris) + top-5 desktop-first / power-user
|
||||
clients (Notedeck, Gossip, Jumble, Coop, Flotilla). Wisp investigated separately
|
||||
(Android-only, not desktop).
|
||||
|
||||
Already shipped on Amethyst Desktop and excluded from this list: deck columns,
|
||||
embedded local relay, NIP-46 bunker login, NWC zapping, custom feeds, advanced
|
||||
search, follow packs (in progress).
|
||||
|
||||
---
|
||||
|
||||
## Priority queue (next up)
|
||||
|
||||
### 1. Hashtag-spam filter — NEXT
|
||||
- **Inspired by:** Damus (auto-hide posts above N hashtags).
|
||||
- **What:** Configurable threshold; posts with `> N` `t` tags get hidden or
|
||||
collapsed across all feeds. User-defined whitelist for legit multi-tag use
|
||||
(events, longform).
|
||||
- **Why desktop:** Spam noise scales with column count on a deck UI; one filter
|
||||
cleans every column at once.
|
||||
- **Module:** `commons/` (filter logic, shared with Android in future) +
|
||||
`desktopApp/` (settings UI).
|
||||
- **Skills:** `feed-patterns`, `compose-expert`, `account-state`.
|
||||
|
||||
### 2. WoT (web-of-trust) score on avatars + filters
|
||||
- **Inspired by:** Gossip, Snort.
|
||||
- **What:** Friends-of-friends score (0–N) computed from follow graph. Visual
|
||||
badge/ring on avatar. Threshold filter for notifications/DM-requests.
|
||||
- **Why desktop:** At-a-glance trust signal scales with multi-column deck.
|
||||
- **Module:** `commons/services/WoTService.kt` (StateFlow<Map<HexKey, Int>>),
|
||||
avatar composable in `commons/.../note/`.
|
||||
- **Skills:** `account-state`, `kotlin-flow-state-event-modeling`,
|
||||
`compose-expert`. Score compute is O(follows × follows) — must be lazy /
|
||||
throttled.
|
||||
|
||||
---
|
||||
|
||||
## Full ranked backlog (10 ideas)
|
||||
|
||||
Ranking heuristic: impact × novelty / implementation cost. Items 1–2 are the
|
||||
priority queue above; 3–10 captured for later.
|
||||
|
||||
| # | Feature | Inspired by | Module | Notes |
|
||||
|---|---------|-------------|--------|-------|
|
||||
| 1 | Hashtag-spam filter | Damus | commons + desktopApp | **NEXT** |
|
||||
| 2 | WoT score on avatars + filters | Gossip, Snort | commons | After #1 |
|
||||
| 3 | Relay-as-column + Relay Sets first-class | Jumble, Flotilla | commons | Drag relay URL → column |
|
||||
| 4 | Cashu ecash wallet alongside NWC | Iris, Primal Spark | quartz + commons | Bearer-token privacy |
|
||||
| 5 | AI "Dave" column (timeline-aware assistant) | Notedeck | desktopApp | LLM reads adjacent columns |
|
||||
| 6 | Algorithmic Feed Marketplace | Primal v3.0 | commons | Discover layer on custom feeds |
|
||||
| 7 | Keyboard / command palette (`Ctrl+K`, `?` overlay) | Snort, Notedeck | desktopApp + commons | Power-user nav |
|
||||
| 8 | Longform reader column + offline publish queue | Damus, YakiHonne | commons | NIP-23, uses local relay |
|
||||
| 9 | WoT-scored notification filter + undo-send | Gossip, Snort | commons | Calmer UX |
|
||||
| 10 | Per-relay column health (sparkline + dot) | Wisp, Gossip | commons | Reuse EOSE manager |
|
||||
| 11 | Discord-style "communities" sidebar (NIP-29) | Flotilla, Chachi | desktopApp + commons | Sidebar second purpose |
|
||||
|
||||
(Extra row #11 added: communities sidebar was the 10th in the original survey;
|
||||
WoT-notif-filter + undo-send promoted to its own row for clarity.)
|
||||
|
||||
---
|
||||
|
||||
## Per-item detail
|
||||
|
||||
### #3 Relay-as-column + Relay Sets first-class
|
||||
- **Drop:** Drag relay URL onto the deck → new column showing that relay's
|
||||
global. Relay Sets become saveable column templates.
|
||||
- **Impl:** New `RelayUrlFeedFilter` + `RelaySetFeedFilter` in `commons/feeds/`.
|
||||
- **Gotcha:** Bypass write-through to local store for these feeds, otherwise
|
||||
local relay pollutes with arbitrary global content.
|
||||
|
||||
### #4 Cashu ecash wallet alongside NWC
|
||||
- **Why:** Different privacy model (bearer tokens, no account). Power users
|
||||
want both — small/private = ecash, big/recurring = NWC.
|
||||
- **Impl:** Check Quartz NIP-60/61 coverage; else new `wallet/cashu/`. UI is a
|
||||
second tab in existing wallet column.
|
||||
- **Gotcha:** Mint trust UX; token backup/restore; encryption story
|
||||
(`accounts.json.enc` slot).
|
||||
|
||||
### #5 AI "Dave" column
|
||||
- **Why:** Big-screen-only feature that breaks the deck-column metaphor wide
|
||||
open. Reads N adjacent columns as context.
|
||||
- **Impl:** `desktopApp/` for API key config + `commons/ai/` for prompt builders.
|
||||
Pluggable model: Ollama local default, OpenAI/Anthropic optional.
|
||||
- **Gotcha:** Privacy story — must be explicit which events get sent where.
|
||||
Default to local Ollama.
|
||||
|
||||
### #6 Algorithmic Feed Marketplace
|
||||
- **Why:** Custom feeds already shipped — marketplace is the discovery layer.
|
||||
- **Impl:** Feeds are published as kind:30000-ish lists or DVM-driven. Browser
|
||||
UI + subscribe button on top of existing custom-feed infra.
|
||||
- **Gotcha:** DVM feeds vs static list feeds — two execution paths.
|
||||
|
||||
### #7 Keyboard / command palette
|
||||
- **Why:** Mouse-first ≠ keyboard-hostile.
|
||||
- **Impl:** `desktopApp/` global `onPreviewKeyEvent` + `commons/ui/CommandPalette.kt`.
|
||||
- **Gotcha:** Focus management across deck columns; cmd vs ctrl on macOS;
|
||||
conflict with text-input fields.
|
||||
|
||||
### #8 Longform reader + offline publish queue
|
||||
- **Why:** NIP-23 is barely surfaced on Desktop. Big screens are *the* surface
|
||||
for reading articles.
|
||||
- **Impl:** New column types `LongformReader` + `LongformComposer`. Composer is
|
||||
block-based. Offline queue writes drafts to local relay; auto-broadcasts on
|
||||
reconnect.
|
||||
- **Gotcha:** Markdown rendering on Compose Desktop is mid — pick or build.
|
||||
Blossom upload alongside drafts.
|
||||
|
||||
### #9 WoT notif filter + undo-send
|
||||
- **Why:** Notifications scale poorly; slow-mode (delay sends 5–30 s with
|
||||
toast-cancel) calms power-user UX.
|
||||
- **Impl:** `NotificationFilter.kt` extension + send-pipeline interceptor in
|
||||
`commons/relayClient/`.
|
||||
- **Gotcha:** Sent-but-cancelled events already in local relay write-through —
|
||||
must scrub from local store on cancel.
|
||||
|
||||
### #10 Per-relay column health
|
||||
- **Why:** Deck UI exposes the multi-relay reality. Latency/EOSE/dup-rate dot +
|
||||
sparkline in column header helps self-tune.
|
||||
- **Impl:** Extend EOSE manager to emit `RelayHealth` per relay; render small
|
||||
sparkline + colored dot.
|
||||
- **Gotcha:** Memory over time — rolling 60 s window, decay older.
|
||||
|
||||
### #11 Communities sidebar (NIP-29)
|
||||
- **Why:** Amethyst Desktop sidebar is nav-only; mapping joined groups /
|
||||
favourite relays to Discord-like server icons doubles its purpose.
|
||||
- **Impl:** `desktopApp/.../sidebar/` new section; on click, populate deck with
|
||||
group channels. `commons/groups/` for NIP-29 if not in Quartz already.
|
||||
- **Gotcha:** NIP-29 spec churn — check Quartz coverage. Don't conflate
|
||||
"favourite relay" with "joined group".
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- nostr.com/clients, nostrapps.com, stats.nostr.band
|
||||
- humai.blog "Best Nostr Apps 2026"; nostr.co.uk/clients; nostrcompass.org #15
|
||||
- opensats.org "Advancements in Nostr Clients"
|
||||
- Repos: damus-io/damus, damus-io/notedeck, mikedilger/gossip, CodyTseng/jumble,
|
||||
lumehq/coop, coracle-social/flotilla (active at gitea.coracle.social),
|
||||
barrydeen/wisp
|
||||
@@ -74,6 +74,9 @@ import androidx.compose.ui.window.rememberWindowState
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings
|
||||
import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
|
||||
import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSettings
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
@@ -749,6 +752,10 @@ fun App(
|
||||
val accountState by accountManager.accountState.collectAsState()
|
||||
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
|
||||
|
||||
// Hashtag-spam filter settings, persisted in the shared java.util.prefs
|
||||
// node so the `amy` CLI binary observes the same toggle.
|
||||
val hashtagSpamSettings = remember { PreferencesHashtagSpamSettings() }
|
||||
|
||||
// Local relay store — persists events to SQLite per account
|
||||
val localRelayStore =
|
||||
remember {
|
||||
@@ -976,6 +983,7 @@ fun App(
|
||||
com.vitorpamplona.amethyst.desktop.ui.deck.LocalDesktopCache provides localCache,
|
||||
com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayManager provides relayManager,
|
||||
com.vitorpamplona.amethyst.desktop.ui.deck.LocalLocalRelayStore provides localRelayStore,
|
||||
LocalHashtagSpamSettings provides hashtagSpamSettings,
|
||||
) {
|
||||
when (accountState) {
|
||||
is AccountState.Loading -> {
|
||||
@@ -1062,6 +1070,11 @@ fun App(
|
||||
// NWC loaded during startup in loadSavedAccount flow
|
||||
|
||||
val currentTorStatus = torManager.status.collectAsState().value
|
||||
val followedUsers by localCache.followedUsers.collectAsState()
|
||||
val spamExemptKeys =
|
||||
remember(followedUsers, account.pubKeyHex) {
|
||||
followedUsers + account.pubKeyHex
|
||||
}
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState provides
|
||||
com.vitorpamplona.amethyst.desktop.ui.tor.TorState(
|
||||
@@ -1079,6 +1092,7 @@ fun App(
|
||||
),
|
||||
LocalNamecoinPreferences provides namecoinPreferences,
|
||||
LocalNamecoinService provides namecoinService,
|
||||
LocalSpamExemptKeys provides spamExemptKeys,
|
||||
) {
|
||||
MainContent(
|
||||
layoutMode = layoutMode,
|
||||
@@ -2074,6 +2088,21 @@ fun RelaySettingsScreen(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// Content Filters section — hashtag-spam filter and future
|
||||
// content-moderation toggles.
|
||||
Text(
|
||||
text = "Content Filters",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
com.vitorpamplona.amethyst.desktop.ui.settings.HashtagSpamSettingsSection(
|
||||
settings = LocalHashtagSpamSettings.current,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
val logoutScope = rememberCoroutineScope()
|
||||
OutlinedButton(
|
||||
onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } },
|
||||
|
||||
+11
-5
@@ -316,12 +316,18 @@ fun BookmarksScreen(
|
||||
onNavigateToThread(event.id)
|
||||
},
|
||||
) {
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender(
|
||||
displayedEvent = event,
|
||||
noteIdHex = event.id,
|
||||
localCache = localCache,
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
)
|
||||
) {
|
||||
NoteCard(
|
||||
note = event.rememberDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
NoteActionsRow(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
|
||||
+25
@@ -20,6 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteDisplayData
|
||||
@@ -27,6 +31,27 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
|
||||
/**
|
||||
* Compose-friendly wrapper around [toNoteDisplayData] that observes the
|
||||
* event author's user-metadata flow and re-derives the display data
|
||||
* whenever it updates. Use at note-card call sites so display names and
|
||||
* avatars populate when the kind-0 event arrives from relays after the
|
||||
* note itself.
|
||||
*/
|
||||
@Composable
|
||||
fun Event.rememberDisplayData(cache: ICacheProvider?): NoteDisplayData {
|
||||
val author = remember(pubKey, cache) { cache?.getUserIfExists(pubKey) }
|
||||
val authorMetaValue by produceState<Any?>(initialValue = null, key1 = author) {
|
||||
val a = author
|
||||
if (a == null) {
|
||||
value = null
|
||||
} else {
|
||||
a.metadata().flow.collect { value = it }
|
||||
}
|
||||
}
|
||||
return remember(this, authorMetaValue) { toNoteDisplayData(cache) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension to convert Event to NoteDisplayData for the shared NoteCard.
|
||||
*/
|
||||
|
||||
@@ -135,6 +135,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor
|
||||
import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList
|
||||
@@ -199,8 +200,54 @@ fun FeedNoteCard(
|
||||
followedUsers: Set<String> = emptySet(),
|
||||
myPubKeyHex: String? = null,
|
||||
onFollow: ((String) -> Unit)? = null,
|
||||
forceReveal: Boolean = false,
|
||||
) {
|
||||
val event = note.event ?: return
|
||||
SpamCheckedNoteRender(
|
||||
note = note,
|
||||
localCache = localCache,
|
||||
forceReveal = forceReveal,
|
||||
) {
|
||||
FeedNoteCardBody(
|
||||
note = note,
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
nwcConnection = nwcConnection,
|
||||
onReply = onReply,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onImageClick = onImageClick,
|
||||
onMediaClick = onMediaClick,
|
||||
onHashtagClick = onHashtagClick,
|
||||
followedUsers = followedUsers,
|
||||
myPubKeyHex = myPubKeyHex,
|
||||
onFollow = onFollow,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeedNoteCardBody(
|
||||
note: Note,
|
||||
event: com.vitorpamplona.quartz.nip01Core.core.Event,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
localCache: DesktopLocalCache,
|
||||
account: AccountState.LoggedIn?,
|
||||
nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null,
|
||||
onReply: () -> Unit,
|
||||
onZapFeedback: (ZapFeedback) -> Unit,
|
||||
onNavigateToProfile: (String) -> Unit = {},
|
||||
onNavigateToThread: (String) -> Unit = {},
|
||||
onImageClick: ((List<String>, Int) -> Unit)? = null,
|
||||
onMediaClick: ((List<String>, Int, Float) -> Unit)? = null,
|
||||
onHashtagClick: ((String) -> Unit)? = null,
|
||||
followedUsers: Set<String> = emptySet(),
|
||||
myPubKeyHex: String? = null,
|
||||
onFollow: ((String) -> Unit)? = null,
|
||||
) {
|
||||
val isRepost = event is RepostEvent || event is GenericRepostEvent
|
||||
|
||||
if (isRepost) {
|
||||
|
||||
+15
-9
@@ -120,7 +120,6 @@ fun SearchScreen(
|
||||
initialQuery: String = "",
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
onNavigateToThread: (String) -> Unit,
|
||||
onNavigateToHashtag: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -348,6 +347,20 @@ fun SearchScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Load author metadata for incoming note results. NIP-50 search relays
|
||||
// typically don't return kind-0 metadata alongside notes, and the
|
||||
// subscription explicitly drops MetadataEvents anyway — so display name
|
||||
// and avatar arrive only after we explicitly fetch them from index
|
||||
// relays via the coordinator.
|
||||
LaunchedEffect(noteResults, subscriptionsCoordinator) {
|
||||
val coordinator = subscriptionsCoordinator ?: return@LaunchedEffect
|
||||
if (noteResults.isEmpty()) return@LaunchedEffect
|
||||
val authors = noteResults.map { it.pubKey }.distinct()
|
||||
if (authors.isNotEmpty()) {
|
||||
coordinator.loadMetadataBatched(authors)
|
||||
}
|
||||
}
|
||||
|
||||
// History state
|
||||
val historyItems by SearchHistoryStore.history.collectAsState()
|
||||
val savedSearches by SearchHistoryStore.savedSearches.collectAsState()
|
||||
@@ -643,7 +656,6 @@ fun SearchScreen(
|
||||
),
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onNavigateToHashtag = onNavigateToHashtag,
|
||||
)
|
||||
if (ncState.result.relays.isNotEmpty()) {
|
||||
Text(
|
||||
@@ -699,7 +711,6 @@ fun SearchScreen(
|
||||
result = result,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onNavigateToHashtag = onNavigateToHashtag,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -897,7 +908,6 @@ private fun SearchResultCard(
|
||||
result: SearchResult,
|
||||
onNavigateToProfile: (String) -> Unit,
|
||||
onNavigateToThread: (String) -> Unit,
|
||||
onNavigateToHashtag: (String) -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier =
|
||||
@@ -908,7 +918,6 @@ private fun SearchResultCard(
|
||||
is SearchResult.UserResult -> onNavigateToProfile(result.pubKeyHex)
|
||||
is SearchResult.NoteResult -> onNavigateToThread(result.noteIdHex)
|
||||
is SearchResult.AddressResult -> onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}")
|
||||
is SearchResult.HashtagResult -> onNavigateToHashtag(result.hashtag)
|
||||
}
|
||||
},
|
||||
colors =
|
||||
@@ -927,7 +936,6 @@ private fun SearchResultCard(
|
||||
is SearchResult.UserResult -> MaterialSymbols.Person
|
||||
is SearchResult.NoteResult -> MaterialSymbols.Description
|
||||
is SearchResult.AddressResult -> MaterialSymbols.Description
|
||||
is SearchResult.HashtagResult -> MaterialSymbols.Tag
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
@@ -940,7 +948,6 @@ private fun SearchResultCard(
|
||||
is SearchResult.UserResult -> "User Profile"
|
||||
is SearchResult.NoteResult -> "Note"
|
||||
is SearchResult.AddressResult -> "Event (kind ${result.kind})"
|
||||
is SearchResult.HashtagResult -> "#${result.hashtag}"
|
||||
},
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
@@ -950,10 +957,9 @@ private fun SearchResultCard(
|
||||
is SearchResult.UserResult -> result.displayId
|
||||
is SearchResult.NoteResult -> result.displayId
|
||||
is SearchResult.AddressResult -> result.displayId
|
||||
is SearchResult.HashtagResult -> "Search posts with this hashtag"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = if (result is SearchResult.HashtagResult) null else FontFamily.Monospace,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -318,6 +318,8 @@ fun ThreadScreen(
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
// Root of an explicitly-opened thread — user opted in, skip spam collapse.
|
||||
forceReveal = true,
|
||||
)
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp)
|
||||
|
||||
+11
-6
@@ -530,13 +530,18 @@ fun QuotedNoteEmbed(
|
||||
authorMetaValue
|
||||
val displayData = event.toNoteDisplayData(localCache)
|
||||
|
||||
NoteCard(
|
||||
note = displayData,
|
||||
SpamCheckedNoteRender(
|
||||
note = note,
|
||||
localCache = localCache,
|
||||
onClick = onNavigateToThread?.let { nav -> { nav(event.id) } },
|
||||
onAuthorClick = onMentionClick,
|
||||
onMentionClick = onMentionClick,
|
||||
)
|
||||
) {
|
||||
NoteCard(
|
||||
note = displayData,
|
||||
localCache = localCache,
|
||||
onClick = onNavigateToThread?.let { nav -> { nav(event.id) } },
|
||||
onAuthorClick = onMentionClick,
|
||||
onMentionClick = onMentionClick,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.note
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamCheck
|
||||
import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings
|
||||
import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
|
||||
import com.vitorpamplona.amethyst.commons.moderation.displayedEvent
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.CollapsedSpamNote
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags
|
||||
|
||||
/**
|
||||
* Wraps a note render in the hashtag-spam check.
|
||||
*
|
||||
* Reads [LocalHashtagSpamSettings] and [LocalSpamExemptKeys] from
|
||||
* composition. If the check trips and the user hasn't revealed this
|
||||
* specific note (per-note [rememberSaveable] keyed by id), renders
|
||||
* [CollapsedSpamNote] with author info pulled from [localCache];
|
||||
* otherwise calls [normal].
|
||||
*
|
||||
* @param displayedEvent The event whose body the [normal] block renders.
|
||||
* For repost wrappers, callers should pass the *inner* event (via
|
||||
* [Note.displayedEvent]). For search results that already hold an
|
||||
* [Event] directly, pass it as-is.
|
||||
* @param noteIdHex Stable id used as the [rememberSaveable] key for the
|
||||
* reveal flag — must match the id of whatever the [normal] block renders.
|
||||
* @param forceReveal When `true`, skips the check entirely and always
|
||||
* renders [normal]. Used by thread-detail screens where the user
|
||||
* explicitly opted into the root note.
|
||||
*/
|
||||
@Composable
|
||||
fun SpamCheckedNoteRender(
|
||||
displayedEvent: Event?,
|
||||
noteIdHex: String,
|
||||
localCache: DesktopLocalCache?,
|
||||
forceReveal: Boolean = false,
|
||||
normal: @Composable () -> Unit,
|
||||
) {
|
||||
val settings = LocalHashtagSpamSettings.current
|
||||
val enabled by settings.enabled.collectAsState()
|
||||
val threshold by settings.threshold.collectAsState()
|
||||
val exemptKeys = LocalSpamExemptKeys.current
|
||||
|
||||
val isSpam =
|
||||
remember(noteIdHex, displayedEvent, enabled, threshold, exemptKeys) {
|
||||
HashtagSpamCheck.isHashtagSpam(
|
||||
displayedEvent = displayedEvent,
|
||||
authorPubkey = displayedEvent?.pubKey,
|
||||
enabled = enabled,
|
||||
threshold = threshold,
|
||||
exemptKeys = exemptKeys,
|
||||
)
|
||||
}
|
||||
|
||||
var revealed by rememberSaveable(noteIdHex) { mutableStateOf(false) }
|
||||
|
||||
if (!forceReveal && isSpam && !revealed && displayedEvent != null) {
|
||||
val authorPubkey = displayedEvent.pubKey
|
||||
val author = localCache?.getUserIfExists(authorPubkey)
|
||||
val displayName = author?.toBestDisplayName() ?: authorPubkey.take(8)
|
||||
val avatarUrl = author?.profilePicture()
|
||||
val hashtagCount = displayedEvent.tags.countHashtags()
|
||||
CollapsedSpamNote(
|
||||
authorPubkeyHex = authorPubkey,
|
||||
authorDisplayName = displayName,
|
||||
authorAvatarUrl = avatarUrl,
|
||||
hashtagCount = hashtagCount,
|
||||
threshold = threshold,
|
||||
onReveal = { revealed = true },
|
||||
)
|
||||
} else {
|
||||
normal()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [Note]-shaped convenience overload — resolves the displayed event
|
||||
* (unwrapping kind 6 / 16 reposts) and delegates to the primary helper.
|
||||
*/
|
||||
@Composable
|
||||
fun SpamCheckedNoteRender(
|
||||
note: Note,
|
||||
localCache: DesktopLocalCache?,
|
||||
forceReveal: Boolean = false,
|
||||
normal: @Composable () -> Unit,
|
||||
) {
|
||||
val displayedEvent = remember(note, note.event) { note.displayedEvent() }
|
||||
SpamCheckedNoteRender(
|
||||
displayedEvent = displayedEvent,
|
||||
noteIdHex = note.idHex,
|
||||
localCache = localCache,
|
||||
forceReveal = forceReveal,
|
||||
normal = normal,
|
||||
)
|
||||
}
|
||||
+60
-29
@@ -60,7 +60,8 @@ import com.vitorpamplona.amethyst.commons.search.SearchSortOrder
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender
|
||||
import com.vitorpamplona.amethyst.desktop.ui.rememberDisplayData
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
|
||||
@Composable
|
||||
@@ -153,24 +154,36 @@ fun SearchResultsList(
|
||||
if (!collapsed) {
|
||||
val displayNotes = textNotes.take(5)
|
||||
items(displayNotes, key = { "note-${it.id}" }) { event ->
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
SpamCheckedNoteRender(
|
||||
displayedEvent = event,
|
||||
noteIdHex = event.id,
|
||||
localCache = localCache,
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
)
|
||||
) {
|
||||
NoteCard(
|
||||
note = event.rememberDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (textNotes.size > 5) {
|
||||
item(key = "notes-expand") {
|
||||
ExpandableSection(
|
||||
remaining = textNotes.drop(5),
|
||||
) { event ->
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
)
|
||||
SpamCheckedNoteRender(
|
||||
displayedEvent = event,
|
||||
noteIdHex = event.id,
|
||||
localCache = localCache,
|
||||
) {
|
||||
NoteCard(
|
||||
note = event.rememberDisplayData(localCache),
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,24 +210,36 @@ fun SearchResultsList(
|
||||
}
|
||||
if (!collapsed) {
|
||||
items(articles.take(5), key = { "article-${it.id}" }) { event ->
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
SpamCheckedNoteRender(
|
||||
displayedEvent = event,
|
||||
noteIdHex = event.id,
|
||||
localCache = localCache,
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
)
|
||||
) {
|
||||
NoteCard(
|
||||
note = event.rememberDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (articles.size > 5) {
|
||||
item(key = "articles-expand") {
|
||||
ExpandableSection(
|
||||
remaining = articles.drop(5),
|
||||
) { event ->
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
)
|
||||
SpamCheckedNoteRender(
|
||||
displayedEvent = event,
|
||||
noteIdHex = event.id,
|
||||
localCache = localCache,
|
||||
) {
|
||||
NoteCard(
|
||||
note = event.rememberDisplayData(localCache),
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,13 +264,19 @@ fun SearchResultsList(
|
||||
}
|
||||
if (!collapsed) {
|
||||
items(otherNotes.take(5), key = { "other-${it.id}" }) { event ->
|
||||
NoteCard(
|
||||
note = event.toNoteDisplayData(localCache),
|
||||
SpamCheckedNoteRender(
|
||||
displayedEvent = event,
|
||||
noteIdHex = event.id,
|
||||
localCache = localCache,
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
)
|
||||
) {
|
||||
NoteCard(
|
||||
note = event.rememberDisplayData(localCache),
|
||||
localCache = localCache,
|
||||
onClick = { onNavigateToThread(event.id) },
|
||||
onAuthorClick = onNavigateToProfile,
|
||||
onMentionClick = onNavigateToProfile,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Settings UI for the hashtag-spam content filter.
|
||||
*
|
||||
* Decoupled-thumb pattern: a local [Float] state drives the [Slider] thumb,
|
||||
* the [StateFlow] only sees the committed [Int] on [onValueChangeFinished].
|
||||
* Prevents per-tick recomposition storms in the feed while the user drags.
|
||||
*/
|
||||
@Composable
|
||||
fun HashtagSpamSettingsSection(
|
||||
settings: HashtagSpamSettings,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val enabled by settings.enabled.collectAsState()
|
||||
val committed by settings.threshold.collectAsState()
|
||||
|
||||
var live by remember { mutableFloatStateOf(committed.toFloat()) }
|
||||
LaunchedEffect(committed) { live = committed.toFloat() }
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "Hashtag-spam filter",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(checked = enabled, onCheckedChange = settings::setEnabled)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (enabled) "On" else "Off",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
if (enabled) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Hide notes with more than ${live.roundToInt()} hashtags",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Slider(
|
||||
value = live,
|
||||
onValueChange = { live = it },
|
||||
onValueChangeFinished = { settings.setThreshold(live.roundToInt()) },
|
||||
valueRange =
|
||||
HashtagSpamSettings.MIN_THRESHOLD
|
||||
.toFloat()..HashtagSpamSettings.MAX_THRESHOLD.toFloat(),
|
||||
steps = HashtagSpamSettings.MAX_THRESHOLD - HashtagSpamSettings.MIN_THRESHOLD - 1,
|
||||
)
|
||||
Text(
|
||||
text = "Long-form articles and posts from people you follow are always shown.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
+3
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-4
@@ -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)
|
||||
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-4
@@ -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)
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+12
@@ -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>>?
|
||||
|
||||
+2
@@ -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)
|
||||
}
|
||||
|
||||
+69
@@ -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,
|
||||
}
|
||||
}
|
||||
+13
@@ -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) {
|
||||
|
||||
+141
@@ -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,
|
||||
)
|
||||
+726
@@ -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.
|
||||
}
|
||||
}
|
||||
+156
-70
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-2
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+309
@@ -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)
|
||||
}
|
||||
}
|
||||
+146
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user