diff --git a/.claude/hooks/pre-push-spotless.sh b/.claude/hooks/pre-push-spotless.sh new file mode 100755 index 0000000000..ff989f68a6 --- /dev/null +++ b/.claude/hooks/pre-push-spotless.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# PreToolUse gate: make sure Kotlin is spotless-clean BEFORE it leaves the box. +# +# Fires on `git push` (Bash tool) and on the create_pull_request MCP tool. Runs +# `spotlessApply`; if that reformats any tracked .kt/.kts file, the push/PR is +# blocked (exit 2) so the agent commits the formatting fix first. This turns +# CI's `spotlessCheck` failure into an in-session block — no red PR, no round +# trip. `spotlessApply` runs the same formatters CI's `spotlessCheck` verifies, +# so a clean apply means a green check. +set -uo pipefail + +cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0 + +# --- Parse the tool call off stdin; decide whether this call is a boundary. --- +payload="$(cat)" +should_gate="$( + printf '%s' "$payload" | python3 -c ' +import json, shlex, sys +try: + data = json.load(sys.stdin) +except Exception: + print("no"); sys.exit(0) +tool = data.get("tool_name", "") +if tool.endswith("create_pull_request"): + print("yes"); sys.exit(0) +if tool != "Bash": + print("no"); sys.exit(0) +cmd = (data.get("tool_input") or {}).get("command", "") +# Tokenize like a shell so `push` inside a quoted commit message or heredoc +# stays one token and is NOT mistaken for the push subcommand. +try: + tokens = shlex.split(cmd, comments=True) +except ValueError: + tokens = cmd.split() +GLOBAL_WITH_ARG = {"-c", "-C", "--namespace", "--git-dir", "--work-tree", "--exec-path"} +for i, t in enumerate(tokens): + if t != "git" and not t.endswith("/git"): + continue + j = i + 1 + while j < len(tokens): # skip git global options to reach the subcommand + tok = tokens[j] + if tok in GLOBAL_WITH_ARG: + j += 2; continue + if tok.startswith("-"): + j += 1; continue + break + if j < len(tokens) and tokens[j] == "push": + print("yes"); sys.exit(0) +print("no") +' 2>/dev/null +)" + +[ "$should_gate" = "yes" ] || exit 0 + +# Nothing to format if no Kotlin is tracked/changed at all — cheap early out. +if ! git ls-files --error-unmatch '*.kt' '*.kts' >/dev/null 2>&1; then + exit 0 +fi + +# Snapshot Kotlin state (vs HEAD, so staged + unstaged both count) before/after +# formatting; any delta means the committed tree wasn't spotless. +before="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)" + +log="$(mktemp /tmp/spotless-gate.XXXXXX.log)" +if ! ./gradlew spotlessApply >"$log" 2>&1; then + # Distinguish a formatting failure (block) from Gradle being unable to RUN — + # e.g. deps can't resolve in a restricted sandbox. An infra failure must not + # strand the agent; warn and let CI's spotlessCheck be the backstop. + if grep -qiE "could not resolve|could not (get|download)|handshake|connect timed out|no address|unable to (find|resolve) host|read timed out" "$log"; then + echo "WARN: could not run spotlessApply (Gradle infra/network failure), skipping the formatting gate." >&2 + echo " CI's spotlessCheck still enforces formatting on the PR." >&2 + rm -f "$log" + exit 0 + fi + echo "BLOCKED: spotlessApply failed — fix the build/formatting error before pushing." >&2 + echo "----- gradle output (tail) -----" >&2 + tail -n 40 "$log" >&2 + rm -f "$log" + exit 2 +fi +rm -f "$log" + +after="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)" + +if [ "$before" != "$after" ]; then + echo "BLOCKED: spotlessApply reformatted Kotlin files that were about to be pushed." >&2 + echo "The changes below are now in your working tree. Commit them, then retry:" >&2 + echo >&2 + git diff --name-only HEAD -- '*.kt' '*.kts' >&2 + echo >&2 + echo " git add -A && git commit -m 'style: apply spotless' && " >&2 + echo "(CI runs 'spotlessCheck'; pushing now would fail the lint job.)" >&2 + exit 2 +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 15b897a70f..53540bdb4d 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,17 @@ { "hooks": { + "PreToolUse": [ + { + "matcher": "Bash|mcp__github__create_pull_request", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-push-spotless.sh", + "timeout": 180 + } + ] + } + ], "SessionStart": [ { "hooks": [ diff --git a/.claude/skills/nostr-expert/SKILL.md b/.claude/skills/nostr-expert/SKILL.md index c8c87c0cd7..8dc31a681a 100644 --- a/.claude/skills/nostr-expert/SKILL.md +++ b/.claude/skills/nostr-expert/SKILL.md @@ -1,6 +1,6 @@ --- name: nostr-expert -description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details. +description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent), (8) Resolving user input (hex, npub, nprofile, or NIP-05 `name@domain` internet identifiers) to a pubkey. Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details. --- # Nostr Protocol Expert (Quartz Implementation) @@ -15,6 +15,7 @@ Practical patterns for working with Nostr in Quartz, AmethystMultiplatform's KMP - Finding NIP implementations in quartz/ codebase - Nostr cryptography (secp256k1 signing, NIP-44 encryption) - Bech32 encoding/decoding (npub, nsec, note formats) +- Resolving user input (hex / npub / nprofile / NIP-05 `name@domain`) to a pubkey - Event validation and verification **For NIP specifications** → Use `nostr-protocol` agent @@ -345,6 +346,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`); @@ -375,6 +425,25 @@ when (val entity = Nip19Parser.uriToRoute(input)?.entity) { } ``` +## Resolving User Input to a Pubkey (NIP-05 + NIP-19) + +**Before writing any `if (isHex) … else if (npub) … else if ("@" in s) fetchWellKnown()` logic, stop — it already exists.** `resolveUserHexOrNull` in `quartz/nip05DnsIdentifiers/` accepts every identifier form a user might type and returns a 64-hex pubkey. + +```kotlin +import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull + +// hex | npub1… | nprofile1… | nsec1… | name@domain.tld → HexKey? (null if unrecognized/lookup fails) +val pubkey = resolveUserHexOrNull(userInput, nip05Client) +``` + +- Tries the **synchronous** hex/bech32 path first (`decodePublicKeyAsHexOrNull`) — only NIP-05-shaped input hits the network. +- `suspend`; re-throws only `CancellationException`. Pass `nip05Client = null` for offline contexts. +- Build the client with `Nip05Client(fetcher = OkHttpNip05Fetcher { _ -> okHttp })` (see `cli/Context.kt`). The OkHttp fetcher already runs on IO and disables redirects per the NIP-05 spec — don't re-implement the `.well-known/nostr.json` fetch or JSON parse. +- Need only hex/bech32 (no network)? Use `decodePublicKeyAsHexOrNull(input)` directly. +- Need to *verify* a claimed identifier maps back to a pubkey? `nip05Client.verify(Nip05Id.parse(id)!!, pubkey)`. + +See `references/nip05-identifiers.md` for the full API surface (`Nip05Id`, `Nip05Client`, `Nip05Parser`, `KeyInfoSet`, Namecoin `.bit`) and the hand-rolled anti-pattern to avoid. + ## Event Validation ```kotlin @@ -503,6 +572,7 @@ Or see `references/nip-catalog.md` for complete catalog. - **references/event-hierarchy.md** - Event class hierarchy, kind classifications, common types - **references/tag-patterns.md** - Tag structure, TagArrayBuilder DSL, common tag types, parsing patterns - **references/nip19-bech32.md** - `Nip19Parser`, `Bech32Util`, `TlvBuilder`, entity types (NPub, NSec, NEvent, NAddress, NProfile, NRelay, NEmbed) +- **references/nip05-identifiers.md** - Resolving any identifier (hex/npub/nprofile/nsec/`name@domain`) to a pubkey via `resolveUserHexOrNull`; `Nip05Client`, `Nip05Id`, `Nip05Parser`, Namecoin `.bit` — and the hand-rolled anti-pattern to avoid - **references/event-factory.md** - `EventFactory` dispatch pattern and how to register a new kind - **references/crypto-and-encryption.md** - Event signing/verification, secp256k1 abstraction, NIP-44 encryption, `SharedKeyCache` - **references/large-cache.md** - `LargeCache` expect/actual + `ICacheOperations` functional API @@ -518,6 +588,9 @@ Or see `references/nip-catalog.md` for complete catalog. | Verify signature | `event.verify()` | nip01Core/core/ | | Encrypt (NIP-44) | `Nip44v2.encrypt(...)` | nip44Encryption/ | | Bech32 encode | `Nip19.npubEncode(...)` | nip19Bech32/ | +| Resolve input → pubkey | `resolveUserHexOrNull(input, nip05Client)` | nip05DnsIdentifiers/ | +| Decode bech32 → pubkey (no net) | `decodePublicKeyAsHexOrNull(input)` | nip19Bech32/ | +| Verify NIP-05 identifier | `nip05Client.verify(Nip05Id.parse(id)!!, hex)` | nip05DnsIdentifiers/ | | Find NIP | `scripts/nip-lookup.sh ` | - | ## Common Event Kinds diff --git a/.claude/skills/nostr-expert/references/nip-catalog.md b/.claude/skills/nostr-expert/references/nip-catalog.md index a18731696f..78ed47ee11 100644 --- a/.claude/skills/nostr-expert/references/nip-catalog.md +++ b/.claude/skills/nostr-expert/references/nip-catalog.md @@ -13,7 +13,7 @@ under `experimental/`**. The categorized list below may lag behind — | 02 | `nip02FollowList/` | ContactListEvent.kt | Follow/contact lists (kind 3) | | 03 | `nip03Timestamp/` | OpenTimestampsAttestation.kt | Timestamps | | 04 | `nip04Dm/` | EncryptedDmEvent.kt | Legacy encrypted DMs (deprecated for NIP-17) | -| 05 | `nip05DnsIdentifiers/` | Nip05Verifier.kt | DNS-based verification | +| 05 | `nip05DnsIdentifiers/` | UserHexResolver.kt, Nip05Client.kt | Internet identifiers; `resolveUserHexOrNull` resolves hex/npub/nprofile/`name@domain` → pubkey (see references/nip05-identifiers.md) | | 06 | `nip06KeyDerivation/` | Mnemonic-related | BIP-39 key derivation | | 09 | `nip09Deletions/` | DeletionEvent.kt | Event deletion requests (kind 5) | | 11 | `nip11RelayInfo/` | RelayInformation.kt | Relay metadata | diff --git a/.claude/skills/nostr-expert/references/nip05-identifiers.md b/.claude/skills/nostr-expert/references/nip05-identifiers.md new file mode 100644 index 0000000000..450f0e86fc --- /dev/null +++ b/.claude/skills/nostr-expert/references/nip05-identifiers.md @@ -0,0 +1,98 @@ +# NIP-05: Identifiers → Pubkey Resolution + +How Quartz turns anything a human might type — a raw hex pubkey, an `npub`/`nprofile`/`nsec`, or a NIP-05 internet identifier (`alice@domain.tld`) — into a 64-hex Nostr pubkey. **Everything below already exists in `quartz/nip05DnsIdentifiers/`. Do not hand-roll it.** + +## TL;DR — the one function you almost always want + +```kotlin +import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull + +// hex | npub1… | nprofile1… | nsec1… | name@domain.tld → 64-hex pubkey (or null) +val pubkey: HexKey? = resolveUserHexOrNull(userInput, nip05Client) +``` + +`resolveUserHexOrNull(input, nip05Client)` (in `UserHexResolver.kt`) is the canonical "accept any identifier form" resolver. It: + +- trims input, tries the **synchronous** bech32/hex path first (`decodePublicKeyAsHexOrNull`), so hex/`npub`/`nprofile`/`nsec` never touch the network; +- only issues an HTTPS fetch for genuinely NIP-05-shaped input (`name@domain.tld`), gated by a cheap `looksLikeNip05()` precheck; +- returns `null` on anything unrecognizable or on a failed NIP-05 lookup (network error / no match); +- re-throws **only** `CancellationException`, so it's safe inside structured concurrency. + +Pass `nip05Client = null` in pure-offline contexts — NIP-05-shaped inputs then fall through to `null` and no HTTP is attempted. + +## ❌ Do not write this (the hand-rolled anti-pattern) + +```kotlin +// DON'T. This re-implements resolveUserHexOrNull badly: +// - no nsec support +// - no input validation (accepts IP-literal / malformed domains → spurious fetches) +// - hand-parses JSON instead of using Nip05Parser +// - bespoke httpGet ignores the "MUST NOT follow redirects" rule +// - swallows CancellationException, breaking structured concurrency +fun resolveObserver(input: String): String? { + if (Hex.isHex64(input)) return input.lowercase() + if (input.startsWith("npub1") || input.startsWith("nprofile1")) { /* … */ } + if ("@" in input) return resolveNip05(input) // bespoke well-known fetch + return null +} +``` + +## ✅ Do this instead + +```kotlin +// CLI already exposes it — commands call Context.requireUserHex(input): +val pubHex = resolveUserHexOrNull(input, nip05Client) + ?: return Output.error("bad_args", "expected npub, nprofile, 64-hex, or name@domain.tld") +``` + +## Building an `Nip05Client` + +`resolveUserHexOrNull` takes an `INip05Client`. On JVM/Android, wire the OkHttp fetcher (mirror what `cli/Context.kt` does): + +```kotlin +import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client +import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher + +val nip05Client = Nip05Client(fetcher = OkHttpNip05Fetcher { _ -> okHttpClient }) +``` + +`OkHttpNip05Fetcher` already runs on `Dispatchers.IO` and disables redirects per the NIP-05 spec ("Fetchers MUST ignore any HTTP redirects"). Don't re-implement the fetch. + +For tests / offline code, `EmptyNip05Client` is a no-op stub. + +## The pieces (all in `quartz/…/nip05DnsIdentifiers/`) + +| Type | File | Purpose | +|------|------|---------| +| `resolveUserHexOrNull(input, client?)` | `UserHexResolver.kt` | **Start here.** Any identifier form → 64-hex pubkey, or null. `suspend`. | +| `Nip05Id` | `Nip05Id.kt` | Parsed `name@domain`. `Nip05Id.parse(str)` validates (RFC 5321 local-part + hostname rules, rejects IP literals) and lowercases. `toUserUrl()` / `toDomainUrl()` build the `.well-known/nostr.json` URLs. `toDisplayValue()` collapses the `_` wildcard to just the domain. | +| `INip05Client` / `Nip05Client` | `INip05Client.kt`, `Nip05Client.kt` | Async resolver. `get(id): Nip05KeyInfo?` (pubkey + relays), `verify(id, hex): Boolean`, `load(id): KeyInfoSet?`, `list(domain): KeyInfoSet`, `loadClinkOffer(id): String?`. Auto-routes `.bit` domains to Namecoin. `EmptyNip05Client` = offline no-op. | +| `Nip05Fetcher` / `OkHttpNip05Fetcher` | `Nip05Fetcher.kt`, `OkHttpNip05Fetcher.kt` (jvmAndroid) | Transport SAM. OkHttp actual disables redirects + runs on IO. | +| `Nip05Parser` | `Nip05Parser.kt` | JSON `.well-known/nostr.json` codec: `parseHexKey`, `parseHexKeyAndRelays`, `parse` → `KeyInfoSet`, `parseClinkOffer`. | +| `Nip05KeyInfo` / `KeyInfoSet` | `Nip05KeyInfo.kt`, `KeyInfoSet.kt` | `Nip05KeyInfo(pubkey, relays)`; `KeyInfoSet(names: Map, relays: Map)` = the full domain listing. | +| `NamecoinNameResolver` | `namecoin/NamecoinNameResolver.kt` | `.bit` / `d/…` / `id/…` blockchain identifiers. `isNamecoinIdentifier(str)`, `resolve(str)`. Invoked automatically by `Nip05Client` — you rarely call it directly. | + +## When you only need the pure (synchronous, no-network) part + +If the input can only be hex/bech32 (no NIP-05), skip the client entirely: + +```kotlin +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull + +// hex | npub1… | nprofile1… | nsec1… → 64-hex pubkey (or null). No suspend, no network. +val pubkey: HexKey? = decodePublicKeyAsHexOrNull(input) +``` + +See `references/nip19-bech32.md` for the full bech32 entity story. `resolveUserHexOrNull` is just this function plus the NIP-05 HTTP fallback. + +## Verifying a claimed identifier + +To confirm a profile's advertised `nip05` actually points back to its pubkey (NIP-05 verification), use `verify`, not `get`: + +```kotlin +val ok: Boolean = nip05Client.verify(Nip05Id.parse("alice@domain.tld")!!, profilePubkeyHex) +``` + +## Tests + +`quartz/src/commonTest/…/nip05DnsIdentifiers/Nip05Test.kt` covers parsing, URL construction, case-normalization, CLINK offers, and the validation rejects (IP literals, malformed domains). diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md index 29b41f2d65..5941682d8d 100644 --- a/.claude/skills/quartz-integration/SKILL.md +++ b/.claude/skills/quartz-integration/SKILL.md @@ -1,6 +1,6 @@ --- name: quartz-integration -description: Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects. +description: Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects, (9) running a relay on Quartz and serving/building its NIP-11 relay information document (application/nostr+json). --- # Quartz Integration Guide @@ -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 @@ -634,7 +755,100 @@ val results = store.query(Filter(search = "bitcoin")) --- -## 15. Quick Reference +## 15. NIP-11 Relay Information Document + +If you're standing up a relay on Quartz's relay-server code, serve your NIP-11 +document with the **type-safe builder** — don't hand-write the JSON string. + +**Package:** `com.vitorpamplona.quartz.nip11RelayInfo` + +```kotlin +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip11RelayInfo.relayInformation + +val info = + relayInformation { + name = "sot" + description = "NIP-50 profile search ranked by Nostr web-of-trust" + software = "https://github.com/vitorpamplona/sot" + version = "0.1" + supports(1, 11, 42, 50) // ints → spec-compliant [1,11,42,50] in the JSON + } + +val json = info.toJson() // null/empty fields are omitted +``` + +Serve it at the relay root, branching on the `Accept` header (Ktor example): + +```kotlin +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import io.ktor.http.ContentType + +get("/") { + val accept = call.request.headers[HttpHeaders.Accept].orEmpty() + if (accept.contains(Nip11RelayInformation.CONTENT_TYPE)) { // "application/nostr+json" + call.respondText(json, ContentType.parse(Nip11RelayInformation.CONTENT_TYPE)) + } else { + call.respondText("Open a WebSocket (NIP-01) or send Accept: ${Nip11RelayInformation.CONTENT_TYPE}") + } +} +``` + +### Nested objects, lists, and enforced limits + +```kotlin +val info = + relayInformation { + name = "Paid Relay" + supports(1, 11, 42) + supportsExtensions("nip50-search") // supported_nip_extensions + countries("US", "CA") // relay_countries; also languages(...), tags(...) + nip50Features("profile_search") // the `nip50` field + + // limitation { } — camelCase maps to NIP-11 snake_case fields + limitation { + maxSubscriptions = 20 + maxFilters = 10 + authRequired = true + } + + // fees { } — each helper is repeatable + fees { + admission(amount = 1000, unit = "msats") + publication(amount = 100, unit = "msats", kinds = listOf(1, 30023)) + } + + // retention(...) — call once per policy entry + retention(kinds = listOf(0, 3), count = 1) + } +``` + +**Keep advertised limits in sync with enforced ones.** If you build a +`RelayLimits` for the server's policy chain, hand the *same* object to the +builder so what you publish can never drift from what you enforce: + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits + +val limits = RelayLimits(maxSubscriptions = 20, maxFilters = 10, maxLimit = 500, authRequired = true) + +val info = + relayInformation { + name = "My Relay" + supports(1, 11, 42, 45) + limitation(limits) // == limits.toNip11Limitation() + } +``` + +To load an operator-supplied doc from disk or a string instead of building it, +use `Nip11RelayInformation.fromJson(json)`. + +> `geode` (Quartz's standalone relay) builds its default document exactly this +> way — see `geode/.../RelayInfo.kt`. + +--- + +## 16. Quick Reference | Task | API | Package | |------|-----|---------| @@ -644,6 +858,13 @@ val results = store.query(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` | @@ -651,6 +872,8 @@ val results = store.query(Filter(search = "bitcoin")) | NIP-44 encrypt | `signer.nip44Encrypt(text, recipientPubKey)` | `nip01Core.signers` | | Bech32 decode | `Nip19Parser.uriToRoute("npub1...")` | `nip19Bech32` | | Bech32 encode | `Nip19Bech32.createNPub(pubKeyHex)` | `nip19Bech32` | +| Build NIP-11 doc | `relayInformation { name = ...; supports(1, 11) }` | `nip11RelayInfo` | +| Serialize NIP-11 doc | `info.toJson()` (media type `Nip11RelayInformation.CONTENT_TYPE`) | `nip11RelayInfo` | ## Common Event Kinds diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..1fbeadf800 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ee372de840..6a42fec122 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/bump-homebrew.yml b/.github/workflows/bump-homebrew.yml index 6300107042..e22343a18f 100644 --- a/.github/workflows/bump-homebrew.yml +++ b/.github/workflows/bump-homebrew.yml @@ -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 diff --git a/.github/workflows/bump-winget.yml b/.github/workflows/bump-winget.yml index 61e99aaceb..d03b4914aa 100644 --- a/.github/workflows/bump-winget.yml +++ b/.github/workflows/bump-winget.yml @@ -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 diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 3a0bfdf9b5..8442a8942b 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -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 }} diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index 3712fed647..f225b047fc 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -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 diff --git a/.github/workflows/smoke-test-desktop.yml b/.github/workflows/smoke-test-desktop.yml index de8cc824b9..0b51683681 100644 --- a/.github/workflows/smoke-test-desktop.yml +++ b/.github/workflows/smoke-test-desktop.yml @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index e5531f56b8..f32cdf0517 100644 Binary files a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt and b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt differ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index fc6a9d14e3..59fc275990 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -289,6 +289,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log @@ -3752,7 +3754,12 @@ object LocalCache : ILocalCache, ICacheProvider { } is PodcastMetadataEvent -> { - consumeBaseReplaceable(event, relay, wasVerified) + // Drop the known "Mock Podcast" spam flood instead of caching thousands of them. + if (event.isMockSpam()) { + false + } else { + consumeBaseReplaceable(event, relay, wasVerified) + } } is AuthoredPodcastsEvent -> { @@ -3763,6 +3770,14 @@ object LocalCache : ILocalCache, ICacheProvider { consumeBaseReplaceable(event, relay, wasVerified) } + is Podcasting20EpisodeEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + + is Podcasting20TrailerEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + is LnZapEvent -> { consume(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index 25f5ba21cb..7ce9b27bd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -205,7 +205,7 @@ class UiSharedPreferences( preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name - preferences[UI_BOTTOM_BAR_ITEMS] = JsonMapper.toJson(sharedSettings.bottomBarItems) + preferences[UI_BOTTOM_BAR_ITEMS] = encodeBottomBarItems(sharedSettings.bottomBarItems) preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab @@ -226,7 +226,19 @@ class UiSharedPreferences( } } - private fun decodeBottomBarItems(raw: String): List? { + /** + * Persists "follow the defaults" as a blank sentinel instead of the concrete default list. + * + * A user who resets the bottom bar (or who never customized it) should track whatever + * [DefaultBottomBarEntries] is in the *installed* app version. Storing the concrete list would + * pin them to today's default, so a future version that changes the default would never reach + * them. Storing a blank value instead makes [decodeBottomBarItems] resolve it back to the + * current [DefaultBottomBarEntries] on every load — i.e. the user is automatically migrated to + * the new default. Any genuinely customized bar is still stored as JSON. + */ + internal fun encodeBottomBarItems(items: List): String = if (items == DefaultBottomBarEntries) "" else JsonMapper.toJson(items) + + internal fun decodeBottomBarItems(raw: String): List? { if (raw.isBlank()) return DefaultBottomBarEntries // Current format: a JSON list of BottomBarEntry (built-ins + favorites). runCatching { return JsonMapper.fromJson>(raw) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt index cd7cc65d4f..90bdbf434c 100644 Binary files a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt and b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt differ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt index 53705a0dac..7ba5102c49 100644 Binary files a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt and b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt differ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt new file mode 100644 index 0000000000..6fa95cfe3f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/V4VPaymentHandler.kt @@ -0,0 +1,289 @@ +/* + * 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.service + +import android.content.Context +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm +import com.vitorpamplona.quartz.podcasts.PodcastBoostagram +import com.vitorpamplona.quartz.podcasts.PodcastValue +import com.vitorpamplona.quartz.podcasts.PodcastValueShare +import com.vitorpamplona.quartz.utils.mapNotNullAsync +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient + +/** + * Executes a Podcasting-2.0 value-for-value (V4V) split: takes a [PodcastValue] block and a total + * amount, computes each recipient's share ([PodcastValue.computeShares]) and pays them. + * + * This is the V4V analogue of [ZapPaymentHandler]. The recipients are raw Lightning destinations + * declared in the value block, but the two kinds are paid very differently: + * + * - [PodcastValue.TYPE_LNADDRESS] — resolved to a BOLT-11 via LNURL-pay and paid through the user's + * default payment source (NWC, CLINK debit, or — when none is set — handed to an external wallet + * via [onPayInvoicesViaIntent]). Same rails as a zap, and when `asZap` is set each share also + * carries a NIP-57 zap request, so a Nostr-aware lnaddress provider issues a real **zap receipt** + * (this is what lets the standard zap button drive a V4V split with its usual icon/counter UI). + * Per-minute streaming pays with `asZap = false` to avoid publishing a receipt every minute. + * - [PodcastValue.TYPE_NODE] — paid by **keysend** (NIP-47 `pay_keysend`) carrying the Podcasting-2.0 + * boostagram TLV ([PodcastValue.PODCAST_TLV_RECORD]) plus any per-recipient custom TLV. There is no + * LNURL endpoint and no invoice, so keysend can never produce a zap receipt regardless of `asZap`. + * Keysend is only available over NWC, so node recipients are skipped (with an error) when no NWC + * wallet is set up. + */ +class V4VPaymentHandler( + val account: Account, +) { + /** A resolved lnaddress share ready to pay: the share plus the BOLT-11 fetched for it. */ + class InvoicePayable( + val share: PodcastValueShare, + val invoice: String, + ) + + suspend fun pay( + value: PodcastValue, + totalMilliSats: Long, + boostagram: PodcastBoostagram, + zappedNote: Note?, + context: Context, + okHttpClient: (String) -> OkHttpClient, + onError: (title: String, message: String) -> Unit, + onProgress: (percent: Float) -> Unit, + onPayInvoicesViaIntent: (invoices: List) -> Unit, + asZap: Boolean = false, + zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + ) = withContext(Dispatchers.IO) { + val shares = value.computeShares(totalMilliSats) + if (shares.isEmpty()) { + onError( + stringRes(context, R.string.podcast_value_error_title), + stringRes(context, R.string.podcast_value_no_recipients), + ) + return@withContext + } + + val nodeShares = shares.filter { it.recipient.type == PodcastValue.TYPE_NODE } + val lnAddressShares = shares.filter { it.recipient.type == PodcastValue.TYPE_LNADDRESS } + + onProgress(0.05f) + + // Keysend (node) recipients can only be paid over NWC. + if (nodeShares.isNotEmpty()) { + if (account.nip47SignerState.hasWalletConnectSetup()) { + payNodeSharesViaKeysend(nodeShares, boostagram, context, onError) + } else { + onError( + stringRes(context, R.string.podcast_value_error_title), + stringRes(context, R.string.podcast_value_keysend_requires_nwc), + ) + } + } + + if (lnAddressShares.isNotEmpty()) { + val payables = + assembleInvoices( + shares = lnAddressShares, + message = boostagram.message.orEmpty(), + asZap = asZap, + zapType = zapType, + zappedNote = zappedNote, + okHttpClient = okHttpClient, + context = context, + onError = onError, + onProgress = { onProgress(it * 0.6f + 0.1f) }, + ) + payInvoices(payables, zappedNote, context, onError, onPayInvoicesViaIntent) { + onProgress(it * 0.25f + 0.7f) + } + } + + onProgress(1f) + } + + /** Hex-encodes a TLV value string as NIP-47 `pay_keysend` requires (UTF-8 bytes → hex). */ + private fun hexTlv(value: String): String = value.encodeToByteArray().toHexKey() + + private suspend fun payNodeSharesViaKeysend( + shares: List, + boostagram: PodcastBoostagram, + context: Context, + onError: (String, String) -> Unit, + ) { + val metadataTlv = TlvRecord(PodcastValue.PODCAST_TLV_RECORD, hexTlv(boostagram.toJson())) + + shares.forEach { share -> + val pubkey = share.recipient.address ?: return@forEach + + val tlvRecords = mutableListOf(metadataTlv) + val customType = share.recipient.customKey?.toLongOrNull() + val customValue = share.recipient.customValue + if (customType != null && customValue != null) { + tlvRecords.add(TlvRecord(customType, hexTlv(customValue))) + } + + val request = + PayKeysendMethod.create( + amount = share.amountMilliSats, + pubkey = pubkey, + tlvRecords = tlvRecords, + ) + + account.sendNwcRequest(request) { response: Response? -> + if (response is IErrorResponseLike) { + onError( + stringRes(context, R.string.error_dialog_pay_invoice_error), + response.errorMessage() + ?: stringRes(context, R.string.error_parsing_error_message), + ) + } + } + } + } + + private suspend fun assembleInvoices( + shares: List, + message: String, + asZap: Boolean, + zapType: LnZapEvent.ZapType, + zappedNote: Note?, + okHttpClient: (String) -> OkHttpClient, + context: Context, + onError: (String, String) -> Unit, + onProgress: (percent: Float) -> Unit, + ): List { + // When paying as a zap, attach a NIP-57 request to each share so the recipient's LNURL + // provider mints a zappable invoice and publishes a receipt. The receipt is attributed to + // the zapped note (toUser = null) since a value-block lnaddress is a raw payee, not + // necessarily a Nostr identity. Send to the show/episode author's inbox so they see it. + val noteEvent = zappedNote?.event + val authorRelays = zappedNote?.author?.inboxRelays()?.toSet() ?: emptySet() + + var progress = 0f + return mapNotNullAsync(shares) { share: PodcastValueShare -> + val lnAddress = share.recipient.address ?: return@mapNotNullAsync null + try { + val nostrRequest = + if (asZap && noteEvent != null) { + account.createZapRequestFor( + event = noteEvent, + pollOption = null, + message = message, + zapType = zapType, + toUser = null, + additionalRelays = authorRelays, + amountMillisats = share.amountMilliSats, + lnurl = LnurlForm.toUrl(lnAddress)?.let(LnurlForm::urlToBech32), + ) + } else { + null + } + + val invoice = + LightningAddressResolver().lnAddressInvoice( + lnAddress = lnAddress, + milliSats = share.amountMilliSats, + message = message, + nostrRequest = nostrRequest, + okHttpClient = okHttpClient, + onProgress = {}, + context = context, + ) + progress += 1f / shares.size + onProgress(progress) + InvoicePayable(share, invoice) + } catch (e: LightningAddressResolver.LightningAddressError) { + onError(e.title, e.msg) + null + } catch (e: Exception) { + if (e is CancellationException) throw e + onError( + stringRes(context, R.string.error_unable_to_fetch_invoice), + e.message ?: stringRes(context, R.string.error_parsing_error_message), + ) + null + } + } + } + + private suspend fun payInvoices( + payables: List, + zappedNote: Note?, + context: Context, + onError: (String, String) -> Unit, + onPayInvoicesViaIntent: (List) -> Unit, + onProgress: (percent: Float) -> Unit, + ) { + if (payables.isEmpty()) return + + when (val source = account.settings.defaultPaymentSource()) { + is PaymentSource.Nwc -> { + var done = 0 + payables.forEach { payable -> + account.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response -> + if (response is IErrorResponseLike) { + onError( + stringRes(context, R.string.error_dialog_pay_invoice_error), + response.errorMessage() + ?: stringRes(context, R.string.error_parsing_error_message), + ) + } + } + done++ + onProgress(done.toFloat() / payables.size) + } + } + + is PaymentSource.ClinkDebit -> { + var done = 0 + payables.forEach { payable -> + val response = ClinkDebitPayer.payInvoice(account, source.wallet.pointer, payable.invoice) + if (response?.isOk() != true) { + onError( + stringRes(context, R.string.error_dialog_pay_invoice_error), + response?.failureDetail() + ?: stringRes(context, R.string.clink_debit_no_response), + ) + } + done++ + onProgress(done.toFloat() / payables.size) + } + } + + null -> { + onPayInvoicesViaIntent(payables.map { it.invoice }) + onProgress(1f) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/podcasts/PodcastRemoteContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/podcasts/PodcastRemoteContent.kt new file mode 100644 index 0000000000..5fafb991be --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/podcasts/PodcastRemoteContent.kt @@ -0,0 +1,64 @@ +/* + * 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.service.podcasts + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.coroutines.executeAsync + +/** + * Fetches the off-event side files a podcast episode references by URL — the Podcasting-2.0 + * `chapters.json` document and the `transcript` file — so the client can render them in-app. + * A bounded read cap keeps a hostile/huge file from blowing up memory. + */ +object PodcastRemoteContent { + /** Refuse bodies larger than this (chapters/transcripts are small text files). */ + private const val MAX_BYTES = 2_000_000L + + suspend fun fetchText( + url: String, + okHttpClient: OkHttpClient, + ): String? = + withContext(Dispatchers.IO) { + try { + val request = + Request + .Builder() + .url(url) + .get() + .build() + okHttpClient.newCall(request).executeAsync().use { response -> + if (!response.isSuccessful) return@use null + val body = response.body ?: return@use null + // Reject an oversized declared length outright; cap the read for chunked bodies. + if (body.contentLength() > MAX_BYTES) return@use null + body.string().take(MAX_BYTES.toInt()) + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 87084e060f..1d79f9f370 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLi import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NsitesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.MyPodcastFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodcastFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssembler @@ -145,6 +146,7 @@ class RelaySubscriptionsCoordinator( val podcastEpisodes = PodcastEpisodesFilterAssembler(client) val podcasts = PodcastsFilterAssembler(client) val onePodcast = OnePodcastFilterAssembler(client) + val myPodcast = MyPodcastFilterAssembler(client) val softwareApps = SoftwareAppsFilterAssembler(client) val napplets = NappletsFilterAssembler(client) val connectedApps = ConnectedAppsFilterAssembler(client) @@ -198,6 +200,7 @@ class RelaySubscriptionsCoordinator( podcastEpisodes, podcasts, onePodcast, + myPodcast, softwareApps, badges, profileBadges, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt index a341fd5a8e..34ebb9abe4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt @@ -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:" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 56b3440198..defca41a12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -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) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 79ce1181cc..bc929ff893 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadat import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.BookmarkedPodcastsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.repositories.BookmarkedRepositoriesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen @@ -179,6 +180,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.pinnednotes.PinnedNotesScre import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastEpisodesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.EditPodcastShowScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.NewPodcastEpisodeScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.NewPodcastTrailerScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.authoring.PodcastAuthoringScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.PollsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen @@ -373,6 +378,10 @@ fun BuildNavigation( composableFromEnd { PodcastEpisodesScreen(accountViewModel, nav) } composableFromEnd { PodcastsScreen(accountViewModel, nav) } composableFromEndArgs { PodcastScreen(it.pubkey, accountViewModel, nav) } + composableFromEnd { PodcastAuthoringScreen(accountViewModel, nav) } + composableFromEnd { EditPodcastShowScreen(accountViewModel, nav) } + composableFromEndArgs { NewPodcastEpisodeScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } + composableFromEnd { NewPodcastTrailerScreen(accountViewModel, nav) } composableFromEndArgs { NewMusicTrackScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } composableFromEndArgs { NewMusicPlaylistScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } composableFromEndArgs { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) } @@ -446,6 +455,7 @@ fun BuildNavigation( composableFromEnd { OldBookmarkListScreen(accountViewModel, nav) } composableFromEnd { PinnedNotesScreen(accountViewModel, nav) } composableFromEnd { BookmarkedRepositoriesScreen(accountViewModel, nav) } + composableFromEnd { BookmarkedPodcastsScreen(accountViewModel, nav) } composableFromEnd { WebBookmarksScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } composableFromEnd { ScheduledPostsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 42760f3437..928af18b7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -196,6 +196,17 @@ sealed class Route { val pubkey: String, ) : Route() + @Serializable object PodcastAuthoring : Route() + + @Serializable object EditPodcastShow : Route() + + @Serializable + data class NewPodcastEpisode( + val dTag: String? = null, + ) : Route() + + @Serializable object NewPodcastTrailer : Route() + @Serializable data class NewMusicTrack( val dTag: String? = null, @@ -304,6 +315,8 @@ sealed class Route { @Serializable object BookmarkedRepositories : Route() + @Serializable object BookmarkedPodcasts : Route() + @Serializable object BookmarkGroups : Route() @Serializable object InterestSets : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt index 99c2278462..ed5f975e6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/TopBarExtensibleWithBackButton.kt @@ -44,13 +44,14 @@ import com.vitorpamplona.amethyst.ui.theme.isLight fun TopBarExtensibleWithBackButton( title: @Composable RowScope.() -> Unit, extendableRow: (@Composable () -> Unit)? = null, + actions: @Composable RowScope.() -> Unit = {}, popBack: () -> Unit, ) { MyExtensibleTopAppBar( title = title, extendableRow = extendableRow, navigationIcon = { IconButton(onClick = popBack) { ArrowBackIcon() } }, - actions = {}, + actions = actions, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index a9c17d31f9..d7cd351841 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -198,6 +198,7 @@ import com.vitorpamplona.amethyst.ui.note.types.observeZapSender import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastTrailerListItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay import com.vitorpamplona.amethyst.ui.stringRes @@ -325,6 +326,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent @@ -344,6 +346,9 @@ import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -982,10 +987,39 @@ private fun RenderNoteRow( RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) } + is Podcasting20EpisodeEvent -> { + RenderPodcastEpisode(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } + + is Podcasting20TrailerEvent -> { + PodcastTrailerListItem(baseNote, accountViewModel, nav) + } + is PodcastMetadataEvent -> { RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) } + is AppSpecificDataEvent -> { + // kind:30078 is overloaded; only the Podcasting-2.0 show-metadata variant renders as a + // podcast card. Anything else (e.g. a client's own settings) keeps the text fallback. + if (noteEvent.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) { + RenderPodcastMetadata(baseNote, makeItShort, canPreview, backgroundColor, accountViewModel, nav) + } else { + RenderTextEvent( + baseNote, + makeItShort, + canPreview, + quotesLeft, + unPackReply, + backgroundColor, + editState, + accountViewModel, + nav, + isBoostedNote = isBoostedNote, + ) + } + } + is DraftWrapEvent -> { RenderDraft(baseNote, quotesLeft, unPackReply, backgroundColor, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt new file mode 100644 index 0000000000..6e33d108df --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastBookmarkButton.kt @@ -0,0 +1,109 @@ +/* + * 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.note.types + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * A bookmark toggle for a podcast (show or episode). "Favoriting"/subscribing to a podcast reuses + * the existing NIP-51 bookmark list (kind 10003) — a public bookmark matches the soft public + * recommendation the dedicated favorites list (10054) was meant for, and the note then appears in + * the standard Bookmarks screen. Works for both regular events (e-tag) and addressable shows/ + * episodes (a-tag) because [AccountViewModel.addPublicBookmark] branches on the note type. + * + * Bookmarked state is read from the public bookmark id/address **sets** (not `List` + * containment) so it reflects reliably for addressable notes and updates the moment the list + * changes — the icon flipping filled/outline is the feedback, no toast needed. + * + * Rendered as a compact clickable glyph (not a Material [androidx.compose.material3.IconButton], + * whose 48dp minimum touch target would stand taller than the title it sits beside). [iconSize] + * defaults to a single title line so it aligns when placed next to a show/episode title. + */ +@Composable +fun PodcastBookmarkButton( + note: Note, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, + iconSize: Dp = 20.dp, +) { + val bookmarkState = accountViewModel.account.bookmarkState + + val publicAddresses by bookmarkState.publicBookmarkAddressIdSet.collectAsStateWithLifecycle() + val publicEvents by bookmarkState.publicBookmarkEventIdSet.collectAsStateWithLifecycle() + + val isBookmarked = + remember(note, publicAddresses, publicEvents) { + if (note is AddressableNote) { + note.address in publicAddresses + } else { + note.idHex in publicEvents + } + } + + Box( + modifier = + modifier + .clip(CircleShape) + .clickable( + role = Role.Button, + onClick = { + if (isBookmarked) { + accountViewModel.removePublicBookmark(note) + } else { + accountViewModel.addPublicBookmark(note) + } + }, + ).padding(4.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = if (isBookmarked) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd, + contentDescription = + stringRes( + if (isBookmarked) R.string.remove_from_public_bookmarks else R.string.add_to_public_bookmarks, + ), + tint = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(iconSize), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersSection.kt new file mode 100644 index 0000000000..f5b8232eda --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersSection.kt @@ -0,0 +1,161 @@ +/* + * 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.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.podcasts.PodcastChapter +import com.vitorpamplona.quartz.podcasts.PodcastChapters +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.coroutines.executeAsync + +private sealed interface ChaptersUiState { + data object Loading : ChaptersUiState + + data class Loaded( + val chapters: List, + ) : ChaptersUiState + + data object Failed : ChaptersUiState +} + +/** + * Fetches the episode's off-event Podcasting-2.0 chapters document on first composition and renders + * it as a tinted list of `timestamp — title` rows. Fetch is lazy (callers gate it behind an expand + * toggle) so scrolling a feed never triggers network. On failure or empty, renders nothing. + */ +@Composable +fun PodcastChaptersSection( + chaptersUrl: String, + accountViewModel: AccountViewModel, +) { + val state by produceState(ChaptersUiState.Loading, chaptersUrl) { + val client = accountViewModel.httpClientBuilder.okHttpClientForPreview(chaptersUrl) + val parsed = loadChapters(chaptersUrl, client) + value = if (parsed != null) ChaptersUiState.Loaded(parsed.chapters) else ChaptersUiState.Failed + } + + when (val current = state) { + is ChaptersUiState.Loading -> + Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + + is ChaptersUiState.Failed -> {} + + is ChaptersUiState.Loaded -> { + val chapters = current.chapters + if (chapters.isEmpty()) return + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + chapters.forEach { chapter -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = formatTimestamp(chapter.startSeconds()), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.widthIn(min = 44.dp), + ) + Text( + text = chapter.title ?: stringRes(R.string.podcast_chapters), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } +} + +private suspend fun loadChapters( + url: String, + client: OkHttpClient, +): PodcastChapters? = + withContext(Dispatchers.IO) { + try { + val request = Request.Builder().url(url).build() + client.newCall(request).executeAsync().use { response -> + if (response.isSuccessful) PodcastChapters.parse(response.body.string()) else null + } + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("PodcastChapters", "Failed to load chapters from $url", e) + null + } + } + +private fun formatTimestamp(seconds: Long): String { + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val secs = seconds % 60 + return if (hours > 0) { + "%d:%02d:%02d".format(hours, minutes, secs) + } else { + "%d:%02d".format(minutes, secs) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersView.kt new file mode 100644 index 0000000000..a5706f8891 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChaptersView.kt @@ -0,0 +1,167 @@ +/* + * 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.note.types + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.podcasts.PodcastRemoteContent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size18Modifier +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.podcasts.PodcastChapter +import com.vitorpamplona.quartz.podcasts.PodcastChapters + +/** + * The episode's Podcasting-2.0 chapters (from the off-event `chapters.json` referenced by the + * `chapters` tag), fetched and rendered as a collapsible, tappable list. Tapping a chapter calls + * [onSeek] with its start in milliseconds so the host can seek the live media controller — the same + * contract as [PodcastSoundbites]. + */ +@Composable +fun PodcastChaptersView( + chaptersUrl: String, + onSeek: (startMillis: Long) -> Unit, + accountViewModel: AccountViewModel, +) { + val chapters by produceState(initialValue = emptyList(), chaptersUrl) { + val client = accountViewModel.httpClientBuilder.okHttpClientForPreview(chaptersUrl) + val body = PodcastRemoteContent.fetchText(chaptersUrl, client) + value = body?.let { PodcastChapters.parse(it)?.chapters }?.filter { it.title?.isNotBlank() == true } ?: emptyList() + } + + if (chapters.isEmpty()) return + + var expanded by remember(chaptersUrl) { mutableStateOf(false) } + + Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + CollapsibleHeader( + symbol = MaterialSymbols.AutoMirrored.FormatListBulleted, + title = pluralStringResource(R.plurals.podcast_chapters_count, chapters.size, chapters.size), + expanded = expanded, + onToggle = { expanded = !expanded }, + ) + + if (expanded) { + chapters.forEach { chapter -> + ChapterRow(chapter, onSeek) + } + } + } +} + +@Composable +private fun ChapterRow( + chapter: PodcastChapter, + onSeek: (Long) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onSeek(chapter.startSeconds() * 1000) } + .padding(horizontal = 4.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = formatChapterTime(chapter.startTime), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + ) + Text( + text = chapter.title.orEmpty(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +internal fun CollapsibleHeader( + symbol: MaterialSymbol, + title: String, + expanded: Boolean, + onToggle: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onToggle) + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Size18Modifier, + tint = MaterialTheme.colorScheme.grayText, + ) + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Icon( + symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.grayText, + ) + } +} + +private fun formatChapterTime(totalSeconds: Double): String { + val total = totalSeconds.toLong() + val h = total / 3600 + val m = (total % 3600) / 60 + val s = total % 60 + val two = { n: Long -> if (n < 10) "0$n" else "$n" } + return if (h > 0) "$h:${two(m)}:${two(s)}" else "$m:${two(s)}" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChips.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChips.kt new file mode 100644 index 0000000000..bd0840bd8b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastChips.kt @@ -0,0 +1,111 @@ +/* + * 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.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol + +/** + * A small rounded, tinted pill for a podcast attribute (explicit, completed, a genre, a season/ + * episode number, …). Shared by the show and episode renderers so they read as one design. + */ +@Composable +internal fun PodcastBadge( + label: String, + symbol: MaterialSymbol?, + container: Color, + content: Color, +) { + Row( + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(container) + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + symbol?.let { + Icon( + symbol = it, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = content, + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = content, + ) + } +} + +/** A clickable pill that opens an external resource (a website, a transcript, chapters, …). */ +@Composable +internal fun PodcastLinkChip( + label: String, + symbol: MaterialSymbol, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = symbol, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt index faa0258569..fc9e6e3930 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisode.kt @@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -30,27 +33,36 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.replyModifier -import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.podcasts.PodcastEpisode // Bottom-rounded border on the audio player so it visually butts up against the cover's // top-rounded corners as one card. Constant — keep out of recomposition. private val PLAYER_BORDER_MODIFIER = Modifier.clip(RoundedCornerShape(bottomStart = 15.dp, bottomEnd = 15.dp)) +@OptIn(ExperimentalLayoutApi::class) @Composable fun RenderPodcastEpisode( note: Note, @@ -60,21 +72,32 @@ fun RenderPodcastEpisode( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? PodcastEpisodeEvent ?: return + val noteEvent = note.event ?: return + // Both NIP-F4 (kind 54) and Podcasting-2.0 (kind 30054) episodes implement PodcastEpisode, + // so this one renderer serves both. Title/image/description/audio come through the shared + // abstraction; content and tags come from the underlying event. + val episode = noteEvent as? PodcastEpisode ?: return - val title = remember(noteEvent) { noteEvent.title() } - val image = remember(noteEvent) { noteEvent.image() } - val description = remember(noteEvent) { noteEvent.description() } - // Pick the first audio URL. Publishers may emit multiple containers in their preferred - // order; clients with codec preferences can extend this later. - val firstAudio = remember(noteEvent) { noteEvent.audios().firstOrNull() } + val title = remember(noteEvent) { episode.episodeTitle() } + val image = remember(noteEvent) { episode.episodeImage() } + val description = remember(noteEvent) { episode.episodeDescription() } + // Prefer audio (podcasts are audio-first); fall back to the video source if that's all the + // episode ships. The media-controller player handles both. + val media = remember(noteEvent) { episode.episodeAudio().firstOrNull() ?: episode.episodeVideo() } + val hasVideo = remember(noteEvent) { episode.episodeVideo() != null } + val season = remember(noteEvent) { episode.episodeSeason() } + val episodeNumber = remember(noteEvent) { episode.episodeNumber() } + val transcriptUrl = remember(noteEvent) { episode.episodeTranscriptUrl() } + val chaptersUrl = remember(noteEvent) { episode.episodeChaptersUrl() } + val value = remember(noteEvent) { episode.episodeValue() } + var chaptersExpanded by remember(noteEvent) { mutableStateOf(false) } // Suppress the markdown block if blank — title + description already describe a short // episode. Otherwise hand off to RichText below. val markdown = remember(noteEvent) { noteEvent.content.ifBlank { null } } Column(MaterialTheme.colorScheme.replyModifier) { PodcastCoverCard(image, note, accountViewModel) - firstAudio?.let { audio -> + media?.let { audio -> PodcastEpisodeAudioPlayer( audio = audio, note = note, @@ -92,15 +115,67 @@ fun RenderPodcastEpisode( .padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(Size5dp), ) { - title?.let { - Text( - text = it, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + PodcastBookmarkButton(note, accountViewModel) + } + + if (season != null || episodeNumber != null || hasVideo || transcriptUrl != null || chaptersUrl != null) { + val uriHandler = LocalUriHandler.current + val seasonEpisodeLabel = + when { + season != null && episodeNumber != null -> stringRes(R.string.podcast_season_episode, season, episodeNumber) + episodeNumber != null -> stringRes(R.string.podcast_episode_number, episodeNumber) + season != null -> stringRes(R.string.podcast_season, season) + else -> null + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + seasonEpisodeLabel?.let { + PodcastBadge( + label = it, + symbol = null, + container = MaterialTheme.colorScheme.secondaryContainer, + content = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + if (hasVideo) { + PodcastBadge( + label = stringRes(R.string.podcast_video), + symbol = MaterialSymbols.Videocam, + container = MaterialTheme.colorScheme.tertiaryContainer, + content = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + transcriptUrl?.let { url -> + PodcastLinkChip(stringRes(R.string.podcast_transcript), MaterialSymbols.Description) { + runCatching { uriHandler.openUri(url) } + } + } + chaptersUrl?.let { + PodcastLinkChip(stringRes(R.string.podcast_chapters), MaterialSymbols.Checklist) { + chaptersExpanded = !chaptersExpanded + } + } + } + } + + if (chaptersExpanded) { + chaptersUrl?.let { PodcastChaptersSection(it, accountViewModel) } } description?.let { @@ -119,6 +194,18 @@ fun RenderPodcastEpisode( ) } + value?.takeIf { !makeItShort }?.let { + PodcastValueSplits(value = it) + } + + if (!makeItShort) { + val persons = remember(noteEvent) { episode.episodePersons() } + PodcastPeople(persons, accountViewModel, nav) + + val transcriptUrl = remember(noteEvent) { episode.episodeTranscriptUrl() } + transcriptUrl?.let { PodcastTranscriptView(it, accountViewModel) } + } + markdown?.takeIf { !makeItShort }?.let { Spacer(Modifier.padding(top = 4.dp)) val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt index 51cde34882..b3b7093468 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastEpisodeAudioPlayer.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -35,7 +36,8 @@ import com.vitorpamplona.amethyst.service.playback.composable.WaveformData import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.service.playback.composable.wavefront.syntheticWaveformFor import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.podcasts.PodcastEpisode // The voice player's internal controls are laid out for 100.dp; 80.dp is the tightest height // that still fits the play button without clipping it. Shared so the feed card and the @@ -43,13 +45,14 @@ import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag private val PLAYER_HEIGHT_MODIFIER = Modifier.fillMaxWidth().height(80.dp) /** - * The inline audio strip for a NIP-F4 episode (kind 54): one [AudioTag] played through the - * shared media-controller stack. [borderModifier] shapes the strip — bottom-rounded when it - * butts up under a cover image, fully rounded when it stands alone in a list. + * The inline audio strip for a podcast episode: one [PodcastAudio] played through the shared + * media-controller stack. Works for both NIP-F4 (kind 54) and Podcasting-2.0 (kind 30054) + * episodes via the spec-neutral audio holder. [borderModifier] shapes the strip — bottom-rounded + * when it butts up under a cover image, fully rounded when it stands alone in a list. */ @Composable fun PodcastEpisodeAudioPlayer( - audio: AudioTag, + audio: PodcastAudio, note: Note, title: String?, image: String?, @@ -69,10 +72,18 @@ fun PodcastEpisodeAudioPlayer( ?: syntheticWaveformFor(note.idHex) } - Row( - PLAYER_HEIGHT_MODIFIER, - verticalAlignment = Alignment.CenterVertically, - ) { + // The episode's value-for-value block, if any — drives the per-minute streaming control below + // the player. Pulled through the spec-neutral PodcastEpisode interface so both kinds work. + val value = remember(note) { (note.event as? PodcastEpisode)?.episodeValue() } + + // Highlight clips (Podcasting-2.0 soundbites) — rendered under the player so a tap can seek the + // live controller to the clip's start. + val soundbites = remember(note) { (note.event as? PodcastEpisode)?.episodeSoundbites().orEmpty() } + + // Off-event chapters document URL, if any — the list seeks the live controller too. + val chaptersUrl = remember(note) { (note.event as? PodcastEpisode)?.episodeChaptersUrl() } + + Column(Modifier.fillMaxWidth()) { GetMediaItem( videoUri = audio.url, title = title, @@ -90,13 +101,45 @@ fun PodcastEpisodeAudioPlayer( muted = false, ) { controller -> PauseControllerWhenInBackground(controller) - RenderVoicePlayer( - mediaItem = mediaItem, - controllerState = controller, - waveform = waveform, - borderModifier = borderModifier, - accountViewModel = accountViewModel, - ) + Row( + PLAYER_HEIGHT_MODIFIER, + verticalAlignment = Alignment.CenterVertically, + ) { + RenderVoicePlayer( + mediaItem = mediaItem, + controllerState = controller, + waveform = waveform, + borderModifier = borderModifier, + accountViewModel = accountViewModel, + ) + } + + value?.let { + PodcastStreamingControl( + value = it, + note = note, + episodeName = title, + podcastName = null, + controllerState = controller, + accountViewModel = accountViewModel, + ) + } + + PodcastSoundbites(soundbites) { startMillis -> + controller.controller.seekTo(startMillis) + controller.controller.play() + } + + chaptersUrl?.let { url -> + PodcastChaptersView( + chaptersUrl = url, + onSeek = { startMillis -> + controller.controller.seekTo(startMillis) + controller.controller.play() + }, + accountViewModel = accountViewModel, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt index c7f829c4c3..6fa80e7a86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastMetadata.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -37,6 +38,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -53,7 +55,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.replyModifier -import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.resolvePodcastShow @OptIn(ExperimentalLayoutApi::class) @Composable @@ -65,14 +67,23 @@ fun RenderPodcastMetadata( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? PodcastMetadataEvent ?: return + val noteEvent = note.event ?: return + // Resolves NIP-F4 kind:10154 and Podcasting-2.0 kind:30078 shows to one PodcastShow view. + val show = remember(noteEvent) { resolvePodcastShow(noteEvent) } ?: return - val title = remember(noteEvent) { noteEvent.title() } - val image = remember(noteEvent) { noteEvent.image() } - val description = remember(noteEvent) { noteEvent.description() } - val websites = remember(noteEvent) { noteEvent.websites() } - // Each podcast is its own keypair, so the author pubkey IS the podcast id used to open - // its dedicated screen with the full episode list. + val title = remember(noteEvent) { show.showTitle() } + val author = remember(noteEvent) { show.showAuthor() } + val image = remember(noteEvent) { show.showImage() } + val description = remember(noteEvent) { show.showDescription() } + val websites = remember(noteEvent) { show.showWebsites() } + val categories = remember(noteEvent) { show.showCategories() } + val fundingUrls = remember(noteEvent) { show.showFundingUrls() } + val isExplicit = remember(noteEvent) { show.showIsExplicit() } + val isComplete = remember(noteEvent) { show.showIsComplete() } + val copyright = remember(noteEvent) { show.showCopyright() } + val value = remember(noteEvent) { show.showValue() } + // In both drafts the show's author pubkey IS the podcast id used to open its dedicated + // screen with the full episode list (episodes are authored by the same key). val podcastPubkey = remember(noteEvent) { noteEvent.pubKey } Column( @@ -89,17 +100,65 @@ fun RenderPodcastMetadata( .padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(Size5dp), ) { - title?.let { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + PodcastBookmarkButton(note, accountViewModel) + } + + author?.let { Text( - text = it, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - maxLines = 2, + text = stringRes(R.string.podcast_by_author, it), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(), ) } + if (isExplicit || isComplete || categories.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (isComplete) { + PodcastBadge( + label = stringRes(R.string.podcast_completed), + symbol = MaterialSymbols.CheckCircle, + container = MaterialTheme.colorScheme.tertiaryContainer, + content = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + if (isExplicit) { + PodcastBadge( + label = stringRes(R.string.podcast_explicit), + symbol = null, + container = MaterialTheme.colorScheme.errorContainer, + content = MaterialTheme.colorScheme.onErrorContainer, + ) + } + categories.forEach { category -> + PodcastBadge( + label = category, + symbol = MaterialSymbols.Tag, + container = MaterialTheme.colorScheme.secondaryContainer, + content = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + description?.takeIf { !makeItShort }?.let { val tags = remember(noteEvent) { noteEvent.tags.toImmutableListOfLists() } @@ -116,23 +175,51 @@ fun RenderPodcastMetadata( ) } + value?.takeIf { !makeItShort }?.let { + PodcastValueSplits(value = it) + } + + if (fundingUrls.isNotEmpty() && !makeItShort) { + val uriHandler = LocalUriHandler.current + Button( + onClick = { runCatching { uriHandler.openUri(fundingUrls.first()) } }, + modifier = Modifier.fillMaxWidth().padding(top = Size5dp), + ) { + Icon( + symbol = MaterialSymbols.Favorite, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onPrimary, + ) + Text( + text = stringRes(R.string.podcast_support_show), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + if (websites.isNotEmpty() && !makeItShort) { + val uriHandler = LocalUriHandler.current FlowRow( horizontalArrangement = Arrangement.spacedBy(Size5dp), verticalArrangement = Arrangement.spacedBy(Size5dp), ) { websites.forEach { website -> - Text( - text = website, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.grayText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + PodcastLinkChip(website, MaterialSymbols.Public) { runCatching { uriHandler.openUri(website) } } } } } + copyright?.takeIf { !makeItShort }?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + modifier = Modifier.padding(top = Size5dp), + ) + } + // Affordance that this card opens a full show page with every episode. Row( modifier = Modifier.fillMaxWidth().padding(top = Size5dp), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt new file mode 100644 index 0000000000..8d4552acfa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastPeople.kt @@ -0,0 +1,206 @@ +/* + * 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.note.types + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.podcasts.PodcastPerson + +/** + * A "Hosts & Guests" strip: the Podcasting-2.0 `podcast:person` credits for a show or episode, + * rendered as a horizontally scrollable row of avatar + name + role. + * + * A person is usually a free-text credit (name + image URL + web link), not a Nostr user, so it's + * drawn with the app's default profile-image loader and its link opens externally. But when the + * publisher's `href` points at an `npub`/`nprofile`, we upgrade the card to a real Nostr profile — + * the standard [ClickableUserPicture] + [UsernameDisplay], tappable through to the profile. + */ +@Composable +fun PodcastPeople( + persons: List, + accountViewModel: AccountViewModel, + nav: INav, +) { + val people = persons.filter { it.isValid() } + if (people.isEmpty()) return + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + Text( + text = stringRes(R.string.podcast_hosts_and_guests), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.grayText, + ) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(people) { person -> + PersonItem(person, accountViewModel, nav) + } + } + } +} + +@Composable +private fun PersonItem( + person: PodcastPerson, + accountViewModel: AccountViewModel, + nav: INav, +) { + val pubKey = remember(person) { person.nostrPubKey() } + + if (pubKey != null) { + LoadUser(pubKey, accountViewModel) { user -> + if (user != null) { + NostrPersonCard(user, person.role, accountViewModel, nav) + } else { + FreeTextPersonCard(person, accountViewModel) + } + } + } else { + FreeTextPersonCard(person, accountViewModel) + } +} + +/** A person that resolved to a real Nostr identity — the standard profile treatment. */ +@Composable +private fun NostrPersonCard( + user: User, + role: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + PersonCardScaffold( + onClick = { nav.nav(routeFor(user)) }, + role = role, + avatar = { ClickableUserPicture(user, 56.dp, accountViewModel) }, + name = { + UsernameDisplay( + user, + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + accountViewModel = accountViewModel, + ) + }, + ) +} + +/** A free-text `podcast:person` credit — default image loader, external link. */ +@Composable +private fun FreeTextPersonCard( + person: PodcastPerson, + accountViewModel: AccountViewModel, +) { + val uriHandler = LocalUriHandler.current + val href = person.href + + PersonCardScaffold( + onClick = href?.let { { runCatching { uriHandler.openUri(it) } } }, + role = person.role, + avatar = { + RobohashFallbackAsyncImage( + robot = person.name, + model = person.img, + contentDescription = person.name, + modifier = Modifier.size(56.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + ) + }, + name = { + Text( + text = person.name, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + }, + ) +} + +/** Shared 72dp centered card layout: avatar, name, and an optional role line. */ +@Composable +private fun PersonCardScaffold( + onClick: (() -> Unit)?, + role: String?, + avatar: @Composable () -> Unit, + name: @Composable () -> Unit, +) { + Column( + modifier = + Modifier + .width(72.dp) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + avatar() + name() + role?.takeIf { it.isNotEmpty() }?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastSoundbites.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastSoundbites.kt new file mode 100644 index 0000000000..00825d41a3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastSoundbites.kt @@ -0,0 +1,90 @@ +/* + * 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.note.types + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.podcasts.PodcastSoundbite + +/** + * The episode's Podcasting-2.0 `podcast:soundbite` highlight clips as "jump to the good part" chips. + * Tapping one calls [onPlayFrom] with the clip's start offset in milliseconds; the host wires that + * to the media controller so playback seeks there. Kept controller-agnostic so it can live wherever + * a seek callback is available. + */ +@OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class) +@Composable +fun PodcastSoundbites( + soundbites: List, + onPlayFrom: (startMillis: Long) -> Unit, +) { + if (soundbites.isEmpty()) return + + FlowRow( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + soundbites.forEach { soundbite -> + val label = soundbite.title?.takeIf { it.isNotBlank() } ?: formatClock(soundbite.startTimeSeconds) + AssistChip( + onClick = { onPlayFrom(soundbite.startMillis()) }, + label = { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + ) + }, + leadingIcon = { + Icon( + symbol = MaterialSymbols.PlayArrow, + contentDescription = stringRes(R.string.podcast_play_soundbite), + modifier = Modifier.size(AssistChipDefaults.IconSize), + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + } + } +} + +private fun formatClock(totalSeconds: Double): String { + val total = totalSeconds.toLong() + val minutes = total / 60 + val seconds = total % 60 + val secStr = if (seconds < 10) "0$seconds" else "$seconds" + return "$minutes:$secStr" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastStreamingControl.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastStreamingControl.kt new file mode 100644 index 0000000000..5dce4e7177 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastStreamingControl.kt @@ -0,0 +1,225 @@ +/* + * 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.note.types + +import android.content.Context +import android.media.AudioManager +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.podcasts.PodcastStreamingAccrual +import com.vitorpamplona.quartz.podcasts.PodcastValue +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +// How often the accrual loop checks whether audio is genuinely playing. Cheap (one wake/second), +// and re-reading the live isPlaying each tick is what keeps streaming honest: if the player paused, +// went to the background, lost audio focus, errored, or was released, we simply stop accruing. +private const val STREAM_TICK_MS = 1_000L + +// Per-minute rates the user can pick from. Deliberately small and explicit — streaming sends sats +// automatically, so the choices are bounded and the selected rate is always shown on the toggle. +private val STREAM_RATE_CHOICES = listOf(1L, 5L, 10L, 21L, 50L, 100L) +private const val DEFAULT_STREAM_RATE = 10L + +/** + * Per-minute "streaming" payments for a podcast episode (Podcasting-2.0 value-for-value, streaming + * model). A switch — **off by default** — that, while on, sends the show/episode's [PodcastValue] + * split once per full minute of playback, at the chosen sats/minute rate. + * + * The whole point is that it must never pay while the user isn't listening, so accrual is bound + * tightly to real playback: + * - It lives inside the player composable, so navigating away disposes it and stops streaming. + * - Every second it re-reads the live [MediaControllerState.controller] and only accrues when audio + * is genuinely **audible**: playing, no playback error, in-app volume > 0, and system media volume + * > 0. `isPlaying` alone is not enough — a muted player (the player's mute button sets volume to 0) + * or a system volume of 0 keeps `isPlaying` true while the user hears nothing, and we must not + * spend then. The player also pauses itself on background / off-screen / error, stopping accrual. + * - Only whole, actually-played minutes are billed ([PodcastStreamingAccrual]); a partial minute is + * dropped when the session ends. + * - The toggle is gated to an in-app wallet (NWC/CLINK debit). Streaming to an external wallet app + * would mean firing a payment intent every minute, which we never do. + */ +@Composable +fun PodcastStreamingControl( + value: PodcastValue, + note: Note, + episodeName: String?, + podcastName: String?, + controllerState: MediaControllerState, + accountViewModel: AccountViewModel, +) { + val payableRecipients = remember(value) { value.recipients.count { it.split > 0 && !it.address.isNullOrBlank() } } + if (payableRecipients == 0) return + + val context = LocalContext.current + val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager } + val hasInAppWallet = remember { accountViewModel.account.settings.defaultPaymentSource() != null } + + // Deliberately plain remember (not rememberSaveable): streaming must never silently resume after + // a rotation or process death. Any fresh creation of this control starts OFF; the user re-opts in. + var enabled by remember(note.idHex) { mutableStateOf(false) } + var rate by remember(note.idHex) { mutableStateOf(DEFAULT_STREAM_RATE) } + var rateMenuOpen by remember { mutableStateOf(false) } + var streamedSats by remember(note.idHex) { mutableLongStateOf(0L) } + + if (enabled) { + // Restart the loop whenever the toggle, the player, or the rate changes; cancels (and so + // stops streaming) when this composable leaves the tree. + LaunchedEffect(controllerState, rate) { + val accrual = PodcastStreamingAccrual() + while (isActive) { + delay(STREAM_TICK_MS) + // Accrue only when audio is genuinely AUDIBLE, not merely "playing". isPlaying stays + // true when the player is muted (the player's mute button sets volume to 0) or when + // the system media volume is at 0 — in both cases the user hears nothing, so we must + // not spend. Require: playing, no error, in-app volume > 0, and system media volume > 0. + val audible = + runCatching { + controllerState.controller.isPlaying && + controllerState.playbackError.value == null && + controllerState.controller.volume > 0.001f && + (audioManager?.let { it.getStreamVolume(AudioManager.STREAM_MUSIC) > 0 } ?: true) + }.getOrDefault(false) + if (audible) { + val minutes = accrual.accrue(STREAM_TICK_MS) + if (minutes > 0) { + val amount = minutes * rate + streamedSats += amount + accountViewModel.payV4V( + value = value, + totalSats = amount, + podcastName = podcastName, + episodeName = episodeName, + zappedNote = note, + context = context, + streaming = true, + ) + } + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringRes(R.string.podcast_value_stream), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + // Rate chip — tap to change sats/minute. + Text( + text = stringRes(R.string.podcast_value_stream_rate, rate.toInt()), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable { rateMenuOpen = true }, + ) + DropdownMenu( + expanded = rateMenuOpen, + onDismissRequest = { rateMenuOpen = false }, + ) { + STREAM_RATE_CHOICES.forEach { choice -> + DropdownMenuItem( + text = { Text(stringRes(R.string.podcast_value_stream_rate, choice.toInt())) }, + onClick = { + rate = choice + rateMenuOpen = false + }, + ) + } + } + } + val status = + if (streamedSats > 0L) { + stringRes(R.string.podcast_value_streamed_total, streamedSats.toInt()) + } else { + stringRes(R.string.podcast_value_stream_hint) + } + Text( + text = status, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + + Switch( + checked = enabled, + onCheckedChange = { wantOn -> + if (wantOn && !hasInAppWallet) { + // No NWC/CLINK wallet -> we won't auto-stream; tell the user why and stay off. + accountViewModel.toastManager.toast( + R.string.podcast_value_error_title, + R.string.podcast_value_stream_requires_wallet, + ) + } else { + enabled = wantOn + if (!wantOn) streamedSats = 0L + } + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastTranscriptView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastTranscriptView.kt new file mode 100644 index 0000000000..86e24c54f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastTranscriptView.kt @@ -0,0 +1,111 @@ +/* + * 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.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.service.podcasts.PodcastRemoteContent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * The episode's transcript (from the off-event file referenced by the `transcript` tag), fetched and + * shown in a collapsible, scrollable panel. VTT/SRT scaffolding (the `WEBVTT` header, cue indices, + * and `-->` timing lines) is stripped so it reads as flowing text; plain-text transcripts pass + * through unchanged. + */ +@Composable +fun PodcastTranscriptView( + transcriptUrl: String, + accountViewModel: AccountViewModel, +) { + val transcript by produceState(initialValue = null as String?, transcriptUrl) { + val client = accountViewModel.httpClientBuilder.okHttpClientForPreview(transcriptUrl) + val body = PodcastRemoteContent.fetchText(transcriptUrl, client) + value = body?.let { cleanTranscript(it) }?.takeIf { it.isNotBlank() } + } + + val text = transcript ?: return + + var expanded by remember(transcriptUrl) { mutableStateOf(false) } + + Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + CollapsibleHeader( + symbol = MaterialSymbols.Description, + title = stringRes(R.string.podcast_transcript), + expanded = expanded, + onToggle = { expanded = !expanded }, + ) + + if (expanded) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 320.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .verticalScroll(rememberScrollState()) + .padding(12.dp), + ) + } + } +} + +/** + * Strips VTT/SRT caption scaffolding and joins the remaining caption lines into readable prose. + * Leaves plain-text transcripts effectively untouched. Falls back to the raw body if the cleanup + * removed everything (e.g. an unexpected format). + */ +private fun cleanTranscript(raw: String): String { + val out = StringBuilder() + for (line in raw.lineSequence()) { + val t = line.trim() + if (t.isEmpty()) continue + if (t == "WEBVTT") continue + if (t.startsWith("NOTE ")) continue + if (t.contains("-->")) continue // cue timing line + if (t.toIntOrNull() != null) continue // SRT cue index + out.append(t).append(' ') + } + return out.toString().trim().ifEmpty { raw.trim() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt new file mode 100644 index 0000000000..f698555e15 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PodcastValueSplits.kt @@ -0,0 +1,137 @@ +/* + * 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.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.podcasts.PodcastValue + +/** + * Renders a Podcasting-2.0 value-for-value split as a tinted card: a "Value-for-Value" header, a + * one-line hint that zaps to this item are split, and one row per recipient (name/address + its + * share of the split). + * + * This is a **breakdown display only** — there is no dedicated send button. Paying the split is the + * job of the standard zap button: when a podcast note carries a value block, [AccountViewModel.zap] + * detects it and fans the chosen amount out to these recipients (lnaddress shares as real zaps, node + * shares as keysend). Keeping a separate "Send value" button here would just duplicate that action. + */ +@Composable +fun PodcastValueSplits(value: PodcastValue) { + val recipients = value.recipients.filter { it.split > 0 || it.address != null } + if (recipients.isEmpty()) return + + val total = value.totalSplit().takeIf { it > 0 } ?: recipients.size + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(R.string.podcast_value_for_value), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + } + + Text( + text = stringRes(R.string.podcast_value_zap_split_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + + recipients.forEach { recipient -> + val label = recipient.name?.takeIf { it.isNotEmpty() } ?: recipient.address.orEmpty() + val percent = recipient.split * 100 / total + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + recipient.address + ?.takeIf { it.isNotEmpty() && it != label } + ?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + text = stringRes(R.string.podcast_value_split_percent, percent), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 9e05c1d6a9..9a2b7b26af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -328,6 +328,24 @@ class TopNavFilterState( ) } + private val _podcastRoutes = + combineTransform( + livePeopleListsFlow, + liveInterestFlows, + ) { peopleLists, interests -> + checkNotInMainThread() + emit( + listOf( + // Same content-style catalog as kind3GlobalPeopleRoutes, plus "Mine" so the + // podcasts + episodes screens can show only the user's own published shows/episodes. + listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow), + peopleLists, + interests, + listOf(muteListFollow), + ).flatten().toImmutableList(), + ) + } + private val _kind3GlobalPeople = livePeopleListsFlow.transform { peopleLists -> checkNotInMainThread() @@ -408,6 +426,11 @@ class TopNavFilterState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + val podcastRoutes = + _podcastRoutes + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, mineFollow, muteListFollow)) + fun destroy() { Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index c4efef0e6b..59f1f722fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuild import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.service.OnlineChecker +import com.vitorpamplona.amethyst.service.V4VPaymentHandler import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -83,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus +import com.vitorpamplona.amethyst.ui.note.payViaIntent import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.showAmountInteger import com.vitorpamplona.amethyst.ui.screen.UiSettingsState @@ -158,6 +160,10 @@ import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip92IMeta.imeta import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.podcasts.PodcastBoostagram +import com.vitorpamplona.quartz.podcasts.PodcastEpisode +import com.vitorpamplona.quartz.podcasts.PodcastShow +import com.vitorpamplona.quartz.podcasts.PodcastValue import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -938,6 +944,27 @@ class AccountViewModel( onPayViaIntent: (ImmutableList) -> Unit, zapType: LnZapEvent.ZapType? = null, ) = launchSigner { + // A podcast note (episode or show) can carry a Podcasting-2.0 value-for-value block. When it + // does, "zapping" it means paying that split — lnaddress recipients go out as real zaps (with + // receipts, which drive this same button's icon/counter), node recipients go out as keysend. + // This makes the standard zap button the single payment action for V4V content. + val v4v = + (note.event as? PodcastEpisode)?.episodeValue() + ?: (note.event as? PodcastShow)?.showValue() + if (v4v != null && v4v.recipients.any { it.split > 0 && !it.address.isNullOrBlank() }) { + executeV4V( + value = v4v, + totalMilliSats = amountInMillisats, + podcastName = (note.event as? PodcastShow)?.showTitle(), + episodeName = (note.event as? PodcastEpisode)?.episodeTitle(), + zappedNote = note, + context = context, + streaming = false, + onProgress = onProgress, + ) + return@launchSigner + } + val requestedType = zapType ?: defaultZapType() // Zaps on private rumors are forced to PRIVATE so the sender and @@ -965,6 +992,92 @@ class AccountViewModel( ) } + /** + * Executes a Podcasting-2.0 value-for-value split for [totalSats] sats: pays every recipient in + * the show/episode's [PodcastValue] block their weighted share (lnaddress via LNURL-pay, node via + * NWC keysend with the boostagram TLV). + * + * [streaming] marks this as a per-minute streaming payment rather than a one-off boost: the + * boostagram action becomes "stream" and errors are swallowed instead of toasted — a streaming + * session fires once a minute and we don't want per-minute toast spam. One-off boosts surface + * errors on [toastManager]. The external-wallet intent fallback is skipped while [streaming] + * (you can't auto-fire a wallet app every minute); streaming is gated to NWC/CLINK callers. + */ + fun payV4V( + value: PodcastValue, + totalSats: Long, + podcastName: String?, + episodeName: String?, + zappedNote: Note?, + context: Context, + streaming: Boolean = false, + onProgress: (Float) -> Unit = {}, + ) = launchSigner { + executeV4V( + value = value, + totalMilliSats = totalSats * 1000, + podcastName = podcastName, + episodeName = episodeName, + zappedNote = zappedNote, + context = context, + streaming = streaming, + onProgress = onProgress, + ) + } + + /** + * Shared V4V execution used by both [payV4V] and the V4V reroute inside [zap]. Must be called + * from within a [launchSigner] block (it does signing). [streaming] = true marks per-minute + * payments: errors are swallowed (no per-minute toast spam), the external-wallet intent fallback + * is skipped (can't auto-launch a wallet every minute), and lnaddress shares are paid WITHOUT a + * zap request so streaming doesn't publish a receipt every minute. One-off boosts ([streaming] = + * false) pay lnaddress shares as real zaps, producing receipts that feed the zap button's UI. + */ + private suspend fun executeV4V( + value: PodcastValue, + totalMilliSats: Long, + podcastName: String?, + episodeName: String?, + zappedNote: Note?, + context: Context, + streaming: Boolean, + onProgress: (Float) -> Unit, + ) { + val boostagram = + PodcastBoostagram( + podcast = podcastName, + episode = episodeName, + action = if (streaming) PodcastBoostagram.ACTION_STREAM else PodcastBoostagram.ACTION_BOOST, + appName = "Amethyst", + valueMsatTotal = totalMilliSats, + senderName = account.userProfile().toBestDisplayName(), + ) + + V4VPaymentHandler(account).pay( + value = value, + totalMilliSats = totalMilliSats, + boostagram = boostagram, + zappedNote = zappedNote, + context = context, + asZap = !streaming, + zapType = LnZapEvent.ZapType.PUBLIC, + okHttpClient = httpClientBuilder::okHttpClientForMoney, + onError = { title, message -> + if (!streaming) toastManager.toast(title, message) + }, + onProgress = onProgress, + onPayInvoicesViaIntent = { invoices -> + if (!streaming) { + invoices.forEach { invoice -> + payViaIntent(invoice, context, onPaid = {}, onError = { + toastManager.toast(stringRes(context, R.string.error_dialog_zap_error), it) + }) + } + } + }, + ) + } + /** * Fire-and-forget NIP-61 nutzap from the zap picker. Picks a mint the * recipient accepts (via their kind:10019) that we also have proofs at, @@ -2254,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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt index 37caa116b7..ea4e410601 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.Size40Modifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastEvent import kotlinx.coroutines.flow.StateFlow @Composable @@ -66,6 +67,7 @@ fun ListOfBookmarkGroupsFeedView( openOldBookmarks: () -> Unit, openPinnedNotes: () -> Unit, openRepositories: () -> Unit, + openPodcasts: () -> Unit, onOpenItem: (String, BookmarkType) -> Unit, onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit, onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -99,6 +101,11 @@ fun ListOfBookmarkGroupsFeedView( HorizontalDivider(thickness = DividerThickness) } + item { + PodcastsBookmarkList(defaultBookmarks, openPodcasts) + HorizontalDivider(thickness = DividerThickness) + } + itemsIndexed( bookmarkGroupFeedState, key = { _: Int, item: LabeledBookmarkList -> item.identifier }, @@ -249,6 +256,54 @@ fun RepositoriesBookmarkList( ) } +@Composable +fun PodcastsBookmarkList( + defaultBookmarks: BookmarkListState, + openPodcasts: () -> Unit, +) { + val bookmarkState by defaultBookmarks.bookmarks.collectAsStateWithLifecycle() + + // Podcasts live in the same kind:10003 list as everything else, so count the podcast subset. + val podcastCount = + (bookmarkState.public + bookmarkState.private).count { isPodcastEvent(it.event) } + + ListItem( + modifier = Modifier.clickable(onClick = openPodcasts), + headlineContent = { + Text(stringRes(R.string.podcast_bookmarks), maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + supportingContent = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringRes(R.string.podcast_bookmarks_explainer), + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + } + }, + leadingContent = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + symbol = MaterialSymbols.Podcasts, + contentDescription = stringRes(R.string.bookmark_list_icon_label), + modifier = Size40Modifier, + ) + Spacer(StdVertSpacer) + BookmarkMembershipStatusAndNumberDisplay( + modifier = Modifier.align(Alignment.CenterHorizontally), + postBookmarksSize = podcastCount, + articleBookmarksSize = 0, + ) + } + }, + ) +} + @Composable fun OldBookmarkList( oldBookmarks: OldBookmarkListState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt index e1aa912ebd..f76f075764 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt @@ -66,6 +66,7 @@ fun ListOfBookmarkGroupsScreen( openOldBookmarks = { nav.nav(Route.OldBookmarks) }, openPinnedNotes = { nav.nav(Route.PinnedNotes) }, openRepositories = { nav.nav(Route.BookmarkedRepositories) }, + openPodcasts = { nav.nav(Route.BookmarkedPodcasts) }, addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) }, openBookmarkGroup = { identifier, bookmarkType -> nav.nav(Route.BookmarkGroupView(identifier, bookmarkType)) @@ -110,6 +111,7 @@ fun ListOfBookmarkGroupsFeed( openOldBookmarks: () -> Unit, openPinnedNotes: () -> Unit, openRepositories: () -> Unit, + openPodcasts: () -> Unit, addBookmarkGroup: () -> Unit, openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit, renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit, @@ -159,6 +161,7 @@ fun ListOfBookmarkGroupsFeed( openOldBookmarks = openOldBookmarks, openPinnedNotes = openPinnedNotes, openRepositories = openRepositories, + openPodcasts = openPodcasts, onOpenItem = openBookmarkGroup, onRenameItem = renameBookmarkGroup, onItemDescriptionChange = changeBookmarkGroupDescription, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/BookmarkedPodcastsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/BookmarkedPodcastsScreen.kt new file mode 100644 index 0000000000..f4b0531ef7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/BookmarkedPodcastsScreen.kt @@ -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.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.dal.BookmarkPodcastsFeedViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun BookmarkedPodcastsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val podcastsFeedViewModel: BookmarkPodcastsFeedViewModel = + viewModel( + key = "NostrBookmarkPodcastsFeedViewModel", + factory = BookmarkPodcastsFeedViewModel.Factory(accountViewModel.account), + ) + + val bookmarks by accountViewModel.account.bookmarkState.bookmarks + .collectAsStateWithLifecycle() + + LaunchedEffect(bookmarks) { + podcastsFeedViewModel.invalidateData() + } + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + TopBarWithBackButton(stringRes(id = R.string.podcast_bookmarks), nav) + }, + accountViewModel = accountViewModel, + ) { + RefresheableFeedView( + podcastsFeedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.kt new file mode 100644 index 0000000000..f9c83106da --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedFilter.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.podcasts.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.FeedFilter +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastEvent + +/** + * The podcast subset of the user's NIP-51 bookmark list (kind 10003): podcasts (shows and episodes) + * are bookmarked into the same general list as everything else, so this filter pulls just the + * podcast-typed notes ([isPodcastEvent]) back out — both public and private — newest first. + */ +class BookmarkPodcastsFeedFilter( + val account: Account, +) : FeedFilter() { + override fun feedKey(): String = + account.bookmarkState.bookmarks.value + .hashCode() + .toString() + + override fun feed(): List { + val bookmarks = account.bookmarkState.bookmarks.value + return (bookmarks.public + bookmarks.private) + .filter { isPodcastEvent(it.event) } + .distinct() + .sortedByDescending { it.createdAt() ?: 0L } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedViewModel.kt new file mode 100644 index 0000000000..2c65ed9673 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/podcasts/dal/BookmarkPodcastsFeedViewModel.kt @@ -0,0 +1,39 @@ +/* + * 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.bookmarkgroups.podcasts.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel + +@Stable +class BookmarkPodcastsFeedViewModel( + val account: Account, +) : AndroidFeedViewModel(BookmarkPodcastsFeedFilter(account)) { + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = BookmarkPodcastsFeedViewModel(account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/BottomConsoleSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/BottomConsoleSheet.kt index 8b970e9df3..99da398e98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/BottomConsoleSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/BottomConsoleSheet.kt @@ -126,8 +126,14 @@ fun BottomConsoleSheet( Surface( color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 3.dp, - shadowElevation = 6.dp, + // No elevation. This panel docks flush against the app's bottom navigation bar, so a + // shadowElevation just casts a shadow onto that bar — a seam that breaks the flush, + // background-matched look. The tonalElevation had no visual effect here anyway (the color + // isn't exactly colorScheme.surface, so Material never applies the surfaceTint); the + // shadow was the only thing clashing with the nav bar. The grabber + divider still + // separate the panel from the page above it. + tonalElevation = 0.dp, + shadowElevation = 0.dp, shape = RoundedCornerShape(bottomStart = 0.dp, bottomEnd = 0.dp), ) { val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.4f).dp diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt index a683b6abdd..97bf92f52c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt @@ -90,7 +90,12 @@ fun TopControlSheet( Surface( color = MaterialTheme.colorScheme.surface, contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 3.dp, + // No tonal elevation. Material 3 only recolors a Surface whose color is EXACTLY + // colorScheme.surface — it swaps in surfaceColorAtElevation(), which blends surfaceTint + // (= primary, Amethyst's purple) over the surface. On the light theme that near-white + + // purple mix reads as a pink/lilac cast instead of the plain background the sheet should + // have. Keep the drop shadow (shadowElevation) to lift the sheet off the page below it. + tonalElevation = 0.dp, shadowElevation = 6.dp, shape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp), ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastAuthors.kt new file mode 100644 index 0000000000..33eb5e7aec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastAuthors.kt @@ -0,0 +1,143 @@ +/* + * 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.podcasts + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag + +/** + * Renders a NIP-F4 podcast's claimed authors (`kind:10154` `p` tags, each a pubkey + role). The + * claims are unverified — the show can name anyone — so each author is cross-checked against their + * own counter-claim ([AuthoredPodcastsEvent], `kind:10064`): a verified check appears only when that + * author's 10064 actually lists this podcast's pubkey. The 10064 is fetched + observed lazily via + * [observeNoteEvent], so it arrives and flips the badge without extra wiring. + */ +@Composable +fun PodcastAuthors( + podcastPubkey: HexKey, + authors: List, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + authors.forEach { author -> + PodcastAuthorRow(podcastPubkey, author, accountViewModel, nav) + } + } +} + +@Composable +private fun PodcastAuthorRow( + podcastPubkey: HexKey, + author: AuthorTag, + accountViewModel: AccountViewModel, + nav: INav, +) { + var user by remember(author.pubKey) { mutableStateOf(accountViewModel.getUserIfExists(author.pubKey)) } + if (user == null) { + LaunchedEffect(author.pubKey) { + user = accountViewModel.checkGetOrCreateUser(author.pubKey) + } + } + + val authoredNote = + remember(author.pubKey) { + LocalCache.getOrCreateAddressableNote(AuthoredPodcastsEvent.createAddress(author.pubKey)) + } + val authored by observeNoteEvent(authoredNote, accountViewModel) + val verified = authored?.authors(podcastPubkey) == true + + val loadedUser = user ?: return + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav(routeFor(loadedUser)) } + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ClickableUserPicture(loadedUser, 28.dp, accountViewModel) + Column(modifier = Modifier.weight(1f)) { + UsernameDisplay(loadedUser, accountViewModel = accountViewModel) + author.role?.let { + Text( + text = roleLabel(it), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + } + if (verified) { + Icon( + symbol = MaterialSymbols.CheckCircle, + contentDescription = stringRes(R.string.podcast_author_verified), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun roleLabel(role: String): String = + when (role) { + AuthorTag.ROLE_HOST -> stringRes(R.string.podcast_role_host) + AuthorTag.ROLE_COHOST -> stringRes(R.string.podcast_role_cohost) + AuthorTag.ROLE_EDITOR -> stringRes(R.string.podcast_role_editor) + else -> role + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt index e75275cb42..a76a1a9796 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodeListItem.kt @@ -36,15 +36,18 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.note.types.PodcastEpisodeAudioPlayer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.grayText -import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.podcasts.PodcastEpisode private val PLAYER_SHAPE = Modifier.clip(RoundedCornerShape(12.dp)) @@ -59,15 +62,28 @@ fun PodcastEpisodeListItem( accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? PodcastEpisodeEvent ?: return + val noteEvent = note.event ?: return + // Both kind 54 (NIP-F4) and kind 30054 (Podcasting 2.0) episodes implement PodcastEpisode. + val episode = noteEvent as? PodcastEpisode ?: return - val title = remember(noteEvent) { noteEvent.title() } - val description = remember(noteEvent) { noteEvent.description() } - val firstAudio = remember(noteEvent) { noteEvent.audios().firstOrNull() } - val image = remember(noteEvent) { noteEvent.image() } + val title = remember(noteEvent) { episode.episodeTitle() } + val description = remember(noteEvent) { episode.episodeDescription() } + // Prefer audio; fall back to a video source so video-only episodes still play inline. + val media = remember(noteEvent) { episode.episodeAudio().firstOrNull() ?: episode.episodeVideo() } + val image = remember(noteEvent) { episode.episodeImage() } + val season = remember(noteEvent) { episode.episodeSeason() } + val episodeNumber = remember(noteEvent) { episode.episodeNumber() } val context = LocalContext.current val dateStr = remember(noteEvent) { timeAgo(noteEvent.createdAt, context, prefix = "") } + val seasonEpisodeLabel = + when { + season != null && episodeNumber != null -> stringRes(R.string.podcast_season_episode, season, episodeNumber) + episodeNumber != null -> stringRes(R.string.podcast_episode_number, episodeNumber) + season != null -> stringRes(R.string.podcast_season, season) + else -> null + } + val subtitle = listOfNotNull(seasonEpisodeLabel, dateStr.takeIf { it.isNotBlank() }).joinToString(" · ") Column( modifier = @@ -77,7 +93,7 @@ fun PodcastEpisodeListItem( .padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(Size5dp), ) { - dateStr.takeIf { it.isNotBlank() }?.let { + subtitle.takeIf { it.isNotBlank() }?.let { Text( text = it, style = MaterialTheme.typography.labelMedium, @@ -107,7 +123,7 @@ fun PodcastEpisodeListItem( ) } - firstAudio?.let { audio -> + media?.let { audio -> PodcastEpisodeAudioPlayer( audio = audio, note = note, @@ -117,5 +133,16 @@ fun PodcastEpisodeListItem( accountViewModel = accountViewModel, ) } + + // Standard engagement row per episode (comment / zap / react) — same as every other note. + // addPadding = false since the row already sits inside this item's horizontal padding. + ReactionsRow( + baseNote = note, + showReactionDetail = true, + addPadding = false, + editState = null, + accountViewModel = accountViewModel, + nav = nav, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt index ca40ecb546..81c27cead6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastEpisodesTopBar.kt @@ -62,9 +62,10 @@ private fun PodcastEpisodesTopNavFilterBar( accountViewModel: AccountViewModel, onChange: (FeedDefinition) -> Unit, ) { - // Same content-style catalog as Music/Articles — All Follows, Your Follows, kind3 - // Follows, Around Me, Global, people lists, interest sets, mute list. - val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + // Same content-style catalog as Music — All Follows, Your Follows, kind3 Follows, + // Around Me, Global, Mine (the user's own published shows/episodes), people lists, + // interest sets, mute list. + val allLists by followListsModel.podcastRoutes.collectAsStateWithLifecycle() FeedFilterSpinner( placeholderCode = listName, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt index 35f90c6444..7c9e736205 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastHeader.kt @@ -44,32 +44,43 @@ import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.note.types.PodcastCoverCard +import com.vitorpamplona.amethyst.ui.note.types.PodcastPeople import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.podcasts.PodcastShow /** * Hero header for a single podcast screen: large cover art, title, websites and the show * description (rich text + translation, same as a profile's About), followed by the * "Episodes (N)" section divider that the episode rows hang under. + * + * Spec-neutral: [show] is either a NIP-F4 [PodcastMetadataEvent] (kind 10154) or a Podcasting-2.0 + * show (kind 30078, `d=podcast-metadata`), both adapting to the shared [PodcastShow]. The claimed- + * author verification row is NIP-F4 only (its `p`-tag claims + kind:10064 counter-claims), so it's + * shown only when the underlying event is a [PodcastMetadataEvent]. */ @OptIn(ExperimentalLayoutApi::class) @Composable fun PodcastHeader( metadataNote: Note, - metadataEvent: PodcastMetadataEvent?, + show: PodcastShow?, episodeCount: Int?, accountViewModel: AccountViewModel, nav: INav, ) { - val title = remember(metadataEvent) { metadataEvent?.title() } - val image = remember(metadataEvent) { metadataEvent?.image() } - val description = remember(metadataEvent) { metadataEvent?.description() } - val websites = remember(metadataEvent) { metadataEvent?.websites() ?: emptyList() } - val tags = remember(metadataEvent) { metadataEvent?.tags?.toImmutableListOfLists() ?: EmptyTagList } + val title = remember(show) { show?.showTitle() } + val image = remember(show) { show?.showImage() } + val description = remember(show) { show?.showDescription() } + val websites = remember(show) { show?.showWebsites() ?: emptyList() } + val f4 = show as? PodcastMetadataEvent + val claimedAuthors = remember(f4) { f4?.claimedAuthors() ?: emptyList() } + val podcastPubkey = remember(f4) { f4?.pubKey } + val tags = remember(metadataNote) { metadataNote.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } Column(Modifier.fillMaxWidth()) { PodcastCoverCard(image, metadataNote, accountViewModel) @@ -122,11 +133,37 @@ fun PodcastHeader( nav = nav, ) } + + if (claimedAuthors.isNotEmpty() && podcastPubkey != null) { + PodcastAuthors(podcastPubkey, claimedAuthors, accountViewModel, nav) + } + + val persons = remember(show) { show?.showPersons() ?: emptyList() } + PodcastPeople(persons, accountViewModel, nav) + } + + // Standard engagement row for the show itself (comment / zap / react), like any other + // content detail. Only shown once the show event resolves so it acts on a real note. + if (show != null) { + HorizontalDivider(thickness = DividerThickness) + + ReactionsRow( + baseNote = metadataNote, + showReactionDetail = true, + addPadding = true, + editState = null, + accountViewModel = accountViewModel, + nav = nav, + ) + + PodcastTopSupporters(metadataNote, accountViewModel, nav) } // Only render once episodes have actually loaded — avoids flashing "0 episodes" // under the cover while the relay request is still in flight. episodeCount?.let { count -> + HorizontalDivider(thickness = DividerThickness) + Text( text = pluralStringResource(R.plurals.podcast_episode_count, count, count), style = MaterialTheme.typography.titleMedium, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt index b161ad3aab..a7034479e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastScreen.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed @@ -53,6 +55,8 @@ import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton +import com.vitorpamplona.amethyst.ui.note.types.PodcastBookmarkButton import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal.OnePodcastFeedViewModel @@ -60,8 +64,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodc import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.resolvePodcastShow +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.podcasts.PodcastShow @Composable fun PodcastScreen( @@ -70,10 +81,18 @@ fun PodcastScreen( nav: INav, ) { val podcast = remember(pubkey) { LocalCache.checkGetOrCreateUser(pubkey) } ?: return - val metadataNote = + // A show is either NIP-F4 (kind 10154) or Podcasting-2.0 (kind 30078, d=podcast-metadata). + // Resolve both addresses; whichever has an event is this podcast's metadata. + val f4Note = remember(pubkey) { LocalCache.getOrCreateAddressableNote(PodcastMetadataEvent.createAddress(pubkey)) } + val p20Note = + remember(pubkey) { + LocalCache.getOrCreateAddressableNote( + Address(AppSpecificDataEvent.KIND, pubkey, Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG), + ) + } val feedViewModel: OnePodcastFeedViewModel = viewModel( @@ -81,23 +100,27 @@ fun PodcastScreen( factory = OnePodcastFeedViewModel.Factory(pubkey, accountViewModel.account), ) - PodcastScreen(podcast, metadataNote, feedViewModel, accountViewModel, nav) + PodcastScreen(podcast, f4Note, p20Note, feedViewModel, accountViewModel, nav) } @Composable fun PodcastScreen( podcast: User, - metadataNote: Note, + f4Note: Note, + p20Note: Note, feedViewModel: OnePodcastFeedViewModel, accountViewModel: AccountViewModel, nav: INav, ) { WatchLifecycleAndUpdateModel(feedViewModel) - // Fetches both the show metadata (kind 10154) and every episode (kind 54) authored by - // this podcast's key from its outbox relays. + // Fetches the show metadata (NIP-F4 kind 10154 / Podcasting-2.0 kind 30078) and every episode + // (kind 54 / kind 30054) + trailer authored by this podcast's key from its outbox relays. OnePodcastFilterAssemblerSubscription(podcast, accountViewModel) - val metadataEvent by observeNoteEvent(metadataNote, accountViewModel) + val f4Event by observeNoteEvent(f4Note, accountViewModel) + val p20Event by observeNoteEvent(p20Note, accountViewModel) + val show: PodcastShow? = remember(f4Event, p20Event) { resolvePodcastShow(f4Event) ?: resolvePodcastShow(p20Event) } + val metadataNote = if (f4Event != null) f4Note else p20Note DisappearingScaffold( isInvertedLayout = false, @@ -105,12 +128,23 @@ fun PodcastScreen( TopBarExtensibleWithBackButton( title = { Text( - text = metadataEvent?.title() ?: stringRes(R.string.route_podcasts), + text = show?.showTitle() ?: stringRes(R.string.route_podcasts), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) }, + actions = { + // Only offer these once the show metadata has loaded — there must be a resolved + // event to bookmark / act on. The bookmark button reflects bookmarked state; the + // 3-dot button is the standard NoteCompose options menu, sitting to its right. + if (show != null) { + PodcastBookmarkButton(metadataNote, accountViewModel) + Spacer(Modifier.width(Size10dp)) + MoreOptionsButton(metadataNote, accountViewModel = accountViewModel, nav = nav) + Spacer(Modifier.width(Size10dp)) + } + }, popBack = nav::popBack, ) }, @@ -120,7 +154,7 @@ fun PodcastScreen( SaveableFeedState(feedViewModel.feedState, scrollStateKey = null) { listState -> PodcastScreenBody( metadataNote = metadataNote, - metadataEvent = metadataEvent, + show = show, feedViewModel = feedViewModel, listState = listState, accountViewModel = accountViewModel, @@ -134,7 +168,7 @@ fun PodcastScreen( @Composable private fun PodcastScreenBody( metadataNote: Note, - metadataEvent: PodcastMetadataEvent?, + show: PodcastShow?, feedViewModel: OnePodcastFeedViewModel, listState: LazyListState, accountViewModel: AccountViewModel, @@ -144,20 +178,20 @@ private fun PodcastScreenBody( when (val state = feedState) { is FeedState.Loaded -> - PodcastEpisodesList(metadataNote, metadataEvent, state, listState, accountViewModel, nav) + PodcastEpisodesList(metadataNote, show, state, listState, accountViewModel, nav) is FeedState.Empty -> - PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) { + PodcastHeaderWithStatus(metadataNote, show, listState, accountViewModel, nav) { StatusText(stringRes(R.string.podcast_no_episodes)) } is FeedState.FeedError -> - PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) { + PodcastHeaderWithStatus(metadataNote, show, listState, accountViewModel, nav) { FeedError(state.errorMessage) { feedViewModel.invalidateData() } } is FeedState.Loading -> - PodcastHeaderWithStatus(metadataNote, metadataEvent, listState, accountViewModel, nav) { + PodcastHeaderWithStatus(metadataNote, show, listState, accountViewModel, nav) { Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } @@ -168,7 +202,7 @@ private fun PodcastScreenBody( @Composable private fun PodcastEpisodesList( metadataNote: Note, - metadataEvent: PodcastMetadataEvent?, + show: PodcastShow?, loaded: FeedState.Loaded, listState: LazyListState, accountViewModel: AccountViewModel, @@ -181,15 +215,21 @@ private fun PodcastEpisodesList( contentPadding = rememberFeedContentPadding(FeedPadding), ) { item("header") { - PodcastHeader(metadataNote, metadataEvent, items.list.size, accountViewModel, nav) + // The list mixes in trailers; the header count should reflect episodes only. + val episodeCount = items.list.count { it.event !is Podcasting20TrailerEvent } + PodcastHeader(metadataNote, show, episodeCount, accountViewModel, nav) } itemsIndexed( items.list, key = { _, item -> item.idHex }, - contentType = { _, _ -> "episode" }, - ) { index, episode -> - PodcastEpisodeListItem(episode, accountViewModel, nav) + contentType = { _, item -> if (item.event is Podcasting20TrailerEvent) "trailer" else "episode" }, + ) { index, item -> + if (item.event is Podcasting20TrailerEvent) { + PodcastTrailerListItem(item, accountViewModel, nav) + } else { + PodcastEpisodeListItem(item, accountViewModel, nav) + } if (index < items.list.lastIndex) { HorizontalDivider(thickness = DividerThickness) @@ -201,7 +241,7 @@ private fun PodcastEpisodesList( @Composable private fun PodcastHeaderWithStatus( metadataNote: Note, - metadataEvent: PodcastMetadataEvent?, + show: PodcastShow?, listState: LazyListState, accountViewModel: AccountViewModel, nav: INav, @@ -212,7 +252,7 @@ private fun PodcastHeaderWithStatus( contentPadding = rememberFeedContentPadding(FeedPadding), ) { item("header") { - PodcastHeader(metadataNote, metadataEvent, null, accountViewModel, nav) + PodcastHeader(metadataNote, show, null, accountViewModel, nav) } item("status") { status() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt new file mode 100644 index 0000000000..f642073f4e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTopSupporters.kt @@ -0,0 +1,188 @@ +/* + * 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.podcasts + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.nip53LiveActivities.LiveActivityTopZappersAggregator +import com.vitorpamplona.amethyst.commons.nip53LiveActivities.TopZapperEntry +import com.vitorpamplona.amethyst.commons.nip53LiveActivities.ZapContribution +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.ZapIcon +import com.vitorpamplona.amethyst.ui.note.showAmountInteger +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.Size16Modifier +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import java.math.BigDecimal + +private val Gold = Color(0xFFFFC300) +private val Silver = Color(0xFFB0B7C0) +private val Bronze = Color(0xFFCD7F32) + +/** + * "Top Supporters" leaderboard for a podcast show: aggregates the zaps on the show note into a + * sats-ranked list (reusing [LiveActivityTopZappersAggregator], the same engine as the live-stream + * leaderboard), top 3 flagged with gold/silver/bronze medals. Renders nothing until there's at least + * one zap. Matches PodStr's ZapLeaderboard, Nostr-native. + */ +@Composable +fun PodcastTopSupporters( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val zapState by observeNoteZaps(note, accountViewModel) + + val entries = + remember(zapState) { + val contributions = + note.zaps.mapNotNull { (_, receiptNote) -> + val receipt = receiptNote?.event as? LnZapEvent ?: return@mapNotNull null + val request = receipt.zapRequest ?: return@mapNotNull null + val sats = receipt.amount()?.toLong() ?: return@mapNotNull null + // Anon/private zaps carry an `anon` tag; collapse them into the shared bucket. + val isAnon = request.tags.any { it.isNotEmpty() && it[0] == "anon" } + ZapContribution(receiptNote.idHex, request.pubKey, isAnon, sats) + } + LiveActivityTopZappersAggregator.aggregate(contributions) + } + + if (entries.isEmpty()) return + + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = stringRes(R.string.podcast_top_supporters), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + + entries.forEachIndexed { index, entry -> + SupporterRow(index, entry, accountViewModel, nav) + } + } +} + +@Composable +private fun SupporterRow( + index: Int, + entry: TopZapperEntry, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + RankBadge(index) + + if (entry.isAnonymous) { + Text( + text = stringRes(R.string.chat_zap_anonymous), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + } else { + LoadUser(entry.bucketKey, accountViewModel) { user -> + if (user != null) { + ClickableUserPicture(user, Size35dp, accountViewModel, onClick = { nav.nav(routeFor(it)) }) + UsernameDisplay(user, Modifier.weight(1f), accountViewModel = accountViewModel) + } else { + Text( + text = "", + modifier = Modifier.weight(1f), + ) + } + } + } + + ZapIcon(Size16Modifier, BitcoinOrange) + Text( + text = showAmountInteger(BigDecimal.valueOf(entry.totalSats)), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + ) + } +} + +/** Gold/silver/bronze medal for the top 3, plain "#N" for the rest. */ +@Composable +private fun RankBadge(index: Int) { + val medal = + when (index) { + 0 -> Gold + 1 -> Silver + 2 -> Bronze + else -> null + } + + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (medal != null) { + Icon( + symbol = MaterialSymbols.MilitaryTech, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = medal, + ) + } else { + Text( + text = "${index + 1}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTrailerListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTrailerListItem.kt new file mode 100644 index 0000000000..1f24265f4c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastTrailerListItem.kt @@ -0,0 +1,133 @@ +/* + * 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.podcasts + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.timeAgo +import com.vitorpamplona.amethyst.ui.note.types.PodcastEpisodeAudioPlayer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.podcasts.PodcastAudio + +private val PLAYER_SHAPE = Modifier.clip(RoundedCornerShape(12.dp)) + +/** + * A Podcasting-2.0 trailer (kind 30055) as a compact row, used on a podcast's show page and as + * the inline renderer in [com.vitorpamplona.amethyst.ui.note.NoteCompose]. A "Trailer" badge (with + * the season, when present) distinguishes it from episode rows; the media plays through the same + * player as episodes via the spec-neutral [PodcastAudio]. + */ +@Composable +fun PodcastTrailerListItem( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? Podcasting20TrailerEvent ?: return + + val title = remember(noteEvent) { noteEvent.title() } + val season = remember(noteEvent) { noteEvent.season() } + val media = + remember(noteEvent) { + noteEvent.url()?.let { PodcastAudio(it, noteEvent.mimeType()) } + } + + val context = LocalContext.current + val dateStr = remember(noteEvent) { timeAgo(noteEvent.createdAt, context, prefix = "") } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(Size5dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringRes(R.string.podcast_trailer), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + season?.let { + Text( + text = stringRes(R.string.podcast_trailer_season, it), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + dateStr.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + } + + title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + + media?.let { audio -> + PodcastEpisodeAudioPlayer( + audio = audio, + note = note, + title = title, + image = null, + borderModifier = PLAYER_SHAPE, + accountViewModel = accountViewModel, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt index 8762972787..ec3685bbfc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsScreen.kt @@ -20,11 +20,18 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState @@ -33,12 +40,14 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn @Composable @@ -82,6 +91,21 @@ fun PodcastsScreen( } } }, + floatingButton = { + FabBottomBarPadded(nav) { + FloatingActionButton( + onClick = { nav.nav(Route.PodcastAuthoring) }, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + symbol = MaterialSymbols.Mic, + contentDescription = stringRes(R.string.podcast_your_podcast), + tint = Color.White, + ) + } + } + }, accountViewModel = accountViewModel, ) { RefresheableBox(podcastsFeedContentState, true) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt index 28dcbeb002..9022f588d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/PodcastsTopBar.kt @@ -62,7 +62,7 @@ private fun PodcastsTopNavFilterBar( accountViewModel: AccountViewModel, onChange: (FeedDefinition) -> Unit, ) { - val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle() + val allLists by followListsModel.podcastRoutes.collectAsStateWithLifecycle() FeedFilterSpinner( placeholderCode = listName, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt new file mode 100644 index 0000000000..2917509791 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowScreen.kt @@ -0,0 +1,212 @@ +/* + * 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.podcasts.authoring + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.CoverImagePicker +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadInProgressBanner +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.collections.immutable.persistentListOf + +/** + * Editor for the creator's Podcasting-2.0 show metadata (`kind:30078`, `d="podcast-metadata"`). + * There is one show per account, so this is always create-or-edit of the same event. Cover upload + * plus the channel fields; explicit / complete / locked as switches; episodic vs serial as a toggle. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditPodcastShowScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val vm: EditPodcastShowViewModel = viewModel() + val context = LocalContext.current + + LaunchedEffect(accountViewModel) { vm.init(accountViewModel) } + + StrippingFailureDialog(vm.strippingFailureConfirmation) + + var wantsToPickCover by remember { mutableStateOf(false) } + if (wantsToPickCover) { + GallerySelectSingle( + onImageUri = { picked -> + wantsToPickCover = false + vm.setPickedCover(if (picked != null) persistentListOf(picked) else persistentListOf()) + }, + ) + } + + val isBusy = vm.isSending.value + LaunchedEffect(vm) { vm.completionEvents.collect { nav.popBack() } } + + Scaffold( + topBar = { + SendingTopBar( + titleRes = R.string.podcast_edit_show, + onCancel = { nav.popBack() }, + isActive = { vm.isValid() && !isBusy }, + onPost = { + if (!vm.isValid() || isBusy) return@SendingTopBar + vm.saveAndPublish(context, accountViewModel) + }, + ) + }, + ) { pad -> + Column( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (isBusy) UploadInProgressBanner(R.string.podcast_publishing_banner) + + CoverImagePicker( + cover = vm.coverMedia.value, + existingUrl = vm.coverUrl.value, + onPick = { wantsToPickCover = true }, + onDelete = { vm.clearPickedCover() }, + accountViewModel = accountViewModel, + enabled = !isBusy, + ctaRes = R.string.podcast_show_cover_cta, + hintRes = R.string.podcast_show_cover_hint, + ) + + Field(vm.title, R.string.podcast_show_title_label, R.string.podcast_show_title_placeholder, isError = vm.title.value.isBlank()) + + OutlinedTextField( + value = vm.description.value, + onValueChange = { vm.description.value = it }, + label = { Text(stringRes(R.string.podcast_show_description_label)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 4, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + ) + + Field(vm.author, R.string.podcast_show_author_label, null, capitalization = KeyboardCapitalization.Words) + Field(vm.email, R.string.podcast_show_email_label, null, keyboardType = KeyboardType.Email) + Field(vm.website, R.string.podcast_show_website_label, null, keyboardType = KeyboardType.Uri) + Field(vm.categories, R.string.podcast_show_categories_label, R.string.podcast_show_categories_placeholder) + Field(vm.funding, R.string.podcast_show_funding_label, R.string.podcast_show_funding_placeholder, keyboardType = KeyboardType.Uri) + Field(vm.language, R.string.podcast_show_language_label, R.string.podcast_show_language_placeholder) + Field(vm.copyright, R.string.podcast_show_copyright_label, null) + + // Episodic vs serial. + Text( + text = stringRes(R.string.podcast_show_type_label), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(top = 4.dp), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = vm.type.value == "episodic" || vm.type.value.isBlank(), + onClick = { vm.type.value = "episodic" }, + label = { Text(stringRes(R.string.podcast_show_type_episodic)) }, + ) + FilterChip( + selected = vm.type.value == "serial", + onClick = { vm.type.value = "serial" }, + label = { Text(stringRes(R.string.podcast_show_type_serial)) }, + ) + } + + SwitchRow(stringRes(R.string.podcast_show_explicit), vm.explicit.value) { vm.explicit.value = it } + SwitchRow(stringRes(R.string.podcast_show_complete), vm.complete.value) { vm.complete.value = it } + SwitchRow(stringRes(R.string.podcast_show_locked), vm.locked.value) { vm.locked.value = it } + + V4VSplitEditor(vm.splitEditor, accountViewModel) + } + } +} + +@Composable +private fun Field( + state: androidx.compose.runtime.MutableState, + labelRes: Int, + placeholderRes: Int?, + isError: Boolean = false, + capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences, + keyboardType: KeyboardType = KeyboardType.Text, +) { + OutlinedTextField( + value = state.value, + onValueChange = { state.value = it }, + label = { Text(stringRes(labelRes)) }, + placeholder = placeholderRes?.let { { Text(stringRes(it)) } }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = isError, + keyboardOptions = KeyboardOptions(capitalization = capitalization, keyboardType = keyboardType), + ) +} + +@Composable +private fun SwitchRow( + label: String, + checked: Boolean, + onChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Switch(checked = checked, onCheckedChange = onChange) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt new file mode 100644 index 0000000000..8543e72b5e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/EditPodcastShowViewModel.kt @@ -0,0 +1,247 @@ +/* + * 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.podcasts.authoring + +import android.content.Context +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.commons.podcasts.V4VSplitEditorState +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.withContext + +/** + * Editor for a creator's Podcasting-2.0 show metadata — the single replaceable `kind:30078` + * (`d="podcast-metadata"`) event whose JSON body holds the channel-level fields. There is one per + * account, so this is always an edit-or-create of the same address; saving replaces it in place. + * + * Mirrors the profile-metadata editor: text fields + a cover upload, no audio. Any value-for-value + * block already on the event is preserved across save (the splits editor is separate). + */ +class EditPodcastShowViewModel : ViewModel() { + private lateinit var account: Account + + val title = mutableStateOf("") + val description = mutableStateOf("") + val author = mutableStateOf("") + val email = mutableStateOf("") + val coverUrl = mutableStateOf("") + val website = mutableStateOf("") + val language = mutableStateOf("") + val categories = mutableStateOf("") + val funding = mutableStateOf("") + val copyright = mutableStateOf("") + + /** "episodic" or "serial" (Podcasting 2.0), or blank for unset. */ + val type = mutableStateOf("") + val explicit = mutableStateOf(false) + val complete = mutableStateOf(false) + val locked = mutableStateOf(false) + + val isSending = mutableStateOf(false) + + private val _completionEvents = MutableSharedFlow(extraBufferCapacity = 1) + val completionEvents: SharedFlow = _completionEvents.asSharedFlow() + + val coverMedia = mutableStateOf(null) + + val strippingFailureConfirmation = SuspendableConfirmation() + val selectedServer = mutableStateOf(null) + val mediaQualitySlider = mutableStateOf(1) + val stripMetadata = mutableStateOf(true) + + /** Editable value-for-value split for the show. */ + val splitEditor = V4VSplitEditorState() + + /** Fields the editor doesn't surface but must not drop on save. */ + private var preservedGuid: String? = null + private var hasExisting = false + + fun init(accountViewModel: AccountViewModel) { + if (::account.isInitialized) return + this.account = accountViewModel.account + this.selectedServer.value = account.settings.defaultFileServer + this.stripMetadata.value = account.settings.stripLocationOnUpload + + val address = Address(AppSpecificDataEvent.KIND, account.userProfile().pubkeyHex, Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) + val existing = (LocalCache.addressables.get(address)?.event as? AppSpecificDataEvent)?.let { Podcasting20PodcastMetadata.parse(it) } + if (existing != null) { + hasExisting = true + preservedGuid = existing.guid() + splitEditor.load(existing.showValue()) + title.value = existing.showTitle().orEmpty() + description.value = existing.showDescription().orEmpty() + author.value = existing.showAuthor().orEmpty() + email.value = existing.email().orEmpty() + coverUrl.value = existing.showImage().orEmpty() + website.value = existing.showWebsites().firstOrNull().orEmpty() + language.value = existing.language().orEmpty() + categories.value = existing.showCategories().joinToString(", ") + funding.value = existing.showFundingUrls().joinToString(", ") + copyright.value = existing.showCopyright().orEmpty() + type.value = existing.type().orEmpty() + explicit.value = existing.showIsExplicit() + complete.value = existing.showIsComplete() + locked.value = existing.isLocked() + } + } + + fun setPickedCover(uris: ImmutableList) { + coverMedia.value = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null + } + + fun clearPickedCover() { + coverMedia.value = null + coverUrl.value = "" + } + + fun isValid(): Boolean = title.value.isNotBlank() + + fun saveAndPublish( + context: Context, + accountViewModel: AccountViewModel, + ) { + if (isSending.value) return + + val coverOrch = coverMedia.value + val server = selectedServer.value + if (coverOrch != null && server == null) { + accountViewModel.toastManager.toast( + "No upload server selected", + "Pick a media server in settings before uploading.", + ) + return + } + + val snapshot = + Snapshot( + content = + Podcasting20PodcastMetadata.Content( + title = title.value.trim(), + description = description.value.trim().ifBlank { null }, + author = author.value.trim().ifBlank { null }, + email = email.value.trim().ifBlank { null }, + image = coverUrl.value.trim().ifBlank { null }, + language = language.value.trim().ifBlank { null }, + categories = PodcastComposerMedia.parseCsv(categories.value), + explicit = explicit.value.takeIf { it }, + website = website.value.trim().ifBlank { null }, + copyright = copyright.value.trim().ifBlank { null }, + funding = PodcastComposerMedia.parseCsv(funding.value), + locked = locked.value.takeIf { it }, + type = type.value.trim().ifBlank { null }, + complete = complete.value.takeIf { it }, + guid = preservedGuid, + value = splitEditor.toPodcastValue(), + ), + coverOrchestrator = coverOrch, + server = server, + quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value), + stripMetadata = stripMetadata.value, + appContext = context.applicationContext, + ) + + isSending.value = true + accountViewModel.launchSigner { + try { + val newCoverUrl = + snapshot.coverOrchestrator?.let { + PodcastComposerMedia.upload( + orchestrator = it, + kind = "cover", + account = account, + server = snapshot.server!!, + quality = snapshot.quality, + stripMetadata = snapshot.stripMetadata, + alt = snapshot.content.title, + context = snapshot.appContext, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) + } + + val finalContent = newCoverUrl?.let { snapshot.content.copyWithImage(it) } ?: snapshot.content + account.signAndComputeBroadcast(Podcasting20PodcastMetadata.build(finalContent)) + + if (snapshot.coverOrchestrator != null) { + account.settings.changeDefaultFileServer(snapshot.server!!) + account.settings.changeStripLocationOnUpload(snapshot.stripMetadata) + } + + withContext(Dispatchers.Main.immediate) { + coverMedia.value = null + if (newCoverUrl != null) coverUrl.value = newCoverUrl + } + _completionEvents.tryEmit(Unit) + } catch (t: Throwable) { + accountViewModel.toastManager.toast( + "Failed to save podcast", + t.message ?: t.javaClass.simpleName, + ) + } finally { + withContext(Dispatchers.Main.immediate) { isSending.value = false } + } + } + } + + private class Snapshot( + val content: Podcasting20PodcastMetadata.Content, + val coverOrchestrator: MultiOrchestrator?, + val server: ServerName?, + val quality: com.vitorpamplona.amethyst.service.uploads.CompressorQuality, + val stripMetadata: Boolean, + val appContext: Context, + ) +} + +/** Copies a metadata content with a new image URL (Content has no copy() — it's a plain class). */ +private fun Podcasting20PodcastMetadata.Content.copyWithImage(image: String): Podcasting20PodcastMetadata.Content = + Podcasting20PodcastMetadata.Content( + title = title, + description = description, + author = author, + email = email, + image = image, + language = language, + categories = categories, + explicit = explicit, + website = website, + copyright = copyright, + funding = funding, + locked = locked, + type = type, + complete = complete, + guid = guid, + value = value, + ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt new file mode 100644 index 0000000000..941a13c52c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeScreen.kt @@ -0,0 +1,374 @@ +/* + * 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.podcasts.authoring + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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 androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.CoverImagePicker +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadInProgressBanner +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadPlaceholder +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.collections.immutable.persistentListOf + +/** + * Composer for a Podcasting-2.0 episode. Cover art + audio file upload at the top (or paste URLs), + * the core fields (title, summary, duration), and a collapsible "More details" section for the + * Podcasting-2.0 extras (season/number, video, transcript, chapters, topics). Create + edit + delete. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewPodcastEpisodeScreen( + editDTag: String? = null, + accountViewModel: AccountViewModel, + nav: INav, +) { + val vm: NewPodcastEpisodeViewModel = viewModel() + val context = LocalContext.current + + LaunchedEffect(accountViewModel) { vm.init(accountViewModel, editDTag) } + + StrippingFailureDialog(vm.strippingFailureConfirmation) + + var wantsToPickCover by remember { mutableStateOf(false) } + if (wantsToPickCover) { + GallerySelectSingle( + onImageUri = { picked -> + wantsToPickCover = false + vm.setPickedCover(if (picked != null) persistentListOf(picked) else persistentListOf()) + }, + ) + } + + var wantsToPickAudio by remember { mutableStateOf(false) } + if (wantsToPickAudio) { + AudioFileSelect { picked -> + wantsToPickAudio = false + vm.setPickedAudio(context, picked) + } + } + + val isBusy = vm.isSending.value + + LaunchedEffect(vm) { vm.completionEvents.collect { nav.popBack() } } + + Scaffold( + topBar = { + SendingTopBar( + titleRes = if (vm.isEditing) R.string.podcast_edit_episode else R.string.podcast_new_episode, + onCancel = { nav.popBack() }, + isActive = { vm.isValid() && !isBusy }, + onPost = { + if (!vm.isValid() || isBusy) return@SendingTopBar + vm.saveAndPublish(context, accountViewModel) + }, + ) + }, + ) { pad -> + Column( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (isBusy) UploadInProgressBanner(R.string.podcast_publishing_banner) + + CoverImagePicker( + cover = vm.coverMedia.value, + existingUrl = vm.coverUrl.value, + onPick = { wantsToPickCover = true }, + onDelete = { vm.clearPickedCover() }, + accountViewModel = accountViewModel, + enabled = !isBusy, + ctaRes = R.string.podcast_cover_upload_cta, + hintRes = R.string.podcast_cover_upload_hint, + ) + + AudioFilePickerRow( + pickedName = vm.pickedAudioName.value, + onPick = { wantsToPickAudio = true }, + onClear = { vm.clearPickedAudio() }, + enabled = !isBusy, + ) + + OutlinedTextField( + value = vm.title.value, + onValueChange = { vm.title.value = it }, + label = { Text(stringRes(R.string.podcast_episode_title_label)) }, + placeholder = { Text(stringRes(R.string.podcast_episode_title_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + isError = vm.title.value.isBlank(), + ) + + OutlinedTextField( + value = vm.description.value, + onValueChange = { vm.description.value = it }, + label = { Text(stringRes(R.string.podcast_episode_summary_label)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 4, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + ) + + OutlinedTextField( + value = vm.durationSeconds.value, + onValueChange = { input -> vm.durationSeconds.value = input.filter { it.isDigit() } }, + label = { Text(stringRes(R.string.podcast_episode_duration_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + + var showMore by rememberSaveable { mutableStateOf(false) } + Row( + modifier = Modifier.fillMaxWidth().clickable { showMore = !showMore }.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(R.string.podcast_episode_more_details), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Icon( + symbol = if (showMore) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(22.dp), + ) + } + + if (showMore) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = vm.season.value, + onValueChange = { input -> vm.season.value = input.filter { it.isDigit() } }, + label = { Text(stringRes(R.string.podcast_episode_season_label)) }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + OutlinedTextField( + value = vm.episodeNumber.value, + onValueChange = { input -> vm.episodeNumber.value = input.filter { it.isDigit() } }, + label = { Text(stringRes(R.string.podcast_episode_number_label)) }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + } + + UrlField(vm.videoUrl, R.string.podcast_episode_video_label) + UrlField(vm.transcriptUrl, R.string.podcast_episode_transcript_label) + UrlField(vm.chaptersUrl, R.string.podcast_episode_chapters_label) + + OutlinedTextField( + value = vm.topics.value, + onValueChange = { vm.topics.value = it }, + label = { Text(stringRes(R.string.podcast_episode_topics_label)) }, + placeholder = { Text(stringRes(R.string.podcast_episode_topics_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = vm.audioUrl.value, + onValueChange = { vm.audioUrl.value = it }, + label = { Text(stringRes(R.string.podcast_episode_audio_url_label)) }, + placeholder = { Text(stringRes(R.string.podcast_episode_audio_url_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + ) + + // Episode-level V4V override; leave empty to inherit the show's split. + V4VSplitEditor(vm.splitEditor, accountViewModel) + } + + if (vm.isEditing) { + HorizontalDivider() + DeleteEpisodeRow(vm = vm, onDeleted = { nav.popBack() }, accountViewModel = accountViewModel) + } + } + } +} + +@Composable +private fun UrlField( + state: androidx.compose.runtime.MutableState, + labelRes: Int, +) { + OutlinedTextField( + value = state.value, + onValueChange = { state.value = it }, + label = { Text(stringRes(labelRes)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + ) +} + +@Composable +private fun AudioFilePickerRow( + pickedName: String?, + onPick: () -> Unit, + onClear: () -> Unit, + enabled: Boolean, +) { + if (pickedName != null) { + Row( + modifier = + Modifier + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(12.dp)) + .let { if (enabled) it.clickable(onClick = onPick) else it } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.MusicNote, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { + Text(text = pickedName, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text( + text = stringRes(R.string.podcast_episode_audio_picked), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (enabled) { + TextButton(onClick = onClear) { Text(stringRes(R.string.cancel)) } + } + } + } else { + UploadPlaceholder( + iconSymbol = MaterialSymbols.MusicNote, + ctaRes = R.string.podcast_episode_audio_upload_cta, + hintRes = R.string.podcast_episode_audio_upload_hint, + onClick = onPick, + aspectRatio = null, + enabled = enabled, + ) + } +} + +// Single audio file via OpenDocument restricted to audio MIME types. +@Composable +private fun AudioFileSelect(onAudioPicked: (SelectedMedia?) -> Unit) { + val resolver = LocalContext.current.contentResolver + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { uri: Uri? -> onAudioPicked(uri?.let { SelectedMedia(it, resolver.getType(it)) }) }, + ) + LaunchedEffect(Unit) { launcher.launch(arrayOf("audio/*")) } +} + +@Composable +private fun DeleteEpisodeRow( + vm: NewPodcastEpisodeViewModel, + onDeleted: () -> Unit, + accountViewModel: AccountViewModel, +) { + var confirming by rememberSaveable { mutableStateOf(false) } + + OutlinedButton( + onClick = { confirming = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(text = stringRes(R.string.podcast_episode_delete)) + } + + if (confirming) { + AlertDialog( + onDismissRequest = { confirming = false }, + title = { Text(stringRes(R.string.podcast_episode_delete)) }, + text = { Text(stringRes(R.string.podcast_episode_delete_confirm)) }, + confirmButton = { + TextButton(onClick = { + confirming = false + accountViewModel.launchSigner { if (vm.deleteLoaded()) onDeleted() } + }) { + Text(text = stringRes(R.string.podcast_episode_delete), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { confirming = false }) { Text(stringRes(R.string.cancel)) } + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt new file mode 100644 index 0000000000..1566ed11bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastEpisodeViewModel.kt @@ -0,0 +1,335 @@ +/* + * 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.podcasts.authoring + +import android.content.Context +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.podcasts.V4VSplitEditorState +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.podcasts.PodcastValue +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Composer for a Podcasting-2.0 episode (`kind:30054`, addressable). Closely mirrors the music-track + * composer: the user picks a cover image and an audio file, Save uploads them through Blossom/NIP-96, + * then publishes the addressable event with the returned URLs. Power users can paste raw URLs instead. + * + * Edit mode (`editDTag` resolves from LocalCache) re-publishes under the same `d` tag so it replaces + * the prior version in place; the original `pubdate` and any value-for-value block are preserved. + */ +class NewPodcastEpisodeViewModel : ViewModel() { + private lateinit var account: Account + + val title = mutableStateOf("") + val description = mutableStateOf("") + val audioUrl = mutableStateOf("") + val coverUrl = mutableStateOf("") + val durationSeconds = mutableStateOf("") + val episodeNumber = mutableStateOf("") + val season = mutableStateOf("") + val videoUrl = mutableStateOf("") + val transcriptUrl = mutableStateOf("") + val chaptersUrl = mutableStateOf("") + val topics = mutableStateOf("") + + val isSending = mutableStateOf(false) + + private val _completionEvents = MutableSharedFlow(extraBufferCapacity = 1) + val completionEvents: SharedFlow = _completionEvents.asSharedFlow() + + val coverMedia = mutableStateOf(null) + val audioMedia = mutableStateOf(null) + val pickedAudioName = mutableStateOf(null) + + val strippingFailureConfirmation = SuspendableConfirmation() + val selectedServer = mutableStateOf(null) + val mediaQualitySlider = mutableStateOf(1) + val stripMetadata = mutableStateOf(true) + + private var dTag: String? = null + private var loadedEvent: Podcasting20EpisodeEvent? = null + + /** Editable value-for-value split for this episode (overrides the show's split). */ + val splitEditor = V4VSplitEditorState() + + /** Carried across an edit so we don't drop the original publish date on save. */ + private var preservedPubDate: String? = null + + val isEditing: Boolean + get() = loadedEvent != null + + fun init( + accountViewModel: AccountViewModel, + editDTag: String?, + ) { + if (::account.isInitialized) return + this.account = accountViewModel.account + this.selectedServer.value = account.settings.defaultFileServer + this.stripMetadata.value = account.settings.stripLocationOnUpload + + if (editDTag != null) { + val address = Address(Podcasting20EpisodeEvent.KIND, account.userProfile().pubkeyHex, editDTag) + (LocalCache.addressables.get(address)?.event as? Podcasting20EpisodeEvent)?.let { existing -> + dTag = editDTag + loadedEvent = existing + preservedPubDate = existing.pubDate() + splitEditor.load(existing.value()) + title.value = existing.title().orEmpty() + description.value = existing.description().orEmpty() + audioUrl.value = + existing + .audios() + .firstOrNull() + ?.url + .orEmpty() + coverUrl.value = existing.image().orEmpty() + durationSeconds.value = existing.durationInSeconds()?.toString().orEmpty() + episodeNumber.value = existing.number()?.toString().orEmpty() + season.value = existing.season()?.toString().orEmpty() + videoUrl.value = existing.video()?.url.orEmpty() + transcriptUrl.value = existing.transcriptUrl().orEmpty() + chaptersUrl.value = existing.chaptersUrl().orEmpty() + topics.value = existing.topics().joinToString(", ") + } + } + } + + fun setPickedCover(uris: ImmutableList) { + coverMedia.value = if (uris.isNotEmpty()) MultiOrchestrator(uris) else null + } + + fun clearPickedCover() { + coverMedia.value = null + coverUrl.value = "" + } + + fun setPickedAudio( + context: Context, + uri: SelectedMedia?, + ) { + if (uri == null) { + audioMedia.value = null + pickedAudioName.value = null + return + } + audioMedia.value = MultiOrchestrator(persistentListOf(uri)) + pickedAudioName.value = uri.uri.lastPathSegment?.substringAfterLast('/') + + val appContext = context.applicationContext + viewModelScope.launch(Dispatchers.IO) { + val probed = PodcastComposerMedia.probeAudio(appContext, uri.uri) ?: return@launch + withContext(Dispatchers.Main.immediate) { + probed.durationSeconds?.let { durationSeconds.value = it.toString() } + if (title.value.isBlank()) probed.title?.let { title.value = it } + } + } + } + + fun clearPickedAudio() { + audioMedia.value = null + pickedAudioName.value = null + } + + /** Valid with a title and a resolvable audio source (picked file or a pasted URL). */ + fun isValid(): Boolean = title.value.isNotBlank() && (audioMedia.value != null || audioUrl.value.isNotBlank()) + + fun saveAndPublish( + context: Context, + accountViewModel: AccountViewModel, + ) { + if (isSending.value) return + + val server = selectedServer.value + if (server == null) { + accountViewModel.toastManager.toast( + "No upload server selected", + "Pick a media server in settings before uploading.", + ) + return + } + + val snapshot = + Snapshot( + title = title.value.trim(), + description = description.value.trim().ifBlank { null }, + durationSeconds = durationSeconds.value.trim().toLongOrNull(), + episodeNumber = episodeNumber.value.trim().toIntOrNull(), + season = season.value.trim().toIntOrNull(), + videoUrl = videoUrl.value.trim().ifBlank { null }, + transcriptUrl = transcriptUrl.value.trim().ifBlank { null }, + chaptersUrl = chaptersUrl.value.trim().ifBlank { null }, + topics = PodcastComposerMedia.parseCsv(topics.value), + value = splitEditor.toPodcastValue(), + coverOrchestrator = coverMedia.value, + audioOrchestrator = audioMedia.value, + existingCoverUrl = coverUrl.value.trim().ifBlank { null }, + existingAudioUrl = audioUrl.value.trim(), + server = server, + quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value), + stripMetadata = stripMetadata.value, + appContext = context.applicationContext, + ) + + isSending.value = true + + accountViewModel.launchSigner { + try { + val (newCoverUrl, newAudioUrl) = performParallelUploads(snapshot) + val finalCoverUrl = newCoverUrl ?: snapshot.existingCoverUrl + val finalAudioUrl = newAudioUrl ?: snapshot.existingAudioUrl + if (finalAudioUrl.isBlank()) { + accountViewModel.toastManager.toast( + "Audio upload failed", + "No audio URL ended up available for the published episode.", + ) + return@launchSigner + } + + publishEpisode(snapshot, finalCoverUrl, finalAudioUrl) + + account.settings.changeDefaultFileServer(snapshot.server) + account.settings.changeStripLocationOnUpload(snapshot.stripMetadata) + + withContext(Dispatchers.Main.immediate) { + coverMedia.value = null + audioMedia.value = null + pickedAudioName.value = null + if (newCoverUrl != null) coverUrl.value = newCoverUrl + if (newAudioUrl != null) audioUrl.value = newAudioUrl + } + _completionEvents.tryEmit(Unit) + } catch (t: Throwable) { + accountViewModel.toastManager.toast( + "Failed to publish episode", + t.message ?: t.javaClass.simpleName, + ) + } finally { + withContext(Dispatchers.Main.immediate) { isSending.value = false } + } + } + } + + private class Snapshot( + val title: String, + val description: String?, + val durationSeconds: Long?, + val episodeNumber: Int?, + val season: Int?, + val videoUrl: String?, + val transcriptUrl: String?, + val chaptersUrl: String?, + val topics: List, + val value: PodcastValue?, + val coverOrchestrator: MultiOrchestrator?, + val audioOrchestrator: MultiOrchestrator?, + val existingCoverUrl: String?, + val existingAudioUrl: String, + val server: ServerName, + val quality: com.vitorpamplona.amethyst.service.uploads.CompressorQuality, + val stripMetadata: Boolean, + val appContext: Context, + ) + + private suspend fun performParallelUploads(snapshot: Snapshot): Pair = + coroutineScope { + val deferreds = + listOf( + async { snapshot.coverOrchestrator?.let { uploadOne(it, "cover", snapshot) } }, + async { snapshot.audioOrchestrator?.let { uploadOne(it, "audio", snapshot) } }, + ) + val results = deferreds.awaitAll() + results[0] to results[1] + } + + private suspend fun uploadOne( + orchestrator: MultiOrchestrator, + kind: String, + snapshot: Snapshot, + ): String = + PodcastComposerMedia.upload( + orchestrator = orchestrator, + kind = kind, + account = account, + server = snapshot.server, + quality = snapshot.quality, + stripMetadata = snapshot.stripMetadata, + alt = snapshot.title.ifBlank { null }, + context = snapshot.appContext, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) + + private suspend fun publishEpisode( + snapshot: Snapshot, + coverUrl: String?, + audioUrl: String, + ) { + // Audio/video MIME isn't tracked once uploaded (the URL is enough; the player sniffs the + // type). The summary lives in the `description` tag; `content` stays empty so the episode + // renderer doesn't show the same text twice (it renders the tag and the markdown body apart). + val template = + Podcasting20EpisodeEvent.build( + dTag = dTag ?: PodcastComposerMedia.generateDTag("episode"), + title = snapshot.title, + audios = listOf(PodcastAudio(audioUrl, null)), + pubdate = preservedPubDate ?: PodcastComposerMedia.rfc2822Now(), + description = snapshot.description, + image = coverUrl?.ifBlank { null }, + durationInSeconds = snapshot.durationSeconds, + video = snapshot.videoUrl?.let { PodcastAudio(it, null) }, + episodeNumber = snapshot.episodeNumber, + season = snapshot.season, + transcriptUrl = snapshot.transcriptUrl, + chaptersUrl = snapshot.chaptersUrl, + value = snapshot.value, + topics = snapshot.topics, + ) + account.signAndComputeBroadcast(template) + } + + suspend fun deleteLoaded(): Boolean { + val target = loadedEvent ?: return false + val note = LocalCache.getOrCreateAddressableNote(target.address()) + account.delete(note) + return true + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerScreen.kt new file mode 100644 index 0000000000..d7f7312460 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerScreen.kt @@ -0,0 +1,204 @@ +/* + * 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.podcasts.authoring + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SendingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadInProgressBanner +import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.UploadPlaceholder +import com.vitorpamplona.amethyst.ui.stringRes + +/** Composer for a Podcasting-2.0 trailer (`kind:30055`): title, a short audio/video clip, and season. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewPodcastTrailerScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val vm: NewPodcastTrailerViewModel = viewModel() + val context = LocalContext.current + + LaunchedEffect(accountViewModel) { vm.init(accountViewModel) } + StrippingFailureDialog(vm.strippingFailureConfirmation) + + var wantsToPick by remember { mutableStateOf(false) } + if (wantsToPick) { + MediaFileSelect { picked -> + wantsToPick = false + vm.setPickedMedia(picked) + } + } + + val isBusy = vm.isSending.value + LaunchedEffect(vm) { vm.completionEvents.collect { nav.popBack() } } + + Scaffold( + topBar = { + SendingTopBar( + titleRes = R.string.podcast_new_trailer, + onCancel = { nav.popBack() }, + isActive = { vm.isValid() && !isBusy }, + onPost = { + if (!vm.isValid() || isBusy) return@SendingTopBar + vm.saveAndPublish(context, accountViewModel) + }, + ) + }, + ) { pad -> + Column( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (isBusy) UploadInProgressBanner(R.string.podcast_publishing_banner) + + val pickedName = vm.pickedName.value + if (pickedName != null) { + Row( + modifier = + Modifier + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(12.dp)) + .let { if (!isBusy) it.clickable { wantsToPick = true } else it } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.MusicNote, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { + Text(text = pickedName, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text( + text = stringRes(R.string.podcast_episode_audio_picked), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (!isBusy) { + TextButton(onClick = { vm.clearPickedMedia() }) { Text(stringRes(R.string.cancel)) } + } + } + } else { + UploadPlaceholder( + iconSymbol = MaterialSymbols.MusicNote, + ctaRes = R.string.podcast_trailer_upload_cta, + hintRes = R.string.podcast_trailer_upload_hint, + onClick = { wantsToPick = true }, + aspectRatio = null, + enabled = !isBusy, + ) + } + + OutlinedTextField( + value = vm.title.value, + onValueChange = { vm.title.value = it }, + label = { Text(stringRes(R.string.podcast_episode_title_label)) }, + placeholder = { Text(stringRes(R.string.podcast_trailer_title_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + isError = vm.title.value.isBlank(), + ) + + OutlinedTextField( + value = vm.url.value, + onValueChange = { vm.url.value = it }, + label = { Text(stringRes(R.string.podcast_trailer_url_label)) }, + placeholder = { Text(stringRes(R.string.podcast_episode_audio_url_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + ) + + OutlinedTextField( + value = vm.season.value, + onValueChange = { input -> vm.season.value = input.filter { it.isDigit() } }, + label = { Text(stringRes(R.string.podcast_episode_season_label)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + } + } +} + +// Audio or video file via OpenDocument. +@Composable +private fun MediaFileSelect(onPicked: (SelectedMedia?) -> Unit) { + val resolver = LocalContext.current.contentResolver + val launcher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { uri: Uri? -> onPicked(uri?.let { SelectedMedia(it, resolver.getType(it)) }) }, + ) + LaunchedEffect(Unit) { launcher.launch(arrayOf("audio/*", "video/*")) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerViewModel.kt new file mode 100644 index 0000000000..1e6483d78b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/NewPodcastTrailerViewModel.kt @@ -0,0 +1,163 @@ +/* + * 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.podcasts.authoring + +import android.content.Context +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.withContext + +/** + * Composer for a Podcasting-2.0 trailer (`kind:30055`, addressable). A short preview clip: a title, + * one audio/video file (uploaded or pasted as a URL), and an optional season number. Always + * create-new — each trailer gets a fresh `d` tag. + */ +class NewPodcastTrailerViewModel : ViewModel() { + private lateinit var account: Account + + val title = mutableStateOf("") + val url = mutableStateOf("") + val season = mutableStateOf("") + val pickedMimeType = mutableStateOf(null) + + val isSending = mutableStateOf(false) + + private val _completionEvents = MutableSharedFlow(extraBufferCapacity = 1) + val completionEvents: SharedFlow = _completionEvents.asSharedFlow() + + val media = mutableStateOf(null) + val pickedName = mutableStateOf(null) + + val strippingFailureConfirmation = SuspendableConfirmation() + val selectedServer = mutableStateOf(null) + val mediaQualitySlider = mutableStateOf(1) + val stripMetadata = mutableStateOf(true) + + fun init(accountViewModel: AccountViewModel) { + if (::account.isInitialized) return + this.account = accountViewModel.account + this.selectedServer.value = account.settings.defaultFileServer + this.stripMetadata.value = account.settings.stripLocationOnUpload + } + + fun setPickedMedia(uri: SelectedMedia?) { + if (uri == null) { + media.value = null + pickedName.value = null + pickedMimeType.value = null + return + } + media.value = MultiOrchestrator(persistentListOf(uri)) + pickedName.value = uri.uri.lastPathSegment?.substringAfterLast('/') + pickedMimeType.value = uri.mimeType + } + + fun clearPickedMedia() { + media.value = null + pickedName.value = null + pickedMimeType.value = null + } + + fun isValid(): Boolean = title.value.isNotBlank() && (media.value != null || url.value.isNotBlank()) + + fun saveAndPublish( + context: Context, + accountViewModel: AccountViewModel, + ) { + if (isSending.value) return + val server = selectedServer.value + if (server == null) { + accountViewModel.toastManager.toast( + "No upload server selected", + "Pick a media server in settings before uploading.", + ) + return + } + + val titleSnap = title.value.trim() + val urlSnap = url.value.trim() + val seasonSnap = season.value.trim().toIntOrNull() + val mimeSnap = pickedMimeType.value + val mediaSnap = media.value + val quality = MediaCompressor.intToCompressorQuality(mediaQualitySlider.value) + val strip = stripMetadata.value + val appContext = context.applicationContext + + isSending.value = true + accountViewModel.launchSigner { + try { + val finalUrl = + if (mediaSnap != null) { + PodcastComposerMedia.upload( + orchestrator = mediaSnap, + kind = "trailer", + account = account, + server = server, + quality = quality, + stripMetadata = strip, + alt = titleSnap.ifBlank { null }, + context = appContext, + onStrippingFailed = strippingFailureConfirmation::awaitConfirmation, + ) + } else { + urlSnap + } + if (finalUrl.isBlank()) { + accountViewModel.toastManager.toast("Trailer upload failed", "No URL was available for the trailer.") + return@launchSigner + } + + val template = + Podcasting20TrailerEvent.build( + dTag = PodcastComposerMedia.generateDTag("trailer"), + title = titleSnap, + url = finalUrl, + pubdate = PodcastComposerMedia.rfc2822Now(), + mimeType = mimeSnap, + season = seasonSnap, + ) + account.signAndComputeBroadcast(template) + + account.settings.changeDefaultFileServer(server) + account.settings.changeStripLocationOnUpload(strip) + + _completionEvents.tryEmit(Unit) + } catch (t: Throwable) { + accountViewModel.toastManager.toast("Failed to publish trailer", t.message ?: t.javaClass.simpleName) + } finally { + withContext(Dispatchers.Main.immediate) { isSending.value = false } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt new file mode 100644 index 0000000000..ee6ee19595 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastAuthoringScreen.kt @@ -0,0 +1,319 @@ +/* + * 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.podcasts.authoring + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import coil3.compose.rememberAsyncImagePainter +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.MyPodcastFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent + +/** + * "Your podcast" — the authoring hub. Shows the creator's own Podcasting-2.0 show (or a create CTA), + * the new-episode / new-trailer / edit-show entry points, and lists the episodes and trailers the + * creator has already published (tap to edit). Data is read from [LocalCache] and refreshed each time + * the screen resumes, so it reflects anything just published from a composer. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PodcastAuthoringScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val me = accountViewModel.account.userProfile().pubkeyHex + + // Keep a REQ open for the creator's own catalog while the hub is visible, so the lists below + // fill in from relays even when nothing was cached yet. + MyPodcastFilterAssemblerSubscription(accountViewModel) + + // Re-scan LocalCache on each resume (returning from a composer) and whenever the creator's own + // podcast events arrive over the open REQ, so the lists stay current without a manual refresh. + var refresh by remember { mutableIntStateOf(0) } + LifecycleResumeEffect(Unit) { + refresh++ + onPauseOrDispose { } + } + LaunchedEffect(me) { + LocalCache.live.newEventBundles.collect { bundle -> + val mine = + bundle.any { + val e = it.event + e?.pubKey == me && + ( + e is Podcasting20EpisodeEvent || + e is Podcasting20TrailerEvent || + (e is AppSpecificDataEvent && e.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) + ) + } + if (mine) refresh++ + } + } + + val show = + remember(refresh) { + val address = Address(AppSpecificDataEvent.KIND, me, Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) + (LocalCache.addressables.get(address)?.event as? AppSpecificDataEvent)?.let { Podcasting20PodcastMetadata.parse(it) } + } + + val episodes = + remember(refresh) { + LocalCache.addressables + .filterIntoSet { _, note -> + val e = note.event + e is Podcasting20EpisodeEvent && e.pubKey == me + }.mapNotNull { it.event as? Podcasting20EpisodeEvent } + .sortedByDescending { it.createdAt } + } + + val trailers = + remember(refresh) { + LocalCache.addressables + .filterIntoSet { _, note -> + val e = note.event + e is Podcasting20TrailerEvent && e.pubKey == me + }.mapNotNull { it.event as? Podcasting20TrailerEvent } + .sortedByDescending { it.createdAt } + } + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(R.string.podcast_your_podcast), nav) }, + ) { pad -> + LazyColumn( + modifier = Modifier.padding(pad).fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { ShowHeaderCard(show, onEdit = { nav.nav(Route.EditPodcastShow) }) } + + item { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Button( + onClick = { nav.nav(Route.NewPodcastEpisode()) }, + modifier = Modifier.weight(1f), + ) { + Icon(symbol = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(text = stringRes(R.string.podcast_new_episode), modifier = Modifier.padding(start = 6.dp)) + } + OutlinedButton( + onClick = { nav.nav(Route.NewPodcastTrailer) }, + modifier = Modifier.weight(1f), + ) { + Text(text = stringRes(R.string.podcast_new_trailer)) + } + } + } + + if (episodes.isNotEmpty()) { + item { SectionHeader(pluralStringResource(R.plurals.podcast_episode_count, episodes.size, episodes.size)) } + items(episodes, key = { it.id }) { ep -> + EpisodeRow( + title = ep.title() ?: stringRes(R.string.podcast_untitled), + subtitle = episodeSubtitle(ep), + onClick = { nav.nav(Route.NewPodcastEpisode(ep.dTag())) }, + ) + } + } + + if (trailers.isNotEmpty()) { + item { SectionHeader(pluralStringResource(R.plurals.podcast_trailer_count, trailers.size, trailers.size)) } + items(trailers, key = { it.id }) { tr -> + EpisodeRow( + title = tr.title() ?: stringRes(R.string.podcast_untitled), + subtitle = tr.url(), + onClick = null, + ) + } + } + + if (episodes.isEmpty()) { + item { + Text( + text = stringRes(R.string.podcast_no_episodes_yet), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + } + } + } +} + +@Composable +private fun ShowHeaderCard( + show: Podcasting20PodcastMetadata?, + onEdit: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onEdit) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + val image = show?.showImage() + Box( + modifier = Modifier.size(64.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + if (!image.isNullOrBlank()) { + Image( + painter = rememberAsyncImagePainter(model = image), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize(), + ) + } else { + Icon(symbol = MaterialSymbols.Podcasts, contentDescription = null, modifier = Modifier.size(30.dp)) + } + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = show?.showTitle()?.takeIf { it.isNotBlank() } ?: stringRes(R.string.podcast_create_your_show), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = if (show != null) stringRes(R.string.podcast_tap_to_edit_show) else stringRes(R.string.podcast_create_show_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Icon(symbol = MaterialSymbols.Edit, contentDescription = null, modifier = Modifier.size(20.dp)) + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 8.dp), + ) +} + +@Composable +private fun EpisodeRow( + title: String, + subtitle: String?, + onClick: (() -> Unit)?, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .let { if (onClick != null) it.clickable(onClick = onClick) else it } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + symbol = MaterialSymbols.MusicNote, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (onClick != null) { + Icon(symbol = MaterialSymbols.ChevronRight, contentDescription = null, modifier = Modifier.size(20.dp), tint = Color.Gray) + } + } +} + +private fun episodeSubtitle(ep: Podcasting20EpisodeEvent): String? { + val season = ep.season() + val number = ep.number() + return when { + season != null && number != null -> "S$season · E$number" + number != null -> "Ep $number" + season != null -> "Season $season" + else -> null + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastComposerMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastComposerMedia.kt new file mode 100644 index 0000000000..d034d20271 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/PodcastComposerMedia.kt @@ -0,0 +1,131 @@ +/* + * 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.podcasts.authoring + +import android.content.Context +import android.media.MediaMetadataRetriever +import android.net.Uri +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +/** + * Shared upload + media-probe mechanics for the Podcasting-2.0 composers (episode, show, trailer). + * Mirrors the music-track composer's approach but kept in one place so all three authoring + * ViewModels resolve a picked file to a hosted URL the same way. + */ +object PodcastComposerMedia { + class UploadException( + kind: String, + details: String, + ) : Exception("$kind upload failed: $details") + + /** + * Uploads a single picked file (cover image or audio) through the user's media server and + * returns the hosted URL. Throws [UploadException] if the server rejects it or returns no URL. + */ + suspend fun upload( + orchestrator: MultiOrchestrator, + kind: String, + account: Account, + server: ServerName, + quality: CompressorQuality, + stripMetadata: Boolean, + alt: String?, + context: Context, + onStrippingFailed: suspend () -> Boolean, + ): String { + val res = + orchestrator.upload( + alt = alt, + contentWarningReason = null, + mediaQuality = quality, + server = server, + account = account, + context = context, + useH265 = false, + stripMetadata = stripMetadata, + onStrippingFailed = onStrippingFailed, + ) + if (!res.allGood) throw UploadException(kind, formatUploadErrors(res.errors, context)) + return firstUploadedUrl(res.successful) + ?: throw UploadException(kind, "Server didn't return a URL for the uploaded $kind.") + } + + private fun firstUploadedUrl(successful: List): String? = + successful + .firstNotNullOfOrNull { it.result as? UploadOrchestrator.OrchestratorResult.ServerResult } + ?.url + + private fun formatUploadErrors( + errors: List, + context: Context, + ): String = + errors + .map { context.getString(it.errorResource, *it.params) } + .distinct() + .joinToString(".\n") + + /** Audio metadata read off a picked file, used to auto-fill the composer. Any field may be null. */ + class ProbedAudio( + val durationSeconds: Int?, + val title: String?, + ) + + /** + * Reads duration + title from a picked audio file via [MediaMetadataRetriever] (heavy — call off + * the main thread). Returns null if the provider rejects it or the file isn't a real container. + */ + fun probeAudio( + context: Context, + uri: Uri, + ): ProbedAudio? { + val retriever = MediaMetadataRetriever() + return try { + retriever.setDataSource(context, uri) + val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() + ProbedAudio( + durationSeconds = durationMs?.let { (it / 1000).toInt().takeIf { secs -> secs > 0 } }, + title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)?.trim()?.ifBlank { null }, + ) + } catch (_: Exception) { + null + } finally { + retriever.release() + } + } + + /** Current time as an RFC2822 date string (`Tue, 24 Jun 2025 12:00:00 GMT`) — the spec's `pubdate`. */ + fun rfc2822Now(): String = DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneId.of("GMT"))) + + /** A fresh, stable `d` tag for a new addressable episode/trailer. */ + fun generateDTag(prefix: String): String = "$prefix-${System.currentTimeMillis() / 1000}-${UUID.randomUUID().toString().take(8)}" + + /** Splits a comma-separated text field into a clean list (trimmed, no blanks). */ + fun parseCsv(text: String): List = text.split(',').map { it.trim() }.filter { it.isNotEmpty() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt new file mode 100644 index 0000000000..7a9c5648ba --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/authoring/V4VSplitEditor.kt @@ -0,0 +1,331 @@ +/* + * 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.podcasts.authoring + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.FilterChip +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.podcasts.RecipientDraft +import com.vitorpamplona.amethyst.commons.podcasts.V4VSplitEditorState +import com.vitorpamplona.amethyst.ui.note.BaseUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size40dp +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage +import com.vitorpamplona.amethyst.ui.theme.grayText + +/** + * Editor for a Podcasting-2.0 value-for-value split. Recipients are added the Amethyst-native way — + * search for a Nostr user and they're rendered with avatar + name, their lightning address resolved + * automatically — with a manual "add address" fallback for raw lightning addresses or node keysend + * destinations. Each recipient's share of incoming sats is shown live as a percentage of the total + * weight. Drives a [V4VSplitEditorState]; the owning composer reads [V4VSplitEditorState.toPodcastValue]. + */ +@Composable +fun V4VSplitEditor( + state: V4VSplitEditorState, + accountViewModel: AccountViewModel, +) { + val total = state.totalSplit() + val userSuggestions = + remember { UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) } + var search by remember { mutableStateOf("") } + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon( + symbol = MaterialSymbols.Bolt, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = stringRes(R.string.podcast_value_for_value), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + + if (state.recipients.isEmpty()) { + Text( + text = stringRes(R.string.podcast_value_editor_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + + state.recipients.forEach { draft -> + if (draft.user.value != null) { + UserRecipientCard(draft, total, accountViewModel, onRemove = { state.remove(draft) }) + } else { + ManualRecipientCard(draft, total, onRemove = { state.remove(draft) }) + } + } + + // Search a Nostr user to add (resolves their lightning address). Beautiful path. + OutlinedTextField( + value = search, + onValueChange = { newValue -> + search = newValue + if (newValue.length > 2) userSuggestions.processCurrentWord(newValue) else userSuggestions.reset() + }, + label = { Text(stringRes(R.string.podcast_value_search_user)) }, + placeholder = { Text(stringRes(R.string.podcast_value_search_user_hint)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + if (search.length > 2) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> + val added = state.addUser(user) + if (!added) { + accountViewModel.toastManager.toast( + R.string.podcast_value_for_value, + R.string.podcast_value_user_no_lnaddress, + ) + } + search = "" + userSuggestions.reset() + }, + accountViewModel = accountViewModel, + modifier = SuggestionListDefaultHeightPage, + ) + } + + // Fallback for raw destinations (a node pubkey for keysend, or a non-Nostr lightning address). + TextButton(onClick = { state.addManual() }, modifier = Modifier.fillMaxWidth()) { + Icon(symbol = MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(text = stringRes(R.string.podcast_value_add_address), modifier = Modifier.padding(start = 6.dp)) + } + } +} + +@Composable +private fun RecipientShell( + total: Int, + draft: RecipientDraft, + onRemove: () -> Unit, + content: @Composable RowScope.() -> Unit, +) { + val weight = + draft.split.value + .trim() + .toIntOrNull() ?: 0 + val percent = if (total > 0 && weight > 0) weight * 100 / total else 0 + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + content() + Text( + text = stringRes(R.string.podcast_value_split_percent, percent), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + IconButton(onClick = onRemove) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = stringRes(R.string.podcast_value_remove_recipient), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + + WeightAndFeeRow(draft, weight) + } +} + +@Composable +private fun UserRecipientCard( + draft: RecipientDraft, + total: Int, + accountViewModel: AccountViewModel, + onRemove: () -> Unit, +) { + val user = draft.user.value ?: return + RecipientShell(total, draft, onRemove) { + BaseUserPicture(user, Size40dp, accountViewModel = accountViewModel) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + UsernameDisplay(user, accountViewModel = accountViewModel) + val lud = user.lnAddress() + Text( + text = lud ?: stringRes(R.string.podcast_value_user_no_lnaddress), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + ) + } + } +} + +@Composable +private fun ManualRecipientCard( + draft: RecipientDraft, + total: Int, + onRemove: () -> Unit, +) { + val isNode by draft.isNode + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val weight = + draft.split.value + .trim() + .toIntOrNull() ?: 0 + val percent = if (total > 0 && weight > 0) weight * 100 / total else 0 + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringRes(R.string.podcast_value_split_percent, percent), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onRemove) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = stringRes(R.string.podcast_value_remove_recipient), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + + OutlinedTextField( + value = draft.name.value, + onValueChange = { draft.name.value = it }, + label = { Text(stringRes(R.string.podcast_value_recipient_name)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = !isNode, + onClick = { draft.isNode.value = false }, + label = { Text(stringRes(R.string.podcast_value_type_lnaddress)) }, + ) + FilterChip( + selected = isNode, + onClick = { draft.isNode.value = true }, + label = { Text(stringRes(R.string.podcast_value_type_node)) }, + ) + } + + OutlinedTextField( + value = draft.address.value, + onValueChange = { draft.address.value = it }, + label = { Text(if (isNode) stringRes(R.string.podcast_value_node_pubkey) else stringRes(R.string.podcast_value_lnaddress)) }, + placeholder = { + Text(if (isNode) stringRes(R.string.podcast_value_node_pubkey_hint) else stringRes(R.string.podcast_value_lnaddress_hint)) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = draft.address.value.isBlank(), + ) + + WeightAndFeeRow(draft, weight) + } +} + +@Composable +private fun WeightAndFeeRow( + draft: RecipientDraft, + weight: Int, +) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = draft.split.value, + onValueChange = { input -> draft.split.value = input.filter { it.isDigit() } }, + label = { Text(stringRes(R.string.podcast_value_weight)) }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + isError = weight <= 0, + ) + FilterChip( + selected = draft.fee.value, + onClick = { draft.fee.value = !draft.fee.value }, + label = { Text(stringRes(R.string.podcast_value_fee)) }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt index 05522350b6..fd9a704db8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/OnePodcastEpisodesFeedFilter.kt @@ -23,15 +23,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.dal import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.podcasts.PodcastEpisode /** - * Every episode of a single podcast. Per NIP-F4 each podcast is its own keypair, so all - * episodes (kind 54) of a show are authored by [podcastPubkey]. Episodes are regular events, - * so they live in `LocalCache.notes`. Most-recent-first via [sortedByDefaultFeedOrder]. + * Everything a single podcast (authored by [podcastPubkey]) publishes for its show page: NIP-F4 + * episodes (kind 54, regular events in `LocalCache.notes`), Podcasting-2.0 episodes (kind 30054) + * and Podcasting-2.0 trailers (kind 30055, both addressable events in `LocalCache.addressables`). + * Episodes go through the shared [PodcastEpisode] interface; trailers are matched by their concrete + * type and rendered distinctly. In every model the show's pubkey authors its own content. + * Most-recent-first via [sortedByDefaultFeedOrder]. */ class OnePodcastEpisodesFeedFilter( val podcastPubkey: HexKey, @@ -41,18 +47,27 @@ class OnePodcastEpisodesFeedFilter( override fun feedKey(): String = "podcast-" + podcastPubkey override fun feed(): List { - val notes = + val regular = cache.notes.filterIntoSet { _, it -> acceptableEvent(it) } - return sort(notes) + val episodes = + cache.addressables.filterIntoSet(Podcasting20EpisodeEvent.KIND) { _, it -> + acceptableEvent(it) + } + val trailers = + cache.addressables.filterIntoSet(Podcasting20TrailerEvent.KIND) { _, it -> + acceptableEvent(it) + } + return sort(regular + episodes + trailers) } override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { acceptableEvent(it) } private fun acceptableEvent(note: Note): Boolean { - val noteEvent = note.event - return noteEvent is PodcastEpisodeEvent && + val noteEvent = note.event ?: return false + val isShowContent = noteEvent is PodcastEpisode || noteEvent is Podcasting20TrailerEvent + return isShowContent && noteEvent.pubKey == podcastPubkey && !note.isHiddenFor(account.hiddenUsers.flow.value) && account.isAcceptable(note) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt index 058e07bdac..6bb8070d66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastEpisodesFeedFilter.kt @@ -28,12 +28,14 @@ import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder -import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.podcasts.PodcastEpisode /** - * Episodes are regular events (kind 54), so they live in `LocalCache.notes` — unlike - * music tracks/playlists which are addressable. Same follow-list + hidden/blocked - * gate as MusicTracksFeedFilter. + * Merges the two podcast-episode kinds into one list. NIP-F4 episodes (kind 54) are regular + * events in `LocalCache.notes`; Podcasting-2.0 episodes (kind 30054) are addressable events in + * `LocalCache.addressables`. Both implement [PodcastEpisode], so a single accept gate covers + * them. Same follow-list + hidden/blocked gate as MusicTracksFeedFilter. */ class PodcastEpisodesFeedFilter( val account: Account, @@ -54,11 +56,15 @@ class PodcastEpisodesFeedFilter( override fun feed(): List { val params = buildFilterParams(account) - val notes = + val regular = LocalCache.notes.filterIntoSet { _, it -> accept(it, params) } - return sort(notes) + val addressable = + LocalCache.addressables.filterIntoSet(Podcasting20EpisodeEvent.KIND) { _, it -> + accept(it, params) + } + return sort(regular + addressable) } override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) @@ -78,8 +84,8 @@ class PodcastEpisodesFeedFilter( note: Note, params: FilterByListParams, ): Boolean { - val noteEvent = note.event - return noteEvent is PodcastEpisodeEvent && + val noteEvent = note.event ?: return false + return noteEvent is PodcastEpisode && params.match(noteEvent, note.relays) && (params.isHiddenList || account.isAcceptable(note)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt index cc452a3f93..01d59060d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/dal/PodcastsFeedFilter.kt @@ -28,11 +28,15 @@ import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastShowEvent /** - * Show-level podcast metadata (kind 10154) is a replaceable event, so it lives in - * `LocalCache.addressables` — same shape as the music-playlists filter. + * Merges show-level podcast metadata from both drafts into one list. NIP-F4 shows (kind 10154) + * and Podcasting-2.0 shows (kind 30078 NIP-78 app-data with `d="podcast-metadata"`) are both + * replaceable, so they live in `LocalCache.addressables`. [isPodcastShowEvent] gates inclusion + * (the kind-30078 scan would otherwise see unrelated app-data, so the `d`-tag check matters). */ class PodcastsFeedFilter( val account: Account, @@ -53,11 +57,15 @@ class PodcastsFeedFilter( override fun feed(): List { val params = buildFilterParams(account) - val notes = + val f4 = LocalCache.addressables.filterIntoSet(PodcastMetadataEvent.KIND) { _, it -> accept(it, params) } - return sort(notes) + val podcasting20 = + LocalCache.addressables.filterIntoSet(AppSpecificDataEvent.KIND) { _, it -> + accept(it, params) + } + return sort(f4 + podcasting20) } override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) @@ -77,8 +85,8 @@ class PodcastsFeedFilter( note: Note, params: FilterByListParams, ): Boolean { - val noteEvent = note.event - return noteEvent is PodcastMetadataEvent && + val noteEvent = note.event ?: return false + return isPodcastShowEvent(noteEvent) && params.match(noteEvent, note.relays) && (params.isHiddenList || account.isAcceptable(note)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt new file mode 100644 index 0000000000..8cd77217a9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterMyPodcast.kt @@ -0,0 +1,81 @@ +/* + * 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.podcasts.datasource + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent + +/** + * REQ for the logged-in creator's own Podcasting-2.0 catalog, so the authoring hub reliably shows + * their show, episodes and trailers even on a fresh install (rather than only what happens to be in + * [LocalCache]). Queried on the creator's own outbox relays. + * + * Two separate filters per relay, and they can't be merged: + * - episodes (kind 30054) + trailers (kind 30055) by author, with no tag constraint; and + * - the show metadata (kind 30078) constrained to `#d=["podcast-metadata"]`. That NIP-78 app-data + * kind is heavily overloaded, so without the `#d` constraint this REQ would pull every app's data + * (including the user's private settings) for the pubkey. The constraint must stay off the episodes + * filter, since each episode/trailer carries its own `d` tag — a combined `#d` would match nothing. + */ +fun filterMyPodcast( + user: User, + since: SincePerRelayMap?, +): List { + val relays = + user.outboxRelays()?.ifEmpty { null } + ?: (user.allUsedRelays() + LocalCache.relayHints.hintsForKey(user.pubkeyHex)) + + val authors = listOf(user.pubkeyHex) + + return relays.flatMap { relay -> + val sinceTime = since?.get(relay)?.time + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = listOf(Podcasting20EpisodeEvent.KIND, Podcasting20TrailerEvent.KIND), + since = sinceTime, + limit = 200, + ), + ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + authors = authors, + kinds = listOf(AppSpecificDataEvent.KIND), + tags = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)), + since = sinceTime, + limit = 10, + ), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt index e526cf3174..6c061b11a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/FilterOnePodcast.kt @@ -25,16 +25,22 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent -// Per NIP-F4 a podcast is its own keypair: the show-level metadata (kind 10154) and every -// episode (kind 54) are authored by the same pubkey. So a single-podcast screen fetches -// both kinds from that one author. +// A single-podcast screen fetches everything authored by the show's pubkey, across both drafts: +// NIP-F4 metadata (kind 10154) + episodes (kind 54), and Podcasting-2.0 episodes (kind 30054) + +// trailers (kind 30055). In both models the show key authors its own episodes and trailers. private val OnePodcastKinds = listOf( PodcastMetadataEvent.KIND, PodcastEpisodeEvent.KIND, + Podcasting20EpisodeEvent.KIND, + Podcasting20TrailerEvent.KIND, ) fun filterOnePodcast( @@ -47,16 +53,31 @@ fun filterOnePodcast( user.outboxRelays()?.ifEmpty { null } ?: (user.allUsedRelays() + LocalCache.relayHints.hintsForKey(user.pubkeyHex)) - return relays.map { relay -> - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = OnePodcastKinds, - authors = listOf(user.pubkeyHex), - limit = 500, - since = since?.get(relay)?.time, - ), + return relays.flatMap { relay -> + listOf( + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = OnePodcastKinds, + authors = listOf(user.pubkeyHex), + limit = 500, + since = since?.get(relay)?.time, + ), + ), + // Podcasting-2.0 show metadata rides on the generic NIP-78 app-data kind (30078), + // so it needs its own #d=podcast-metadata filter rather than a bare kind match. + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(AppSpecificDataEvent.KIND), + authors = listOf(user.pubkeyHex), + tags = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)), + limit = 1, + since = since?.get(relay)?.time, + ), + ), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFeedSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFeedSubAssembler.kt new file mode 100644 index 0000000000..d9ce4aba05 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFeedSubAssembler.kt @@ -0,0 +1,38 @@ +/* + * 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.podcasts.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class MyPodcastFeedSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUserEoseManager(client, allKeys) { + override fun updateFilter( + key: MyPodcastQueryState, + since: SincePerRelayMap?, + ): List = filterMyPodcast(user(key), since) + + override fun user(key: MyPodcastQueryState) = key.user +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssembler.kt new file mode 100644 index 0000000000..443d114108 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssembler.kt @@ -0,0 +1,46 @@ +/* + * 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.podcasts.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient + +// Keyed by the logged-in creator's own pubkey. Drives the authoring hub's REQ for the creator's +// own Podcasting-2.0 catalog (episodes, trailers, and the show-metadata event). +class MyPodcastQueryState( + val user: User, +) + +class MyPodcastFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + MyPodcastFeedSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..9fd9240b5c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/MyPodcastFilterAssemblerSubscription.kt @@ -0,0 +1,41 @@ +/* + * 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.podcasts.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * While the authoring hub is on screen, keeps a REQ open for the logged-in creator's own + * Podcasting-2.0 catalog (episodes, trailers, and show metadata), so the hub fills in even when the + * events weren't already cached. + */ +@Composable +fun MyPodcastFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + val state = + remember(accountViewModel) { + MyPodcastQueryState(accountViewModel.account.userProfile()) + } + + LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().myPodcast) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt index 19b65c62aa..d332342799 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/SubAssemblyHelper.kt @@ -30,8 +30,10 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopN import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCASTING20_METADATA_KINDS import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_EPISODE_KINDS import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_KINDS +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.PODCAST_METADATA_D_FILTER import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByAuthors import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.subassemblies.filterPodcastEventsByCommunity @@ -52,22 +54,28 @@ fun makePodcastsFilter( feedSettings: IFeedTopNavPerRelayFilterSet, since: SincePerRelayMap?, defaultSince: Long? = null, -): List = makePodcastFilter(feedSettings, PODCAST_KINDS, since, defaultSince) +): List = + // Two REQs: NIP-F4 shows (kind:10154, no tag constraint) plus Podcasting-2.0 shows + // (kind:30078, constrained to `#d=["podcast-metadata"]` so the overloaded app-data kind + // doesn't flood the feed). Both land in the merged PodcastsFeedFilter. + makePodcastFilter(feedSettings, PODCAST_KINDS, since, defaultSince) + + makePodcastFilter(feedSettings, PODCASTING20_METADATA_KINDS, since, defaultSince, PODCAST_METADATA_D_FILTER) private fun makePodcastFilter( feedSettings: IFeedTopNavPerRelayFilterSet, kinds: List, since: SincePerRelayMap?, defaultSince: Long?, + additionalTags: Map>? = null, ): List = when (feedSettings) { - is AllCommunitiesTopNavPerRelayFilterSet -> filterPodcastEventsByAllCommunities(feedSettings, kinds, since, defaultSince) - is AllFollowsTopNavPerRelayFilterSet -> filterPodcastEventsByFollows(feedSettings, kinds, since, defaultSince) - is AuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByAuthors(feedSettings, kinds, since, defaultSince) - is GlobalTopNavPerRelayFilterSet -> filterPodcastEventsGlobal(feedSettings, kinds, since, defaultSince) - is HashtagTopNavPerRelayFilterSet -> filterPodcastEventsByHashtag(feedSettings, kinds, since, defaultSince) - is LocationTopNavPerRelayFilterSet -> filterPodcastEventsByGeohashes(feedSettings, kinds, since, defaultSince) - is MutedAuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByMutedAuthors(feedSettings, kinds, since, defaultSince) - is SingleCommunityTopNavPerRelayFilterSet -> filterPodcastEventsByCommunity(feedSettings, kinds, since, defaultSince) + is AllCommunitiesTopNavPerRelayFilterSet -> filterPodcastEventsByAllCommunities(feedSettings, kinds, since, defaultSince, additionalTags) + is AllFollowsTopNavPerRelayFilterSet -> filterPodcastEventsByFollows(feedSettings, kinds, since, defaultSince, additionalTags) + is AuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByAuthors(feedSettings, kinds, since, defaultSince, additionalTags) + is GlobalTopNavPerRelayFilterSet -> filterPodcastEventsGlobal(feedSettings, kinds, since, defaultSince, additionalTags) + is HashtagTopNavPerRelayFilterSet -> filterPodcastEventsByHashtag(feedSettings, kinds, since, defaultSince, additionalTags) + is LocationTopNavPerRelayFilterSet -> filterPodcastEventsByGeohashes(feedSettings, kinds, since, defaultSince, additionalTags) + is MutedAuthorsTopNavPerRelayFilterSet -> filterPodcastEventsByMutedAuthors(feedSettings, kinds, since, defaultSince, additionalTags) + is SingleCommunityTopNavPerRelayFilterSet -> filterPodcastEventsByCommunity(feedSettings, kinds, since, defaultSince, additionalTags) else -> emptyList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt index 4a56858280..48a5213fe0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByAuthors.kt @@ -27,14 +27,42 @@ 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.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata // Each podcast screen issues its own REQ keyed on its own follow-list selector — keep // the kind lists split so episodes and show metadata never co-mingle in one filter. -internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND) +// Episodes span both podcast drafts: NIP-F4 kind 54 (podcast-is-a-keypair) and Podcasting-2.0 +// kind 30054 (creator-is-a-keypair). Both are authored by the followed pubkey, so the same +// author-scoped REQ pulls them into one merged feed. +internal val PODCAST_EPISODE_KINDS = listOf(PodcastEpisodeEvent.KIND, Podcasting20EpisodeEvent.KIND) internal val PODCAST_KINDS = listOf(PodcastMetadataEvent.KIND) +// Podcasting-2.0 stores show metadata as a kind:30078 NIP-78 app-data event. That kind is +// heavily overloaded, so this REQ MUST be constrained by `#d=["podcast-metadata"]` (see +// [PODCAST_METADATA_D_FILTER]) or it would pull every client's app-data. Kept separate from +// [PODCAST_KINDS] because the NIP-F4 kind:10154 metadata must NOT carry the `#d` constraint. +internal val PODCASTING20_METADATA_KINDS = listOf(AppSpecificDataEvent.KIND) +internal val PODCAST_METADATA_D_FILTER = mapOf("d" to listOf(Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG)) + +/** + * Merges two relay-filter tag maps, unioning the value lists per key. Used to layer an extra + * constraint (e.g. `#d`) onto a variant that already pins its own tags (`#t`, `#g`, `#a`). + * A null on either side passes the other through unchanged. + */ +internal fun mergeFilterTags( + base: Map>?, + extra: Map>?, +): Map>? = + when { + base == null -> extra + extra == null -> base + else -> (base.keys + extra.keys).associateWith { (base[it].orEmpty() + extra[it].orEmpty()).distinct() } + } + // NOTE on TopFilter.AllFollows / Following for the Podcasts tab: per NIP-F4 each podcast is // its own keypair, so podcast pubkeys generally aren't in the user's kind:3 contact list — // they live in kind:10054 (FavoritePodcastsListEvent) and kind:10064 (AuthoredPodcastsEvent). @@ -47,6 +75,7 @@ fun filterPodcastEventsByAuthors( kinds: List, authors: Set, since: Long? = null, + additionalTags: Map>? = null, ): List { val authorList = authors.sorted() return listOf( @@ -56,6 +85,7 @@ fun filterPodcastEventsByAuthors( Filter( authors = authorList, kinds = kinds, + tags = additionalTags, limit = 200, since = since, ), @@ -68,6 +98,7 @@ fun filterPodcastEventsByAuthors( kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { if (authorSet.set.isEmpty()) return emptyList() @@ -81,6 +112,7 @@ fun filterPodcastEventsByAuthors( kinds = kinds, authors = it.value.authors, since = since?.get(it.key)?.time ?: defaultSince, + additionalTags = additionalTags, ) } }.flatten() @@ -91,6 +123,7 @@ fun filterPodcastEventsByMutedAuthors( kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { if (authorSet.set.isEmpty()) return emptyList() @@ -104,6 +137,7 @@ fun filterPodcastEventsByMutedAuthors( kinds = kinds, authors = it.value.authors, since = since?.get(it.key)?.time ?: defaultSince, + additionalTags = additionalTags, ) } }.flatten() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt index beb0d05142..edfd12d10f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByCommunities.kt @@ -33,6 +33,7 @@ fun filterPodcastEventsFromAllCommunities( kinds: List, communities: Set, since: Long? = null, + additionalTags: Map>? = null, ): List { val communityList = communities.sorted() val kindsAsStrings = kinds.map { it.toString() } @@ -57,7 +58,7 @@ fun filterPodcastEventsFromAllCommunities( filter = Filter( kinds = kinds, - tags = mapOf("a" to communityList), + tags = mergeFilterTags(mapOf("a" to communityList), additionalTags), limit = communityList.size * 20, since = since, ), @@ -70,6 +71,7 @@ fun filterPodcastEventsByAllCommunities( kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { if (communitySet.set.isEmpty()) return emptyList() @@ -79,6 +81,7 @@ fun filterPodcastEventsByAllCommunities( kinds = kinds, communities = it.value.communities, since = since?.get(it.key)?.time ?: defaultSince, + additionalTags = additionalTags, ) } } @@ -89,6 +92,7 @@ fun filterPodcastEventsFromCommunity( community: String, authors: Set?, since: Long? = null, + additionalTags: Map>? = null, ): List { val authorList = authors?.sorted() val kindsAsStrings = kinds.map { it.toString() } @@ -114,7 +118,7 @@ fun filterPodcastEventsFromCommunity( Filter( authors = authorList, kinds = kinds, - tags = mapOf("a" to listOf(community)), + tags = mergeFilterTags(mapOf("a" to listOf(community)), additionalTags), limit = 100, since = since, ), @@ -127,6 +131,7 @@ fun filterPodcastEventsByCommunity( kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { if (communitySet.set.isEmpty()) return emptyList() @@ -137,6 +142,7 @@ fun filterPodcastEventsByCommunity( community = it.value.community, authors = it.value.authors, since = since?.get(it.key)?.time ?: defaultSince, + additionalTags = additionalTags, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt index 0b573739d2..4249f4982d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByFollows.kt @@ -29,6 +29,7 @@ fun filterPodcastEventsByFollows( kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { if (followsSet.set.isEmpty()) return emptyList() @@ -38,7 +39,7 @@ fun filterPodcastEventsByFollows( listOfNotNull( it.value.authors?.let { authors -> - filterPodcastEventsByAuthors(relay, kinds, authors, sinceForRelay) + filterPodcastEventsByAuthors(relay, kinds, authors, sinceForRelay, additionalTags) }, ).flatten() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt index f746865b0d..7693832ccc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByGeohashes.kt @@ -31,6 +31,7 @@ fun filterPodcastEventsByGeohashes( kinds: List, geotags: Set, since: Long?, + additionalTags: Map>? = null, ): List { if (geotags.isEmpty()) return emptyList() @@ -40,7 +41,7 @@ fun filterPodcastEventsByGeohashes( filter = Filter( kinds = kinds, - tags = mapOf("g" to geotags.sorted()), + tags = mergeFilterTags(mapOf("g" to geotags.sorted()), additionalTags), limit = 100, since = since, ), @@ -53,6 +54,7 @@ fun filterPodcastEventsByGeohashes( kinds: List, since: SincePerRelayMap?, defaultSince: Long?, + additionalTags: Map>? = null, ): List { if (geoSet.set.isEmpty()) return emptyList() @@ -66,6 +68,7 @@ fun filterPodcastEventsByGeohashes( kinds = kinds, geotags = it.value.geotags, since = since?.get(it.key)?.time ?: defaultSince, + additionalTags = additionalTags, ) } }.flatten() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt index 124c464a17..cbd6961074 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsByHashtag.kt @@ -31,6 +31,7 @@ fun filterPodcastEventsByHashtag( kinds: List, hashtags: Set, since: Long? = null, + additionalTags: Map>? = null, ): List = listOf( RelayBasedFilter( @@ -38,7 +39,7 @@ fun filterPodcastEventsByHashtag( filter = Filter( kinds = kinds, - tags = mapOf("t" to hashtags.sorted()), + tags = mergeFilterTags(mapOf("t" to hashtags.sorted()), additionalTags), limit = 200, since = since, ), @@ -50,6 +51,7 @@ fun filterPodcastEventsByHashtag( kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { if (hashtagSet.set.isEmpty()) return emptyList() @@ -63,6 +65,7 @@ fun filterPodcastEventsByHashtag( kinds = kinds, hashtags = it.value.hashtags, since = since?.get(it.key)?.time ?: defaultSince, + additionalTags = additionalTags, ) } }.flatten() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt index e0fc0c4cba..95e4778505 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/FilterPodcastEventsGlobal.kt @@ -30,6 +30,7 @@ fun filterPodcastEventsGlobal( kinds: List, since: SincePerRelayMap?, defaultSince: Long? = null, + additionalTags: Map>? = null, ): List { if (relays.set.isEmpty()) return emptyList() @@ -42,6 +43,7 @@ fun filterPodcastEventsGlobal( filter = Filter( kinds = kinds, + tags = additionalTags, limit = 200, since = sinceForRelay, ), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt index 478ed05d8c..e1202d0fe0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/AddSearchRelayListDialog.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search.SearchRelayLi import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.imageModifier +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -114,7 +115,11 @@ private fun Explanation(postViewModel: SearchRelayListViewModel) { Spacer(modifier = StdVertSpacer) Text( - text = stringRes(id = R.string.search_relays_not_found_examples), + text = + stringRes( + R.string.search_relays_not_found_examples, + DefaultSearchRelayList.joinToString("\n") { " - ${it.displayUrl()}" }, + ), ) Spacer(modifier = StdVertSpacer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index e2319e7d03..85c84cfa69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -220,6 +220,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastTrailerListItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.dal.LevelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay import com.vitorpamplona.amethyst.ui.stringRes @@ -324,6 +325,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent @@ -341,6 +343,9 @@ import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -832,8 +837,15 @@ private fun FullBleedNoteCompose( RenderMusicPlaylist(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) } else if (noteEvent is PodcastEpisodeEvent) { RenderPodcastEpisode(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is Podcasting20EpisodeEvent) { + RenderPodcastEpisode(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is Podcasting20TrailerEvent) { + PodcastTrailerListItem(baseNote, accountViewModel, nav) } else if (noteEvent is PodcastMetadataEvent) { RenderPodcastMetadata(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) + } else if (noteEvent is AppSpecificDataEvent && noteEvent.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) { + // kind:30078 is overloaded; only the Podcasting-2.0 show-metadata variant renders as a podcast card. + RenderPodcastMetadata(baseNote, makeItShort = false, canPreview = true, backgroundColor, accountViewModel, nav) } else if (noteEvent is CommunityPostApprovalEvent) { RenderPostApproval( baseNote, diff --git a/amethyst/src/main/res/values-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index ab041f49a1..d918c46883 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -2265,7 +2265,7 @@ أعد مرحلات البحث إنشاء قائمة مرحلات مخصصة للبحث سوف يحسن من نتائج البحث. أدخل من واحد إلى ثلاث مرحلات لاستخدامهم عند البحث عن محتوى أو اشخاص، تأكد من أن هذا المرحلات تدعم NIP-50 - بعض المرحلات الجيدة:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + بعض المرحلات الجيدة:\n%1$s أعدّ Public Outbox relays للنشر إنشاء قائمة relay مخصصة لاستقبال محتواك أمر بالغ الأهمية لتجربتك على Nostr، وهو الطريقة الوحيدة التي يمكن من خلالها لمتابعيك إيجادك. أدخل ما بين 1 و3 relays تستقبل منشوراتك. تأكد من أنها لا تتطلب دفعًا إذا لم تكن تدفع للإدراج diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index 9d17c2a4e4..c2405400c2 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -2147,7 +2147,7 @@ আপনার সার্চ relay সেট আপ করুন বিশেষভাবে সার্চ এবং ব্যবহারকারী ট্যাগিংয়ের জন্য ডিজাইন করা একটি relay তালিকা তৈরি করলে ফলাফল উন্নত হবে। কন্টেন্ট বা ব্যবহারকারী সার্চ করতে এবং ট্যাগিংয়ের জন্য ১–৩টি relay ঢোকান। নিশ্চিত করুন যে আপনার বেছে নেওয়া relay গুলো NIP-50 সমর্থন করে - ভালো বিকল্পগুলো হলো:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + ভালো বিকল্পগুলো হলো:\n%1$s আউটবক্স Relay পোস্ট করতে আপনার পাবলিক আউটবক্স relay সেট আপ করুন আপনার কন্টেন্ট গ্রহণের জন্য বিশেষভাবে ডিজাইন করা একটি relay তালিকা তৈরি করা আপনার Nostr অভিজ্ঞতার জন্য অত্যন্ত গুরুত্বপূর্ণ এবং আপনার অনুসরণকারীরা আপনাকে খুঁজে পাওয়ার একমাত্র উপায়। diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index d3823ccace..cd8b68ad00 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -2237,7 +2237,7 @@ Nastavte svá vyhledávací relé Vytvoření seznamu relé speciálně navržených pro vyhledávání a označování uživatelů zlepší tyto výsledky. Vložte mezi 1–3 relé, která chcete použít při vyhledávání obsahu nebo označování uživatelů. Ujistěte se, že vaše vybraná relé implementují NIP-50 - Dobré možnosti jsou:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Dobré možnosti jsou:\n%1$s Relaye pro odesílání Nastavte své veřejné relaye pro odesílání příspěvků Vytvoření seznamu relayů určených pro přijímání vašeho obsahu je zásadní pro váš zážitek z Nostr a jediný způsob, jak vás mohou vaši sledující najít. diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index c8d6a6cc09..dbb706aa9d 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -649,7 +649,7 @@ Nastavte svá vyhledávací relé Vytvoření seznamu relé speciálně navržených pro vyhledávání a označování uživatelů zlepší tyto výsledky. Vložte mezi 1–3 relé, která chcete použít při vyhledávání obsahu nebo označování uživatelů. Ujistěte se, že vaše vybraná relé implementují NIP-50 - Dobré možnosti jsou:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Dobré možnosti jsou:\n%1$s Nastavení relé Veřejná domácí relé Tento typ relé ukládá veškerý váš obsah. Amethyst sem pošle vaše příspěvky a ostatní použijí tato relé, aby našli váš obsah. Vložte mezi 1–3 relé. Mohou to být osobní relé, placená relé nebo veřejná relé. diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 67bb4280db..6bfda4e846 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -2186,7 +2186,7 @@ anz der Bedingungen ist erforderlich Richten Sie Ihre Suchrelais ein Das Erstellen einer Relaisliste, die speziell für die Suche und Benutzerkennzeichnung entwickelt wurde, wird diese Ergebnisse verbessern. Fügen Sie 1–3 Relais ein, die beim Suchen nach Inhalten oder beim Taggen von Benutzern verwendet werden sollen. Stellen Sie sicher, dass Ihre ausgewählten Relais NIP-50 implementieren - Gute Optionen sind:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Gute Optionen sind:\n%1$s Ausgehende Relays Richte deine öffentlichen Ausgehenden Relays zum Posten ein Das Erstellen einer Relay-Liste, die speziell zum Empfangen deiner Inhalte gedacht ist, ist entscheidend für dein Nostr-Erlebnis und der einzige Weg, wie dich deine Follower finden können. diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml index d6ee190771..12b2abea2c 100644 --- a/amethyst/src/main/res/values-de/strings.xml +++ b/amethyst/src/main/res/values-de/strings.xml @@ -655,7 +655,7 @@ anz der Bedingungen ist erforderlich Richten Sie Ihre Suchrelais ein Das Erstellen einer Relaisliste, die speziell für die Suche und Benutzerkennzeichnung entwickelt wurde, wird diese Ergebnisse verbessern. Fügen Sie 1–3 Relais ein, die beim Suchen nach Inhalten oder beim Taggen von Benutzern verwendet werden sollen. Stellen Sie sicher, dass Ihre ausgewählten Relais NIP-50 implementieren - Gute Optionen sind:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Gute Optionen sind:\n%1$s Relaiseinstellungen Öffentliche Heimrelais Dieser Relais-Typ speichert alle Ihre Inhalte. Amethyst sendet Ihre Beiträge hierher und andere werden diese Relais verwenden, um Ihre Inhalte zu finden. Fügen Sie 1–3 Relais ein. Sie können persönliche Relais, bezahlte Relais oder öffentliche Relais sein. diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index 9803b34be1..61256a3579 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -2127,7 +2127,7 @@ Ρυθμίστε τα relay αναζήτησής σας Η δημιουργία λίστας relay ειδικά σχεδιασμένης για αναζήτηση και επισήμανση χρηστών θα βελτιώσει αυτά τα αποτελέσματα. Εισάγετε από 1 έως 3 relay για χρήση κατά την αναζήτηση περιεχομένου ή επισήμανση χρηστών. Βεβαιωθείτε ότι τα επιλεγμένα relay υλοποιούν NIP-50 - Καλές επιλογές είναι:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Καλές επιλογές είναι:\n%1$s Relay Εξερχόμενων Ρυθμίστε τα δημόσια relay εξερχόμενων για ανάρτηση Η δημιουργία λίστας relay ειδικά σχεδιασμένης για τη λήψη του περιεχομένου σας είναι κρίσιμη για την εμπειρία σας στο Nostr και ο μόνος τρόπος για να σας βρουν οι ακόλουθοί σας. diff --git a/amethyst/src/main/res/values-eo-rUY/strings.xml b/amethyst/src/main/res/values-eo-rUY/strings.xml index 28c428f56b..2c649f5b87 100644 --- a/amethyst/src/main/res/values-eo-rUY/strings.xml +++ b/amethyst/src/main/res/values-eo-rUY/strings.xml @@ -2151,7 +2151,7 @@ Agordu viajn Serĉajn relay-ojn Krei relay-liston specife dezajnitan por serĉo kaj uzanta etikedado plibonigos ĉi tiujn rezultojn. Enigu inter 1–3 relay-ojn por uzi kiam serĉante enhavon aŭ etikedante uzantojn. Certigu ke viaj elektitaj relay-oj implementas NIP-50 - Bonaj opcioj estas:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Bonaj opcioj estas:\n%1$s Eligaj Relay-oj Agordu viajn Publikajn Eligajn relay-ojn por afiŝi Krei relay-liston specife dezajnitan por ricevi vian enhavon estas esenca por via Nostr-sperto kaj la sola maniero viaj sekvantoj povas trovi vin. diff --git a/amethyst/src/main/res/values-eo/strings.xml b/amethyst/src/main/res/values-eo/strings.xml index 82720eabfe..0c1c4e18cf 100644 --- a/amethyst/src/main/res/values-eo/strings.xml +++ b/amethyst/src/main/res/values-eo/strings.xml @@ -2038,7 +2038,7 @@ Agordu viajn Serĉajn relay-ojn Krei relay-liston specife dezajnitan por serĉo kaj uzanta etikedado plibonigos ĉi tiujn rezultojn. Enigu inter 1–3 relay-ojn por uzi kiam serĉante enhavon aŭ etikedante uzantojn. Certigu ke viaj elektitaj relay-oj implementas NIP-50 -Bonaj opcioj estas:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com +Bonaj opcioj estas:\n%1$s Eligaj Relay-oj Agordu viajn Publikajn Eligajn relay-ojn por afiŝi Krei relay-liston specife dezajnitan por ricevi vian enhavon estas esenca por via Nostr-sperto kaj la sola maniero viaj sekvantoj povas trovi vin. diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 35dae4fb1b..7a19ce8c02 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -2201,7 +2201,7 @@ Configura tus relés de búsqueda La creación de una lista de relés específicamente diseñada para la búsqueda y el etiquetado de usuarios mejorará estos resultados. Inserta entre 1 y 3 relés para usarlos al buscar contenido o etiquetar usuarios. Asegúrate de que los relés que elijas implementen NIP-50. - Algunas buenas opciones son: \n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Algunas buenas opciones son: \n%1$s Relays de salida Configura tus relays de salida públicos para publicar Crear una lista de relays diseñada específicamente para recibir tu contenido es fundamental para tu experiencia en Nostr y la única forma en que tus seguidores pueden encontrarte. diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 2ef5387dd6..c8625fc5f4 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -2192,7 +2192,7 @@ Configura tus relés de búsqueda La creación de una lista de relés específicamente diseñada para la búsqueda y el etiquetado de usuarios mejorará estos resultados. Inserta entre 1 y 3 relés para usarlos al buscar contenido o etiquetar usuarios. Asegúrate de que los relés que elijas implementen NIP-50. - Algunas buenas opciones son: \n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Algunas buenas opciones son: \n%1$s Relays de salida Configura tus relays de salida públicos para publicar Crear una lista de relays diseñada específicamente para recibir tu contenido es fundamental para tu experiencia en Nostr y la única forma en que tus seguidores pueden encontrarte. diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index 432f2761e1..c2c707a8fe 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -2192,7 +2192,7 @@ Configura tus relés de búsqueda La creación de una lista de relés específicamente diseñada para la búsqueda y el etiquetado de usuarios mejorará estos resultados. Inserta entre 1 y 3 relés para usarlos al buscar contenido o etiquetar usuarios. Asegúrate de que los relés que elijas implementen NIP-50. - Algunas buenas opciones son: \n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Algunas buenas opciones son: \n%1$s Relays de salida Configura tus relays de salida públicos para publicar Crear una lista de relays diseñada específicamente para recibir tu contenido es fundamental para tu experiencia en Nostr y la única forma en que tus seguidores pueden encontrarte. diff --git a/amethyst/src/main/res/values-es/strings.xml b/amethyst/src/main/res/values-es/strings.xml index efc8db68ef..8ca8201d19 100644 --- a/amethyst/src/main/res/values-es/strings.xml +++ b/amethyst/src/main/res/values-es/strings.xml @@ -2528,7 +2528,7 @@ Inserta entre 1 y 3 relés para usarlos al buscar contenido o etiquetar usuarios. Asegúrate de que los relés que elijas implementen NIP-50. -Algunas buenas opciones son: \n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com +Algunas buenas opciones son: \n%1$s Configuración de relés diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index 63d605eb63..9836f9d933 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -2164,7 +2164,7 @@ تنظیم رله های جستجو ساخت لیستی از رله های مخصوص جستجو و تگ کردن کاربر باعث بهبود این نتایج خواهد شد. بین ۱-۳ رله بیافزایید تا هنگام جستجوی محتوا یا تگ کردن کاربران از آنها استفاده شود. حتما رله های انتخابی باید NIP-50 را پیاده‌سازی کرده باشند. - انتخاب های مناسب: \n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + انتخاب های مناسب: \n%1$s relay های صادره relay های صادره عمومی خود را برای ارسال پست تنظیم کنید ایجاد یک لیست relay که به طور خاص برای دریافت محتوای شما طراحی شده، برای تجربه Nostr شما حیاتی است و تنها راهی است که دنبال‌کنندگانتان می‌توانند شما را پیدا کنند. diff --git a/amethyst/src/main/res/values-fa/strings.xml b/amethyst/src/main/res/values-fa/strings.xml index aeec1af6bf..d2c9bce73b 100644 --- a/amethyst/src/main/res/values-fa/strings.xml +++ b/amethyst/src/main/res/values-fa/strings.xml @@ -637,7 +637,7 @@ تنظیم رله های جستجو ساخت لیستی از رله های مخصوص جستجو و تگ کردن کاربر باعث بهبود این نتایج خواهد شد. بین ۱-۳ رله بیافزایید تا هنگام جستجوی محتوا یا تگ کردن کاربران از آنها استفاده شود. حتما رله های انتخابی باید NIP-50 را پیاده‌سازی کرده باشند. - انتخاب های مناسب: \n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + انتخاب های مناسب: \n%1$s تنظیمات رله رله های خانه/ صندوق خروجی عمومی این نوع از رله تمام محتوای شما را ذخریه می کند. امتیست پست های شما را اینجا می فرستد و دیگران از این رله ها برای یافتن محتوای شما استفاده می کنند. بین ۱-۳ رله وارد کنید. این رله ها ممکن است رله های شخصی، پولی، یا عمومی باشند. diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index 40a133660b..f56c94345a 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -2133,7 +2133,7 @@ Määritä hakurelayit Hakuun ja käyttäjien merkitsemiseen tarkoitetun relay-listan luominen parantaa näitä tuloksia. Lisää 1–3 relayia käytettäväksi sisällön haussa tai käyttäjien merkitsemisessä. Varmista, että valitsemasi relayit toteuttavat NIP-50:n - Hyviä vaihtoehtoja ovat:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Hyviä vaihtoehtoja ovat:\n%1$s Lähtevien relayit Määritä julkiset lähtevien relayit julkaistaksesi Sisällön vastaanottamiseen suunnitellun relay-listan luominen on keskeistä Nostr-kokemuksellesi ja ainoa tapa, jolla seuraajasi voivat löytää sinut. diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index 4432b4f592..d8cde039f8 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -2073,7 +2073,7 @@ Configurez vos relais de recherche La création d\'une liste de relais spécialement conçue pour la recherche et l\'étiquetage utilisateur améliorera ces résultats. Insérez de 1 à 3 relais à utiliser lors de la recherche de contenu ou de l\'étiquetage utilisateur. Assurez-vous que les relais que vous avez choisis implémentent le NIP-50 - De bonnes options sont:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + De bonnes options sont:\n%1$s Relais d\'envoi Configurez vos relais d\'envoi pour publier Créer une liste de relais spécialement conçue pour recevoir votre contenu est crucial pour votre expérience Nostr et la seule façon que vos abonnés puissent vous trouver. diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index d9f4f6660b..0df0c6313f 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -2199,7 +2199,7 @@ Configurez vos relais de recherche La création d\'une liste de relais spécialement conçue pour la recherche et l\'étiquetage utilisateur améliorera ces résultats. Insérez de 1 à 3 relais à utiliser lors de la recherche de contenu ou de l\'étiquetage utilisateur. Assurez-vous que les relais que vous avez choisis implémentent le NIP-50 - De bonnes options sont:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + De bonnes options sont:\n%1$s Relais d\'envoi Configurez vos relais d\'envoi pour publier Créer une liste de relais spécialement conçue pour recevoir votre contenu est crucial pour votre expérience Nostr et la seule façon que vos abonnés puissent vous trouver. diff --git a/amethyst/src/main/res/values-fr/strings.xml b/amethyst/src/main/res/values-fr/strings.xml index 9b8c797b55..abbf896ec1 100644 --- a/amethyst/src/main/res/values-fr/strings.xml +++ b/amethyst/src/main/res/values-fr/strings.xml @@ -645,7 +645,7 @@ Configurez vos relais de recherche La création d\'une liste de relais spécialement conçue pour la recherche et l\'étiquetage utilisateur améliorera ces résultats. Insérez de 1 à 3 relais à utiliser lors de la recherche de contenu ou de l\'étiquetage utilisateur. Assurez-vous que les relais que vous avez choisis implémentent le NIP-50 - De bonnes options sont:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + De bonnes options sont:\n%1$s Paramètres du relais Relais publics d\'accueil Ce type de relais stocke tout votre contenu. Amethyst enverra vos messages ici et d\'autres utiliseront ces relais pour trouver votre contenu. Insérez entre 1 et 3 relais : il peut s\'agir de relais personnels, de relais payants ou de relais publics. diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index f2fee54e50..14c17b1cb0 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -2315,7 +2315,7 @@ आपके खोज पुनःप्रसारकों की स्थापना करें खोज तथा उपयोगकर्ता सूचक जोडने के लिए स्पष्टतः रूपांकित पुनःप्रसारक सूची बनाने से इन परिणामों में शोधन होगा। विषयवस्तु खोजने के लिए अथवा उपयोगकर्ता सूचक जोडने में उपयोग करनें के लिए १ - ३ पुनःप्रसारकों को जोडें। सुनिश्चित करें कि आपके चयनित पुनःप्रसारक निप॰-५० को कार्यान्वित किये हैं। - ये अच्छे विकल्प हैं :\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + ये अच्छे विकल्प हैं :\n%1$s निर्गतपेटिका पुनःप्रसारक सूची अपने सार्वजनिक निर्गतपेटिका पुनःप्रसारकों की स्थापना करें पत्र प्रकाशन के लिए आपके विषयवस्तु प्राप्त करने के लिए पुनःप्रसारक सूची की विशिष्ट रचना आवश्यक है नोस्टर में। तथा आपके अनुचरों के लिए आपको ढूँढने की एकमात्र विधि। diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index fa561de488..818ac6c66a 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -2194,7 +2194,7 @@ Keresési átjátszók beállítása A kifejezetten a kereséshez és a felhasználói címkézéshez tervezett átjátszólista létrehozása javítani fogja ezeket az eredményeket. Adjon hozzá 1–3 átjátszót a tartalom kereséshez vagy a felhasználók címkézéséhez. Győződjön meg arról, hogy a kiválasztott átjátszók alkalmazzák a NIP-50-et - Jó választási lehetőségek:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Jó választási lehetőségek:\n%1$s Átjátszók a kimenő üzenetkhez Állítsa be a nyilvános kimenő üzenetek átjátszóit a bejegyzéshez A tartalom fogadására kifejezetten kialakított átjátszólista létrehozása elengedhetetlen a Nostr élményhez, és ez az egyetlen módja annak, hogy a követői megtalálják Önt. diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index f494ddc3a6..745df77d17 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -2086,7 +2086,7 @@ Seharusnya %3$s Siapkan relay Pencarian Anda Membuat daftar relay yang dirancang khusus untuk pencarian dan penandaan pengguna akan meningkatkan hasil ini. Masukkan 1–3 relay untuk digunakan saat mencari konten atau menandai pengguna. Pastikan relay yang Anda pilih mengimplementasikan NIP-50 - Pilihan yang baik:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Pilihan yang baik:\n%1$s Relay Outbox Siapkan relay Outbox Publik Anda untuk memposting Membuat daftar relay yang dirancang khusus untuk menerima konten Anda sangat penting untuk pengalaman Nostr Anda dan satu-satunya cara pengikut Anda dapat menemukan Anda. diff --git a/amethyst/src/main/res/values-in/strings.xml b/amethyst/src/main/res/values-in/strings.xml index 26491e9405..51cc6610c8 100644 --- a/amethyst/src/main/res/values-in/strings.xml +++ b/amethyst/src/main/res/values-in/strings.xml @@ -2552,7 +2552,7 @@ Masukkan 1–3 relay untuk digunakan saat mencari konten atau menandai pengguna. Pastikan relay yang Anda pilih mengimplementasikan NIP-50 - Pilihan yang baik:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Pilihan yang baik:\n%1$s Relay Outbox diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index 2934e6a29a..df68e718c5 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -2104,7 +2104,7 @@ Configura i tuoi relay di ricerca Creare una lista relay progettata per la ricerca e il tag utenti migliorerà questi risultati. Inserisci tra 1 e 3 relay da usare nella ricerca di contenuti o nel tag utenti. Assicurati che i relay scelti implementino NIP-50 - Buone opzioni sono:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Buone opzioni sono:\n%1$s Relay Outbox Configura i tuoi relay Outbox pubblici per pubblicare Creare una lista relay progettata per ricevere i tuoi contenuti è fondamentale per la tua esperienza su Nostr e l\'unico modo in cui i tuoi follower possono trovarti. diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index 311183cedd..55cd62403c 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -2121,7 +2121,7 @@ 検索 relay を設定する 検索およびユーザータグ付け専用の relay リストを作成すると、検索結果が改善されます。 コンテンツ検索やユーザーのタグ付けに使用する relay を 1〜3 つ入力してください。選択した relay が NIP-50 を実装していることを確認してください。 - おすすめの選択肢:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + おすすめの選択肢:\n%1$s Outbox Relay 投稿するためのパブリック Outbox relay を設定する コンテンツ受信専用の relay リストを作成することは、Nostr エクスペリエンスにとって不可欠であり、フォロワーがあなたを見つけるための唯一の手段です。 diff --git a/amethyst/src/main/res/values-ja/strings.xml b/amethyst/src/main/res/values-ja/strings.xml index 72d88c5695..8ddae5afed 100644 --- a/amethyst/src/main/res/values-ja/strings.xml +++ b/amethyst/src/main/res/values-ja/strings.xml @@ -2644,7 +2644,7 @@ コンテンツ検索やユーザーのタグ付けに使用する relay を 1〜3 つ入力してください。選択した relay が NIP-50 を実装していることを確認してください。 - おすすめの選択肢:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + おすすめの選択肢:\n%1$s Outbox Relay diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index 7038c6985e..e3225a6edd 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -2111,7 +2111,7 @@ 검색 relay 설정 검색 및 사용자 태깅을 위한 relay 목록을 생성하면 결과가 개선됩니다. 콘텐츠 검색이나 사용자 태깅 시 사용할 relay를 1~3개 입력하세요. 선택한 relay가 NIP-50을 구현하는지 확인하세요. - 좋은 옵션:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + 좋은 옵션:\n%1$s Outbox Relay 게시를 위한 공개 Outbox relay 설정 콘텐츠 수신을 위한 relay 목록을 생성하는 것은 Nostr 경험에 필수적이며, 팔로워가 나를 찾을 수 있는 유일한 방법입니다. diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index c70bc11df4..c8d6ad8e3e 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -2159,7 +2159,7 @@ Iestatiet savus meklēšanas relay Speciāla relay saraksta izveide meklēšanai un lietotāju atzīmēšanai uzlabos šos rezultātus. Ievietojiet 1–3 relay, ko izmantot, meklējot saturu vai atzīmējot lietotājus. Pārliecinieties, ka izvēlētie relay ievieš NIP-50 - Labas iespējas ir:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Labas iespējas ir:\n%1$s Izejošo ziņojumu Relay Iestatiet savus publiskos izejošo ziņojumu relay, lai publicētu Speciāla relay saraksta izveide jūsu satura saņemšanai ir ļoti svarīga jūsu Nostr pieredzei un vienīgais veids, kā jūsu sekotāji var jūs atrast. diff --git a/amethyst/src/main/res/values-nl-rBE/strings.xml b/amethyst/src/main/res/values-nl-rBE/strings.xml index 9066679569..73a4c7371e 100644 --- a/amethyst/src/main/res/values-nl-rBE/strings.xml +++ b/amethyst/src/main/res/values-nl-rBE/strings.xml @@ -3632,7 +3632,7 @@ Voeg 1 tot 3 relays toe die NIP-50 ondersteunen (zoeken). -Goede opties zijn:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com +Goede opties zijn:\n%1$s Outbox-relays diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index c31423c71b..e58aacc490 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -2125,7 +2125,7 @@ Stel je zoek-relays in Een speciale lijst voor zoeken en taggen verbetert de resultaten aanzienlijk. Voeg 1 tot 3 relays toe die NIP-50 ondersteunen (zoeken). - Goede opties zijn:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Goede opties zijn:\n%1$s Outbox-relays Stel je openbare outbox-relays in Een speciale lijst voor je eigen berichten is essentieel zodat anderen je kunnen vinden. diff --git a/amethyst/src/main/res/values-nl/strings.xml b/amethyst/src/main/res/values-nl/strings.xml index e6de54a4a4..6ab57fbb53 100644 --- a/amethyst/src/main/res/values-nl/strings.xml +++ b/amethyst/src/main/res/values-nl/strings.xml @@ -644,7 +644,7 @@ Stel uw zoek relays in Het maken van een relay lijst speciaal ontworpen voor zoekopdracht en gebruikers-tagging zal deze resultaten verbeteren. Voeg tussen de 1 en 3 relays toe om te gebruiken bij het zoeken naar content of bij het taggen van gebruikers. Zorg ervoor dat uw gekozen relays implementatie van NIP-50 - Goede opties zijn:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Goede opties zijn:\n%1$s Relay-instellingen Openbare thuis relays Dit relay type slaat al je inhoud op. Amethyst stuurt je berichten hierheen en anderen gebruiken deze relays om je inhoud te vinden. Voeg tussen de 1 en 3 relays toe. Het gaat om persoonlijke relays, betaalde relays of openbare relays. diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index aa5fb3d2ab..e0657d042d 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -2271,7 +2271,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Skonfiguruj transmitery wyszukujące Stworzenie listy transmiterów specjalnie przeznaczonych do wyszukiwania i tagowania użytkowników poprawi te wyniki. Wstaw od 1 do 3 transmiterów, które będą używane podczas wyszukiwania treści lub tagowania użytkowników. Upewnij się, że zaimplementowano NIP-50 - Dobre opcje to:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Dobre opcje to:\n%1$s Transmitery wysyłające Skonfiguruj swoje publiczne transmitery wysyłające, aby publikować Stworzenie listy transmiterów specjalnie zaprojektowanej do odbierania Twoich wpisów ma kluczowe znaczenie dla korzystania z sieci Nostr i jest jedynym sposobem, w jaki Twoi obserwujący mogą Cię znaleźć. diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 53df688aca..96e93a5a53 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -2181,7 +2181,7 @@ Configurar seus relés de Pesquisa Criar uma lista de relés especificamente projetada para pesquisa e marcação de usuários melhorará esses resultados. Insira entre 1–3 relés para usar ao pesquisar conteúdo ou marcar usuários. Certifique-se de que seus relés escolhidos implementem o NIP-50 - Boas opções são:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Boas opções são:\n%1$s Relays de Saída Configure seus relays públicos de saída para postar Criar uma lista de relays especificamente projetada para receber seu conteúdo é essencial para sua experiência no Nostr e a única forma de seus seguidores encontrarem você. diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index 9bdc77ee5b..cf66618298 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -2118,7 +2118,7 @@ Configurar seus relés de Pesquisa Criar uma lista de relés especificamente projetada para pesquisa e marcação de usuários melhorará esses resultados. Insira entre 1–3 relés para usar ao pesquisar conteúdo ou marcar usuários. Certifique-se de que seus relés escolhidos implementem o NIP-50 - Boas opções são:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Boas opções são:\n%1$s Relays de Saída Configure seus relays públicos de saída para postar Criar uma lista de relays especificamente projetada para receber seu conteúdo é essencial para sua experiência no Nostr e a única forma de seus seguidores encontrarem você. diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index 1392eb1050..538996e8da 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -2200,7 +2200,7 @@ Настройте свои поисковые relay Создание списка relay, специально предназначенного для поиска и упоминания пользователей, улучшит эти результаты. Укажите от 1 до 3 relay для поиска контента или упоминания пользователей. Убедитесь, что выбранные relay реализуют NIP-50 - Хорошие варианты:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Хорошие варианты:\n%1$s Настройте свои публичные Outbox relay для публикации Создание списка relay, специально предназначенного для приёма вашего контента, крайне важно для вашего опыта в Nostr и является единственным способом, которым ваши подписчики могут вас найти. Укажите от 1 до 3 relay, принимающих ваши публикации. Убедитесь, что они не требуют оплаты, если вы не платите за размещение diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index 49a2a08b7c..6bbf9635c1 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -2184,7 +2184,7 @@ Настройте свои поисковые relay Создание списка relay, специально предназначенного для поиска и упоминания пользователей, улучшит эти результаты. Укажите от 1 до 3 relay для поиска контента или упоминания пользователей. Убедитесь, что выбранные relay реализуют NIP-50 - Хорошие варианты:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Хорошие варианты:\n%1$s Настройте свои публичные Outbox relay для публикации Создание списка relay, специально предназначенного для приёма вашего контента, крайне важно для вашего опыта в Nostr и является единственным способом, которым ваши подписчики могут вас найти. Укажите от 1 до 3 relay, принимающих ваши публикации. Убедитесь, что они не требуют оплаты, если вы не платите за размещение diff --git a/amethyst/src/main/res/values-ru/strings.xml b/amethyst/src/main/res/values-ru/strings.xml index 72f693a61a..6d6d33c1ad 100644 --- a/amethyst/src/main/res/values-ru/strings.xml +++ b/amethyst/src/main/res/values-ru/strings.xml @@ -2422,7 +2422,7 @@ Укажите от 1 до 3 relay для поиска контента или упоминания пользователей. Убедитесь, что выбранные relay реализуют NIP-50 - Хорошие варианты:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Хорошие варианты:\n%1$s Outbox Relays diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 3e6f1f731a..8c4221c622 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -611,6 +611,13 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Obdeluje z Na voljo na + Kategorije + Sorodno + Implementacije + Podprti NIPi + prek %1$s + NIP-%1$s + Aplikacija Splet Android iOS @@ -671,6 +678,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Odpri Počisti Priljubljene + Odkrijte spletne aplikacije + Spletna mesta ljudi, ki jim sledite + Aplikacije ljudi, ki jim sledite Možnosti Odstrani iz zgodovine Priljubljene aplikacije @@ -696,6 +706,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Pozabi ta nApplet Blokirano Prekliči + Vsakič me vprašaj Še ni najdenih nobenih nAppletov. nApplet%1$s… @@ -747,13 +758,70 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ta nApplet želi plačati Lightning račun za %1$d satov. + Povezava z Nostrom + se želi povezati z vašim nostr računom + Kako naj obravnavam zahteve te aplikacije? + Poveži + Blokiraj in ignoriraj %1$s + Popolnama zaupam + Samodejno podpiši vse zahteve (razen plačil) + Bodimo razumni + Samodejno odobri običajne zahteve + Sem malce paranoičen + Nič ne podpisuj brez mojega soglasja! + želi %1$s + Pokaži dogodek + Skrij dogodek + Več možnosti + Manj možnosti + dovoli enkrat + Dovoli v tej seji + Dovoli za 24h + dovoli do 30 dni + Ne vprašaj me več, da %1$s + Ne sprašuj več za zahteve Nostr + Zavrni + Vedno zavrni %1$s + Nazadnje uporabljeno + Preteče + podpiši tip (kind) %1$d dogodek + podpiši za %1$s (kind: %2$d) + Šifriraj sporočilo + berem tvoja zasebna sporočila + Povezane aplikacije + Prekliči vsa dovoljenja + Preglasitve operacij + Nobena aplikacija se še ni povezala. + Prekliči vsa dovoljenja + Dovoli + Vprašaj + Zavrni + Aplikacije, dovoljenja, podpisovalnik, napplet, nsite, spletna aplikacije zaupnik povezan. + Podpisujem stopnjo zaupanja + Zmogljivosti + Pozabi to aplikacijo + Preglasitve operacij podpisovanja + Nobena aplikacija se še ni povezala..\n\nKo se spletna aplikacija poveže z vašim ključem Nostr, bo prikazana tukaj. - + Avtentikacija releja + Preverjanje pristnosti (Auth), rele (Relay), podpis (Sign), preverjanje (Verify), NIP-42, identiteta + Globalna pravila + Vedno zahtevaj avtentikacijo + Podpiši avtentikacijske izzive za vsak relej, ki to zahteva + Nikoli ne avtenticiraj + Prezri avtentikacijske izzive vseh relejev + Samo moji releji + Avtenticiraj samo z releji s seznama + Prilagoditve po posameznem releju + Brez preglasitev na posameznih relejih — globalna politika velja povsod + Dovoli + Zavrni + Odstrani prilagoditev Vir: %1$s v%1$s Prenos @@ -952,6 +1020,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Privatni zaznamki Javni zaznamki + Repozitoriji + Vaši zaznamovani repozitoriji Git Dodaj v privatne zaznamke Dodaj v javne zaznamke Odstrani iz privatnih zaznamkov @@ -1501,6 +1571,26 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Wallet Connect Jezik Tema + Poudarjena barva + Poudarjena barva: Glavna barva za gumbe in povezave + Vijolična + Modra + Zelena + Oranžna + Rdeča + Roza + Pisava + Pisava v aplikaciji + Sistemske prednastavitve + Sans Serif + Serif + Monospace + Velikost pisave + Prilagoditev velikosti besedila v aplikaciji + Majhno + Normalno + Veliko + Ogromno Predogled slike Predvajaj Video Video predvajaj samodejno @@ -1817,6 +1907,34 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ustvari Cashu denarnico za shranjevanje žetonov ecash in prejemanje nutzapov. Povezovanje z denarnico… 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. + Nastavi lastno denarnico + Iščem vašo denarnico… + Iskanje denarnice Cashu, objavljene pod vašim ključem Nostr, na vseh relejih. + Preiskovanje relejev… %1$d / %2$d + Preverjanje najdenih denarnic… + Denarnica ni najdena + Pod vašim ključem Nostr na nobenem releju nismo našli obstoječe denarnice Cashu. Da začnete, ustvarite novo. + Ustvari novo denarnico + Vaša denarnica je bila najdena + 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. + Uporabi to denarnico + Našli smo nekaj denarnic + 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. + Najnovejše — Vaša glavna denarnica + Starejša denarnica + Nastavi kot glavno denarnico + Obnovi sredstva v glavno denarnico + Na voljo za obnovitev: %1$s satov + Obnovljenih %1$s satov + Ni sredstev za obnovitev + Te denarnice ni bilo mogoče prebrati + Najprej nastavite glavno denarnico, nato obnovite sredstva iz starejših. + Nastavljanje denarnice… + Išči ponovno + Kovnice: %1$s + Denarnica ustvarjena + Izberite nekaj kovnic za hrambo svojih satov. + Izberite kovnice Stanje Kovnice URL kovnice @@ -2264,7 +2382,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Nastavi iskalne releje Ustvarjanje seznama relejev, posebej zasnovanega za iskanje in označevanje uporabnikov, bo izboljšalo te rezultate. Vnesite 1–3 releje za iskanje vsebine ali označevanje uporabnikov. Prepričajte se, da vaši izbrani releji podpirajo NIP-50 - Dobre možnosti so\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Dobre možnosti so\n%1$s Izhodni releji Nastavi svoje javne izhodne releje za objave Ustvarjanje seznama relejev, namensko zasnovanih za prejem vaše vsebine, je ključnega pomena za vašo izkušnjo z Nostrom in edini način, da vas lahko najdejo vaši sledilci. @@ -2344,12 +2462,81 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Popravki in PR-ji Odprto Zaprto in rešeno + Vsi + Zaznamuj repozitorij + Veje + Oznake + Datoteke + Posodobljeno + Nedavna dejavnost + Vzdržuje: + Gostuje zunanje + Tega repozitorija ni mogoče klonirati prek http(s), zato ogled kode tukaj ni na voljo. + Odpri v brskalniku Neimenovano Vizitka repozitorija Povezave Vzdrževalci Teme Osebni fork + Readme + Koda + Nalagam repozitorij… + Repozitorija iz njegovega klona URL-ja ni bilo mogoče naložiti. + Ta obvestila o repozitoriju nima http(s) klona URL-ja za brskanje. + Ta repozitorij nima datoteke README. + Te datoteke ni bilo mogoče naložiti. + Binarna datoteka (%1$s) — predogled ni na voljo. + Ta mapa je prazna. + Koren + Poskusi ponovno + Privzeto + Veje + Oznake + Išči datoteke… + Ni ujemajočih se datotek. + Objave (Commits) + Zgodovina objav ni na voljo. + Kopiraj vsebino datoteke + Tekst + + %1$d element + %1$d elementa + %1$d elementi + %1$d elementov + + + %1$d datoteka spremenjena + %1$d datoteki spremenjeni + %1$d datoteke spremenjene + %1$d datotek spremenjenih + + Binarna datoteka ni prikazana. + Nov + Prijavi težavo + Naslov + Opis + Ustvari + Prekliči + Oznake + Hrošč, izboljšava… + Zapri težavo + Ponovno odpri + Zapri + Ponovno odpri + Označi kot združeno + Poglej spremembe + Nalagam spremembe… + Ni najdenih sprememb datotek. + Ponovno naloži spremembe + Popravljeno + Uredi repozitorij + Ime + Opis + Podvoji URL-je (eden v vrsti) + Web URL-ji (eden v vrsti) + Teme (ločene z vejicami) + Shrani Git repozitoriji Statična spletna stran: %1$s nApplet: %1$s diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index 6eb9f900ee..d6fb1802e7 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -2150,7 +2150,7 @@ Podesite vaše relay-e za pretragu Kreiranje liste relay-a posebno dizajnirane za pretragu i označavanje korisnika poboljšaće ove rezultate. Unesite između 1–3 relay-a koji će se koristiti pri pretrazi sadržaja ili označavanju korisnika. Uverite se da vaši odabrani relay-i implementiraju NIP-50 - Dobre opcije su:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Dobre opcije su:\n%1$s Outbox Relay-i Podesite vaše javne Outbox relay-e za objavljivanje Kreiranje liste relay-a posebno dizajnirane za primanje vašeg sadržaja je ključno za vaše Nostr iskustvo i jedini način na koji vas vaši pratioci mogu pronaći. diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 2d183a5e28..8d7c7a9f96 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -2184,7 +2184,7 @@ Ställ in dina sökreläer Att skapa en relälista speciellt utformad för sökning och användartaggning kommer att förbättra dessa resultat. Sätt in mellan 1–3 reläer att använda när du söker efter innehåll eller taggar användare. Se till att dina valda reläer implementerar NIP-50 - Bra alternativ är:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Bra alternativ är:\n%1$s Utgående reläer Ställ in dina offentliga utgående reläer för att kunna posta Att skapa en relälista specifikt för att ta emot ditt innehåll är avgörande för din Nostr-upplevelse och det enda sättet dina följare kan hitta dig. diff --git a/amethyst/src/main/res/values-sw/strings.xml b/amethyst/src/main/res/values-sw/strings.xml index 7cc06129f1..d50d671c1b 100644 --- a/amethyst/src/main/res/values-sw/strings.xml +++ b/amethyst/src/main/res/values-sw/strings.xml @@ -2136,7 +2136,7 @@ Weka relay zako za Utafutaji Kuunda orodha ya relay iliyoundwa maalum kwa utafutaji na kuweka alama kwa watumiaji kutaboresha matokeo haya. Ingiza kati ya relay 1–3 za kutumia wakati wa kutafuta maudhui au kuweka alama kwa watumiaji. Hakikisha relay ulizochagua zinatekeleza NIP-50 - Chaguo nzuri ni:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Chaguo nzuri ni:\n%1$s Relay za Sanduku la Nje Weka relay zako za Sanduku la Nje la Umma ili kuchapisha Kuunda orodha ya relay iliyoundwa maalum kupokea maudhui yako ni muhimu kwa uzoefu wako wa Nostr na njia pekee ambayo wafuataji wako wanaweza kukupata. diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index 985f05f6ee..79f8aa8f0f 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -2141,7 +2141,7 @@ உங்கள் தேடல் relays அமைக்கவும் தேடல் மற்றும் பயனர் குறியிடலுக்காக குறிப்பாக வடிவமைக்கப்பட்ட relay பட்டியல் உருவாக்குவது இந்த முடிவுகளை மேம்படுத்தும். உள்ளடக்கம் தேடுவதற்கு அல்லது பயனர்களை குறியிடுவதற்கு 1–3 relays சேர்க்கவும். தேர்ந்தெடுத்த relays NIP-50 செயல்படுத்துகிறதா என்று உறுதிப்படுத்தவும் - நல்ல விருப்பங்கள்:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + நல்ல விருப்பங்கள்:\n%1$s அவுட்பாக்ஸ் Relays இடுகையிட உங்கள் பொது அவுட்பாக்ஸ் relays அமைக்கவும் உங்கள் உள்ளடக்கத்தை பெறுவதற்காக குறிப்பாக வடிவமைக்கப்பட்ட relay பட்டியல் உருவாக்குவது உங்கள் Nostr அனுபவத்திற்கு முக்கியம், மேலும் உங்கள் பின்தொடர்பவர்கள் உங்களை கண்டுபிடிக்க ஒரே வழி. diff --git a/amethyst/src/main/res/values-ta/strings.xml b/amethyst/src/main/res/values-ta/strings.xml index a2e1dc0211..64c9f7d885 100644 --- a/amethyst/src/main/res/values-ta/strings.xml +++ b/amethyst/src/main/res/values-ta/strings.xml @@ -2038,7 +2038,7 @@ உங்கள் தேடல் relays அமைக்கவும் தேடல் மற்றும் பயனர் குறியிடலுக்காக குறிப்பாக வடிவமைக்கப்பட்ட relay பட்டியல் உருவாக்குவது இந்த முடிவுகளை மேம்படுத்தும். உள்ளடக்கம் தேடுவதற்கு அல்லது பயனர்களை குறியிடுவதற்கு 1–3 relays சேர்க்கவும். தேர்ந்தெடுத்த relays NIP-50 செயல்படுத்துகிறதா என்று உறுதிப்படுத்தவும் -நல்ல விருப்பங்கள்:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com +நல்ல விருப்பங்கள்:\n%1$s அவுட்பாக்ஸ் Relays இடுகையிட உங்கள் பொது அவுட்பாக்ஸ் relays அமைக்கவும் உங்கள் உள்ளடக்கத்தை பெறுவதற்காக குறிப்பாக வடிவமைக்கப்பட்ட relay பட்டியல் உருவாக்குவது உங்கள் Nostr அனுபவத்திற்கு முக்கியம், மேலும் உங்கள் பின்தொடர்பவர்கள் உங்களை கண்டுபிடிக்க ஒரே வழி. diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index e7a3ec922e..4aaba15abb 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -2100,7 +2100,7 @@ ตั้งค่ารีเลย์เพื่อใช้ค้นหา การสร้างรายชื่อรีเลย์ที่ออกแบบมาเฉพาะสำหรับการค้นหาและการแท็กผู้ใช้จะช่วยปรับปรุงผลลัพธ์เหล่านี้ได้ ใส่รีเลย์ 1–3 ตัวเพื่อใช้ในการค้นหาข้อมูลหรือแท็กผู้ใช้ ตรวจสอบให้แน่ใจว่ารีเลย์ที่คุณเลือกใช้รองรับ NIP-50 - ตัวเลือกที่ดี ได้แก่:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + ตัวเลือกที่ดี ได้แก่:\n%1$s ตั้งค่า Outbox Relays สาธารณะเพื่อโพสต์ การสร้างรายการ relay ที่ออกแบบมาโดยเฉพาะเพื่อรับเนื้อหาของคุณเป็นสิ่งสำคัญสำหรับประสบการณ์ Nostr ของคุณ และเป็นวิธีเดียวที่ผู้ติดตามของคุณจะค้นหาคุณได้ ใส่ relay 1–3 รายการที่รับโพสต์ของคุณ ตรวจสอบให้แน่ใจว่าไม่ต้องชำระเงินหากคุณไม่ได้ชำระเงินเพื่อใส่ diff --git a/amethyst/src/main/res/values-th/strings.xml b/amethyst/src/main/res/values-th/strings.xml index 89c439c3c8..d6a900f1ac 100644 --- a/amethyst/src/main/res/values-th/strings.xml +++ b/amethyst/src/main/res/values-th/strings.xml @@ -549,7 +549,7 @@ ตั้งค่ารีเลย์เพื่อใช้ค้นหา การสร้างรายชื่อรีเลย์ที่ออกแบบมาเฉพาะสำหรับการค้นหาและการแท็กผู้ใช้จะช่วยปรับปรุงผลลัพธ์เหล่านี้ได้ ใส่รีเลย์ 1–3 ตัวเพื่อใช้ในการค้นหาข้อมูลหรือแท็กผู้ใช้ ตรวจสอบให้แน่ใจว่ารีเลย์ที่คุณเลือกใช้รองรับ NIP-50 - ตัวเลือกที่ดี ได้แก่:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + ตัวเลือกที่ดี ได้แก่:\n%1$s ตั้งค่ารีเลย์ รีเลย์สาธารณะสำหรับหน้า Home รีเลย์ประเภทนี้จะเก็บเนื้อหาทั้งหมดของคุณ Amethyst จะส่งโพสต์ของคุณไปยังรีเลย์เหล่านี้ และคนอื่น ๆ จะใช้รีเลย์เหล่านี้เพื่อค้นหาเนื้อหาของคุณ ใส่รีเลย์ 1–3 ตัว ซึ่งสามารถเป็นรีเลย์ส่วนตัว รีเลย์ที่ต้องเสียค่าบริการ หรือรีเลย์สาธารณะก็ได้ diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index 0cd9ce4b8e..a4bd765f42 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -2145,7 +2145,7 @@ Arama relay\'lerinizi ayarlayın Arama ve kullanıcı etiketlemesi için özel olarak tasarlanmış bir relay listesi oluşturmak bu sonuçları iyileştirecektir. İçerik ararken veya kullanıcıları etiketlerken kullanmak için 1–3 relay ekleyin. Seçtiğiniz relay\'lerin NIP-50\'yi uyguladığından emin olun - İyi seçenekler:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + İyi seçenekler:\n%1$s Giden Kutusu Relay\'leri Yayınlamak için Genel Giden Kutusu relay\'lerinizi ayarlayın İçeriğinizi almak için özel olarak tasarlanmış bir relay listesi oluşturmak, Nostr deneyiminiz için çok önemlidir ve takipçilerinizin sizi bulabileceği tek yoldur. diff --git a/amethyst/src/main/res/values-tr/strings.xml b/amethyst/src/main/res/values-tr/strings.xml index 4efdc65c58..561c98aa9c 100644 --- a/amethyst/src/main/res/values-tr/strings.xml +++ b/amethyst/src/main/res/values-tr/strings.xml @@ -2822,7 +2822,7 @@ İçerik ararken veya kullanıcıları etiketlerken kullanmak için 1–3 relay ekleyin. Seçtiğiniz relay\'lerin NIP-50\'yi uyguladığından emin olun - İyi seçenekler:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + İyi seçenekler:\n%1$s Giden Kutusu Relay\'leri diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index 4b09de25e2..a518e85a72 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -2197,7 +2197,7 @@ Налаштуйте свої relay-сервери для пошуку Створення списку relay-серверів, спеціально призначених для пошуку та позначення користувачів, покращить ці результати. Введіть від 1 до 3 relay-серверів для пошуку вмісту або позначення користувачів. Переконайтесь, що обрані relay-сервери підтримують NIP-50 - Гарні варіанти:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Гарні варіанти:\n%1$s Relay для вихідних Налаштуйте свої публічні relay-сервери для публікацій Створення списку relay-серверів, спеціально призначених для публікації вашого вмісту, є важливим для вашого досвіду з Nostr і єдиним способом, яким ваші підписники зможуть вас знайти. diff --git a/amethyst/src/main/res/values-uk/strings.xml b/amethyst/src/main/res/values-uk/strings.xml index 06ec9725a3..d587710e89 100644 --- a/amethyst/src/main/res/values-uk/strings.xml +++ b/amethyst/src/main/res/values-uk/strings.xml @@ -2612,7 +2612,7 @@ Введіть від 1 до 3 relay-серверів для пошуку вмісту або позначення користувачів. Переконайтесь, що обрані relay-сервери підтримують NIP-50 - Гарні варіанти:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Гарні варіанти:\n%1$s Relay для вихідних diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index f042689151..e6f2f930a3 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -2140,7 +2140,7 @@ Qidiruv relaylaringizni sozlang Qidiruv va foydalanuvchi teglash uchun maxsus relay ro\'yxati yaratish bu natijalarni yaxshilaydi. Kontent qidirish yoki foydalanuvchilarni teglashda foydalanish uchun 1–3 relay kiriting. Tanlangan relaylaringiz NIP-50 ni qo\'llayotganiga ishonch hosil qiling - Yaxshi variantlar:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Yaxshi variantlar:\n%1$s Chiqish qutisi Relaylari Post qilish uchun Umumiy chiqish qutisi relaylarini sozlang Kontentingizni qabul qilish uchun maxsus relay ro\'yxati yaratish Nostr tajribangiz uchun juda muhim va kuzatuvchilaringiz sizni topishining yagona yo\'lidir. diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index 2e3ed9252e..880ed6298a 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -2100,7 +2100,7 @@ Thiết lập relay Tìm kiếm của bạn Tạo danh sách relay được thiết kế riêng cho tìm kiếm và gắn thẻ người dùng sẽ cải thiện các kết quả này. Nhập từ 1–3 relay để sử dụng khi tìm kiếm nội dung hoặc gắn thẻ người dùng. Đảm bảo relay bạn chọn hỗ trợ NIP-50 - Các tùy chọn tốt là:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Các tùy chọn tốt là:\n%1$s Relay Outbox Thiết lập relay Outbox Công khai của bạn để đăng Tạo danh sách relay được thiết kế riêng để nhận nội dung của bạn là điều cần thiết cho trải nghiệm Nostr của bạn và là cách duy nhất để người theo dõi tìm thấy bạn. diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index e8fef22863..5b69307719 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -2288,7 +2288,7 @@ 设置你的搜索中继 通过创建专用于关键词和标签检索的中继列表能够改善搜索结果。 设置 1 ~ 3 个支持 NIP-50 的中继,用于关键词和标签检索。 - 示例:\n - nostr.wine\n - reiny.nostr.band\n - reliy.noswhere.com + 示例:\n%1$s 发件箱中继 设置您的公共发件箱中继来发布内容 创建专为接收您的内容而设计的中继列表对于您的Nostr体验至关重要,也是您的关注者找到您的唯一途径。 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index 94dc2f081d..8b43fc442c 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -2143,7 +2143,7 @@ 设置你的搜索中继 通过创建专用于关键词和标签检索的中继列表能够改善搜索结果。 设置 1 ~ 3 个支持 NIP-50 的中继,用于关键词和标签检索。 - 示例:\n - nostr.wine\n - reiny.nostr.band\n - reliy.noswhere.com + 示例:\n%1$s 发件箱中继 设置您的公共发件箱中继来发布内容 创建专为接收您的内容而设计的中继列表对于您的Nostr体验至关重要,也是您的关注者找到您的唯一途径。 diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 6d81e2b598..e5fc1ffbda 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -2143,7 +2143,7 @@ 设置你的搜索中继 通过创建专用于关键词和标签检索的中继列表能够改善搜索结果。 设置 1 ~ 3 个支持 NIP-50 的中继,用于关键词和标签检索。 - 示例:\n - nostr.wine\n - reiny.nostr.band\n - reliy.noswhere.com + 示例:\n%1$s 发件箱中继 设置您的公共发件箱中继来发布内容 创建专为接收您的内容而设计的中继列表对于您的Nostr体验至关重要,也是您的关注者找到您的唯一途径。 diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index 29919d4fb3..9a47cddf53 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -2121,7 +2121,7 @@ 設置你的搜索中繼器 建立專為搜索和標記用戶設計的中繼列表能夠改善這些結果。 輸入 1–3 個中繼以在搜尋內容或標記用戶時使用。確保你選擇的中繼器實施 NIP-50 - 不錯的選擇是:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + 不錯的選擇是:\n%1$s 發件匣 Relay 設定您的公共發件匣 relay 以發佈貼文 建立專門接收您內容的 relay 清單對您的 Nostr 體驗至關重要,也是您的追蹤者找到您的唯一方式。 diff --git a/amethyst/src/main/res/values-zh/strings.xml b/amethyst/src/main/res/values-zh/strings.xml index 82df1937c8..4dd6d7bff0 100644 --- a/amethyst/src/main/res/values-zh/strings.xml +++ b/amethyst/src/main/res/values-zh/strings.xml @@ -4040,7 +4040,7 @@ 设置 1 ~ 3 个支持 NIP-50 的中继,用于关键词和标签检索。 -示例:\n - nostr.wine\n - reiny.nostr.band\n - reliy.noswhere.com +示例:\n%1$s 发件箱中继 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 43a78b57a9..141dcf4508 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -995,6 +995,118 @@ Podcasts View episodes No episodes found yet + Trailer + Season %1$d + Explicit + Completed + Premium + Support the show + by %1$s + S%1$d · E%2$d + Ep %1$d + Season %1$d + Video + Transcript + Chapters + Value-for-Value + %1$d%% + Zaps to this are split between: + Hosts & Guests + Play highlight + Top Supporters + + %1$d chapter + %1$d chapters + + Host + Co-host + Editor + Verified author + Value-for-Value error + This podcast has no payable value recipients. + Connect a Nostr Wallet Connect wallet to send to keysend (node) recipients. + Stream sats + %1$d sats/min + Sends value automatically while you listen. + Streamed %1$d sats this session + Connect a Nostr Wallet Connect or debit wallet to stream sats while listening. + New episode + Edit episode + Publishing… + Add cover art + Square image shown for the episode + Title + Episode title + Summary + Duration (seconds) + More details + Season + Episode # + Video URL + Transcript URL + Chapters URL + Topics + comma, separated, tags + Audio URL + https://…/episode.mp3 + Audio file ready to upload + Add audio file + MP3, M4A, or other audio + Delete episode + Delete this episode? This can\'t be undone. + Your podcast + Add cover art + Square artwork for your show + Show title + My Podcast + Description + Author + Contact email + Website + Categories + Technology, News + Funding links + https://… + Language + en + Copyright + Show type + Episodic + Serial + Explicit content + Show complete (no more episodes) + Locked (premium) + New trailer + Add trailer clip + Short audio or video preview + Trailer title + Media URL + Your podcast + Untitled + No episodes yet. Tap New episode to publish your first one. + Create your podcast + Tap to edit show details + Set up your show\'s title, art, and details + + %1$d trailer + %1$d trailers + + Add recipients to split incoming sats by weight. Listeners boost or stream value to these destinations. + Add recipient + Add address manually + Add a Nostr user + Search by name or @handle + This user has no Lightning address + Remove recipient + Name (optional) + Lightning address + Node (keysend) + Lightning address + name@example.com + Node pubkey + 02abc… (33-byte hex) + Weight + Fee %1$d episode %1$d episodes @@ -1025,6 +1137,8 @@ Public Bookmarks Repositories Your bookmarked git repositories + Podcasts + Your bookmarked podcasts and episodes Add to Private Bookmarks Add to Public Bookmarks Remove from Private Bookmarks @@ -2587,7 +2701,7 @@ Set up your Search relays Creating a relay list specifically designed for search and user tagging will improve these results. Insert between 1–3 relays to use when searching for content or tagging users. Make sure your chosen relays implement NIP-50 - Good options are:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Good options are:\n%1$s Outbox Relays Set up your Public Outbox relays to post diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BottomBarPersistenceTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BottomBarPersistenceTest.kt new file mode 100644 index 0000000000..2ea2f21a23 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/preferences/BottomBarPersistenceTest.kt @@ -0,0 +1,61 @@ +/* + * 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.preferences + +import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry +import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries +import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Locks the "reset to defaults migrates automatically" behavior. + * + * Resetting the bottom bar (or never customizing it) is persisted as a blank sentinel rather than the + * concrete default list, so that a user on the defaults tracks whatever [DefaultBottomBarEntries] the + * *installed* app version ships. If a future version changes the default, that blank value resolves to + * the new default on load — the user is migrated instead of being pinned to the old default. + */ +class BottomBarPersistenceTest { + @Test + fun defaultsAreStoredAsBlankSentinel() { + assertEquals("", UiSharedPreferences.encodeBottomBarItems(DefaultBottomBarEntries)) + } + + @Test + fun blankSentinelDecodesToCurrentDefaults() { + // The blank sentinel resolves to whatever DefaultBottomBarEntries this build ships. Because it + // returns the current constant (not a value frozen at reset time), a future version that changes + // the default automatically migrates every user who is on the defaults. + assertEquals(DefaultBottomBarEntries, UiSharedPreferences.decodeBottomBarItems("")) + } + + @Test + fun customizedBarIsStoredVerbatimAndRoundTrips() { + val custom = + listOf( + BottomBarEntry.BuiltIn(NavBarItem.HOME), + BottomBarEntry.Favorite("url:https://example.com"), + ) + val encoded = UiSharedPreferences.encodeBottomBarItems(custom) + assertEquals(custom, UiSharedPreferences.decodeBottomBarItems(encoded)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/MergeFilterTagsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/MergeFilterTagsTest.kt new file mode 100644 index 0000000000..36f34e1a39 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/podcasts/datasource/subassemblies/MergeFilterTagsTest.kt @@ -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.ui.screen.loggedIn.podcasts.datasource.subassemblies + +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MergeFilterTagsTest { + @Test + fun `null base passes the extra through`() { + assertEquals(mapOf("d" to listOf("podcast-metadata")), mergeFilterTags(null, PODCAST_METADATA_D_FILTER)) + } + + @Test + fun `null extra passes the base through`() { + val base = mapOf("t" to listOf("tech")) + assertEquals(base, mergeFilterTags(base, null)) + } + + @Test + fun `both null stays null`() { + assertNull(mergeFilterTags(null, null)) + } + + @Test + fun `disjoint keys are unioned - layering d onto an existing t constraint`() { + val merged = mergeFilterTags(mapOf("t" to listOf("tech")), PODCAST_METADATA_D_FILTER) + assertEquals( + mapOf("t" to listOf("tech"), "d" to listOf("podcast-metadata")), + merged, + ) + } + + @Test + fun `same key merges and de-duplicates values`() { + val merged = mergeFilterTags(mapOf("d" to listOf("podcast-metadata")), mapOf("d" to listOf("podcast-metadata", "other"))) + assertEquals(mapOf("d" to listOf("podcast-metadata", "other")), merged) + } + + @Test + fun `the d filter targets the podstr metadata kind and d-tag`() { + assertEquals(listOf(AppSpecificDataEvent.KIND), PODCASTING20_METADATA_KINDS) + assertEquals(mapOf("d" to listOf("podcast-metadata")), PODCAST_METADATA_D_FILTER) + } +} diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BaseLargeCacheBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BaseLargeCacheBenchmark.kt index d3c3ac19b0..1da2be3a7e 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BaseLargeCacheBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/BaseLargeCacheBenchmark.kt @@ -33,7 +33,7 @@ open class BaseLargeCacheBenchmark { companion object { fun getEventDB(): List { // 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>( GZIPInputStream(fullDBInputStream), diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt index 85adfb1b83..d9138f5dce 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/CacheBenchmark.kt @@ -41,7 +41,7 @@ import java.util.zip.GZIPInputStream open class BaseCacheBenchmark { fun getEventDB(): List { // 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>( GZIPInputStream(fullDBInputStream), diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index f457cb6887..4211bd2509 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.cli.commands.NotesCommands import com.vitorpamplona.amethyst.cli.commands.NsiteCommands import com.vitorpamplona.amethyst.cli.commands.OfferCommands import com.vitorpamplona.amethyst.cli.commands.OutboxCommand +import com.vitorpamplona.amethyst.cli.commands.Podcast20Commands import com.vitorpamplona.amethyst.cli.commands.PodcastCommands import com.vitorpamplona.amethyst.cli.commands.ProfileCommands import com.vitorpamplona.amethyst.cli.commands.PublishCommand @@ -233,6 +234,7 @@ private suspend fun dispatch(argv: Array): Int { "serve" -> ServeCommand.run(dataDir, tail) "cashu" -> CashuCommands.dispatch(dataDir, tail) "podcast" -> PodcastCommands.dispatch(dataDir, tail) + "podcast20" -> Podcast20Commands.dispatch(dataDir, tail) "bunker" -> BunkerCommand.run(dataDir, tail) else -> { System.err.println("unknown subcommand: $head") @@ -478,6 +480,21 @@ private fun printUsage() { | [--image URL] [--content MARKDOWN] | podcast list [USER] [--limit N] list a user's metadata + episodes | + |Podcasts (Podcasting 2.0 / podstr): + | podcast20 metadata --title T publish kind:30078 show metadata (JSON body) + | [--description D] [--author A] [--image URL] [--language L] + | [--categories A,B] [--funding URL,URL] [--website URL] + | [--copyright C] [--type episodic|serial] [--explicit] [--complete] + | [--value-json JSON] value-for-value split block + | podcast20 episode --title T --audio URL[,URL] publish a kind:30054 episode + | [--d ID] [--audio-type MIME] [--description D] [--image URL] + | [--duration SECS] [--video URL] [--video-type MIME] + | [--episode N] [--season N] [--transcript URL] [--chapters URL] + | [--value-json JSON] [--topic A,B] [--content MARKDOWN] [--pubdate RFC2822] + | podcast20 trailer --title T --url URL publish a kind:30055 trailer + | [--d ID] [--type MIME] [--length BYTES] [--season N] [--pubdate RFC2822] + | podcast20 list [USER] [--limit N] list a creator's metadata + episodes + trailers + | |Static websites (NIP-5A kind:15128/35128): | nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and | [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt index 515a2c17a2..665c4a039c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NipCommand.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient @@ -29,7 +30,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId 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.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED @@ -54,11 +54,8 @@ import okhttp3.Request object NipCommand { private const val RAW_BASE = "https://raw.githubusercontent.com/nostr-protocol/nips/master" - // NIP-50-capable relays for the Nostr fallback (search isn't universal). - private val SEARCH_RELAYS = - listOf("wss://relay.nostr.band", "wss://relay.damus.io") - .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } - .toSet() + // Amethyst's default NIP-50-capable search relays (search isn't universal). + private val SEARCH_RELAYS = DefaultSearchRelayList // Quartz's canonical "NIP published on Nostr" kind (NipTextEvent), plus the // wiki + long-form kinds clients also use to mirror NIP text. diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt new file mode 100644 index 0000000000..42fdfc1dc9 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt @@ -0,0 +1,327 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.podcasts.PodcastValue +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +/** + * `amy podcast20 ` — the Podcasting-2.0 draft (derekross/podstr), + * kept separate from the NIP-F4 `podcast` commands because the two models differ: here the + * logged-in account IS the creator and signs everything with its own key, and episodes/trailers + * are addressable (`d`-tag) events that can be edited in place. + * + * metadata publish kind:30078 show metadata (`d=podcast-metadata`, JSON body) + * episode publish a kind:30054 episode + * trailer publish a kind:30055 trailer + * list list a creator's metadata + episodes + trailers + * + * Thin assembly only: events and JSON live in quartz (`Podcasting20EpisodeEvent`, + * `Podcasting20TrailerEvent`, `Podcasting20PodcastMetadata`). + */ +object Podcast20Commands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "podcast20", + tail, + "podcast20 ", + mapOf( + "metadata" to { rest -> metadata(dataDir, rest) }, + "episode" to { rest -> episode(dataDir, rest) }, + "trailer" to { rest -> trailer(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + ), + ) + + private suspend fun metadata( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val title = args.flag("title") ?: return Output.error("bad_args", "podcast20 metadata requires --title") + val value = + valueFlag(args).getOrElse { + return Output.error("bad_args", "podcast20 metadata --value-json is not valid JSON") + } + + val content = + Podcasting20PodcastMetadata.Content( + title = title, + description = args.flag("description"), + author = args.flag("author"), + email = args.flag("email"), + image = args.flag("image"), + language = args.flag("language"), + categories = listFlag(args, "categories"), + explicit = trueIfPresent(args, "explicit"), + website = args.flag("website"), + copyright = args.flag("copyright"), + funding = listFlag(args, "funding"), + locked = trueIfPresent(args, "locked"), + type = args.flag("type"), + complete = trueIfPresent(args, "complete"), + guid = args.flag("guid"), + value = value, + ) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val signed = ctx.signer.sign(Podcasting20PodcastMetadata.build(content)) + val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args)) + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "d" to Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG, + "title" to title, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun episode( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val title = args.flag("title") ?: return Output.error("bad_args", "podcast20 episode requires --title") + val audioType = args.flag("audio-type") + val audios = + args + .flag("audio") + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.map { PodcastAudio(it, audioType) } + .orEmpty() + if (audios.isEmpty()) return Output.error("bad_args", "podcast20 episode requires --audio URL[,URL…]") + val value = + valueFlag(args).getOrElse { + return Output.error("bad_args", "podcast20 episode --value-json is not valid JSON") + } + + val dTag = args.flag("d") ?: generateDTag("episode") + val video = args.flag("video")?.let { PodcastAudio(it, args.flag("video-type")) } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val template = + Podcasting20EpisodeEvent.build( + dTag = dTag, + title = title, + audios = audios, + pubdate = args.flag("pubdate") ?: rfc2822Now(), + description = args.flag("description"), + image = args.flag("image"), + durationInSeconds = args.flag("duration")?.toLongOrNull(), + video = video, + episodeNumber = args.flag("episode")?.toIntOrNull(), + season = args.flag("season")?.toIntOrNull(), + transcriptUrl = args.flag("transcript"), + chaptersUrl = args.flag("chapters"), + value = value, + topics = listFlag(args, "topic"), + markdownContent = args.flag("content", "") ?: "", + ) + val signed = ctx.signer.sign(template) + val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args)) + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "d" to dTag, + "title" to title, + "audios" to audios.map { it.url }, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun trailer( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val title = args.flag("title") ?: return Output.error("bad_args", "podcast20 trailer requires --title") + val url = args.flag("url") ?: return Output.error("bad_args", "podcast20 trailer requires --url") + val dTag = args.flag("d") ?: generateDTag("trailer") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val template = + Podcasting20TrailerEvent.build( + dTag = dTag, + title = title, + url = url, + pubdate = args.flag("pubdate") ?: rfc2822Now(), + lengthInBytes = args.flag("length")?.toLongOrNull(), + mimeType = args.flag("type"), + season = args.flag("season")?.toIntOrNull(), + ) + val signed = ctx.signer.sign(template) + val ack = ctx.publish(signed, RawEventSupport.publishTargets(ctx, args)) + Output.emit( + mapOf( + "event_id" to signed.id, + "kind" to signed.kind, + "d" to dTag, + "title" to title, + "url" to url, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val limit = args.intFlag("limit", 50) + Context.open(dataDir).use { ctx -> + ctx.prepare() + val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + val relays = RawEventSupport.queryTargets(ctx, args) + val received = + ctx.drain( + relays.associateWith { + listOf( + Filter( + kinds = listOf(Podcasting20EpisodeEvent.KIND, Podcasting20TrailerEvent.KIND, AppSpecificDataEvent.KIND), + authors = listOf(author), + limit = limit, + ), + ) + }, + ) + val events = received.map { it.second }.distinctBy { it.id } + val show = + events + .filterIsInstance() + .mapNotNull { Podcasting20PodcastMetadata.parse(it) } + .maxByOrNull { it.event.createdAt } + val episodes = + events + .filterIsInstance() + .sortedByDescending { it.createdAt } + .map { + mapOf( + "event_id" to it.id, + "d" to it.dTag(), + "title" to it.title(), + "season" to it.season(), + "episode" to it.number(), + "audios" to it.audios().map { a -> a.url }, + "created_at" to it.createdAt, + ) + } + val trailers = + events + .filterIsInstance() + .sortedByDescending { it.createdAt } + .map { + mapOf( + "event_id" to it.id, + "d" to it.dTag(), + "title" to it.title(), + "url" to it.url(), + "season" to it.season(), + "created_at" to it.createdAt, + ) + } + Output.emit( + mapOf( + "pubkey" to author, + "metadata" to + show?.let { + mapOf( + "title" to it.showTitle(), + "description" to it.showDescription(), + "image" to it.showImage(), + "author" to it.showAuthor(), + "categories" to it.showCategories(), + "funding" to it.showFundingUrls(), + ) + }, + "episode_count" to episodes.size, + "episodes" to episodes, + "trailer_count" to trailers.size, + "trailers" to trailers, + ), + ) + return 0 + } + } + + private fun listFlag( + args: Args, + name: String, + ): List = + args + .flag(name) + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + + /** A boolean flag maps to `true` when present and `null` when absent, so it's omitted from the JSON. */ + private fun trueIfPresent( + args: Args, + name: String, + ): Boolean? = if (args.bool(name)) true else null + + /** + * Parses the `--value-json` value-for-value block. Success with null means the flag was absent; + * a failure means it was present but malformed (the caller turns that into a bad_args error). + */ + private fun valueFlag(args: Args): Result { + val json = args.flag("value-json") ?: return Result.success(null) + return runCatching { JsonMapper.fromJson(json) } + } + + private fun generateDTag(prefix: String): String = "$prefix-${System.currentTimeMillis() / 1000}-${UUID.randomUUID().toString().take(8)}" + + /** Current time as an RFC2822 date string (e.g. `Tue, 24 Jun 2025 12:00:00 GMT`), as the spec's `pubdate` expects. */ + private fun rfc2822Now(): String = DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneId.of("GMT"))) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt index 089e775419..c341746e1c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt @@ -31,7 +31,6 @@ object Constants { val nos = RelayUrlNormalizer.normalize("wss://nos.lol") val mom = RelayUrlNormalizer.normalize("wss://nostr.mom") val primal = RelayUrlNormalizer.normalize("wss://relay.primal.net") - val damus = RelayUrlNormalizer.normalize("wss://relay.damus.io") val wine = RelayUrlNormalizer.normalize("wss://nostr.wine") val where = RelayUrlNormalizer.normalize("wss://relay.noswhere.com") @@ -55,6 +54,6 @@ object Constants { val yabu = RelayUrlNormalizer.normalize("wss://directory.yabu.me") val nostr1 = RelayUrlNormalizer.normalize("wss://profiles.nostr1.com") - val bootstrapInbox = setOf(damus, primal, mom, nos, bitcoiner, oxtr, yabu) - val eventFinderRelays = setOf(wine, damus, primal, mom, nos, bitcoiner, oxtr) + val bootstrapInbox = setOf(primal, mom, nos, bitcoiner, oxtr, yabu) + val eventFinderRelays = setOf(wine, primal, mom, nos, bitcoiner, oxtr) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/DisplayedEvent.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/DisplayedEvent.kt new file mode 100644 index 0000000000..c23bc3477f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/DisplayedEvent.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent + +/** + * Returns the event whose body a renderer would actually display. + * + * For kind 6 / kind 16 reposts that is the *wrapped* inner event — which + * the consume pipeline already resolves onto [Note.replyTo]. Falls back to + * the slower [RepostEvent.containedPost] / [GenericRepostEvent.containedPost] + * JSON decode only when the cache hasn't materialised the reply yet; both + * already null-return on parse failure. + * + * For non-repost events, the displayed event is the note's own event. + * + * Returns null when there is no event at all (placeholder not yet + * hydrated). + */ +fun Note.displayedEvent(): Event? { + val ev = this.event ?: return null + return when (ev) { + is RepostEvent -> replyTo?.lastOrNull()?.event ?: ev.containedPost() + is GenericRepostEvent -> replyTo?.lastOrNull()?.event ?: ev.containedPost() + else -> ev + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamCheck.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamCheck.kt new file mode 100644 index 0000000000..d704e81e7a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamCheck.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasMoreHashtagsThan +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent + +/** + * Pure decision: should this note be collapsed because it abuses hashtag `t` tags? + * + * Reuses [hasMoreHashtagsThan] which short-circuits on total count before + * counting unique tags, so a note that repeats the same hashtag does not + * trip the filter. + * + * Long-form articles (kind 30023) legitimately use many topic tags and are + * always exempt. Authors whose pubkey appears in [exemptKeys] (the user's + * follow list plus self) are also exempt — the caller assembles that set. + */ +object HashtagSpamCheck { + fun isHashtagSpam( + displayedEvent: Event?, + authorPubkey: HexKey?, + enabled: Boolean, + threshold: Int, + exemptKeys: Set, + ): Boolean { + if (!enabled) return false + if (displayedEvent == null) return false + if (displayedEvent.kind == LongTextNoteEvent.KIND) return false + if (authorPubkey != null && authorPubkey in exemptKeys) return false + return displayedEvent.tags.hasMoreHashtagsThan(threshold) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamSettings.kt new file mode 100644 index 0000000000..c3531b9153 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamSettings.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import androidx.compose.runtime.Stable +import kotlinx.coroutines.flow.StateFlow + +/** + * User-tunable settings for the hashtag-spam content filter. + * + * Implementations are platform-specific (Desktop uses java.util.prefs; + * Android can use DataStore). Both observables emit on change so Compose + * reads via [collectAsState] re-render automatically. + */ +@Stable +interface HashtagSpamSettings { + val enabled: StateFlow + val threshold: StateFlow + + fun setEnabled(enabled: Boolean) + + fun setThreshold(threshold: Int) + + companion object { + const val MIN_THRESHOLD = 1 + const val MAX_THRESHOLD = 20 + const val DEFAULT_THRESHOLD = 5 + const val DEFAULT_ENABLED = true + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/LocalHashtagSpamSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/LocalHashtagSpamSettings.kt new file mode 100644 index 0000000000..fcb6461e4f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/LocalHashtagSpamSettings.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Settings for the hashtag-spam filter, provided at App() root by each + * front end (Desktop, Android). Defaults to an error so missing provision + * fails loudly during development; production binaries always wire this. + */ +val LocalHashtagSpamSettings: ProvidableCompositionLocal = + compositionLocalOf { error("LocalHashtagSpamSettings not provided. Wire it at App() root.") } + +/** + * Pubkeys exempted from the hashtag-spam check — the active account's + * follow set union the active account's own pubkey. Updated by the App() + * root whenever the active account changes. Defaults to empty (strict + * filter applies to everyone) so leaf composables can read it without a + * null check. + */ +val LocalSpamExemptKeys: ProvidableCompositionLocal> = + compositionLocalOf { emptySet() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessConfig.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessConfig.kt index fb8079b7af..80a1137d88 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessConfig.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessConfig.kt @@ -31,12 +31,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl object ChessConfig { /** * Relays for chess events. - * Includes relays used by jester.nyo.dev (relay.damus.io, offchain.pub) + * Includes relays used by jester.nyo.dev (offchain.pub) * and popular community relays for broader reach. */ val CHESS_RELAYS = listOf( - "wss://relay.damus.io", "wss://nos.lol", "wss://relay.primal.net", "wss://offchain.pub", @@ -47,7 +46,6 @@ object ChessConfig { */ val CHESS_RELAY_NAMES = listOf( - "relay.damus.io".normalizeRelayUrl(), "nos.lol".normalizeRelayUrl(), "relay.primal.net".normalizeRelayUrl(), "offchain.pub".normalizeRelayUrl(), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/podcasts/V4VSplitEditorState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/podcasts/V4VSplitEditorState.kt new file mode 100644 index 0000000000..f218a4684c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/podcasts/V4VSplitEditorState.kt @@ -0,0 +1,148 @@ +/* + * 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.podcasts + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.quartz.podcasts.PodcastValue +import com.vitorpamplona.quartz.podcasts.PodcastValueRecipient + +/** + * Editable state for a Podcasting-2.0 value-for-value (V4V) split, shared by the show and episode + * composers. Holds one [RecipientDraft] per payee; [toPodcastValue] turns the drafts back into a + * [PodcastValue] on save (or null when there are no payable recipients). The suggested amount / + * currency / enabled flag on a loaded block are carried through untouched — the editor only manages + * the recipient list. + * + * Lives in `commons` (a CLI-safe snapshot-state holder, no Compose UI) so any front end can drive a + * V4V split editor; the actual editor composable is platform-side. + */ +class V4VSplitEditorState { + val recipients = mutableStateListOf() + + private var amount: Long? = null + private var currency: String? = null + private var enabled: Boolean? = null + + fun load(value: PodcastValue?) { + recipients.clear() + amount = value?.amount + currency = value?.currency + enabled = value?.enabled + value?.recipients?.forEach { recipients.add(RecipientDraft.from(it)) } + } + + /** Add a blank row for a raw destination (a node keysend, or a non-Nostr lightning address). */ + fun addManual() { + recipients.add(RecipientDraft()) + } + + /** + * Add a Nostr user as a recipient. Returns false (and adds nothing) if the user has no lightning + * address to pay — there's nothing to put in the value block. Duplicate users are ignored. + */ + fun addUser(user: User): Boolean { + if (user.lnAddress().isNullOrBlank()) return false + if (recipients.any { it.user.value?.pubkeyHex == user.pubkeyHex }) return true + recipients.add(RecipientDraft.forUser(user)) + return true + } + + fun remove(draft: RecipientDraft) { + recipients.remove(draft) + } + + /** Sum of the weights of the payable recipients — used to show each as a percentage. */ + fun totalSplit(): Int = recipients.mapNotNull { it.toRecipient() }.sumOf { it.split } + + fun toPodcastValue(): PodcastValue? { + val valid = recipients.mapNotNull { it.toRecipient() } + if (valid.isEmpty()) return null + return PodcastValue( + enabled = enabled, + amount = amount, + currency = currency, + recipients = valid, + ) + } +} + +/** + * One editable recipient row. Either backed by a Nostr [user] (rendered with avatar + name; its + * lightning address resolves at save time) or a raw destination typed by hand ([name]/[isNode]/ + * [address]). All fields are Compose state so the editor recomposes as they change. + */ +class RecipientDraft { + /** When set, this row is a Nostr user — paid at their lud16, shown with avatar + name. */ + val user = mutableStateOf(null) + + val name = mutableStateOf("") + + /** false = lnaddress (LNURL-pay), true = node (keysend to a raw node pubkey). */ + val isNode = mutableStateOf(false) + val address = mutableStateOf("") + val split = mutableStateOf("1") + val fee = mutableStateOf(false) + + /** A recipient is payable once it resolves to an address and has a positive weight. */ + fun toRecipient(): PodcastValueRecipient? { + val weight = split.value.trim().toIntOrNull() ?: return null + if (weight <= 0) return null + + user.value?.let { u -> + val lud = u.lnAddress()?.takeIf { it.isNotBlank() } ?: return null + return PodcastValueRecipient( + name = u.toBestDisplayName(), + type = PodcastValue.TYPE_LNADDRESS, + address = lud, + split = weight, + fee = if (fee.value) true else null, + ) + } + + val addr = address.value.trim() + if (addr.isBlank()) return null + return PodcastValueRecipient( + name = name.value.trim().ifBlank { null }, + type = if (isNode.value) PodcastValue.TYPE_NODE else PodcastValue.TYPE_LNADDRESS, + address = addr, + split = weight, + fee = if (fee.value) true else null, + ) + } + + companion object { + fun forUser(user: User): RecipientDraft = + RecipientDraft().apply { + this.user.value = user + } + + fun from(recipient: PodcastValueRecipient): RecipientDraft = + RecipientDraft().apply { + name.value = recipient.name.orEmpty() + isNode.value = recipient.type == PodcastValue.TYPE_NODE + address.value = recipient.address.orEmpty() + split.value = recipient.split.toString() + fee.value = recipient.fee == true + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt index c2abb6171d..787daa4ac8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchParser.kt @@ -30,8 +30,12 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.utils.Hex /** - * Parses search input and returns matching results. - * Supports: npub, nprofile, nsec (extracts pubkey), note, nevent, naddr, hex keys, hashtags + * Parses search input for direct-lookup entries: npub, nprofile, nsec + * (extracts pubkey), note, nevent, naddr, and 64-char hex keys. + * + * Hashtags are intentionally NOT returned here — they are handled by + * [QueryParser], which extracts `#xxx` tokens into the query's hashtag + * filter so the normal results pipeline drives a tag-filtered search. * * Shared between Android and Desktop for consistent Bech32 parsing. */ @@ -41,12 +45,6 @@ fun parseSearchInput(input: String): List { val trimmed = input.trim() val results = mutableListOf() - // Check for hashtag - if (trimmed.startsWith("#") && trimmed.length > 1) { - results.add(SearchResult.HashtagResult(trimmed.substring(1))) - return results - } - // Try to parse as Bech32 (npub, nevent, naddr, etc.) val parsed = Nip19Parser.uriToRoute(trimmed)?.entity if (parsed != null) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt index 21cd3a0a24..a0fe0fcb8e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResult.kt @@ -50,11 +50,4 @@ sealed class SearchResult { val dTag: String, val displayId: String, ) : SearchResult() - - /** - * Hashtag search. - */ - data class HashtagResult( - val hashtag: String, - ) : SearchResult() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CollapsedSpamNote.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CollapsedSpamNote.kt new file mode 100644 index 0000000000..b366128ec5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CollapsedSpamNote.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.note + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar + +/** + * Replacement card rendered in place of a normal note when the + * hashtag-spam check trips. Scalar params only so it stays reusable across + * Desktop, Android, and future iOS without leaking platform display + * shapes. + */ +@Composable +fun CollapsedSpamNote( + authorPubkeyHex: String, + authorDisplayName: String, + authorAvatarUrl: String?, + hashtagCount: Int, + threshold: Int, + onReveal: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .padding(horizontal = 12.dp, vertical = 8.dp) + .semantics { + contentDescription = + "Hidden note from $authorDisplayName, $hashtagCount hashtags. Tap to reveal." + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + UserAvatar( + userHex = authorPubkeyHex, + pictureUrl = authorAvatarUrl, + size = 32.dp, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = authorDisplayName.ifBlank { "Hidden note" }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Filtered: $hashtagCount hashtags · threshold $threshold", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(4.dp)) + TextButton(onClick = onReveal) { + Text("Reveal") + } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/DisplayedEventTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/DisplayedEventTest.kt new file mode 100644 index 0000000000..616e470289 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/DisplayedEventTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DisplayedEventTest { + private val author = "a".repeat(64) + private val sig = "0".repeat(128) + + private fun textNoteEvent(id: String = "1".padEnd(64, '0')): Event = + Event( + id = id, + pubKey = author, + createdAt = 0L, + kind = 1, + tags = emptyArray(), + content = "hello", + sig = sig, + ) + + private fun repostWrapper(content: String): RepostEvent = + RepostEvent( + id = "r".repeat(64), + pubKey = author, + createdAt = 0L, + tags = emptyArray(), + content = content, + sig = sig, + ) + + @Test + fun nonRepostReturnsOwnEvent() { + val inner = textNoteEvent() + val note = Note(idHex = inner.id).apply { event = inner } + assertEquals(inner, note.displayedEvent()) + } + + @Test + fun repostWithReplyToReturnsInnerEvent() { + val inner = textNoteEvent(id = "i".repeat(64)) + val innerNote = Note(idHex = inner.id).apply { event = inner } + val wrapper = repostWrapper(content = "{}") // valid JSON but missing fields + val wrapperNote = + Note(idHex = wrapper.id).apply { + event = wrapper + replyTo = listOf(innerNote) + } + assertEquals(inner, wrapperNote.displayedEvent()) + } + + @Test + fun repostWithoutReplyToFallsBackToContainedPost() { + // Malformed inner JSON → containedPost() returns null → displayedEvent() returns null. + val wrapper = repostWrapper(content = "not-valid-json-at-all") + val wrapperNote = Note(idHex = wrapper.id).apply { event = wrapper } + assertNull(wrapperNote.displayedEvent()) + } + + @Test + fun noteWithoutEventReturnsNull() { + val note = Note(idHex = "x".repeat(64)) + assertNull(note.displayedEvent()) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamCheckTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamCheckTest.kt new file mode 100644 index 0000000000..e588c733fe --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/HashtagSpamCheckTest.kt @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class HashtagSpamCheckTest { + private val author = "a".repeat(64) + private val other = "b".repeat(64) + + private fun event( + kind: Int = 1, + hashtags: List = emptyList(), + pubkey: String = author, + ): Event = + Event( + id = "id".padEnd(64, '0'), + pubKey = pubkey, + createdAt = 0L, + kind = kind, + tags = hashtags.map { arrayOf("t", it) }.toTypedArray(), + content = "", + sig = "sig".padEnd(128, '0'), + ) + + @Test + fun returnsFalseWhenDisabled() { + val e = event(hashtags = (1..20).map { "h$it" }) + assertFalse( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = e.pubKey, + enabled = false, + threshold = 5, + exemptKeys = emptySet(), + ), + ) + } + + @Test + fun returnsFalseForNullDisplayedEvent() { + assertFalse( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = null, + authorPubkey = author, + enabled = true, + threshold = 5, + exemptKeys = emptySet(), + ), + ) + } + + @Test + fun returnsFalseForLongformEvenWithManyTags() { + // kind 30023 = long-form content; exempt regardless of tag count. + val e = event(kind = 30023, hashtags = (1..20).map { "h$it" }) + assertFalse( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = e.pubKey, + enabled = true, + threshold = 5, + exemptKeys = emptySet(), + ), + ) + } + + @Test + fun returnsFalseForFollowedAuthor() { + val e = event(hashtags = (1..20).map { "h$it" }) + assertFalse( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = e.pubKey, + enabled = true, + threshold = 5, + exemptKeys = setOf(author), + ), + ) + } + + @Test + fun returnsFalseUnderThreshold() { + val e = event(hashtags = listOf("nostr", "bitcoin", "amethyst")) + assertFalse( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = e.pubKey, + enabled = true, + threshold = 5, + exemptKeys = emptySet(), + ), + ) + } + + @Test + fun returnsFalseAtThresholdExactly() { + // hasMoreHashtagsThan(5) returns true only for count > 5. + val e = event(hashtags = (1..5).map { "h$it" }) + assertFalse( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = e.pubKey, + enabled = true, + threshold = 5, + exemptKeys = emptySet(), + ), + ) + } + + @Test + fun returnsTrueOverThresholdWithDistinctTags() { + val e = event(hashtags = (1..10).map { "h$it" }) + assertTrue( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = e.pubKey, + enabled = true, + threshold = 5, + exemptKeys = emptySet(), + ), + ) + } + + @Test + fun returnsFalseOverThresholdWhenAllTagsAreDuplicates() { + // hasMoreHashtagsThan checks count AND unique count > limit. + // 20 copies of the same hashtag → unique = 1 → not spam. + val e = event(hashtags = List(20) { "bitcoin" }) + assertFalse( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = e.pubKey, + enabled = true, + threshold = 5, + exemptKeys = emptySet(), + ), + ) + } + + @Test + fun authorNotInExemptKeysIsNotExempt() { + val e = event(hashtags = (1..10).map { "h$it" }) + assertTrue( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = e.pubKey, + enabled = true, + threshold = 5, + exemptKeys = setOf(other), + ), + ) + } + + @Test + fun nullAuthorPubkeyDoesNotShortCircuit() { + val e = event(hashtags = (1..10).map { "h$it" }) + assertTrue( + HashtagSpamCheck.isHashtagSpam( + displayedEvent = e, + authorPubkey = null, + enabled = true, + threshold = 5, + exemptKeys = setOf(author), + ), + ) + } +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesHashtagSpamSettings.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesHashtagSpamSettings.kt new file mode 100644 index 0000000000..ed6e507158 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesHashtagSpamSettings.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings.Companion.DEFAULT_ENABLED +import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings.Companion.DEFAULT_THRESHOLD +import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings.Companion.MAX_THRESHOLD +import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings.Companion.MIN_THRESHOLD +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.prefs.Preferences + +/** + * JVM-platform implementation backed by [java.util.prefs.Preferences]. + * + * The default node `com/vitorpamplona/amethyst/filters` is shared between + * the Desktop app and the `amy` CLI — both binaries running as the same OS + * user observe the same setting without any extra plumbing. + * + * `Preferences` auto-flushes on JVM shutdown and periodically, so no + * explicit `flush()` call is needed and avoids per-set disk thrash. + */ +class PreferencesHashtagSpamSettings( + private val prefs: Preferences = Preferences.userRoot().node(NODE_NAME), +) : HashtagSpamSettings { + private val mutableEnabled = MutableStateFlow(prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)) + private val mutableThreshold = + MutableStateFlow( + prefs.getInt(KEY_THRESHOLD, DEFAULT_THRESHOLD).coerceIn(MIN_THRESHOLD, MAX_THRESHOLD), + ) + + override val enabled: StateFlow = mutableEnabled.asStateFlow() + override val threshold: StateFlow = mutableThreshold.asStateFlow() + + override fun setEnabled(enabled: Boolean) { + mutableEnabled.value = enabled + prefs.putBoolean(KEY_ENABLED, enabled) + } + + override fun setThreshold(threshold: Int) { + val clamped = threshold.coerceIn(MIN_THRESHOLD, MAX_THRESHOLD) + mutableThreshold.value = clamped + prefs.putInt(KEY_THRESHOLD, clamped) + } + + companion object { + const val NODE_NAME = "com/vitorpamplona/amethyst/filters" + const val KEY_ENABLED = "hashtag_spam_enabled" + const val KEY_THRESHOLD = "hashtag_spam_threshold" + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesHashtagSpamSettingsTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesHashtagSpamSettingsTest.kt new file mode 100644 index 0000000000..e74376e83c --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesHashtagSpamSettingsTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.moderation + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.prefs.Preferences + +class PreferencesHashtagSpamSettingsTest { + private val testNode = "com/vitorpamplona/amethyst/test/hashtag_spam_${System.currentTimeMillis()}" + + private fun prefs(): Preferences = Preferences.userRoot().node(testNode) + + @Before + fun setup() { + prefs().clear() + } + + @After + fun teardown() { + prefs().removeNode() + } + + @Test + fun defaultsAreOnAndFive() { + val settings = PreferencesHashtagSpamSettings(prefs()) + assertTrue(settings.enabled.value) + assertEquals(5, settings.threshold.value) + } + + @Test + fun setEnabledPersists() { + val settings = PreferencesHashtagSpamSettings(prefs()) + settings.setEnabled(false) + assertFalse(settings.enabled.value) + + val reloaded = PreferencesHashtagSpamSettings(prefs()) + assertFalse(reloaded.enabled.value) + } + + @Test + fun setThresholdPersists() { + val settings = PreferencesHashtagSpamSettings(prefs()) + settings.setThreshold(12) + assertEquals(12, settings.threshold.value) + + val reloaded = PreferencesHashtagSpamSettings(prefs()) + assertEquals(12, reloaded.threshold.value) + } + + @Test + fun thresholdClampsBelowMin() { + val settings = PreferencesHashtagSpamSettings(prefs()) + settings.setThreshold(-50) + assertEquals(HashtagSpamSettings.MIN_THRESHOLD, settings.threshold.value) + } + + @Test + fun thresholdClampsAboveMax() { + val settings = PreferencesHashtagSpamSettings(prefs()) + settings.setThreshold(9999) + assertEquals(HashtagSpamSettings.MAX_THRESHOLD, settings.threshold.value) + } +} diff --git a/desktopApp/plans/2026-06-29-hashtag-spam-filter-manual-testing-sheet.md b/desktopApp/plans/2026-06-29-hashtag-spam-filter-manual-testing-sheet.md new file mode 100644 index 0000000000..0cf2f65ec1 --- /dev/null +++ b/desktopApp/plans/2026-06-29-hashtag-spam-filter-manual-testing-sheet.md @@ -0,0 +1,66 @@ +# Manual testing sheet — Hashtag-Spam Filter (Desktop) + +Plan: `docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md` + +Run with `./gradlew :desktopApp:run`. Sign in with any account that has a +follow list (or use a fresh nsec — global feed still works without follows). + +## Setup + +1. Confirm default state: filter is **ON**, threshold is **5** on first + launch. Settings → Content Filters shows Switch=On, slider at 5. +2. Confirm a follow list is populated (Home column should display notes + from people you follow). Otherwise the follow-exemption test (T4) is a + no-op. + +## Scenarios + +| # | Scenario | Expected | +|---|----------|----------| +| **T1** | **Slider live re-collapse.** Open a Home column. Locate a visible note that has the spammy pattern (e.g. 8+ distinct hashtags). Drag the threshold slider to a value below its hashtag count. | Card collapses to placeholder after drag release. No mid-drag jank. | +| **T2** | **Repost spam (precomputed).** Find a `nostr:nevent…` link to a kind-6 repost whose wrapped event has many hashtags, paste into a hashtag column or use Search. After the cache materialises the inner event (give it a second), confirm the repost wrapper renders collapsed. | Wrapper card shows `CollapsedSpamNote` with the inner event's author. | +| **T3** | **Repost spam (uncached fallback).** Same as T2 immediately on first paste, before the cache resolves the inner event. | Wrapper shows collapsed (via `containedPost()` fallback) OR shows normal until cache populates — both acceptable; nothing crashes. | +| **T4** | **Followed-author exemption.** Locate (or temporarily follow) someone who posts 10+ hashtag stuff. Reload Home. | Their posts render normally, never collapsed. | +| **T5** | **Self exemption.** Compose and publish a note with 10 distinct hashtags from the active account. | The note appears normally in your own activity / Home, not collapsed. | +| **T6** | **Long-form exemption.** Open a longform article (kind 30023) that uses many topical tags — via Search → Articles tab, or via a quoted `naddr1…`. | Article renders normally. | +| **T7** | **Thread root auto-expand.** Find a collapsed card in a feed and click it to open the thread. | Root note auto-expands (`forceReveal = true`); 12-hashtag replies inside the thread still appear collapsed. | +| **T8** | **Embedded quote.** Read a note that quotes a hashtag-spam note via `nostr:nevent…`. | The inline embedded card is collapsed. Revealing it does NOT cascade to other places — open the same quoted note in Search and confirm it's still collapsed there (per-call-site `rememberSaveable`). | +| **T9** | **Settings persistence across restart.** Toggle Switch to Off. Restart Desktop. | Open Settings → Content Filters: still Off. Spam notes render uncollapsed. | +| **T10** | **`amy` parity.** From a terminal, run `java -cp com.vitorpamplona.amethyst.cli.Main …` (or any utility that reads the shared `Preferences` node). Or write a one-line Kotlin REPL: `Preferences.userRoot().node("com/vitorpamplona/amethyst/filters").getBoolean("hashtag_spam_enabled", true)`. | Returns the same value Desktop wrote. Reverse: write `false` via the API, relaunch Desktop, observe filter is off. | +| **T11** | **Malformed inner repost JSON.** Test event with a kind-6 wrapper whose `content` field is not parseable JSON. Quickest reproduction: use the unit test `repostWithoutReplyToFallsBackToContainedPost` ✓ already covers the `displayedEvent()` path; manually you can paste a deliberately-broken nevent and watch nothing crash. | No exception; wrapper renders normally (since `displayedEvent()` returns null and `isSpam = false`). | +| **T12** | **Hashtag-feed column.** Subscribe to a hashtag-feed column (e.g. `#bitcoin`). | Posts that are tagged `#bitcoin` AND have many other hashtags still collapse — filter applies inside hashtag columns. | +| **T13** | **Notifications tab (negative).** Receive a mention from someone who used many hashtags in the parent note. Open Notifications. | Compact notification card (56dp) renders normally — does NOT apply the filter (v1 scope: Notifications-tab card is a custom composable, deferred). Documented as a known limitation. | +| **T14** | **Slider does not cause feed recomposition storm.** Open a feed with several visible notes. Open Settings, drag threshold quickly back and forth. | Slider thumb moves smoothly; feed contents do not re-render per-tick (only on drag-end). No frame drops. | +| **T15** | **Toggle off → notes uncollapse live.** With filter ON and a collapsed card visible, toggle the Switch to Off in Settings. | Card immediately uncollapses (StateFlow re-evaluates, `isSpam` becomes false). | +| **T16** | **Threshold change updates label live.** Drag the slider with the Settings sheet open. | The "Hide notes with more than N hashtags" text updates per drag tick. | + +## Known v1 limitations to surface during testing + +- Cross-call-site reveal: revealing a collapsed card in Home does NOT reveal + the same card in a Hashtag column or in a thread — each render site has + its own `rememberSaveable` reveal flag. +- Notifications compact card does NOT respect the filter (v2). +- Follow/unfollow during a session does not re-evaluate already-rendered + cards in place (follow set is non-reactive at the check site v1; refresh + feed to update). + +## Sign-off + +- [ ] T1 Slider live re-collapse +- [ ] T2 Repost spam (precomputed) +- [ ] T3 Repost spam (uncached) +- [ ] T4 Followed-author exemption +- [ ] T5 Self exemption +- [ ] T6 Long-form exemption +- [ ] T7 Thread root auto-expand +- [ ] T8 Embedded quote +- [ ] T9 Settings persistence +- [ ] T10 `amy` parity +- [ ] T11 Malformed inner repost JSON +- [ ] T12 Hashtag-feed column +- [ ] T13 Notifications tab limitation (known) +- [ ] T14 No recomposition storm during drag +- [ ] T15 Toggle off live +- [ ] T16 Threshold label live update + +Tester: ________________ Date: ________________ diff --git a/desktopApp/plans/_desktop-feature-backlog.md b/desktopApp/plans/_desktop-feature-backlog.md new file mode 100644 index 0000000000..6d6489c08c --- /dev/null +++ b/desktopApp/plans/_desktop-feature-backlog.md @@ -0,0 +1,142 @@ +# Desktop Feature Backlog — Inspiration from Nostr Ecosystem + +Survey date: 2026-06-29. Source: cross-client research across top-5 used clients +(Damus, Primal, YakiHonne, Snort, Iris) + top-5 desktop-first / power-user +clients (Notedeck, Gossip, Jumble, Coop, Flotilla). Wisp investigated separately +(Android-only, not desktop). + +Already shipped on Amethyst Desktop and excluded from this list: deck columns, +embedded local relay, NIP-46 bunker login, NWC zapping, custom feeds, advanced +search, follow packs (in progress). + +--- + +## Priority queue (next up) + +### 1. Hashtag-spam filter — NEXT +- **Inspired by:** Damus (auto-hide posts above N hashtags). +- **What:** Configurable threshold; posts with `> N` `t` tags get hidden or + collapsed across all feeds. User-defined whitelist for legit multi-tag use + (events, longform). +- **Why desktop:** Spam noise scales with column count on a deck UI; one filter + cleans every column at once. +- **Module:** `commons/` (filter logic, shared with Android in future) + + `desktopApp/` (settings UI). +- **Skills:** `feed-patterns`, `compose-expert`, `account-state`. + +### 2. WoT (web-of-trust) score on avatars + filters +- **Inspired by:** Gossip, Snort. +- **What:** Friends-of-friends score (0–N) computed from follow graph. Visual + badge/ring on avatar. Threshold filter for notifications/DM-requests. +- **Why desktop:** At-a-glance trust signal scales with multi-column deck. +- **Module:** `commons/services/WoTService.kt` (StateFlow>), + avatar composable in `commons/.../note/`. +- **Skills:** `account-state`, `kotlin-flow-state-event-modeling`, + `compose-expert`. Score compute is O(follows × follows) — must be lazy / + throttled. + +--- + +## Full ranked backlog (10 ideas) + +Ranking heuristic: impact × novelty / implementation cost. Items 1–2 are the +priority queue above; 3–10 captured for later. + +| # | Feature | Inspired by | Module | Notes | +|---|---------|-------------|--------|-------| +| 1 | Hashtag-spam filter | Damus | commons + desktopApp | **NEXT** | +| 2 | WoT score on avatars + filters | Gossip, Snort | commons | After #1 | +| 3 | Relay-as-column + Relay Sets first-class | Jumble, Flotilla | commons | Drag relay URL → column | +| 4 | Cashu ecash wallet alongside NWC | Iris, Primal Spark | quartz + commons | Bearer-token privacy | +| 5 | AI "Dave" column (timeline-aware assistant) | Notedeck | desktopApp | LLM reads adjacent columns | +| 6 | Algorithmic Feed Marketplace | Primal v3.0 | commons | Discover layer on custom feeds | +| 7 | Keyboard / command palette (`Ctrl+K`, `?` overlay) | Snort, Notedeck | desktopApp + commons | Power-user nav | +| 8 | Longform reader column + offline publish queue | Damus, YakiHonne | commons | NIP-23, uses local relay | +| 9 | WoT-scored notification filter + undo-send | Gossip, Snort | commons | Calmer UX | +| 10 | Per-relay column health (sparkline + dot) | Wisp, Gossip | commons | Reuse EOSE manager | +| 11 | Discord-style "communities" sidebar (NIP-29) | Flotilla, Chachi | desktopApp + commons | Sidebar second purpose | + +(Extra row #11 added: communities sidebar was the 10th in the original survey; +WoT-notif-filter + undo-send promoted to its own row for clarity.) + +--- + +## Per-item detail + +### #3 Relay-as-column + Relay Sets first-class +- **Drop:** Drag relay URL onto the deck → new column showing that relay's + global. Relay Sets become saveable column templates. +- **Impl:** New `RelayUrlFeedFilter` + `RelaySetFeedFilter` in `commons/feeds/`. +- **Gotcha:** Bypass write-through to local store for these feeds, otherwise + local relay pollutes with arbitrary global content. + +### #4 Cashu ecash wallet alongside NWC +- **Why:** Different privacy model (bearer tokens, no account). Power users + want both — small/private = ecash, big/recurring = NWC. +- **Impl:** Check Quartz NIP-60/61 coverage; else new `wallet/cashu/`. UI is a + second tab in existing wallet column. +- **Gotcha:** Mint trust UX; token backup/restore; encryption story + (`accounts.json.enc` slot). + +### #5 AI "Dave" column +- **Why:** Big-screen-only feature that breaks the deck-column metaphor wide + open. Reads N adjacent columns as context. +- **Impl:** `desktopApp/` for API key config + `commons/ai/` for prompt builders. + Pluggable model: Ollama local default, OpenAI/Anthropic optional. +- **Gotcha:** Privacy story — must be explicit which events get sent where. + Default to local Ollama. + +### #6 Algorithmic Feed Marketplace +- **Why:** Custom feeds already shipped — marketplace is the discovery layer. +- **Impl:** Feeds are published as kind:30000-ish lists or DVM-driven. Browser + UI + subscribe button on top of existing custom-feed infra. +- **Gotcha:** DVM feeds vs static list feeds — two execution paths. + +### #7 Keyboard / command palette +- **Why:** Mouse-first ≠ keyboard-hostile. +- **Impl:** `desktopApp/` global `onPreviewKeyEvent` + `commons/ui/CommandPalette.kt`. +- **Gotcha:** Focus management across deck columns; cmd vs ctrl on macOS; + conflict with text-input fields. + +### #8 Longform reader + offline publish queue +- **Why:** NIP-23 is barely surfaced on Desktop. Big screens are *the* surface + for reading articles. +- **Impl:** New column types `LongformReader` + `LongformComposer`. Composer is + block-based. Offline queue writes drafts to local relay; auto-broadcasts on + reconnect. +- **Gotcha:** Markdown rendering on Compose Desktop is mid — pick or build. + Blossom upload alongside drafts. + +### #9 WoT notif filter + undo-send +- **Why:** Notifications scale poorly; slow-mode (delay sends 5–30 s with + toast-cancel) calms power-user UX. +- **Impl:** `NotificationFilter.kt` extension + send-pipeline interceptor in + `commons/relayClient/`. +- **Gotcha:** Sent-but-cancelled events already in local relay write-through — + must scrub from local store on cancel. + +### #10 Per-relay column health +- **Why:** Deck UI exposes the multi-relay reality. Latency/EOSE/dup-rate dot + + sparkline in column header helps self-tune. +- **Impl:** Extend EOSE manager to emit `RelayHealth` per relay; render small + sparkline + colored dot. +- **Gotcha:** Memory over time — rolling 60 s window, decay older. + +### #11 Communities sidebar (NIP-29) +- **Why:** Amethyst Desktop sidebar is nav-only; mapping joined groups / + favourite relays to Discord-like server icons doubles its purpose. +- **Impl:** `desktopApp/.../sidebar/` new section; on click, populate deck with + group channels. `commons/groups/` for NIP-29 if not in Quartz already. +- **Gotcha:** NIP-29 spec churn — check Quartz coverage. Don't conflate + "favourite relay" with "joined group". + +--- + +## Sources + +- nostr.com/clients, nostrapps.com, stats.nostr.band +- humai.blog "Best Nostr Apps 2026"; nostr.co.uk/clients; nostrcompass.org #15 +- opensats.org "Advancements in Nostr Clients" +- Repos: damus-io/damus, damus-io/notedeck, mikedilger/gossip, CodyTseng/jumble, + lumehq/coop, coracle-social/flotilla (active at gitea.coracle.social), + barrydeen/wisp diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 6cdf9b9205..dfc908106b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -74,6 +74,9 @@ import androidx.compose.ui.window.rememberWindowState import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols +import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings +import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys +import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSettings import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -832,6 +835,10 @@ private fun AppInner( 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 { @@ -1059,6 +1066,7 @@ private fun AppInner( 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 -> { @@ -1145,6 +1153,11 @@ private fun AppInner( // 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( @@ -1162,6 +1175,7 @@ private fun AppInner( ), LocalNamecoinPreferences provides namecoinPreferences, LocalNamecoinService provides namecoinService, + LocalSpamExemptKeys provides spamExemptKeys, ) { MainContent( layoutMode = layoutMode, @@ -2164,6 +2178,21 @@ fun RelaySettingsScreen( HorizontalDivider() Spacer(Modifier.height(16.dp)) + // Content Filters section — hashtag-spam filter and future + // content-moderation toggles. + Text( + text = "Content Filters", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + com.vitorpamplona.amethyst.desktop.ui.settings.HashtagSpamSettingsSection( + settings = LocalHashtagSpamSettings.current, + ) + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + val logoutScope = rememberCoroutineScope() OutlinedButton( onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPacksState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPacksState.kt index ae95ded0e9..80e88355c0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPacksState.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/followpacks/FollowPacksState.kt @@ -75,8 +75,6 @@ class FollowPacksState( // it uses these popular relays. Verified empirically. val DISCOVERY_RELAYS: Set = listOfNotNull( - RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io"), - RelayUrlNormalizer.normalizeOrNull("wss://relay.nostr.band"), RelayUrlNormalizer.normalizeOrNull("wss://relay.primal.net"), RelayUrlNormalizer.normalizeOrNull("wss://nos.lol"), ).toSet() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt index a31f47355f..b7fc38fa52 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.model +import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.amethyst.desktop.network.DefaultRelays import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -76,7 +77,7 @@ class DesktopRelayCategories( .distinctUntilChanged() .stateIn(scope, SharingStarted.Eagerly, defaultRelays) - /** Search relays: kind 10007 → relay.nostr.band, minus blocked */ + /** Search relays: kind 10007 → Amethyst's default search relays, minus blocked */ val searchRelays: StateFlow> = combine( accountRelays.searchRelayList, @@ -99,7 +100,6 @@ class DesktopRelayCategories( .stateIn(scope, SharingStarted.Eagerly, defaultRelays) companion object { - val DEFAULT_SEARCH_RELAYS = - setOfNotNull(RelayUrlNormalizer.normalizeOrNull("wss://relay.nostr.band")) + val DEFAULT_SEARCH_RELAYS = DefaultSearchRelayList } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt index e31000e7e4..d108d6df0d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt @@ -40,10 +40,7 @@ data class RelayStatus( object DefaultRelays { val RELAYS = listOf( - "wss://relay.damus.io", - "wss://relay.nostr.band", "wss://nos.lol", - "wss://relay.snort.social", "wss://nostr.wine", "wss://relay.noswhere.com", "wss://relay.primal.net", diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt index f27a843bb9..50ded7df46 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt @@ -149,7 +149,7 @@ fun createThreadRepliesSubscription( /** * Creates a NIP-50 search subscription for user profiles. - * Requires NIP-50 compatible relays (e.g., relay.nostr.band, nostr.wine). + * Requires NIP-50 compatible relays (e.g., nostr.wine). * * @param searchQuery Text to search for in user profiles * @param limit Maximum results to return diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt index 9f31007ef5..3b7d215fe0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -386,7 +386,7 @@ object FilterBuilders { /** * Creates a NIP-50 search filter for user metadata (kind 0). * Searches user profiles by name, displayName, about, nip05, etc. - * Requires a NIP-50 compatible relay (e.g., relay.nostr.band, nostr.wine). + * Requires a NIP-50 compatible relay (e.g., nostr.wine). * * @param searchQuery The text to search for in user profiles * @param limit Maximum number of results to return diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index f38e064569..48a38a56a6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -316,12 +316,18 @@ fun BookmarksScreen( onNavigateToThread(event.id) }, ) { - NoteCard( - note = event.toNoteDisplayData(localCache), + com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender( + displayedEvent = event, + noteIdHex = event.id, localCache = localCache, - onAuthorClick = onNavigateToProfile, - onMentionClick = onNavigateToProfile, - ) + ) { + NoteCard( + note = event.rememberDisplayData(localCache), + localCache = localCache, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + ) + } NoteActionsRow( event = event, relayManager = relayManager, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt index 0367228efa..c580f863c7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/EventExtensions.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.desktop.ui.note.NoteDisplayData @@ -27,6 +31,27 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip19Bech32.toNpub +/** + * Compose-friendly wrapper around [toNoteDisplayData] that observes the + * event author's user-metadata flow and re-derives the display data + * whenever it updates. Use at note-card call sites so display names and + * avatars populate when the kind-0 event arrives from relays after the + * note itself. + */ +@Composable +fun Event.rememberDisplayData(cache: ICacheProvider?): NoteDisplayData { + val author = remember(pubKey, cache) { cache?.getUserIfExists(pubKey) } + val authorMetaValue by produceState(initialValue = null, key1 = author) { + val a = author + if (a == null) { + value = null + } else { + a.metadata().flow.collect { value = it } + } + } + return remember(this, authorMetaValue) { toNoteDisplayData(cache) } +} + /** * Extension to convert Event to NoteDisplayData for the shared NoteCard. */ diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index ddce2a3ce2..c240c645d2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -135,6 +135,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard +import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList @@ -199,8 +200,54 @@ fun FeedNoteCard( followedUsers: Set = emptySet(), myPubKeyHex: String? = null, onFollow: ((String) -> Unit)? = null, + forceReveal: Boolean = false, ) { val event = note.event ?: return + SpamCheckedNoteRender( + note = note, + localCache = localCache, + forceReveal = forceReveal, + ) { + FeedNoteCardBody( + note = note, + event = event, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = onReply, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onImageClick = onImageClick, + onMediaClick = onMediaClick, + onHashtagClick = onHashtagClick, + followedUsers = followedUsers, + myPubKeyHex = myPubKeyHex, + onFollow = onFollow, + ) + } +} + +@Composable +private fun FeedNoteCardBody( + note: Note, + event: com.vitorpamplona.quartz.nip01Core.core.Event, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + account: AccountState.LoggedIn?, + nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, + onReply: () -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + onNavigateToProfile: (String) -> Unit = {}, + onNavigateToThread: (String) -> Unit = {}, + onImageClick: ((List, Int) -> Unit)? = null, + onMediaClick: ((List, Int, Float) -> Unit)? = null, + onHashtagClick: ((String) -> Unit)? = null, + followedUsers: Set = emptySet(), + myPubKeyHex: String? = null, + onFollow: ((String) -> Unit)? = null, +) { val isRepost = event is RepostEvent || event is GenericRepostEvent if (isRepost) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index f457552eb8..29f56c5047 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -120,7 +120,6 @@ fun SearchScreen( initialQuery: String = "", onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, - onNavigateToHashtag: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -348,6 +347,20 @@ fun SearchScreen( } } + // Load author metadata for incoming note results. NIP-50 search relays + // typically don't return kind-0 metadata alongside notes, and the + // subscription explicitly drops MetadataEvents anyway — so display name + // and avatar arrive only after we explicitly fetch them from index + // relays via the coordinator. + LaunchedEffect(noteResults, subscriptionsCoordinator) { + val coordinator = subscriptionsCoordinator ?: return@LaunchedEffect + if (noteResults.isEmpty()) return@LaunchedEffect + val authors = noteResults.map { it.pubKey }.distinct() + if (authors.isNotEmpty()) { + coordinator.loadMetadataBatched(authors) + } + } + // History state val historyItems by SearchHistoryStore.history.collectAsState() val savedSearches by SearchHistoryStore.savedSearches.collectAsState() @@ -643,7 +656,6 @@ fun SearchScreen( ), onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, - onNavigateToHashtag = onNavigateToHashtag, ) if (ncState.result.relays.isNotEmpty()) { Text( @@ -699,7 +711,6 @@ fun SearchScreen( result = result, onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, - onNavigateToHashtag = onNavigateToHashtag, ) } } @@ -897,7 +908,6 @@ private fun SearchResultCard( result: SearchResult, onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, - onNavigateToHashtag: (String) -> Unit, ) { Card( modifier = @@ -908,7 +918,6 @@ private fun SearchResultCard( is SearchResult.UserResult -> onNavigateToProfile(result.pubKeyHex) is SearchResult.NoteResult -> onNavigateToThread(result.noteIdHex) is SearchResult.AddressResult -> onNavigateToThread("${result.kind}:${result.pubKeyHex}:${result.dTag}") - is SearchResult.HashtagResult -> onNavigateToHashtag(result.hashtag) } }, colors = @@ -927,7 +936,6 @@ private fun SearchResultCard( is SearchResult.UserResult -> MaterialSymbols.Person is SearchResult.NoteResult -> MaterialSymbols.Description is SearchResult.AddressResult -> MaterialSymbols.Description - is SearchResult.HashtagResult -> MaterialSymbols.Tag }, contentDescription = null, modifier = Modifier.size(24.dp), @@ -940,7 +948,6 @@ private fun SearchResultCard( is SearchResult.UserResult -> "User Profile" is SearchResult.NoteResult -> "Note" is SearchResult.AddressResult -> "Event (kind ${result.kind})" - is SearchResult.HashtagResult -> "#${result.hashtag}" }, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, @@ -950,10 +957,9 @@ private fun SearchResultCard( is SearchResult.UserResult -> result.displayId is SearchResult.NoteResult -> result.displayId is SearchResult.AddressResult -> result.displayId - is SearchResult.HashtagResult -> "Search posts with this hashtag" }, style = MaterialTheme.typography.bodySmall, - fontFamily = if (result is SearchResult.HashtagResult) null else FontFamily.Monospace, + fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 78f8b224d4..f637ae4523 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -318,6 +318,8 @@ fun ThreadScreen( com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer .toggleFullscreen() }, + // Root of an explicitly-opened thread — user opted in, skip spam collapse. + forceReveal = true, ) } HorizontalDivider(thickness = 1.dp) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 269507999a..565a9b2156 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -530,13 +530,18 @@ fun QuotedNoteEmbed( authorMetaValue val displayData = event.toNoteDisplayData(localCache) - NoteCard( - note = displayData, + SpamCheckedNoteRender( + note = note, localCache = localCache, - onClick = onNavigateToThread?.let { nav -> { nav(event.id) } }, - onAuthorClick = onMentionClick, - onMentionClick = onMentionClick, - ) + ) { + NoteCard( + note = displayData, + localCache = localCache, + onClick = onNavigateToThread?.let { nav -> { nav(event.id) } }, + onAuthorClick = onMentionClick, + onMentionClick = onMentionClick, + ) + } } else { Card( modifier = Modifier.fillMaxWidth(), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/SpamCheckedNoteRender.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/SpamCheckedNoteRender.kt new file mode 100644 index 0000000000..6924ed6235 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/SpamCheckedNoteRender.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.note + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamCheck +import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings +import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys +import com.vitorpamplona.amethyst.commons.moderation.displayedEvent +import com.vitorpamplona.amethyst.commons.ui.note.CollapsedSpamNote +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags + +/** + * Wraps a note render in the hashtag-spam check. + * + * Reads [LocalHashtagSpamSettings] and [LocalSpamExemptKeys] from + * composition. If the check trips and the user hasn't revealed this + * specific note (per-note [rememberSaveable] keyed by id), renders + * [CollapsedSpamNote] with author info pulled from [localCache]; + * otherwise calls [normal]. + * + * @param displayedEvent The event whose body the [normal] block renders. + * For repost wrappers, callers should pass the *inner* event (via + * [Note.displayedEvent]). For search results that already hold an + * [Event] directly, pass it as-is. + * @param noteIdHex Stable id used as the [rememberSaveable] key for the + * reveal flag — must match the id of whatever the [normal] block renders. + * @param forceReveal When `true`, skips the check entirely and always + * renders [normal]. Used by thread-detail screens where the user + * explicitly opted into the root note. + */ +@Composable +fun SpamCheckedNoteRender( + displayedEvent: Event?, + noteIdHex: String, + localCache: DesktopLocalCache?, + forceReveal: Boolean = false, + normal: @Composable () -> Unit, +) { + val settings = LocalHashtagSpamSettings.current + val enabled by settings.enabled.collectAsState() + val threshold by settings.threshold.collectAsState() + val exemptKeys = LocalSpamExemptKeys.current + + val isSpam = + remember(noteIdHex, displayedEvent, enabled, threshold, exemptKeys) { + HashtagSpamCheck.isHashtagSpam( + displayedEvent = displayedEvent, + authorPubkey = displayedEvent?.pubKey, + enabled = enabled, + threshold = threshold, + exemptKeys = exemptKeys, + ) + } + + var revealed by rememberSaveable(noteIdHex) { mutableStateOf(false) } + + if (!forceReveal && isSpam && !revealed && displayedEvent != null) { + val authorPubkey = displayedEvent.pubKey + val author = localCache?.getUserIfExists(authorPubkey) + val displayName = author?.toBestDisplayName() ?: authorPubkey.take(8) + val avatarUrl = author?.profilePicture() + val hashtagCount = displayedEvent.tags.countHashtags() + CollapsedSpamNote( + authorPubkeyHex = authorPubkey, + authorDisplayName = displayName, + authorAvatarUrl = avatarUrl, + hashtagCount = hashtagCount, + threshold = threshold, + onReveal = { revealed = true }, + ) + } else { + normal() + } +} + +/** + * [Note]-shaped convenience overload — resolves the displayed event + * (unwrapping kind 6 / 16 reposts) and delegates to the primary helper. + */ +@Composable +fun SpamCheckedNoteRender( + note: Note, + localCache: DesktopLocalCache?, + forceReveal: Boolean = false, + normal: @Composable () -> Unit, +) { + val displayedEvent = remember(note, note.event) { note.displayedEvent() } + SpamCheckedNoteRender( + displayedEvent = displayedEvent, + noteIdHex = note.idHex, + localCache = localCache, + forceReveal = forceReveal, + normal = normal, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/SearchRelayEditor.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/SearchRelayEditor.kt index 7b242e4a40..f7e96256c2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/SearchRelayEditor.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/SearchRelayEditor.kt @@ -74,7 +74,7 @@ fun SearchRelayEditor( Column(modifier = modifier.fillMaxWidth()) { Text( - "Add relays that support NIP-50 full-text search (e.g., relay.nostr.band).", + "Add relays that support NIP-50 full-text search (e.g., nostr.wine).", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 4.dp), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt index d88b712add..4b35e4d95c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt @@ -60,7 +60,8 @@ import com.vitorpamplona.amethyst.commons.search.SearchSortOrder import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard -import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData +import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender +import com.vitorpamplona.amethyst.desktop.ui.rememberDisplayData import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @Composable @@ -153,24 +154,36 @@ fun SearchResultsList( if (!collapsed) { val displayNotes = textNotes.take(5) items(displayNotes, key = { "note-${it.id}" }) { event -> - NoteCard( - note = event.toNoteDisplayData(localCache), + SpamCheckedNoteRender( + displayedEvent = event, + noteIdHex = event.id, localCache = localCache, - onClick = { onNavigateToThread(event.id) }, - onAuthorClick = onNavigateToProfile, - onMentionClick = onNavigateToProfile, - ) + ) { + NoteCard( + note = event.rememberDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + ) + } } if (textNotes.size > 5) { item(key = "notes-expand") { ExpandableSection( remaining = textNotes.drop(5), ) { event -> - NoteCard( - note = event.toNoteDisplayData(localCache), - onClick = { onNavigateToThread(event.id) }, - onAuthorClick = onNavigateToProfile, - ) + SpamCheckedNoteRender( + displayedEvent = event, + noteIdHex = event.id, + localCache = localCache, + ) { + NoteCard( + note = event.rememberDisplayData(localCache), + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + ) + } } } } @@ -197,24 +210,36 @@ fun SearchResultsList( } if (!collapsed) { items(articles.take(5), key = { "article-${it.id}" }) { event -> - NoteCard( - note = event.toNoteDisplayData(localCache), + SpamCheckedNoteRender( + displayedEvent = event, + noteIdHex = event.id, localCache = localCache, - onClick = { onNavigateToThread(event.id) }, - onAuthorClick = onNavigateToProfile, - onMentionClick = onNavigateToProfile, - ) + ) { + NoteCard( + note = event.rememberDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + ) + } } if (articles.size > 5) { item(key = "articles-expand") { ExpandableSection( remaining = articles.drop(5), ) { event -> - NoteCard( - note = event.toNoteDisplayData(localCache), - onClick = { onNavigateToThread(event.id) }, - onAuthorClick = onNavigateToProfile, - ) + SpamCheckedNoteRender( + displayedEvent = event, + noteIdHex = event.id, + localCache = localCache, + ) { + NoteCard( + note = event.rememberDisplayData(localCache), + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + ) + } } } } @@ -239,13 +264,19 @@ fun SearchResultsList( } if (!collapsed) { items(otherNotes.take(5), key = { "other-${it.id}" }) { event -> - NoteCard( - note = event.toNoteDisplayData(localCache), + SpamCheckedNoteRender( + displayedEvent = event, + noteIdHex = event.id, localCache = localCache, - onClick = { onNavigateToThread(event.id) }, - onAuthorClick = onNavigateToProfile, - onMentionClick = onNavigateToProfile, - ) + ) { + NoteCard( + note = event.rememberDisplayData(localCache), + localCache = localCache, + onClick = { onNavigateToThread(event.id) }, + onAuthorClick = onNavigateToProfile, + onMentionClick = onNavigateToProfile, + ) + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/HashtagSpamSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/HashtagSpamSettingsSection.kt new file mode 100644 index 0000000000..e6c77083cd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/HashtagSpamSettingsSection.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.moderation.HashtagSpamSettings +import kotlin.math.roundToInt + +/** + * Settings UI for the hashtag-spam content filter. + * + * Decoupled-thumb pattern: a local [Float] state drives the [Slider] thumb, + * the [StateFlow] only sees the committed [Int] on [onValueChangeFinished]. + * Prevents per-tick recomposition storms in the feed while the user drags. + */ +@Composable +fun HashtagSpamSettingsSection( + settings: HashtagSpamSettings, + modifier: Modifier = Modifier, +) { + val enabled by settings.enabled.collectAsState() + val committed by settings.threshold.collectAsState() + + var live by remember { mutableFloatStateOf(committed.toFloat()) } + LaunchedEffect(committed) { live = committed.toFloat() } + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = "Hashtag-spam filter", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = enabled, onCheckedChange = settings::setEnabled) + Spacer(Modifier.width(8.dp)) + Text( + text = if (enabled) "On" else "Off", + style = MaterialTheme.typography.bodyMedium, + ) + } + if (enabled) { + Spacer(Modifier.height(8.dp)) + Text( + text = "Hide notes with more than ${live.roundToInt()} hashtags", + style = MaterialTheme.typography.bodyMedium, + ) + Slider( + value = live, + onValueChange = { live = it }, + onValueChangeFinished = { settings.setThreshold(live.roundToInt()) }, + valueRange = + HashtagSpamSettings.MIN_THRESHOLD + .toFloat()..HashtagSpamSettings.MAX_THRESHOLD.toFloat(), + steps = HashtagSpamSettings.MAX_THRESHOLD - HashtagSpamSettings.MIN_THRESHOLD - 1, + ) + Text( + text = "Long-form articles and posts from people you follow are always shown.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index d067f6c530..95c2d26b18 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -127,6 +127,12 @@ "Hindi" ] }, + { + "user": "StellarStoic", + "languages": [ + "Slovenian" + ] + }, { "user": "maxblake2015", "languages": [ @@ -153,12 +159,6 @@ "Spanish, United States" ] }, - { - "user": "StellarStoic", - "languages": [ - "Slovenian" - ] - }, { "user": "summoner001", "languages": [ diff --git a/docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md b/docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md new file mode 100644 index 0000000000..91630c2506 --- /dev/null +++ b/docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md @@ -0,0 +1,867 @@ +--- +title: Desktop Hashtag-Spam Filter +type: feat +status: active +date: 2026-06-29 +origin: docs/brainstorms/2026-06-29-feat-hashtag-spam-filter-brainstorm.md +deepened: 2026-06-29 +--- + +# Desktop Hashtag-Spam Filter + +## Enhancement Summary + +**Deepened on:** 2026-06-29 (same day as plan write). + +**Review agents used:** architecture-strategist, code-simplicity-reviewer, +pattern-recognition-specialist, performance-oracle, agent-native-reviewer, +security-sentinel · plus best-practices research (Compose collapse-UX + Slider +patterns) and a Quartz/NoteCard-variant verification sweep. + +### Key corrections vs initial plan + +1. **Repost unwrap uses `note.replyTo`, not `containedPost()`.** Desktop's + `DesktopLocalCache.consumeRepost()` already resolves the boosted event into + `note.replyTo` at consume time — a pointer chase, not a JSON parse per render. +2. **NoteCard signature mismatch.** Desktop `NoteCard` takes `NoteDisplayData`, + not `Note`. Spam check is hoisted to the **caller** (`FeedScreen`, + `QuotedNoteEmbed`, thread renderer); `NoteCard` itself stays untouched, the + caller chooses between `NoteCard(...)` and `CollapsedSpamNote(...)`. +3. **Persistence moves to `commons/jvmMain/`** with a stable + `java.util.prefs` node name shared between Desktop and the `amy` CLI — + closes the agent-native parity gap from day one. +4. **Settings UI gets its own "Content Filters" section** in `Main.kt`'s + settings screen, not under `RelaySettingsScreen` (this isn't a relay + concern). +5. **Reveal state simplifies to `rememberSaveable(note.idHex)`** — drop the + `LocalRevealedSpamState` CompositionLocal + `SnapshotStateMap`. Trade-off: + the same spam note revealed in column A stays collapsed in column B — + accepted as v1 limitation. +6. **Slider commits on `onValueChangeFinished`** with a local `Float` state + driving the thumb. Live label reads the local float. No `debounce`, no + `prefs.flush()` thrash, no recomposition storm during drag. +7. **`collectAsState` hoisted to column scope** (or higher) and scalars + pushed down to leaf items — not collected per-card. +8. **`HashtagSpamCheck` placed in `commons/.../hashtags/`** (existing + package), not a new `filters/` package. +9. **Self + follow exemption** merged into a single `exemptKeys: Set` + parameter. +10. **`NoOpHashtagSpamSettings` dropped.** Always provide + `PreferencesHashtagSpamSettings` at App root via + `compositionLocalOf { error("Provide LocalHashtagSpamSettings") }`. +11. **Notifications-tab compact card is out of scope v1** — uses a custom + 56 dp composable that doesn't funnel through `NoteCard`. All other + Desktop note-render surfaces (Home/Hashtag/Profile/Search/Thread + replies/Embedded quotes) DO funnel through `NoteCard` and pick up the + filter for free. +12. **Quartz API name correction:** `containedPost()`, not + `containedNote()`. + +### New considerations discovered + +- `note.replyTo` is precomputed by `DesktopLocalCache.consumeRepost` + (`desktopApp/.../cache/DesktopLocalCache.kt:401-416`). Use it. +- `Kind3Follows.authors` is a `val` on an `@Immutable` data class + (`commons/.../nip02FollowList/Kind3FollowListState.kt:100-104`), so + `account.followingKeySet()` returns a stable reference — no extra caching + needed. The plan's earlier reactivity hedge was unnecessary. +- `containedPost()` already returns `null` on parse failure + (`quartz/.../nip18Reposts/RepostEvent.kt:87-92`) — no extra try/catch. +- Embedded quotes (`QuotedNoteEmbed`) and search results both call + `NoteCard()` directly, so the caller-side check handles them naturally + when we patch those call sites. + +--- + +## Overview + +Detect notes that abuse `t` (hashtag) tags as a visibility trick and render +them as a compact reveal-on-click placeholder instead of the normal note +card. Logic in `commons/` (callable by Desktop, future Android, and the +`amy` CLI). UI + settings shipped on Desktop first. Reuses Quartz's +`hasMoreHashtagsThan` primitive. + +**Carried forward from brainstorm** +(`docs/brainstorms/2026-06-29-feat-hashtag-spam-filter-brainstorm.md`): +collapse-with-reveal · single global threshold · default 5 (slider 1–20, +with off-switch) · auto-exempt long-form articles (kind 30023) + followed +authors · persisted via `java.util.prefs.Preferences`. + +**Resolved during planning + deepening:** default ON · repost wrapper +checks the inner wrapped event's tags (via pre-resolved `note.replyTo`) · +thread root auto-expands but replies stay collapsed · hashtag-feed columns +still filter · reveal state is session-scoped via `rememberSaveable` keyed +by note id · settings persisted under a `commons/jvmMain` `java.util.prefs` +node shared with `amy`. + +## Problem Statement + +Hashtag-spam — posts with 10–30 `t` tags chosen to maximize cross-feed +visibility — pollutes every multi-column deck view. The cost scales with +column count: Desktop's TweetDeck-style UI exposes the problem more than +Android. Existing `AntiSpamFilter` only catches duplicate content, not +visibility-bombs. + +## Proposed Solution + +### High-level approach + +A pure check function in `commons/` (`HashtagSpamCheck.isHashtagSpam(...)`) +called by the **callers** of each note-card composable (feed `LazyColumn` +item lambdas, embedded-quote renderer, thread item renderer). When the check +returns `true`, the caller renders a `CollapsedSpamNote` placeholder +instead of the normal note card. The placeholder takes primitive scalars +so the same composable is reusable across Desktop and (later) Android. + +Settings (enabled flag + integer threshold) live behind a +`HashtagSpamSettings` interface in `commons/`, with a +`PreferencesHashtagSpamSettings` JVM implementation backing +`java.util.prefs.Preferences.userRoot().node("com/vitorpamplona/amethyst/filters")` +— shared with `amy` CLI for agent-native parity. + +### Why **not** a FeedFilter + +`commons/.../ui/feeds/AdditiveFeedFilter.kt` is **exclusionary** — +`applyFilter()` returns a `Set` and items not in the set vanish. +Collapse-with-reveal needs to keep the item and render it differently, so +the decision belongs at render time, not in the feed pipeline. The +brainstorm doc named the unit `HashtagSpamFilter`; renamed to +`HashtagSpamCheck`. + +### Architecture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ commons/ (platform-agnostic, callable by Desktop / amy / Android)│ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ commonMain/ │ │ +│ │ hashtags/HashtagSpamCheck.kt (pure function) │ │ +│ │ hashtags/HashtagSpamSettings.kt (interface, @Stable) │ │ +│ │ hashtags/displayedEvent.kt (Note → Event? helper) │ │ +│ │ ui/note/CollapsedSpamNote.kt (scalar-param card) │ │ +│ │ ui/LocalHashtagSpamSettings.kt (CompositionLocal, │ │ +│ │ error-on-default) │ │ +│ │ jvmMain/ │ │ +│ │ hashtags/PreferencesHashtagSpamSettings.kt │ │ +│ │ (java.util.prefs-backed impl, shared node name) │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + ▲ + │ +┌─────────────────────────────┴────────────────────────────────────┐ +│ desktopApp/ (only the UI wire-up + caller-side check) │ +│ Main.kt │ +│ CompositionLocalProvider(LocalHashtagSpamSettings provides │ +│ PreferencesHashtagSpamSettings()) { │ +│ App() … │ +│ } │ +│ ui/settings/HashtagSpamSettingsSection.kt │ +│ (Switch + Slider; local Float thumb; commit on │ +│ onValueChangeFinished) │ +│ feeds/FeedScreen.kt (and QuotedNoteEmbed, ThreadScreen replies)│ +│ LazyColumn { items(notes, key = { it.idHex }) { note -> │ +│ if (isHashtagSpam(...)) CollapsedSpamNote(...) │ +│ else NoteCard(NoteDisplayData(note), …) │ +│ }} │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ cli/ (amy — v2 subcommands, plan-out only) │ +│ amy filter hashtag-spam get / set │ +│ amy notes is-spam │ +│ amy notes feed --include-spam=false (default) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### Pure check function + +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/HashtagSpamCheck.kt`: + +```kotlin +object HashtagSpamCheck { + /** + * Pure: no side effects, no IO. Caller passes plain scalars so this + * stays Compose-friendly for `remember(...)` keys and unit-testable + * without setting up a CompositionLocal. + * + * `displayedEvent` is the event whose body the note card actually + * renders — for kind 6/16 reposts that means the wrapped inner event + * resolved through `Note.displayedEvent()`. + * + * `exemptKeys` should already include the user's self pubkey plus + * every pubkey in the current follow list; merged at the call site so + * this function stays single-purpose. + */ + fun isHashtagSpam( + displayedEvent: Event?, + authorPubkey: HexKey?, + enabled: Boolean, + threshold: Int, + exemptKeys: Set, + ): Boolean { + if (!enabled) return false + if (displayedEvent == null) return false + if (displayedEvent.kind == LongFormContentEvent.KIND) return false // 30023 + if (authorPubkey != null && authorPubkey in exemptKeys) return false + return displayedEvent.tags.hasMoreHashtagsThan(threshold) + } +} +``` + +### Displayed-event resolver + +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/displayedEvent.kt`: + +```kotlin +/** + * For reposts (kind 6) and generic reposts (kind 16), the "displayed + * event" is the wrapped inner event already resolved on `note.replyTo` + * by the consume pipeline. Falls back to `containedPost()` only if the + * cache hasn't materialised the reply yet; that path JSON-parses and + * returns null on bad content (Quartz API already handles try/catch). + */ +fun Note.displayedEvent(): Event? { + val e = this.event ?: return null + return when (e) { + is RepostEvent -> replyTo?.lastOrNull()?.event ?: e.containedPost() + is GenericRepostEvent -> replyTo?.lastOrNull()?.event ?: e.containedPost() + else -> e + } +} +``` + +`containedPost()` (not `containedNote()` — corrected) lives at +`quartz/.../nip18Reposts/RepostEvent.kt:87` and +`.../GenericRepostEvent.kt:87`. Both already wrap `fromJson(content)` in +try/catch returning null. + +### Settings interface + +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/HashtagSpamSettings.kt`: + +```kotlin +@Stable +interface HashtagSpamSettings { + val enabled: StateFlow + val threshold: StateFlow + fun setEnabled(enabled: Boolean) + fun setThreshold(threshold: Int) +} +``` + +`LocalHashtagSpamSettings`: + +```kotlin +val LocalHashtagSpamSettings: ProvidableCompositionLocal = + compositionLocalOf { error("LocalHashtagSpamSettings not provided") } +``` + +No `NoOpHashtagSpamSettings` — caller (`Main.kt` `App()`) is always required +to provide the real impl. Compose idiom for "must provide" via `error { ... }` +default, mirroring how Material3 enforces theme provision. + +### Persistence (commons/jvmMain — shared with `amy`) + +`commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/PreferencesHashtagSpamSettings.kt`: + +```kotlin +class PreferencesHashtagSpamSettings( + prefs: Preferences = Preferences.userRoot().node("com/vitorpamplona/amethyst/filters"), +) : HashtagSpamSettings { + private val _enabled = MutableStateFlow(prefs.getBoolean(KEY_ENABLED, true)) + private val _threshold = MutableStateFlow(prefs.getInt(KEY_THRESHOLD, 5)) + override val enabled = _enabled.asStateFlow() + override val threshold = _threshold.asStateFlow() + + override fun setEnabled(v: Boolean) { + _enabled.value = v + prefs.putBoolean(KEY_ENABLED, v) + } + + override fun setThreshold(v: Int) { + val clamped = v.coerceIn(MIN, MAX) + _threshold.value = clamped + prefs.putInt(KEY_THRESHOLD, clamped) + } + + companion object { + const val KEY_ENABLED = "hashtag_spam_enabled" + const val KEY_THRESHOLD = "hashtag_spam_threshold" + const val MIN = 1 + const val MAX = 20 + } +} +``` + +- Shared `java.util.prefs` node `com/vitorpamplona/amethyst/filters` (not + per-class) so `amy` constructing the same impl observes the same node. +- No `prefs.flush()` — `java.util.prefs` auto-flushes on JVM shutdown and + periodically; explicit flush thrashes disk. +- Defaults: enabled = `true`, threshold = `5`. New installs and existing + users (absent keys) both get the defaults. No migration code. + +### Collapsed-note placeholder + +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/CollapsedSpamNote.kt`: + +```kotlin +@Composable +fun CollapsedSpamNote( + authorDisplayName: String, + authorAvatarUrl: String?, + createdAtSeconds: Long, + hashtagCount: Int, + threshold: Int, + onReveal: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .padding(horizontal = 12.dp, vertical = 8.dp) + .semantics { + contentDescription = + "Hidden note from $authorDisplayName, $hashtagCount hashtags. Tap to reveal." + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar(url = authorAvatarUrl, size = 32.dp) + Spacer(Modifier.width(8.dp)) + Column(Modifier.weight(1f)) { + Text(authorDisplayName, style = MaterialTheme.typography.labelLarge) + Text( + "Filtered: $hashtagCount hashtags · threshold $threshold", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text(relativeTimeShort(createdAtSeconds), style = MaterialTheme.typography.bodySmall) + TextButton(onClick = onReveal) { Text("Reveal") } + } +} +``` + +Parameters are **scalars only** — no `Note`, no `NoteDisplayData`, no +`Event`. This is the single shareable surface; Android, Desktop, and any +future front-end render it from their own data shape by mapping at the call +site. + +Always visible: avatar, display name, timestamp, hashtag count, reason +chip, Reveal button. Hidden: body, media, link previews. Convergent with +Mastodon/Tusky/Bluesky CW-card conventions (research: best-practices). + +### Caller-side integration + +The check happens **outside** `NoteCard` because Desktop's `NoteCard` takes +a `NoteDisplayData` that doesn't carry the raw `Note`/`Event` we need. Each +caller resolves `Note → displayed event → spam decision` and renders +`CollapsedSpamNote` or `NoteCard`. + +`desktopApp/.../ui/FeedScreen.kt` (representative): + +```kotlin +@Composable +fun FeedScreen(notes: List, account: IAccount) { + val settings = LocalHashtagSpamSettings.current + val enabled by settings.enabled.collectAsState() + val threshold by settings.threshold.collectAsState() + val exemptKeys = remember(account) { + // Stable: Kind3Follows.authors is @Immutable, selfPubkey is a String + account.followingKeySet() + account.userProfile().pubkeyHex + } + + LazyColumn { + items(notes, key = { it.idHex }) { note -> + val displayedEvent = note.displayedEvent() + val isSpam = remember(note.idHex, displayedEvent, enabled, threshold, exemptKeys) { + HashtagSpamCheck.isHashtagSpam( + displayedEvent = displayedEvent, + authorPubkey = displayedEvent?.pubKey, + enabled = enabled, + threshold = threshold, + exemptKeys = exemptKeys, + ) + } + var revealed by rememberSaveable(note.idHex) { mutableStateOf(false) } + + Box(Modifier.animateItem()) { // smooth resize + if (isSpam && !revealed) { + CollapsedSpamNote( + authorDisplayName = note.author?.bestDisplayName() ?: "", + authorAvatarUrl = note.author?.profilePicture(), + createdAtSeconds = displayedEvent?.createdAt ?: 0L, + hashtagCount = displayedEvent?.tags?.countHashtags() ?: 0, + threshold = threshold, + onReveal = { revealed = true }, + ) + } else { + NoteCard(NoteDisplayData(note), /* … */) + } + } + } + } +} +``` + +Important specifics from research: + +- `collectAsState()` is called **once at column scope**, not once per note — + prevents 60–360 redundant collectors per visible viewport (perf-oracle). +- `key = { it.idHex }` on `items(...)` is required for `Modifier.animateItem()` + to animate the size change correctly. +- `rememberSaveable(note.idHex)` survives `LazyColumn` recycling on scroll. +- Trade-off: the same spam note shown simultaneously in Home + Hashtag + columns has independent reveal state per column. Acceptable v1. + +#### Other caller sites that need the same pattern + +1. **`QuotedNoteEmbed`** at `desktopApp/.../ui/note/NoteCard.kt:470-535` — when + a note body contains `nostr:nevent…` and the rich-text renderer recurses + into `NoteCard`. Patch this entry to apply the same check. +2. **Thread replies** in `desktopApp/.../ui/ThreadScreen.kt` — replies use + `NoteCard` too. The **root** note auto-expands (caller passes + `forceReveal = true`); replies use the normal collapse rule. +3. **Search results** at `desktopApp/.../ui/search/SearchResultsList.kt:156+` + — same call-site change. +4. **Notifications-tab compact 56 dp card** at `NotificationsScreen.kt:271-366` + does NOT funnel through `NoteCard` (it's a bespoke compact composable). + Verified by Quartz/NoteCard-variant sweep. **Deferred to v2** — the + notification's snippet text isn't a full note body so the impact is + lower. + +#### Thread root auto-expand + +Thread screen owns its caller and simply passes `forceReveal = true` to +the root note's `revealed` state initialization, while replies keep +`rememberSaveable(note.idHex) { mutableStateOf(false) }`. No `ReplyContext` +plumbing through `NoteCard` (that didn't exist anyway — corrected from the +draft). + +### Settings UI (Desktop) + +`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/HashtagSpamSettingsSection.kt`: + +```kotlin +@Composable +fun HashtagSpamSettingsSection( + settings: HashtagSpamSettings, + modifier: Modifier = Modifier, +) { + val enabled by settings.enabled.collectAsState() + val committed by settings.threshold.collectAsState() + + // Local slider state — decouples drag from StateFlow churn. + // LaunchedEffect resyncs if a different surface changes the threshold. + var live by remember { mutableFloatStateOf(committed.toFloat()) } + LaunchedEffect(committed) { live = committed.toFloat() } + + Column(modifier.padding(16.dp)) { + Text("Hashtag-spam filter", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = enabled, onCheckedChange = settings::setEnabled) + Spacer(Modifier.width(8.dp)) + Text(if (enabled) "On" else "Off") + } + if (enabled) { + Text("Hide notes with more than ${live.roundToInt()} hashtags", + style = MaterialTheme.typography.bodyMedium) + Slider( + value = live, + onValueChange = { live = it }, // local only — no recompose storm + onValueChangeFinished = { settings.setThreshold(live.roundToInt()) }, + valueRange = 1f..20f, + steps = 18, + ) + Text("Long-form articles and posts from people you follow are always shown.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} +``` + +Wired into `Main.kt` settings screen as a dedicated **Content Filters** +section (sibling to Wallet Connect, Media Server, Namecoin, Local Relay), +not under `RelaySettingsScreen`'s relay-specific entries. Section header +spans the screen so users searching "spam" or "hashtag" find it. + +The settings impl is provided at App root: + +```kotlin +// Main.kt App() +val hashtagSpamSettings = remember { PreferencesHashtagSpamSettings() } +CompositionLocalProvider( + LocalHashtagSpamSettings provides hashtagSpamSettings, + // existing CompositionLocals … +) { + App(…) +} +``` + +## Technical Considerations + +### Performance + +- Quartz `hasMoreHashtagsThan` is O(n) over the note's tag array (n ≈ 5–30 + worst case) with short-circuit on total count before any hashset + allocation. Sub-microsecond per note. +- `note.displayedEvent()` is a pointer chase via the precomputed + `note.replyTo` for reposts — no JSON parse on the hot path. Falls back + to `containedPost()` (which itself has try/catch + null return) only + for the rare unconsumed-repost case. +- `isHashtagSpam` is `remember(...)`-memoized against + `(noteId, displayedEvent, enabled, threshold, exemptKeys)`. The + `exemptKeys` set is `remember(account)`-cached at column scope so + references stay stable across recompositions. +- Settings StateFlows are `collectAsState`d **once per column**, not per + note — prevents O(visible notes) flow collectors per deck. +- `Slider` uses local `Float` state; downstream collectors only see the + committed `Int` on drag-end → zero feed recompositions while the user + drags the thumb. +- `rememberSaveable(note.idHex)` for reveal flag survives LazyColumn + recycling on scroll without any external state container. + +### Stability annotations + +- `HashtagSpamSettings` interface: `@Stable` — public observable surface + (`StateFlow` instances) changes only via `setEnabled`/`setThreshold`, + honest stability contract. +- `PreferencesHashtagSpamSettings` class: omit annotation — `@Stable` would + be redundant on a class implementing a `@Stable` interface, and + `@Immutable` is wrong (it holds `MutableStateFlow`s). +- `HashtagSpamCheck` is `object` — implicitly `@Immutable`. + +### Threshold reactivity + +`enabled` and `threshold` are exposed as `StateFlow` and read via +`collectAsState()` at column scope. Setting changes → StateFlow emit → +column recomposes → all visible cards re-evaluate `isSpam` and +re-collapse/re-reveal accordingly. Project pattern memory: +`?.collectAsState()?.value` does NOT work for tracking — use `by … .collectAsState()`. + +### Follow-set reactivity + +`account.followingKeySet()` returns +`kind3FollowList.flow.value.authors`. `Kind3Follows` is `@Immutable`, so +the returned `Set` reference is stable until the follow list +mutates upstream. Currently no `StateFlow>` accessor exists — +follow/unfollow during a session won't reactively re-evaluate cards in +place. Acceptable for v1 (follow churn is low). A future refactor to expose +`kind3FollowList.flow` directly on `IAccount` closes the gap. + +### Reposts and the displayed event + +| `note.event` kind | `note.displayedEvent()` | +|-------------------|--------------------------| +| 6 (`RepostEvent`) | `replyTo.last().event` (precomputed) ?: `containedPost()` | +| 16 (`GenericRepostEvent`) | `replyTo.last().event` (precomputed) ?: `containedPost()` | +| anything else | `note.event` | + +The wrapped event is **already materialised on `note.replyTo`** by +`DesktopLocalCache.consumeRepost()` — no JSON parsing on render. + +### Inside threads + +Thread screen owns the caller; for the **root** note it initialises +`revealed = true` (auto-expand on opt-in click-through). Replies use +the default collapsed/`rememberSaveable` path. No special threading types. + +### Inside embedded/quoted notes + +`QuotedNoteEmbed` (the entry that recurses into `NoteCard` for inline +`nostr:nevent…` references) gets the same check at its call site. +Independent `rememberSaveable` per quote location. + +### Security / privacy + +- Pure client-side: no network IO, no relay filter derivation, no telemetry. +- App-global persistence (not per-account) intentionally avoids leaking + per-Nostr-identity behavioural fingerprints to anyone with filesystem + access — the OS-account boundary is the trust boundary. +- Malformed inner repost events: `containedPost()` already returns null, + `isHashtagSpam` returns false (does not collapse, does not throw). +- Censorship-resistance smell test: content is **collapsed, not dropped**; + one click reveals; settings live in a top-level "Content Filters" section + (not buried under relays). + +## System-Wide Impact + +### Interaction graph + +``` +PreferencesHashtagSpamSettings (java.util.prefs node) + │ + ▼ CompositionLocalProvider in Main.kt App() +LocalHashtagSpamSettings + │ + ▼ collectAsState() at column scope +(enabled, threshold) as scalars + │ + ▼ per-note remember(...) at LazyColumn item +HashtagSpamCheck.isHashtagSpam(displayedEvent, …) + │ + ▼ if true & !revealed +CollapsedSpamNote(scalars, onReveal) + │ + └─ onReveal → rememberSaveable(note.idHex) flips → recomposes this row only +``` + +### Error & failure propagation + +- `note.displayedEvent()`: fallback chain `replyTo → containedPost → null`. + Null displayed event → `isHashtagSpam = false` → normal NoteCard renders. + Never throws. +- `Preferences.put*` may throw `BackingStoreException`. Wrap in try/catch + inside `setEnabled`/`setThreshold`; log and continue (in-memory state + already updated; persistence is best-effort). +- Settings UI: `LocalHashtagSpamSettings` default `error("...")` only fires + if App root forgot the provider. Caught at first launch, never in prod. + +### State lifecycle risks + +- Reveal state is `rememberSaveable` per LazyColumn item. Survives scroll + recycling; lost on screen close (intentional). +- Settings keys absent on first launch → defaults (`true`, `5`). No + partial-state risk; `java.util.prefs` is atomic per key. +- Slider local-Float state is column-instance scoped; lost on settings + screen close. Re-derived from committed `Int` on next open. + +### API surface parity + +- **commons:** `HashtagSpamCheck` (pure), `HashtagSpamSettings` (interface), + `Note.displayedEvent()` extension, `CollapsedSpamNote` (scalar + composable), `LocalHashtagSpamSettings` (CompositionLocal), + `PreferencesHashtagSpamSettings` (`jvmMain` impl, shared `java.util.prefs` + node with `amy`). +- **desktopApp:** App-root provider, `HashtagSpamSettingsSection` Compose + UI, caller-side check in `FeedScreen` / `QuotedNoteEmbed` / `ThreadScreen` + / `SearchResultsList`. +- **amethyst (Android):** no changes v1. Future adoption is a single + `AndroidHashtagSpamSettings` (DataStore-backed) + `LocalHashtagSpamSettings` + provider + the same caller-side pattern. Commons does not leak Desktop + types so the path is clean. +- **cli (amy):** day-1 picks up the shared `java.util.prefs` node — agents + using `amy` see the same setting users configured in Desktop. v2 adds + `amy filter hashtag-spam {get|set}` and `amy notes is-spam ` + subcommands; v2 also adds `amy notes feed --include-spam` (default + respects setting). + +### Integration test scenarios + +1. **Slider live re-collapse.** Open a Home column with a known spammy + visible (revealed). Drag threshold down past its tag count. + `onValueChangeFinished` commits → all visible cards re-evaluate → that + card collapses. No mid-drag visual jank. +2. **Repost spam (precomputed).** A kind:6 wrapping a 12-hashtag kind:1 + already consumed → `replyTo` materialised → check trips on inner → + wrapper collapses. +3. **Repost spam (unconsumed).** Same scenario but the inner event hasn't + been cached → fallback to `containedPost()` succeeds → same outcome. +4. **Followed-author exemption.** 15-hashtag note from a key in follow set + → does NOT collapse. +5. **Self exemption.** 15-hashtag note from the active account → does NOT + collapse for self. +6. **Long-form exemption.** kind:30023 with 12 topic tags → no collapse. +7. **Thread root auto-expand.** Tap a collapsed card → thread screen → root + shows full content; a 12-hashtag reply in the thread stays collapsed. +8. **Embedded quote.** A note quotes a 15-hashtag note via `nostr:nevent…` + → the inline embedded card renders as collapsed; revealing it does NOT + reveal the same note in the parent feed column (independent + `rememberSaveable` per call site). +9. **Settings persistence.** Toggle off, restart Desktop app → off-state + restored from `java.util.prefs` node. +10. **amy parity.** From command line, write enabled=false to the shared + prefs node via plain `java.util.prefs` API → relaunch Desktop → + Desktop reads the same node → filter is off. +11. **Malformed inner repost.** `containedPost()` returns null on bad + inner JSON → `isHashtagSpam` returns false → renders normal NoteCard + of the wrapper (which itself has 0 hashtags). No crash. + +## Acceptance Criteria + +### Functional + +- [x] `HashtagSpamCheck.isHashtagSpam` in + `commons/commonMain/.../moderation/HashtagSpamCheck.kt`. Compiles for + `:commons:compileKotlinJvm`. *(Note: filed under `moderation/` not + `hashtags/` — `hashtags/` is the existing icon-only package.)* +- [x] `HashtagSpamSettings` (`@Stable` interface) in + `commons/commonMain/.../moderation/HashtagSpamSettings.kt`. +- [x] `LocalHashtagSpamSettings` CompositionLocal with `error(...)` + default. `LocalSpamExemptKeys` CompositionLocal added for the + account-derived exempt set. +- [x] `PreferencesHashtagSpamSettings` JVM impl in `commons/jvmMain` + bound to `Preferences.userRoot().node("com/vitorpamplona/amethyst/filters")` + with keys `hashtag_spam_enabled` (default `true`) and + `hashtag_spam_threshold` (default `5`, range 1–20). +- [x] `CollapsedSpamNote` composable in + `commons/commonMain/.../ui/note/CollapsedSpamNote.kt` taking scalar + params only (no `Note`, no `NoteDisplayData`, no `Event`); includes + a11y `contentDescription`. +- [x] `Note.displayedEvent()` extension in + `commons/commonMain/.../moderation/DisplayedEvent.kt` returning the + wrapped event for kind 6 / 16 via `replyTo` (precomputed), falling + back to `containedPost()` and finally null. +- [x] Desktop `FeedNoteCard` (covers FeedScreen + ThreadScreen + + UserProfileScreen), `QuotedNoteEmbed`, `BookmarksScreen`, and + `SearchResultsList` (5 sites) all branch on the spam check + caller-side via the shared `SpamCheckedNoteRender` helper and render + either `CollapsedSpamNote` or `NoteCard`. +- [x] `FeedNoteCard` exposes `forceReveal: Boolean = false`; ThreadScreen + passes `forceReveal = true` for the root note. Replies inside the + thread use default `rememberSaveable` collapsed. +- [x] `Main.kt` provides `LocalHashtagSpamSettings` at App root (always-on) + and `LocalSpamExemptKeys` inside the LoggedIn branch (account-aware); + adds a **Content Filters** settings section containing + `HashtagSpamSettingsSection`. +- [x] `HashtagSpamSettingsSection`: Switch + Slider with local `Float` + state, commit on `onValueChangeFinished`, live label, exemption + footnote. +- [x] Notifications-tab compact card explicitly **out of scope v1** — + documented in the deferred section + manual testing sheet T13. +- [x] `collectAsState` for settings called once inside + `SpamCheckedNoteRender` (per visible card, but with cheap stable + `StateFlow` references); slider settings UI also collects once. + +### Non-functional + +- [ ] No measurable frame-time regression in profiler runs of a 200-note + column (manual smoke test pending — see T14 in testing sheet). +- [x] Spotless clean: `./gradlew spotlessApply` produces no diff. +- [x] Compiles cleanly: `./gradlew :commons:compileKotlinJvm + :desktopApp:compileKotlin`. +- [x] No `Preferences.flush()` calls; rely on JVM auto-flush. + +### Quality gates + +- [x] Unit tests for `HashtagSpamCheck` (commons) — disabled, longform + exempt, self exempt, follow exempt, under threshold, equal threshold, + over threshold with duplicates only (helper short-circuits on + uniques), over threshold with uniques, null displayedEvent, null + authorPubkey. **10 tests, all green.** +- [x] Unit test for `Note.displayedEvent()` — repost with materialised + `replyTo`, repost with null `replyTo` (fallback returns null on + malformed JSON), non-repost (returns own event), null event. + **4 tests, all green.** +- [x] Unit test for `PreferencesHashtagSpamSettings` — read default, read + after set, threshold clamps to 1..20, both keys persist across + instance recreation (test-specific node suffix). **5 tests, all + green.** +- [x] Manual testing sheet at + `desktopApp/plans/2026-06-29-hashtag-spam-filter-manual-testing-sheet.md` + covering 16 integration scenarios. + +## Success Metrics + +- Subjective deck-feed cleanliness on default ON / threshold 5 (no + collapsed legit follow content, visible spam collapsed). +- No frame drops > 16 ms during slider drag or feed scroll in a heavy + column. +- Settings persist across app restarts and across the Desktop ↔ `amy` + boundary. + +## Dependencies & Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| `replyTo` unset for very fresh reposts | low | Fall back to `containedPost()`; both already null-safe | +| Embedded `NoteCard` recursion misses a call site | medium | Audit every site that constructs `NoteCard(NoteDisplayData(note), …)` during impl; integration scenario #8 catches misses | +| Follow-set non-reactive during a session | low | Acceptable v1; track for a future `IAccount.followingFlow()` accessor | +| Default-on surprises power users | medium | Settings entry is in a top-level **Content Filters** section and easy to find; no toast (per simplicity review) | +| amy and Desktop diverge on settings node name | low | Use a constant `Preferences` node name in commons and reference it from both binaries | +| `Modifier.animateItem()` on an alpha API surface | low | Compose Foundation 1.7+ stable; project already uses it elsewhere — verify during impl | + +## Out of Scope (deferred) + +- User-curated hashtag allowlist (waiting for false-positive feedback). +- Per-column threshold override. +- Aggregated "N filtered today" badge. +- Account-synced setting via NIP-78/NIP-51. +- Android UI (commons logic ready; Android wires later). +- **`amy` subcommands** for the filter (`amy filter hashtag-spam …`, + `amy notes is-spam`, `amy notes feed --include-spam`) — v2; v1 ships the + shared `java.util.prefs` node so the data is already accessible. +- Banner when feed is 100 % collapsed. +- Cross-column shared reveal state (each column's reveal flag is + independent in v1 — `rememberSaveable` per call site). +- Notifications-tab compact 56 dp card spam check (custom composable that + doesn't go through `NoteCard`). +- Persistent "permanently revealed" notes across sessions. + +## Sources & References + +### Origin + +- **Brainstorm:** `docs/brainstorms/2026-06-29-feat-hashtag-spam-filter-brainstorm.md` + — collapse-with-reveal · global threshold · default 5 · longform + + followed exemptions · `Preferences` persistence. + +### Internal references + +- `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/hashtags/TagArrayExt.kt:41` + — `hasMoreHashtagsThan(limit)` primitive. +- `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt:87` + + `.../GenericRepostEvent.kt:87` — `containedPost()` (null-safe). +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:401-416` + — `consumeRepost()` precomputes `note.replyTo` (use this, not JSON + decode). +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt:100-104` + — `@Immutable Kind3Follows.authors: Set` (stable ref). +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt:101` + — `followingKeySet(): Set`. +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt:96-112` + — `NoteCard(note: NoteDisplayData, …)` signature (caller-side check). +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt:470-535` + — `QuotedNoteEmbed` recursion site (also needs caller-side check). +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt:156+` + — search-result call sites. +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt` + — existing `java.util.prefs` pattern (referenced; settings live in + commons/jvmMain instead). +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/LocalRelaySettingsScreen.kt` + — Switch + Slider settings UI pattern. +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt:64-87` + — existing `Local*` CompositionLocal pattern. +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/AdditiveFeedFilter.kt` + — why we don't extend `FeedFilter` (exclusionary contract). +- `commons/ARCHITECTURE.md` — module taxonomy / `commons/jvmMain` placement + for JVM-only helpers. + +### External references + +- [Android Developers — Lazy lists and lazy grids](https://developer.android.com/develop/ui/compose/lists) + — stable `key`, `Modifier.animateItem()`. +- [Android Developers — Where to hoist state](https://developer.android.com/develop/ui/compose/state-hoisting). +- [JetBrains compose-multiplatform #4366 — Slider draggable state](https://github.com/JetBrains/compose-multiplatform/issues/4366) + — why local `Float` + `onValueChangeFinished` is needed. +- [issuetracker #240599812 — AnimatedVisibility in LazyColumn](https://issuetracker.google.com/issues/240599812) + — pitfalls with collapsed-item layout. +- [Mastodon moderating docs](https://docs.joinmastodon.org/user/moderating/) + + [Bluesky moderation docs](https://docs.bsky.app/docs/advanced-guides/moderation) + — convergent "always show author, gate body" convention. + +### Skill references + +- `feed-patterns` — confirmed `FeedFilter` is exclusionary; not used here. +- `compose-expert` — note-card composition + state hoisting. +- `compose-side-effects` — `LaunchedEffect(committed) { live = … }` to + resync slider local state on upstream change. +- `compose-recomposition-performance` — hoist `collectAsState` to column + scope; `rememberSaveable` survives recycling; local `Float` thumb. +- `compose-stability-diagnostics` — `@Stable` on the settings interface. +- `kotlin-flow-state-event-modeling` — StateFlow + collectAsState + reactivity. +- `account-state` — `IAccount.followingKeySet()`; `Kind3Follows` + immutability. +- `nostr-expert` — repost event unwrap via `replyTo`; `containedPost()` + semantics. +- `amy-expert` — shared `java.util.prefs` node so `amy` reads the same + setting users configure in Desktop. +- `kotlin-multiplatform` — `commons/jvmMain` placement for JVM-only + persistence impls. + +### Backlog reference + +- `desktopApp/plans/_desktop-feature-backlog.md` priority item #1; WoT + score on avatars is the next item. diff --git a/geode/config.example.toml b/geode/config.example.toml index a95b625d59..7c83311b7b 100644 --- a/geode/config.example.toml +++ b/geode/config.example.toml @@ -62,6 +62,12 @@ verify_signatures = true # Require clients to NIP-42 AUTH before REQ/EVENT/COUNT. require_auth = false +# Advertise NIP-42 AUTH without requiring it: the relay sends the +# challenge and records clients that authenticate (so downstream +# policies can gate on identity), but REQ/EVENT/COUNT still work for +# clients that never AUTH. Ignored when require_auth = true. +# optional_auth = false + # Reject events whose `created_at` is more than this many seconds in # the future. Enforced by RejectFutureEventsPolicy. # reject_future_seconds = 1800 diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index e2becaf114..e0cd7f815e 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -24,10 +24,12 @@ import com.vitorpamplona.geode.config.BannedEntry import com.vitorpamplona.geode.config.RuntimeConfig import com.vitorpamplona.geode.config.RuntimeConfigData import com.vitorpamplona.geode.config.StaticConfig +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RejectFutureEventsPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyAuthOnlyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.VerifyPolicy @@ -64,6 +66,8 @@ import java.io.File * --info NIP-11 doc file (overrides [info] section) * --db sqlite db path (overrides [database].file) * --auth require NIP-42 AUTH (sets options.require_auth = true) + * --optional-auth advertise NIP-42 AUTH but don't require it (sets + * options.optional_auth = true; ignored when --auth is set) * --no-verify DO NOT verify event signatures (off by default * verify is on; use only for trusted-input * scenarios like fixture replay). @@ -84,6 +88,9 @@ fun main(args: Array) { val cliInfoFile = a.opt("--info")?.let { File(it) } val dbFile = a.opt("--db") ?: config.database.file?.takeUnless { config.database.in_memory } val requireAuth = a.flag("--auth") || config.options.require_auth + // Optional AUTH advertises the challenge without gating commands on it. + // Mandatory AUTH already sends the challenge, so it wins when both are set. + val optionalAuth = !requireAuth && (a.flag("--optional-auth") || config.options.optional_auth) // Verify is on by default; only disable when the operator explicitly // opts out (CLI `--no-verify` or `[options].verify_signatures = false` // in the config). @@ -109,7 +116,7 @@ fun main(args: Array) { val store: IEventStore = EventStore(dbName = dbFile, relay = advertisedUrl) val policyBuilder: () -> IRelayPolicy = { - composePolicy(config, advertisedUrl, requireAuth, verifySigs, parallelVerify) + composePolicy(config, advertisedUrl, requireAuth, optionalAuth, verifySigs, parallelVerify) } // Static [authorization] feeds the runtime BanStore only when no @@ -182,8 +189,9 @@ fun main(args: Array) { */ private fun composePolicy( config: StaticConfig, - advertisedUrl: com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, + advertisedUrl: NormalizedRelayUrl, requireAuth: Boolean, + optionalAuth: Boolean, verifySigs: Boolean, parallelVerify: Boolean, ): IRelayPolicy { @@ -191,6 +199,8 @@ private fun composePolicy( if (requireAuth) { pieces += FullAuthPolicy(advertisedUrl) + } else if (optionalAuth) { + pieces += OptionalAuthPolicy(advertisedUrl) } config.options.reject_future_seconds?.let { secs -> diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt index 5cde5a1989..4170fa14b4 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/RelayInfo.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.geode import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip11RelayInfo.relayInformation import java.io.File /** @@ -68,13 +69,13 @@ data class RelayInfo( /** Pre-built default for `RelayEngine(url = ...)` — advertises the supported NIPs. */ fun default(url: NormalizedRelayUrl): RelayInfo = RelayInfo( - Nip11RelayInformation( - name = NAME, - description = DESCRIPTION, - software = SOFTWARE, - version = VERSION, - supported_nips = SUPPORTED_NIPS, - ), + relayInformation { + name = NAME + description = DESCRIPTION + software = SOFTWARE + version = VERSION + supports(*SUPPORTED_NIPS.toTypedArray()) + }, ) /** Loads a NIP-11 doc from a JSON file (e.g. a relay operator's config). */ diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt index 06f9120311..9b59d9f45e 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/config/StaticConfig.kt @@ -107,6 +107,13 @@ data class StaticConfig( data class OptionsSection( val reject_future_seconds: Int? = null, val require_auth: Boolean = false, + /** + * Advertise NIP-42 AUTH without requiring it: the relay sends the + * challenge and records clients that authenticate, but EVENT/REQ/COUNT + * still work for clients that never do. Ignored when [require_auth] is + * true (mandatory AUTH already sends the challenge). + */ + val optional_auth: Boolean = false, /** * Defaults to `true`: any relay accepting real traffic should * verify Schnorr signatures, and verifying-by-default closes diff --git a/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt index 7384be0c4a..89764247dc 100644 --- a/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt +++ b/geode/src/testFixtures/kotlin/com/vitorpamplona/geode/fixtures/RelayFixtures.kt @@ -67,5 +67,5 @@ object RelayFixtures { fun vitorShort(): List = OptimizedJsonMapper.fromJsonToEventList(loadString("nostr_vitor_short.json")) /** Loads `nostr_vitor_startup_data.json.gz` — the larger Vitor startup corpus. */ - fun vitorStartup(): List = OptimizedJsonMapper.fromJsonToEventList(loadGzipString("nostr_vitor_startup_data.json")) + fun vitorStartup(): List = OptimizedJsonMapper.fromJsonToEventList(loadGzipString("nostr_vitor_startup_data.json.gz")) } diff --git a/quartz/RELAY.md b/quartz/RELAY.md index dda794bae9..8ff7ee7dcb 100644 --- a/quartz/RELAY.md +++ b/quartz/RELAY.md @@ -76,12 +76,15 @@ val store = EventStore( indexTagsByCreatedAtAlone = false, indexTagsWithKindAndPubkey = false, useAndIndexIdOnOrderBy = false, + indexFullTextSearch = true, ), ) ``` By default, all single-letter tags with values are indexed. Override `shouldIndex(kind, tag)` for custom behavior. More indexes = faster queries but larger database. +`indexFullTextSearch` defaults to `true` and controls the NIP-50 full-text index (`event_fts`). Set it to `false` when search is served elsewhere (e.g. a Vespa backend, or a `SearchEventSource` as shown below): inserts skip the FTS tokenization cost, no `event_fts` table/trigger is created, and any filter carrying a non-empty `search` term returns no matches. + ## Non-Storage Relays (search, redirector, computed) A relay's job is to *answer REQs*. When the answer doesn't come from a stored diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt index 6ed1b1428d..f7f5b655a0 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/LargeDBSignatureCheck.kt @@ -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>( diff --git a/quartz/src/androidDeviceTest/resources/nostr_vitor_startup_data.json b/quartz/src/androidDeviceTest/resources/nostr_vitor_startup_data.json.gz similarity index 100% rename from quartz/src/androidDeviceTest/resources/nostr_vitor_startup_data.json rename to quartz/src/androidDeviceTest/resources/nostr_vitor_startup_data.json.gz diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 3376178217..5e522ade47 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -300,6 +300,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent /** * Human-readable label and defining NIP for a Nostr event kind. @@ -337,6 +339,8 @@ object KindNames { PodcastMetadataEvent.KIND to KindName("Podcast Show", "F4"), AuthoredPodcastsEvent.KIND to KindName("Authored Podcasts", "F4"), FavoritePodcastsListEvent.KIND to KindName("Favorite Podcasts", "F4"), + Podcasting20EpisodeEvent.KIND to KindName("Podcast Episode (Podcasting 2.0)", null), + Podcasting20TrailerEvent.KIND to KindName("Podcast Trailer (Podcasting 2.0)", null), AttestationEvent.KIND to KindName("Attestation", null), AttestationRequestEvent.KIND to KindName("Attestation Request", null), AttestorRecommendationEvent.KIND to KindName("Attestor Recommendation", null), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt index e7fefe331b..a682753914 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt index 1c9ad080bc..99b4450cea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt @@ -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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt index 02b4ee74bc..125643864e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/INostrClient.kt @@ -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>? fun getCountFiltersOrNull(subId: String): Map>? diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 693aa74661..f87b6622d4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -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) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropySyncException.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropySyncException.kt new file mode 100644 index 0000000000..f6018a8098 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropySyncException.kt @@ -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, + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt index 8bacb7b383..9c09bf586d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchAllPagesExt.kt @@ -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) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncEventsExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncEventsExt.kt new file mode 100644 index 0000000000..4c064003a6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncEventsExt.kt @@ -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 = + 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 = + 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 = + 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 = + negentropySyncOrFetchEvents( + relay = RelayUrlNormalizer.normalize(relay), + filter = filter, + maxEvents = maxEvents, + maxConcurrentReqs = maxConcurrentReqs, + fetchBatch = fetchBatch, + idleTimeoutMs = idleTimeoutMs, + ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt new file mode 100644 index 0000000000..a5b218cf76 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropySyncExt.kt @@ -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(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() + 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) -> 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(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>(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, + idleTimeoutMs: Long, +): List { + val subId = newSubId() + val done = Channel(Channel.CONFLATED) + val collected = ArrayList(batch.size) + val seen = HashSet(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?, + ) { + clock.bump() + if (seen.add(event.id)) collected.add(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + done.trySend(Unit) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + done.trySend(Unit) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + 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 Channel.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. + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt index 5bf4dff727..8ed3822081 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolRequests.kt @@ -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 = 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 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? = 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? = 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? = 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?>>() + 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?, - newFilters: List?, - 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. } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/OptionalAuthPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/OptionalAuthPolicy.kt new file mode 100644 index 0000000000..dc8124a90c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/OptionalAuthPolicy.kt @@ -0,0 +1,57 @@ +/* + * 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.server.policies + +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Runs the full NIP-42 challenge/verify handshake but never *requires* it. + * + * Like [FullAuthPolicy], [onConnect] emits the AUTH challenge and [accept] + * (AuthCmd) validates a returned event (expiration, freshness, challenge match, + * relay match), recording verified pubkeys into the engine-owned connection + * scope. Unlike [FullAuthPolicy], EVENT, REQ, and COUNT are **always accepted** — + * a client that ignores the challenge and never authenticates keeps working. + * + * Use this when you want the relay to *advertise* authentication (so clients that + * do support NIP-42 can identify themselves, and downstream policies can gate or + * rewrite on the caller's identity via [authenticatedUsers]) without locking out + * clients that don't. It is the middle ground between [EmptyPolicy] (no challenge + * at all) and [FullAuthPolicy] (challenge required for every command). + * + * Because it subclasses [FullAuthPolicy], the [authorize] hook and the + * per-connection [authenticatedUsers] set behave identically; only the gating on + * EVENT/REQ/COUNT is relaxed. Subclasses can still tighten specific commands + * (e.g. require auth for kind 4 only) by overriding the relevant [accept] and + * reading [authenticatedUsers]. + */ +open class OptionalAuthPolicy( + relay: NormalizedRelayUrl, +) : FullAuthPolicy(relay) { + override fun accept(cmd: EventCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun accept(cmd: ReqCmd): PolicyResult = PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd): PolicyResult = PolicyResult.Accepted(cmd) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt index 71f55b1096..1283b78e16 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt @@ -28,13 +28,28 @@ import com.vitorpamplona.quartz.nip01Core.store.FtsReindexProgress import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.utils.EventFactory -class FullTextSearchModule : IModule { +/** + * NIP-50 full-text search index over event content. + * + * When [enabled] is `false` the module becomes an inert no-op: no + * `event_fts` virtual table and no `fts_foreign_key` delete trigger are + * created, inserts skip the per-event tokenization cost, and the reindex + * entry points do nothing. This is for deployments that never query the + * SQLite store for search — e.g. a relay that offloads NIP-50 to an + * external engine such as Vespa — and don't want to pay the write-time + * FTS indexing (or the per-delete trigger) overhead. Search filters are + * handled by [QueryBuilder], which returns no matches when search is off. + */ +class FullTextSearchModule( + val enabled: Boolean = true, +) : IModule { val tableName = "event_fts" val triggerName = "fts_foreign_key" val eventHeaderRowIdName = "event_header_row_id" val contentName = "content" override fun create(db: SQLiteConnection) { + if (!enabled) return val ftsVersion = versionFinder(db) db.execSQL( """ @@ -88,6 +103,7 @@ class FullTextSearchModule : IModule { headerId: Long, db: SQLiteConnection, ) { + if (!enabled) return if (event is SearchableEvent) { db.prepare(insertFTS).use { stmt -> stmt.bindLong(1, headerId) @@ -127,6 +143,7 @@ class FullTextSearchModule : IModule { } override fun deleteAll(db: SQLiteConnection) { + if (!enabled) return db.execSQL("DELETE FROM event_fts") } @@ -144,6 +161,7 @@ class FullTextSearchModule : IModule { * Must run inside the caller's write transaction. */ fun reindexAll(db: SQLiteConnection) { + if (!enabled) return dropTrigger(db) drop(db) create(db) @@ -199,6 +217,7 @@ class FullTextSearchModule : IModule { afterRowId: Long, batchSize: Int, ): FtsReindexProgress { + if (!enabled) return FtsReindexProgress(cursor = null, processedThisBatch = 0, done = true) // A non-positive page would select no rows yet never report done, // spinning the caller's loop forever — clamp to at least one. val limit = batchSize.coerceAtLeast(1) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt index 9c0f197847..bcb36f6140 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt @@ -81,6 +81,21 @@ interface IndexingStrategy { */ val useAndIndexIdOnOrderBy: Boolean + /** + * Maintain the NIP-50 full-text search index (`event_fts`). + * + * Unlike the other flags this defaults to **on**: search is a core + * feature and every searchable event is tokenized into an FTS virtual + * table on insert, with an `AFTER DELETE` trigger keeping it in sync. + * + * Turn it **off** when this store never serves search from SQLite — + * e.g. a relay that offloads NIP-50 to an external engine like Vespa. + * With it off, no `event_fts` table or trigger is created, inserts + * skip the tokenization cost, deletes skip the trigger, and any filter + * carrying a non-empty `search` term returns no matches. + */ + val indexFullTextSearch: Boolean + fun shouldIndex( kind: Int, tag: Tag, @@ -95,6 +110,7 @@ class DefaultIndexingStrategy( override val indexTagsByCreatedAtAlone: Boolean = false, override val indexTagsWithKindAndPubkey: Boolean = false, override val useAndIndexIdOnOrderBy: Boolean = false, + override val indexFullTextSearch: Boolean = true, ) : IndexingStrategy { override fun shouldIndex( kind: Int, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt index 46fa1579a4..3be4353679 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt @@ -114,6 +114,12 @@ class QueryBuilder( ): QuerySpec { val newFilter = filter.toFilterWithDTags() + // With FTS off there is no event_fts table to MATCH against, so a + // search term can never be satisfied — the filter matches nothing. + if (searchDisabledMatchesNothing(newFilter.search)) { + return QuerySpec(makeMatchesNothingQuery()) + } + if (newFilter.isSimpleQuery()) { return makeSimpleQuery( project = true, @@ -215,6 +221,11 @@ class QueryBuilder( ): QuerySpec { val newFilter = filter.toFilterWithDTags() + // With FTS off a search term can never be satisfied — no matches. + if (searchDisabledMatchesNothing(newFilter.search)) { + return QuerySpec("SELECT id, created_at FROM event_headers WHERE 0") + } + // Simple path — no tag joins, no FTS — collapses to a single // SELECT against event_headers. if (newFilter.isSimpleQuery()) { @@ -389,6 +400,18 @@ class QueryBuilder( results } + /** + * True when full-text search is turned off ([FullTextSearchModule.enabled] + * is `false`) and the filter carries a non-empty `search` term. Such a + * filter can never be satisfied — there is no `event_fts` table to + * MATCH — so callers short-circuit to a "matches nothing" query. An + * empty-string search imposes no constraint and is left to the normal + * (non-search) query path, matching the FTS-enabled behaviour. + */ + private fun searchDisabledMatchesNothing(search: String?): Boolean = !fts.enabled && search != null && search.isNotEmpty() + + private fun makeMatchesNothingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers WHERE 0" + private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC${if (indexStrategy.useAndIndexIdOnOrderBy) ", id ASC" else ""}" private fun makeQueryIn(rowIdQuery: String) = @@ -480,6 +503,9 @@ class QueryBuilder( ): Int { val newFilter = filter.toFilterWithDTags() + // With FTS off a search term can never be satisfied — count is 0. + if (searchDisabledMatchesNothing(newFilter.search)) return 0 + if (newFilter.isSimpleQuery()) { val sql = makeSimpleQuery( @@ -622,7 +648,15 @@ class QueryBuilder( ): QuerySpec? { if (filter.isEmpty()) return null - val mustJoinSearch = (filter.search != null) + // With FTS off there is no event_fts table to MATCH/JOIN against. + // A non-empty search can never match, so this filter contributes + // no row ids (returns an empty branch to any UNION/COUNT/DELETE); + // an empty search imposes no constraint and is simply dropped below. + if (searchDisabledMatchesNothing(filter.search)) { + return QuerySpec("SELECT event_headers.row_id as row_id FROM event_headers WHERE 0") + } + + val mustJoinSearch = filter.search != null && fts.enabled val nonDTagsIn = filter.tags?.filter { it.key != "d" } ?: emptyMap() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md index afac612fcf..8a7fa59cbb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/README.md @@ -189,6 +189,42 @@ val result = eventStore.query(Filter(search = "bitcoin", limit = 20)) This will match any event whose content contains "bitcoin", returning the most recent 20 results. +#### Disabling full-text search + +FTS indexing has a write-time cost: every inserted `SearchableEvent` is +tokenized into the `event_fts` virtual table, and an `AFTER DELETE` +trigger keeps that table in sync on every deletion. If you never serve +NIP-50 search from this store — e.g. a relay that offloads search to an +external engine like Vespa — turn it off through the `IndexingStrategy`, +alongside the other per-index toggles. `indexFullTextSearch` defaults to +`true` (search is a core feature); set it to `false`: + +```kotlin +val eventStore = EventStore( + "dbname.db", + relayUrlIdentifier, + indexStrategy = DefaultIndexingStrategy(indexFullTextSearch = false), +) +``` + +With FTS off: + +- The `event_fts` table and its `fts_foreign_key` delete trigger are + never created (no schema dependency on FTS3/4/5 being compiled in). +- Inserts skip the per-event `indexableContent()` + tokenization work, + and deletes skip the trigger. +- Any query/count/delete filter carrying a non-empty `search` term + returns **no matches** (an empty-string search imposes no constraint + and behaves like a normal query). Everything else — replaceable and + addressable handling, deletions, expirations, right-to-vanish, + negentropy — is unchanged. + +The flag is chosen at store-construction time and only governs whether +new writes maintain the index; it does not migrate an existing database +(it won't drop an `event_fts` table left over from a store previously +opened with FTS enabled). Pick the mode when the database is first +created. + ### Periodic cleanup of expired events. The store exposes a `deleteExpiredEvents` to be used in a periodic clean up procedure. Users diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index ad9088275b..bbbcead7b9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -50,7 +50,7 @@ class SQLiteEventStore( val seedModule = SeedModule() - val fullTextSearchModule = FullTextSearchModule() + val fullTextSearchModule = FullTextSearchModule(indexStrategy.indexFullTextSearch) val eventIndexModule = EventIndexesModule( seedModule::hasher, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt index fa2a3cd128..6ba3ce2fce 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/FlexibleIntListSerializer.kt @@ -31,6 +31,7 @@ import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonPrimitive @@ -87,8 +88,21 @@ object FlexibleIntListSerializer : KSerializer?> { ) { if (value == null) { encoder.encodeNull() - } else { - listSerializer.serialize(encoder, value) + return } + + require(encoder is JsonEncoder) { "This serializer can only be used with Json format" } + + // NIP-11 expects `supported_nips` as an array of integers ([1, 11, 42]), + // so emit numeric entries as JSON numbers. Non-numeric ids (rare, but the + // model tolerates them) fall back to JSON strings so nothing is lost. + val array = + JsonArray( + value.map { nip -> + val asLong = nip.toLongOrNull() + if (asLong != null) JsonPrimitive(asLong) else JsonPrimitive(nip) + }, + ) + encoder.encodeJsonElement(array) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt new file mode 100644 index 0000000000..8a5fbcd460 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilder.kt @@ -0,0 +1,245 @@ +/* + * 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.nip11RelayInfo + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFee +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationFees +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationLimitation +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation.RelayInformationRetentionData + +/** + * Scopes the NIP-11 DSL so an inner builder (e.g. `limitation { }`) can't + * accidentally reach the outer builder's members. + */ +@DslMarker annotation class Nip11DslMarker + +/** + * Type-safe builder for a [Nip11RelayInformation] document, so a relay operator + * never has to hand-write the JSON. + * + * Instead of the string that smells: + * ``` + * private val NIP11 = + * """{"name":"sot","description":"...","supported_nips":[1,11,42,50],"software":"...","version":"0.1"}""" + * ``` + * write: + * ``` + * val info = relayInformation { + * name = "sot" + * description = "NIP-50 profile search ranked by Nostr web-of-trust" + * software = "https://github.com/vitorpamplona/sot" + * version = "0.1" + * supports(1, 11, 42, 50) + * } + * ``` + * and serve it with [Nip11RelayInformation.toJson] under + * [Nip11RelayInformation.CONTENT_TYPE]. Null/empty fields are omitted, and + * numeric NIP numbers serialize as the JSON integers the spec expects + * (`[1,11,42,50]`, not `["1","11","42","50"]`). + */ +inline fun relayInformation(initializer: Nip11RelayInformationBuilder.() -> Unit): Nip11RelayInformation = Nip11RelayInformationBuilder().apply(initializer).build() + +@Nip11DslMarker +class Nip11RelayInformationBuilder { + var id: String? = null + var name: String? = null + var description: String? = null + var banner: String? = null + var icon: String? = null + var pubkey: HexKey? = null + var self: HexKey? = null + var contact: String? = null + var software: String? = null + var version: String? = null + var postingPolicy: String? = null + var privacyPolicy: String? = null + var termsOfService: String? = null + var paymentsUrl: String? = null + + private val supportedNips = mutableListOf() + private val supportedNipExtensions = mutableListOf() + private val relayCountries = mutableListOf() + private val languageTags = mutableListOf() + private val tags = mutableListOf() + private val nip50Subfeatures = mutableListOf() + private val supportedGrasps = mutableListOf() + private val retention = mutableListOf() + + private var limitation: RelayInformationLimitation? = null + private var fees: RelayInformationFees? = null + + /** Advertise supported NIP numbers, e.g. `supports(1, 11, 42, 50)`. Repeatable. */ + fun supports(vararg nips: Int) = apply { nips.forEach { supportedNips.add(it.toString()) } } + + /** Advertise supported NIPs by string id (for non-numeric extensions). Repeatable. */ + fun supports(vararg nips: String) = apply { supportedNips.addAll(nips) } + + /** Advertise `supported_nip_extensions` (draft/experimental features). Repeatable. */ + fun supportsExtensions(vararg extensions: String) = apply { supportedNipExtensions.addAll(extensions) } + + /** ISO-3166 country codes the relay serves under (`relay_countries`). Repeatable. */ + fun countries(vararg codes: String) = apply { relayCountries.addAll(codes) } + + /** IETF language tags of content on the relay (`language_tags`). Repeatable. */ + fun languages(vararg codes: String) = apply { languageTags.addAll(codes) } + + /** Free-form topic tags describing the relay's focus. Repeatable. */ + fun tags(vararg values: String) = apply { tags.addAll(values) } + + /** NIP-50 search sub-features the relay implements (the `nip50` field). Repeatable. */ + fun nip50Features(vararg features: String) = apply { nip50Subfeatures.addAll(features) } + + /** GRASP git-server capabilities the relay implements (`supported_grasps`). Repeatable. */ + fun grasps(vararg values: String) = apply { supportedGrasps.addAll(values) } + + /** Declare the relay's `limitation` object via a nested DSL. */ + fun limitation(initializer: LimitationBuilder.() -> Unit) = + apply { + limitation = LimitationBuilder().apply(initializer).build() + } + + /** + * Advertise the exact limits the relay actually enforces, keeping what is + * published in sync with what is applied. See [RelayLimits.toNip11Limitation]. + */ + fun limitation(limits: RelayLimits) = apply { limitation = limits.toNip11Limitation() } + + /** Declare the relay's `fees` object via a nested DSL. */ + fun fees(initializer: FeesBuilder.() -> Unit) = + apply { + fees = FeesBuilder().apply(initializer).build() + } + + /** Add a data-retention policy entry (`retention`). Call multiple times for multiple entries. */ + fun retention( + kinds: List? = null, + time: Int? = null, + count: Int? = null, + ) = apply { + retention.add(RelayInformationRetentionData(kinds?.let { ArrayList(it) }, time, count)) + } + + fun build(): Nip11RelayInformation = + Nip11RelayInformation( + id = id, + name = name, + description = description, + banner = banner, + icon = icon, + pubkey = pubkey, + self = self, + contact = contact, + supported_nips = supportedNips.ifEmpty { null }?.toList(), + supported_nip_extensions = supportedNipExtensions.ifEmpty { null }?.toList(), + software = software, + version = version, + limitation = limitation, + relay_countries = relayCountries.ifEmpty { null }?.toList(), + language_tags = languageTags.ifEmpty { null }?.toList(), + tags = tags.ifEmpty { null }?.toList(), + posting_policy = postingPolicy, + privacy_policy = privacyPolicy, + terms_of_service = termsOfService, + payments_url = paymentsUrl, + retention = retention.ifEmpty { null }?.toList(), + fees = fees, + nip50 = nip50Subfeatures.ifEmpty { null }?.toList(), + supported_grasps = supportedGrasps.ifEmpty { null }?.toList(), + ) + + @Nip11DslMarker + class LimitationBuilder { + var maxMessageLength: Int? = null + var maxSubscriptions: Int? = null + var maxFilters: Int? = null + var maxLimit: Int? = null + var defaultLimit: Int? = null + var maxSubidLength: Int? = null + var minPrefix: Int? = null + var maxEventTags: Int? = null + var maxContentLength: Int? = null + var minPowDifficulty: Int? = null + var authRequired: Boolean? = null + var paymentRequired: Boolean? = null + var restrictedWrites: Boolean? = null + var createdAtLowerLimit: Int? = null + var createdAtUpperLimit: Int? = null + + fun build(): RelayInformationLimitation = + RelayInformationLimitation( + max_message_length = maxMessageLength, + max_subscriptions = maxSubscriptions, + max_filters = maxFilters, + max_limit = maxLimit, + default_limit = defaultLimit, + max_subid_length = maxSubidLength, + min_prefix = minPrefix, + max_event_tags = maxEventTags, + max_content_length = maxContentLength, + min_pow_difficulty = minPowDifficulty, + auth_required = authRequired, + payment_required = paymentRequired, + restricted_writes = restrictedWrites, + created_at_lower_limit = createdAtLowerLimit, + created_at_upper_limit = createdAtUpperLimit, + ) + } + + @Nip11DslMarker + class FeesBuilder { + private val admission = mutableListOf() + private val subscription = mutableListOf() + private val publication = mutableListOf() + + /** A one-time fee to be allowed to write to the relay. Repeatable. */ + fun admission( + amount: Int? = null, + unit: String? = null, + period: Int? = null, + kinds: List? = null, + ) = apply { admission.add(RelayInformationFee(amount, unit, period, kinds)) } + + /** A recurring fee to keep writing to the relay. Repeatable. */ + fun subscription( + amount: Int? = null, + unit: String? = null, + period: Int? = null, + kinds: List? = null, + ) = apply { subscription.add(RelayInformationFee(amount, unit, period, kinds)) } + + /** A per-event fee to publish. Repeatable (e.g. one entry per kind group). */ + fun publication( + amount: Int? = null, + unit: String? = null, + period: Int? = null, + kinds: List? = null, + ) = apply { publication.add(RelayInformationFee(amount, unit, period, kinds)) } + + fun build(): RelayInformationFees = + RelayInformationFees( + admission = admission.ifEmpty { null }?.toList(), + subscription = subscription.ifEmpty { null }?.toList(), + publication = publication.ifEmpty { null }?.toList(), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt index 3b664718aa..2cc561e561 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/bech32/Bech32Util.kt @@ -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 { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt index 183f2702bf..71816e68cf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/episode/PodcastEpisodeEvent.kt @@ -25,11 +25,14 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.DescriptionTag import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.ImageTag import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.TitleTag +import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.podcasts.PodcastEpisode import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -48,6 +51,8 @@ class PodcastEpisodeEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + PodcastEpisode, + RootScope, SearchableEvent { override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n") @@ -59,6 +64,20 @@ class PodcastEpisodeEvent( fun audios() = tags.mapNotNull(AudioTag::parse) + override fun episodeTitle() = title() + + override fun episodeImage() = image() + + override fun episodeDescription() = description() + + override fun episodeAudio() = audios().map { PodcastAudio(it.url, it.mediaType) } + + // NIP-F4 episodes carry no duration tag; clients derive it from the audio stream. + override fun episodeDurationInSeconds(): Long? = null + + // NIP-F4 episodes are regular events ordered by their publication time. + override fun episodePublishedAt() = createdAt + companion object { const val KIND = 54 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt index 42874c0747..fa02e05ec0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/metadata/PodcastMetadataEvent.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.DescriptionTag import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.ImageTag import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.TitleTag import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.WebsiteTag +import com.vitorpamplona.quartz.podcasts.PodcastShow import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -54,6 +55,7 @@ class PodcastMetadataEvent( content: String, sig: HexKey, ) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PodcastShow, SearchableEvent { override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") @@ -65,6 +67,14 @@ class PodcastMetadataEvent( fun websites() = tags.mapNotNull(WebsiteTag::parse) + override fun showTitle() = title() + + override fun showImage() = image() + + override fun showDescription() = description() + + override fun showWebsites() = websites() + /** * Returns claimed authors and their roles. The spec warns these claims are * unverified — a podcast can name anyone as author. Before surfacing an author @@ -73,9 +83,22 @@ class PodcastMetadataEvent( */ fun claimedAuthors() = tags.mapNotNull(AuthorTag::parse) + /** + * Fingerprint of a known spam flood — thousands of identical headless-test "Mock Podcast" + * shows. Their structure is exactly `title="Mock Podcast"`, `description="Headless test feed"`, + * `content="Headless test feed"`. Matched so the client can drop them before consuming. + */ + fun isMockSpam(): Boolean = + content == MOCK_SPAM_CONTENT && + title() == MOCK_SPAM_TITLE && + description() == MOCK_SPAM_CONTENT + companion object { const val KIND = 10154 + private const val MOCK_SPAM_TITLE = "Mock Podcast" + private const val MOCK_SPAM_CONTENT = "Headless test feed" + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, FIXED_D_TAG) fun createAddressATag(pubKey: HexKey) = ATag(KIND, pubKey, FIXED_D_TAG, null) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt new file mode 100644 index 0000000000..96be4d42e0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/Podcasting20EpisodeEvent.kt @@ -0,0 +1,190 @@ +/* + * 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.nipXXPodcasting20.episode + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.AudioTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ChaptersTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DescriptionTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DurationTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EpisodeNumberTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ImageTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PersonTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SeasonTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SoundbiteTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TranscriptTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ValueTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.VideoTag +import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.podcasts.PodcastEpisode +import com.vitorpamplona.quartz.podcasts.PodcastPerson +import com.vitorpamplona.quartz.podcasts.PodcastSoundbite +import com.vitorpamplona.quartz.podcasts.PodcastValue +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Podcasting-2.0 draft podcast episode (`kind:30054`), as published by clients like + * derekross/podstr. Unlike NIP-F4 (where the podcast is its own keypair and episodes + * are regular `kind:54` events), here the **human creator** is the keypair and each + * episode is an *addressable* event keyed by its `d` tag, so it can be edited in place. + * + * Implements the spec-neutral [PodcastEpisode] so it lands in the same merged + * episode list as NIP-F4 episodes. + */ +@Immutable +class Podcasting20EpisodeEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + PodcastEpisode, + RootScope, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n") + + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + + fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse) + + fun audios() = tags.mapNotNull(AudioTag::parse) + + fun video() = tags.firstNotNullOfOrNull(VideoTag::parse) + + fun number() = tags.firstNotNullOfOrNull(EpisodeNumberTag::parse) + + fun season() = tags.firstNotNullOfOrNull(SeasonTag::parse) + + fun transcriptUrl() = tags.firstNotNullOfOrNull(TranscriptTag::parse) + + fun chaptersUrl() = tags.firstNotNullOfOrNull(ChaptersTag::parse) + + fun value() = tags.firstNotNullOfOrNull(ValueTag::parse) + + fun persons() = tags.mapNotNull(PersonTag::parse) + + fun soundbites() = tags.mapNotNull(SoundbiteTag::parse) + + fun durationInSeconds() = tags.firstNotNullOfOrNull(DurationTag::parse) + + /** RFC2822 publication date string, kept verbatim for RSS generation. */ + fun pubDate() = tags.firstNotNullOfOrNull(PubDateTag::parse) + + fun alt() = tags.firstNotNullOfOrNull(AltTag::parse) + + fun topics() = hashtags() + + /** Event id of the original publication when this is an edit, if present. */ + fun editsEventId() = tags.firstNotNullOfOrNull(EditTag::parse) + + override fun episodeTitle() = title() + + override fun episodeImage() = image() + + override fun episodeDescription() = description() + + override fun episodeAudio() = audios() + + override fun episodeDurationInSeconds() = durationInSeconds() + + override fun episodePublishedAt() = createdAt + + override fun episodeVideo() = video() + + override fun episodeNumber() = number() + + override fun episodeSeason() = season() + + override fun episodeTranscriptUrl() = transcriptUrl() + + override fun episodeChaptersUrl() = chaptersUrl() + + override fun episodeValue() = value() + + override fun episodePersons() = persons() + + override fun episodeSoundbites() = soundbites() + + companion object { + const val KIND = 30054 + + fun build( + dTag: String, + title: String, + audios: List, + pubdate: String, + alt: String = "Podcast episode: $title", + description: String? = null, + image: String? = null, + durationInSeconds: Long? = null, + video: PodcastAudio? = null, + episodeNumber: Int? = null, + season: Int? = null, + transcriptUrl: String? = null, + chaptersUrl: String? = null, + value: PodcastValue? = null, + persons: List = emptyList(), + soundbites: List = emptyList(), + topics: List = emptyList(), + markdownContent: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, markdownContent, createdAt) { + dTag(dTag) + title(title) + audios.forEach { audio(it) } + pubdate(pubdate) + alt(alt) + + description?.let { description(it) } + image?.let { image(it) } + durationInSeconds?.let { duration(it) } + video?.let { video(it) } + episodeNumber?.let { episodeNumber(it) } + season?.let { season(it) } + transcriptUrl?.let { transcript(it) } + chaptersUrl?.let { chapters(it) } + value?.let { value(it) } + persons.filter { it.isValid() }.forEach { person(it) } + soundbites.forEach { soundbite(it) } + if (topics.isNotEmpty()) hashtags(topics) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..bfe6c921bc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/TagArrayBuilderExt.kt @@ -0,0 +1,73 @@ +/* + * 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.nipXXPodcasting20.episode + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.AudioTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ChaptersTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DescriptionTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.DurationTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EpisodeNumberTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ImageTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PersonTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SeasonTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SoundbiteTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TranscriptTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.ValueTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.VideoTag +import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.podcasts.PodcastPerson +import com.vitorpamplona.quartz.podcasts.PodcastSoundbite +import com.vitorpamplona.quartz.podcasts.PodcastValue + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.description(description: String) = addUnique(DescriptionTag.assemble(description)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) + +fun TagArrayBuilder.audio(audio: PodcastAudio) = add(AudioTag.assemble(audio)) + +fun TagArrayBuilder.video(video: PodcastAudio) = addUnique(VideoTag.assemble(video)) + +fun TagArrayBuilder.episodeNumber(number: Int) = addUnique(EpisodeNumberTag.assemble(number)) + +fun TagArrayBuilder.season(season: Int) = addUnique(SeasonTag.assemble(season)) + +fun TagArrayBuilder.transcript(url: String) = addUnique(TranscriptTag.assemble(url)) + +fun TagArrayBuilder.chapters(url: String) = addUnique(ChaptersTag.assemble(url)) + +fun TagArrayBuilder.value(value: PodcastValue) = addUnique(ValueTag.assemble(value)) + +fun TagArrayBuilder.person(person: PodcastPerson) = add(PersonTag.assemble(person)) + +fun TagArrayBuilder.soundbite(soundbite: PodcastSoundbite) = add(SoundbiteTag.assemble(soundbite)) + +fun TagArrayBuilder.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date)) + +fun TagArrayBuilder.duration(durationInSeconds: Long) = addUnique(DurationTag.assemble(durationInSeconds)) + +fun TagArrayBuilder.edit(originalEventId: HexKey) = addUnique(EditTag.assemble(originalEventId)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/AudioTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/AudioTag.kt new file mode 100644 index 0000000000..2eb726e0f1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/AudioTag.kt @@ -0,0 +1,58 @@ +/* + * 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.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.utils.ensure + +/** + * Podcasting-2.0 episode audio tag: `["audio", "", ""]`. + * + * Wire-identical to the NIP-F4 audio tag, so it parses straight into the shared + * [PodcastAudio] holder that the unified [com.vitorpamplona.quartz.podcasts.PodcastEpisode] + * abstraction exposes. + */ +class AudioTag { + companion object { + const val TAG_NAME = "audio" + + fun parse(tag: Array): PodcastAudio? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val mediaType = tag.getOrNull(2)?.takeIf { it.isNotEmpty() } + return PodcastAudio(tag[1], mediaType) + } + + fun assemble( + url: String, + mediaType: String? = null, + ): Array = + if (mediaType.isNullOrEmpty()) { + arrayOf(TAG_NAME, url) + } else { + arrayOf(TAG_NAME, url, mediaType) + } + + fun assemble(audio: PodcastAudio) = assemble(audio.url, audio.mediaType) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ChaptersTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ChaptersTag.kt new file mode 100644 index 0000000000..dd24cdf9ab --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ChaptersTag.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Podcasting-2.0 episode chapters file URL: `["chapters", ""]`. Points at a Podcasting-2.0 + * JSON chapters document (timestamped chapter list) hosted off-event. + */ +class ChaptersTag { + companion object { + const val TAG_NAME = "chapters" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DescriptionTag.kt new file mode 100644 index 0000000000..4cd243e927 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DescriptionTag.kt @@ -0,0 +1,39 @@ +/* + * 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.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class DescriptionTag { + companion object { + const val TAG_NAME = "description" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(description: String) = arrayOf(TAG_NAME, description) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DurationTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DurationTag.kt new file mode 100644 index 0000000000..cc45a71a81 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/DurationTag.kt @@ -0,0 +1,40 @@ +/* + * 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.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Podcasting-2.0 episode duration in whole seconds: `["duration", "3600"]`. */ +class DurationTag { + companion object { + const val TAG_NAME = "duration" + + fun parse(tag: Array): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(durationInSeconds: Long) = arrayOf(TAG_NAME, durationInSeconds.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt new file mode 100644 index 0000000000..2deea22cc9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EditTag.kt @@ -0,0 +1,45 @@ +/* + * 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.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Podcasting-2.0 edit-history pointer: `["edit", ""]`. References + * the event id of the original publication when an addressable episode/trailer is + * updated, so clients can reconstruct edit history. + */ +class EditTag { + companion object { + const val TAG_NAME = "edit" + + fun parse(tag: Array): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(originalEventId: HexKey) = arrayOf(TAG_NAME, originalEventId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EpisodeNumberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EpisodeNumberTag.kt new file mode 100644 index 0000000000..c07d7edf2c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/EpisodeNumberTag.kt @@ -0,0 +1,40 @@ +/* + * 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.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Podcasting-2.0 episode number within its season/show: `["episode", "5"]`. */ +class EpisodeNumberTag { + companion object { + const val TAG_NAME = "episode" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(episodeNumber: Int) = arrayOf(TAG_NAME, episodeNumber.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ImageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ImageTag.kt new file mode 100644 index 0000000000..32b96c2cd6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ImageTag.kt @@ -0,0 +1,39 @@ +/* + * 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.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ImageTag { + companion object { + const val TAG_NAME = "image" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PersonTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PersonTag.kt new file mode 100644 index 0000000000..4186262de8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PersonTag.kt @@ -0,0 +1,59 @@ +/* + * 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.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.podcasts.PodcastPerson +import com.vitorpamplona.quartz.utils.ensure + +/** + * Podcasting-2.0 episode person credit: `["person", "", "", "", ""]`. + * + * Only the name (slot 1) is required; role/img/href are optional and carried positionally, with + * empty strings standing in for absent middle values. Maps to the shared [PodcastPerson] holder. + * (The show-level `podcast:person` `group` attribute isn't carried on the episode tag — it's + * organizational metadata that lives in the show's JSON when present.) + */ +class PersonTag { + companion object { + const val TAG_NAME = "person" + + fun parse(tag: Array): PodcastPerson? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return PodcastPerson( + name = tag[1], + role = tag.getOrNull(2)?.takeIf { it.isNotEmpty() }, + img = tag.getOrNull(3)?.takeIf { it.isNotEmpty() }, + href = tag.getOrNull(4)?.takeIf { it.isNotEmpty() }, + ) + } + + fun assemble(person: PodcastPerson): Array { + // Trim trailing empties so a person with only a name is a 2-element tag, but keep empty + // placeholders in the middle so href stays in slot 4 when role/img are missing. + val slots = listOf(person.name, person.role ?: "", person.img ?: "", person.href ?: "") + val lastNonEmpty = slots.indexOfLast { it.isNotEmpty() }.coerceAtLeast(0) + return (listOf(TAG_NAME) + slots.subList(0, lastNonEmpty + 1)).toTypedArray() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PubDateTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PubDateTag.kt new file mode 100644 index 0000000000..123b9f256f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/PubDateTag.kt @@ -0,0 +1,47 @@ +/* + * 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.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Podcasting-2.0 publication date in RFC2822 format, set once when first published + * and preserved across edits: `["pubdate", "Thu, 04 Nov 2023 12:00:00 GMT"]`. + * + * The string is kept verbatim — it feeds RSS generation, where the exact RFC2822 + * spelling matters. Feed ordering relies on the event's `created_at` instead, so + * no date parsing is required here. + */ +class PubDateTag { + companion object { + const val TAG_NAME = "pubdate" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(rfc2822Date: String) = arrayOf(TAG_NAME, rfc2822Date) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SeasonTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SeasonTag.kt new file mode 100644 index 0000000000..eabe9c157f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SeasonTag.kt @@ -0,0 +1,40 @@ +/* + * 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.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Podcasting-2.0 season number an episode belongs to: `["season", "2"]`. */ +class SeasonTag { + companion object { + const val TAG_NAME = "season" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(season: Int) = arrayOf(TAG_NAME, season.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SoundbiteTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SoundbiteTag.kt new file mode 100644 index 0000000000..cc104dca99 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/SoundbiteTag.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.podcasts.PodcastSoundbite +import com.vitorpamplona.quartz.utils.ensure + +/** + * Podcasting-2.0 episode soundbite: `["soundbite", "", "", ""]`. + * + * `startTime` and `duration` are in seconds (may be fractional). Title is optional. Maps to the + * shared [PodcastSoundbite] holder. A soundbite with a non-positive duration is dropped as invalid. + */ +class SoundbiteTag { + companion object { + const val TAG_NAME = "soundbite" + + fun parse(tag: Array): PodcastSoundbite? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + val start = tag[1].toDoubleOrNull() ?: return null + val duration = tag[2].toDoubleOrNull() ?: return null + ensure(start >= 0.0) { return null } + ensure(duration > 0.0) { return null } + return PodcastSoundbite(start, duration, tag.getOrNull(3)?.takeIf { it.isNotEmpty() }) + } + + fun assemble(soundbite: PodcastSoundbite): Array { + val head = arrayOf(TAG_NAME, soundbite.startTimeSeconds.toString(), soundbite.durationSeconds.toString()) + val title = soundbite.title + return if (title.isNullOrEmpty()) head else head + title + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TitleTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TitleTag.kt new file mode 100644 index 0000000000..71ac61f4e8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TitleTag.kt @@ -0,0 +1,39 @@ +/* + * 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.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TitleTag { + companion object { + const val TAG_NAME = "title" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(title: String) = arrayOf(TAG_NAME, title) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TranscriptTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TranscriptTag.kt new file mode 100644 index 0000000000..996539a264 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/TranscriptTag.kt @@ -0,0 +1,40 @@ +/* + * 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.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Podcasting-2.0 episode transcript file URL: `["transcript", ""]`. */ +class TranscriptTag { + companion object { + const val TAG_NAME = "transcript" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ValueTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ValueTag.kt new file mode 100644 index 0000000000..6b593e42f1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/ValueTag.kt @@ -0,0 +1,46 @@ +/* + * 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.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.podcasts.PodcastValue +import com.vitorpamplona.quartz.utils.ensure + +/** + * Podcasting-2.0 episode value-for-value tag: `["value", ""]`, where the value is a JSON + * [PodcastValue] object (with `enabled` for episode-level overrides). Parses leniently — a malformed + * body yields null rather than throwing. + */ +class ValueTag { + companion object { + const val TAG_NAME = "value" + + fun parse(tag: Array): PodcastValue? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return runCatching { JsonMapper.fromJson(tag[1]) }.getOrNull() + } + + fun assemble(value: PodcastValue) = arrayOf(TAG_NAME, JsonMapper.toJson(value)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/VideoTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/VideoTag.kt new file mode 100644 index 0000000000..f91bc56c6a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/episode/tags/VideoTag.kt @@ -0,0 +1,56 @@ +/* + * 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.nipXXPodcasting20.episode.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.utils.ensure + +/** + * Podcasting-2.0 episode video tag: `["video", "", ""]`. The same wire + * shape as the audio tag; an episode MAY ship a video alongside (or instead of) its audio. Parses + * into the shared [PodcastAudio] media holder so a client can play it through the same path. + */ +class VideoTag { + companion object { + const val TAG_NAME = "video" + + fun parse(tag: Array): PodcastAudio? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val mediaType = tag.getOrNull(2)?.takeIf { it.isNotEmpty() } + return PodcastAudio(tag[1], mediaType) + } + + fun assemble( + url: String, + mediaType: String? = null, + ): Array = + if (mediaType.isNullOrEmpty()) { + arrayOf(TAG_NAME, url) + } else { + arrayOf(TAG_NAME, url, mediaType) + } + + fun assemble(video: PodcastAudio) = assemble(video.url, video.mediaType) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt new file mode 100644 index 0000000000..f4a052e78b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/PodcastShowResolver.kt @@ -0,0 +1,60 @@ +/* + * 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.nipXXPodcasting20.metadata + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.podcasts.PodcastEpisode +import com.vitorpamplona.quartz.podcasts.PodcastShow + +/** + * Cheap type/`d`-tag gate for whether [event] represents a podcast show — used by feeds to decide + * inclusion without parsing the Podcasting-2.0 JSON content. Matches NIP-F4 `kind:10154` and the + * Podcasting-2.0 `kind:30078` app-data event with `d="podcast-metadata"`. + */ +fun isPodcastShowEvent(event: Event?): Boolean = + event is PodcastMetadataEvent || + (event is AppSpecificDataEvent && event.dTag() == Podcasting20PodcastMetadata.PODCAST_METADATA_D_TAG) + +/** + * Whether [event] is any podcast event — a show ([isPodcastShowEvent]), an episode (NIP-F4 `kind:54` + * or Podcasting-2.0 `kind:30054`, both [PodcastEpisode]), or a Podcasting-2.0 trailer (`kind:30055`). + * Used to pull podcast items out of mixed lists (e.g. the NIP-51 bookmark list) for podcast-only views. + */ +fun isPodcastEvent(event: Event?): Boolean = + isPodcastShowEvent(event) || + event is PodcastEpisode || + event is Podcasting20TrailerEvent + +/** + * Adapts [event] to the spec-neutral [PodcastShow], or returns null if it is not a podcast show + * (or its Podcasting-2.0 JSON content fails to parse). NIP-F4 metadata events implement + * [PodcastShow] directly; Podcasting-2.0 app-data events are wrapped via + * [Podcasting20PodcastMetadata.parse]. + */ +fun resolvePodcastShow(event: Event?): PodcastShow? = + when (event) { + is PodcastMetadataEvent -> event + is AppSpecificDataEvent -> Podcasting20PodcastMetadata.parse(event) + else -> null + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt new file mode 100644 index 0000000000..3129ed1edd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/metadata/Podcasting20PodcastMetadata.kt @@ -0,0 +1,135 @@ +/* + * 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.nipXXPodcasting20.metadata + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.podcasts.PodcastPerson +import com.vitorpamplona.quartz.podcasts.PodcastShow +import com.vitorpamplona.quartz.podcasts.PodcastValue +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.serialization.Serializable + +/** + * The Podcasting-2.0 draft stores show-level metadata in a `kind:30078` (NIP-78 app-data) event + * with `d="podcast-metadata"`, where the channel fields live as a JSON object in `content` rather + * than in tags. This is a parsed, read-only view over such an event that adapts it to the + * spec-neutral [PodcastShow], so a podstr show merges into the same list and card as a NIP-F4 + * [com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent]. + * + * Note: `kind:30078` is heavily overloaded across NIPs and clients; only events whose `d` tag is + * exactly [PODCAST_METADATA_D_TAG] and whose content is valid podcast-metadata JSON resolve here. + */ +@Immutable +class Podcasting20PodcastMetadata( + val event: AppSpecificDataEvent, + private val content: Content, +) : PodcastShow { + override fun showTitle() = content.title + + override fun showImage() = content.image + + override fun showDescription() = content.description + + override fun showWebsites() = listOfNotNull(content.website?.takeIf { it.isNotEmpty() }) + + override fun showAuthor() = content.author?.takeIf { it.isNotEmpty() } + + override fun showCategories() = content.categories.filter { it.isNotEmpty() } + + override fun showFundingUrls() = content.funding.filter { it.isNotEmpty() } + + override fun showIsExplicit() = content.explicit ?: false + + override fun showIsComplete() = content.complete ?: false + + override fun showCopyright() = content.copyright?.takeIf { it.isNotEmpty() } + + override fun showValue() = content.value + + override fun showPersons() = content.persons.filter { it.isValid() } + + fun language() = content.language + + /** Contact email for the show, if provided. */ + fun email() = content.email?.takeIf { it.isNotEmpty() } + + /** "episodic" or "serial" per Podcasting 2.0, if provided. */ + fun type() = content.type?.takeIf { it.isNotEmpty() } + + /** Whether the show is locked (premium / subscription-gated). */ + fun isLocked() = content.locked ?: false + + /** The stable podcast GUID (Podcasting 2.0 `podcast:guid`), if provided. */ + fun guid() = content.guid?.takeIf { it.isNotEmpty() } + + /** + * The subset of the Podcasting-2.0 `kind:30078` metadata JSON this client reads. Unknown keys + * (notably `value` for value-for-value splits) are ignored by the lenient mapper and can be + * surfaced later without changing the wire format. + */ + @Serializable + class Content( + val title: String? = null, + val description: String? = null, + val author: String? = null, + val email: String? = null, + val image: String? = null, + val language: String? = null, + val categories: List = emptyList(), + val explicit: Boolean? = null, + val website: String? = null, + val copyright: String? = null, + val funding: List = emptyList(), + val locked: Boolean? = null, + val type: String? = null, + val complete: Boolean? = null, + val guid: String? = null, + val value: PodcastValue? = null, + val persons: List = emptyList(), + ) + + companion object { + const val PODCAST_METADATA_D_TAG = "podcast-metadata" + + /** + * Returns a view if [event] is a podcast-metadata app-data event with parseable JSON, + * otherwise null (wrong `d` tag, or non-JSON/encrypted content such as a user's own + * app settings). + */ + fun parse(event: AppSpecificDataEvent): Podcasting20PodcastMetadata? { + if (event.dTag() != PODCAST_METADATA_D_TAG) return null + val content = runCatching { JsonMapper.fromJson(event.content) }.getOrNull() ?: return null + return Podcasting20PodcastMetadata(event, content) + } + + /** + * Builds the kind:30078 show-metadata event template (`d="podcast-metadata"`) by serializing + * [content] to its JSON body. Unset/default fields are omitted, keeping the payload minimal. + */ + fun build( + content: Content, + createdAt: Long = TimeUtils.now(), + ): EventTemplate = AppSpecificDataEvent.build(PODCAST_METADATA_D_TAG, JsonMapper.toJson(content), createdAt) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt new file mode 100644 index 0000000000..e9efd02524 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/Podcasting20TrailerEvent.kt @@ -0,0 +1,98 @@ +/* + * 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.nipXXPodcasting20.trailer + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.LengthTag +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.SeasonTag +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.TypeTag +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.UrlTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Podcasting-2.0 draft podcast trailer (`kind:30055`), following the Podcast 2.0 + * `` element. Addressable like the episode (`kind:30054`) and + * signed by the human creator's keypair, keyed by its `d` tag. + */ +@Immutable +class Podcasting20TrailerEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + RootScope { + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun url() = tags.firstNotNullOfOrNull(UrlTag::parse) + + /** RFC2822 publication date string, kept verbatim for RSS generation. */ + fun pubDate() = tags.firstNotNullOfOrNull(PubDateTag::parse) + + fun lengthInBytes() = tags.firstNotNullOfOrNull(LengthTag::parse) + + fun mimeType() = tags.firstNotNullOfOrNull(TypeTag::parse) + + fun season() = tags.firstNotNullOfOrNull(SeasonTag::parse) + + fun alt() = tags.firstNotNullOfOrNull(AltTag::parse) + + companion object { + const val KIND = 30055 + + fun build( + dTag: String, + title: String, + url: String, + pubdate: String, + alt: String = "Podcast trailer: $title", + lengthInBytes: Long? = null, + mimeType: String? = null, + season: Int? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, title, createdAt) { + dTag(dTag) + title(title) + url(url) + pubdate(pubdate) + alt(alt) + + lengthInBytes?.let { length(it) } + mimeType?.let { type(it) } + season?.let { season(it) } + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..001925624d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/TagArrayBuilderExt.kt @@ -0,0 +1,45 @@ +/* + * 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.nipXXPodcasting20.trailer + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.EditTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PubDateTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.TitleTag +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.LengthTag +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.SeasonTag +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.TypeTag +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.tags.UrlTag + +fun TagArrayBuilder.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.url(url: String) = addUnique(UrlTag.assemble(url)) + +fun TagArrayBuilder.pubdate(rfc2822Date: String) = addUnique(PubDateTag.assemble(rfc2822Date)) + +fun TagArrayBuilder.length(lengthInBytes: Long) = addUnique(LengthTag.assemble(lengthInBytes)) + +fun TagArrayBuilder.type(mimeType: String) = addUnique(TypeTag.assemble(mimeType)) + +fun TagArrayBuilder.season(season: Int) = addUnique(SeasonTag.assemble(season)) + +fun TagArrayBuilder.edit(originalEventId: HexKey) = addUnique(EditTag.assemble(originalEventId)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/LengthTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/LengthTag.kt new file mode 100644 index 0000000000..4d2ceae27e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/LengthTag.kt @@ -0,0 +1,40 @@ +/* + * 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.nipXXPodcasting20.trailer.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Podcasting-2.0 trailer file size in bytes: `["length", "1024000"]`. */ +class LengthTag { + companion object { + const val TAG_NAME = "length" + + fun parse(tag: Array): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(lengthInBytes: Long) = arrayOf(TAG_NAME, lengthInBytes.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/SeasonTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/SeasonTag.kt new file mode 100644 index 0000000000..0e487621f8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/SeasonTag.kt @@ -0,0 +1,40 @@ +/* + * 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.nipXXPodcasting20.trailer.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Podcasting-2.0 season number a trailer represents: `["season", "2"]`. */ +class SeasonTag { + companion object { + const val TAG_NAME = "season" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(season: Int) = arrayOf(TAG_NAME, season.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/TypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/TypeTag.kt new file mode 100644 index 0000000000..0c369e7ff8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/TypeTag.kt @@ -0,0 +1,40 @@ +/* + * 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.nipXXPodcasting20.trailer.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Podcasting-2.0 trailer MIME type: `["type", "audio/mpeg"]`. */ +class TypeTag { + companion object { + const val TAG_NAME = "type" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(mimeType: String) = arrayOf(TAG_NAME, mimeType) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/UrlTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/UrlTag.kt new file mode 100644 index 0000000000..1cd51f9252 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/trailer/tags/UrlTag.kt @@ -0,0 +1,40 @@ +/* + * 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.nipXXPodcasting20.trailer.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Podcasting-2.0 trailer media URL: `["url", ""]`. */ +class UrlTag { + companion object { + const val TAG_NAME = "url" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(url: String) = arrayOf(TAG_NAME, url) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastAudio.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastAudio.kt new file mode 100644 index 0000000000..4a76869e15 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastAudio.kt @@ -0,0 +1,38 @@ +/* + * 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.podcasts + +import androidx.compose.runtime.Immutable + +/** + * Spec-neutral media reference (a URL plus optional MIME type) for a podcast episode, used by the + * shared [PodcastEpisode] abstraction so a UI can play episodes regardless of which podcast NIP + * produced them. Despite the name it covers both audio and video sources. + * + * Both NIP-F4 (`kind:54`) and the Podcasting-2.0 draft (`kind:30054`) carry audio in identical + * `["audio", "", ""]` tags; the Podcasting-2.0 draft uses the same shape + * for its `video` tag. Each event maps its own tag class into this holder. + */ +@Immutable +class PodcastAudio( + val url: String, + val mediaType: String? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagram.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagram.kt new file mode 100644 index 0000000000..f45bb32a82 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagram.kt @@ -0,0 +1,57 @@ +/* + * 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.podcasts + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * The Podcasting-2.0 keysend metadata blob ("boostagram") carried in TLV record + * [PodcastValue.PODCAST_TLV_RECORD] (7629169). It tells the receiving node which podcast/episode the + * payment is for and how much was sent in total. Field names follow the satoshis.stream convention + * (). + * + * Unset fields are omitted from the JSON ([JsonMapper] does not encode defaults), keeping the record + * small enough to fit comfortably inside a keysend onion. + */ +@Serializable +class PodcastBoostagram( + val podcast: String? = null, + val episode: String? = null, + /** "stream" for per-minute streaming sats, "boost" for a deliberate lump-sum tip. */ + val action: String? = null, + @SerialName("app_name") + val appName: String? = null, + /** Total sats (not millisats) the listener sent across all splits. */ + @SerialName("value_msat_total") + val valueMsatTotal: Long? = null, + val message: String? = null, + @SerialName("sender_name") + val senderName: String? = null, +) { + fun toJson(): String = JsonMapper.toJson(this) + + companion object { + const val ACTION_STREAM = "stream" + const val ACTION_BOOST = "boost" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChapters.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChapters.kt new file mode 100644 index 0000000000..22b21fdd88 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChapters.kt @@ -0,0 +1,59 @@ +/* + * 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.podcasts + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import kotlinx.serialization.Serializable + +/** + * The Podcasting-2.0 chapters document referenced by an episode's `chapters` tag — a JSON file + * (per the podcast-namespace `chapters.json` spec) hosted off-event. Episodes carry only the URL; + * a client fetches and parses it into this model to render a tappable chapter list. + */ +@Immutable +@Serializable +class PodcastChapters( + val version: String? = null, + val chapters: List = emptyList(), +) { + companion object { + /** Lenient parse of a chapters.json body; returns null on malformed input. */ + fun parse(json: String): PodcastChapters? = runCatching { JsonMapper.fromJson(json) }.getOrNull() + } +} + +/** One chapter marker. [startTime] is in seconds (may be fractional per the spec). */ +@Immutable +@Serializable +class PodcastChapter( + val startTime: Double = 0.0, + val title: String? = null, + /** Chapter artwork URL (the spec field is `img`). */ + val img: String? = null, + /** A related link for the chapter. */ + val url: String? = null, + /** Whether the chapter should appear in a table of contents; absent means yes. */ + val toc: Boolean? = null, +) { + /** Whole-second start used for seeking/labeling. */ + fun startSeconds(): Long = startTime.toLong() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt new file mode 100644 index 0000000000..c0fd1cc3d6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastEpisode.kt @@ -0,0 +1,99 @@ +/* + * 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.podcasts + +/** + * Spec-neutral view of a single podcast episode. + * + * Two competing podcast drafts publish episodes with incompatible identity models + * and event kinds: + * - **NIP-F4** ([com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent], + * `kind:54`): a regular event where the *podcast itself* is a Nostr keypair. + * - **Podcasting-2.0 draft** ([com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent], + * `kind:30054`): an addressable event where the *human creator* is the keypair + * and episodes are editable via their `d` tag. + * + * The two cannot share a single wire kind, but a client can still render them in + * one list. This interface is that merge point: feeds and UI depend on it instead + * of a concrete event, so both kinds flow into the same podcast/episode list. + */ +interface PodcastEpisode { + /** The episode title shown in listings. */ + fun episodeTitle(): String? + + /** Cover/episode artwork URL, if any. */ + fun episodeImage(): String? + + /** Short, plain-text episode description/summary, if any. */ + fun episodeDescription(): String? + + /** + * One or more audio sources for the episode. Multiple entries typically offer + * the same audio in different containers/codecs (e.g. mp3 + opus). + */ + fun episodeAudio(): List + + /** Episode duration in seconds, if the publisher provided it. */ + fun episodeDurationInSeconds(): Long? + + /** + * Unix timestamp (seconds) used to order episodes in a merged feed. Both drafts + * fall back to the event's `created_at`; the Podcasting-2.0 draft additionally + * carries an RFC2822 `pubdate` tag exposed by its own event class. + */ + fun episodePublishedAt(): Long + + /** + * A video source for the episode, if it ships one. NIP-F4 has no video tag and returns null; + * the Podcasting-2.0 draft carries a `video` tag. + */ + fun episodeVideo(): PodcastAudio? = null + + /** Episode number within the show/season, if provided. */ + fun episodeNumber(): Int? = null + + /** Season number the episode belongs to, if provided. */ + fun episodeSeason(): Int? = null + + /** URL of an off-event transcript document, if provided. */ + fun episodeTranscriptUrl(): String? = null + + /** URL of an off-event Podcasting-2.0 chapters document, if provided. */ + fun episodeChaptersUrl(): String? = null + + /** + * The episode's value-for-value split block, if it overrides the show default. NIP-F4 has no + * V4V and returns null. + */ + fun episodeValue(): PodcastValue? = null + + /** + * Hosts/guests credited on this specific episode (Podcasting-2.0 `podcast:person`). Empty when + * the publisher lists no per-episode people (NIP-F4 has no person tag and returns empty). + */ + fun episodePersons(): List = emptyList() + + /** + * Highlight clips of the episode (Podcasting-2.0 `podcast:soundbite`), each a start offset + + * duration into the audio. Empty when none are declared. + */ + fun episodeSoundbites(): List = emptyList() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt new file mode 100644 index 0000000000..2ed1ca1e55 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastPerson.kt @@ -0,0 +1,67 @@ +/* + * 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.podcasts + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import kotlinx.serialization.Serializable + +/** + * A Podcasting-2.0 `podcast:person` — a host, guest, or other contributor credited on a show or a + * single episode. Unlike a NIP-F4 author (a Nostr pubkey), a person is free-text: a [name] plus an + * optional [role]/[group], an avatar [img] URL, and a [href] link to their page. None of these need + * to be a Nostr identity, so it maps RSS `` credits straight through. + * + * Carried two ways: show-level persons live in the `kind:30078` metadata JSON (a `persons` array), + * and episode-level persons are `["person", ...]` tags on the `kind:30054` event. + */ +@Immutable +@Serializable +class PodcastPerson( + val name: String = "", + /** e.g. "host", "guest", "cohost" — free text per the Podcasting 2.0 taxonomy. */ + val role: String? = null, + /** Grouping such as "cast" or "writing"; rarely displayed, kept for round-trip fidelity. */ + val group: String? = null, + /** Avatar image URL. */ + val img: String? = null, + /** Link to the person's page/profile. */ + val href: String? = null, +) { + fun isValid() = name.isNotBlank() + + /** + * The Nostr pubkey (hex) this person points at, when [href] is (or embeds) an `npub`/`nprofile` + * — including `nostr:` URIs and `njump.me`-style links. Null for a plain web link or no href. + * Lets a client upgrade a free-text credit to a real Nostr profile when the publisher linked one. + */ + fun nostrPubKey(): HexKey? = + href?.let { + when (val entity = Nip19Parser.uriToRoute(it)?.entity) { + is NPub -> entity.hex + is NProfile -> entity.hex + else -> null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt new file mode 100644 index 0000000000..dcf87a2f8a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastShow.kt @@ -0,0 +1,80 @@ +/* + * 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.podcasts + +/** + * Spec-neutral view of a podcast show (the channel-level metadata), the companion of + * [PodcastEpisode]. + * + * Two competing drafts model the show differently: + * - **NIP-F4** ([com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent], + * `kind:10154`): a dedicated replaceable event whose own pubkey *is* the podcast, with the + * show fields in tags. + * - **Podcasting-2.0 draft** ([com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata], + * a view over a `kind:30078` NIP-78 app-data event with `d="podcast-metadata"`): the creator's + * pubkey owns the show and the fields live in a JSON content blob. + * + * In both models the show's pubkey is also the author of its episodes, so a single show card and + * a single per-show episode list serve both. Feeds and UI depend on this interface to merge them. + */ +interface PodcastShow { + /** The show/podcast name. */ + fun showTitle(): String? + + /** Cover-art URL, if any. */ + fun showImage(): String? + + /** Show description/summary, if any. */ + fun showDescription(): String? + + /** Associated website URLs (possibly empty). */ + fun showWebsites(): List + + /** + * Free-text author/host byline (not a Nostr pubkey), if the draft carries one. NIP-F4 models + * authors as pubkeys with roles instead, so it leaves this null. + */ + fun showAuthor(): String? = null + + /** Genre/category labels (e.g. "Technology"), possibly empty. */ + fun showCategories(): List = emptyList() + + /** Donation/funding page URLs (Podcasting 2.0 `funding`), possibly empty. */ + fun showFundingUrls(): List = emptyList() + + /** Whether the show is flagged as explicit. */ + fun showIsExplicit(): Boolean = false + + /** Whether the show is marked complete/finished (no further episodes expected). */ + fun showIsComplete(): Boolean = false + + /** Copyright line, if provided. */ + fun showCopyright(): String? = null + + /** The show's default value-for-value split block, if any. NIP-F4 has no V4V and returns null. */ + fun showValue(): PodcastValue? = null + + /** + * Show-level hosts/guests (Podcasting-2.0 `podcast:person`) — the recurring cast credited across + * the podcast. Empty when none are declared (NIP-F4 uses author p-tags instead and returns empty). + */ + fun showPersons(): List = emptyList() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastSoundbite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastSoundbite.kt new file mode 100644 index 0000000000..e88cb2fbc8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastSoundbite.kt @@ -0,0 +1,41 @@ +/* + * 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.podcasts + +import androidx.compose.runtime.Immutable + +/** + * A Podcasting-2.0 `podcast:soundbite` — a highlight clip of an episode, defined as a + * [startTimeSeconds] offset into the episode audio and a [durationSeconds] length, with an optional + * [title]. Players surface these as tappable "jump to the good part" chips. + * + * Carried as `["soundbite", "", "", ""]` tags on the episode + * event; times are in seconds and may be fractional. + */ +@Immutable +class PodcastSoundbite( + val startTimeSeconds: Double, + val durationSeconds: Double, + val title: String? = null, +) { + /** Start offset in whole milliseconds, ready for a media player `seekTo`. */ + fun startMillis(): Long = (startTimeSeconds * 1000).toLong() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrual.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrual.kt new file mode 100644 index 0000000000..755cbc6f47 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrual.kt @@ -0,0 +1,56 @@ +/* + * 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.podcasts + +/** + * Accumulates *played* time for Podcasting-2.0 per-minute "streaming" payments and tells the caller + * how many whole minutes are now billable. + * + * The safety contract — a listener must never be charged for time they did not actually listen to — + * lives here, kept pure so it can be unit-tested away from the audio stack: + * + * - Only time the caller reports via [accrue] counts. The caller feeds it elapsed time *only while + * audio is genuinely playing*; paused/stopped/backgrounded time is simply never accrued. + * - Only **whole** minutes are ever billed. A partial minute stays pending and is dropped on the + * floor if the session ends (just discard the instance) — so an interrupted minute is free, never + * rounded up. + */ +class PodcastStreamingAccrual( + private val minuteMillis: Long = 60_000L, +) { + private var pendingMillis = 0L + + /** + * Adds [playingMillis] of genuinely-played time and returns the number of whole minutes that + * just became billable (already subtracted from the pending remainder). Non-positive input is + * ignored and returns 0. + */ + fun accrue(playingMillis: Long): Int { + if (playingMillis <= 0L) return 0 + pendingMillis += playingMillis + val minutes = (pendingMillis / minuteMillis).toInt() + pendingMillis -= minutes * minuteMillis + return minutes + } + + /** Played time accrued toward the next minute but not yet billed. Dropped if the session ends. */ + fun pendingMillis(): Long = pendingMillis +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt new file mode 100644 index 0000000000..5f5cf7c270 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValue.kt @@ -0,0 +1,137 @@ +/* + * 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.podcasts + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +/** + * Podcasting-2.0 value-for-value (V4V) block — how a podcast splits incoming sats across its + * participants. Carried as a JSON object: nested under `value` in the show's `kind:30078` metadata, + * and serialized into the episode's `["value", ""]` tag (where an episode override also sets + * [enabled]). + * + * This is the parsed data model; performing the actual Lightning splits (keysend to `node` + * recipients, LNURL-pay to `lnaddress` recipients, weighted by [PodcastValueRecipient.split]) is a + * separate wallet/NWC concern and is not done here. + */ +@Immutable +@Serializable +class PodcastValue( + /** Episode-level override switch; absent/null on show-level value blocks. */ + val enabled: Boolean? = null, + /** Suggested amount (per the spec, typically per-minute streaming), in [currency] units. */ + val amount: Long? = null, + /** Currency of [amount], e.g. "sat" or "USD". */ + val currency: String? = null, + val recipients: List = emptyList(), +) { + /** Sum of recipient splits, used to turn each [PodcastValueRecipient.split] into a share. */ + fun totalSplit(): Int = recipients.sumOf { it.split } + + /** + * Splits [totalMilliSats] across the recipients per the Podcasting-2.0 value rules and returns + * the non-zero shares (recipient + amount in millisats), preserving recipient order. + * + * - A recipient with [PodcastValueRecipient.fee] = true takes its [PodcastValueRecipient.split] + * as a **percentage of the total**, off the top (e.g. an app/host fee). + * - The remainder is divided among the non-fee recipients **by relative weight** + * ([PodcastValueRecipient.split] / sum of non-fee splits). + * + * Recipients without a payable [PodcastValueRecipient.address] or with a non-positive split are + * ignored. Integer division floors each share, so a few millisats may go unallocated (dust) — + * acceptable for value-for-value streaming. + */ + fun computeShares(totalMilliSats: Long): List { + if (totalMilliSats <= 0) return emptyList() + + val active = recipients.filter { it.split > 0 && !it.address.isNullOrBlank() } + if (active.isEmpty()) return emptyList() + + var feeTotalMillis = 0L + val feeAmounts = HashMap() + for (recipient in active) { + if (recipient.fee == true) { + val millis = totalMilliSats * recipient.split / 100 + if (millis > 0) { + feeAmounts[recipient] = millis + feeTotalMillis += millis + } + } + } + + val remainder = (totalMilliSats - feeTotalMillis).coerceAtLeast(0) + val sharedWeight = active.filter { it.fee != true }.sumOf { it.split } + + val shares = ArrayList(active.size) + for (recipient in active) { + val millis = + if (recipient.fee == true) { + feeAmounts[recipient] ?: 0L + } else if (sharedWeight > 0 && remainder > 0) { + remainder * recipient.split / sharedWeight + } else { + 0L + } + if (millis > 0) shares.add(PodcastValueShare(recipient, millis)) + } + return shares + } + + companion object { + /** + * TLV record type for the Podcasting-2.0 keysend metadata blob (the "boostagram"), a JSON + * object carrying podcast/episode/app/value context. Registered value, used by the whole + * Podcasting-2.0 ecosystem. See . + */ + const val PODCAST_TLV_RECORD: Long = 7629169L + + /** Recipient [PodcastValueRecipient.type] for a keysend to a raw Lightning node pubkey. */ + const val TYPE_NODE = "node" + + /** Recipient [PodcastValueRecipient.type] for an LNURL-pay to a lightning address. */ + const val TYPE_LNADDRESS = "lnaddress" + } +} + +/** One recipient's resolved share of a [PodcastValue] split, in millisats. */ +@Immutable +class PodcastValueShare( + val recipient: PodcastValueRecipient, + val amountMilliSats: Long, +) + +/** One destination in a [PodcastValue] split. */ +@Immutable +@Serializable +class PodcastValueRecipient( + val name: String? = null, + /** "node" (keysend to a node pubkey) or "lnaddress" (LNURL-pay). */ + val type: String? = null, + /** The node pubkey or lightning address, per [type]. */ + val address: String? = null, + /** Relative weight of this recipient's share (not necessarily a percentage). */ + val split: Int = 0, + val customKey: String? = null, + val customValue: String? = null, + /** When true, this recipient takes its share off the top as a fee before the rest is split. */ + val fee: Boolean? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index c3871af281..3e3a3081a0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -309,6 +309,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent interface EventBuilder { fun build( @@ -566,6 +568,8 @@ class EventFactory { PodcastEpisodeEvent.KIND -> PodcastEpisodeEvent(id, pubKey, createdAt, tags, content, sig) AuthoredPodcastsEvent.KIND -> AuthoredPodcastsEvent(id, pubKey, createdAt, tags, content, sig) FavoritePodcastsListEvent.KIND -> FavoritePodcastsListEvent(id, pubKey, createdAt, tags, content, sig) + Podcasting20EpisodeEvent.KIND -> Podcasting20EpisodeEvent(id, pubKey, createdAt, tags, content, sig) + Podcasting20TrailerEvent.KIND -> Podcasting20TrailerEvent(id, pubKey, createdAt, tags, content, sig) ProductEvent.KIND -> ProductEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt index 9ce1389ce0..4c834b2e8c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Hex.kt @@ -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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/RandomInstance.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/RandomInstance.kt index 39419811fe..7ae9134994 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/RandomInstance.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/RandomInstance.kt @@ -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 = ('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() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/StringUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/StringUtils.kt index e02e2227d6..3971722890 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/StringUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/StringUtils.kt @@ -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): Boolean { if (terms.isEmpty()) return true // Empty string is contained @@ -99,6 +109,7 @@ fun String.containsAny(terms: List): 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): Boolean { if (terms.isEmpty()) return true // Empty string is contained @@ -111,6 +122,12 @@ fun String.startsWithAny(terms: List): 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, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt index b8f1a09ec8..19849b5b65 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.kt index b9bbf9bf48..53225a1d3c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.kt @@ -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 /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt index 07d9cc0667..13f4f00046 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/NostrServerAuthTest.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.OptionalAuthPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PassThroughPolicy import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult import com.vitorpamplona.quartz.nip01Core.store.IEventStore @@ -464,6 +465,100 @@ class NostrServerAuthTest { server.close() } + // -- NIP-42: optional AUTH ------------------------------------------------- + + @Test + fun optionalAuthSendsChallengeOnConnect() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { OptionalAuthPolicy(relayUrl) }) + val collector = MessageCollector() + + server.connect(collector.sendCallback) + + assertEquals(1, collector.messages.size) + assertTrue(collector.messages[0].contains("\"AUTH\"")) + + server.close() + } + + @Test + fun optionalAuthAllowsCommandsWithoutAuth() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { OptionalAuthPolicy(relayUrl) }) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + // The challenge was sent, but a client that ignores it can still write. + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains(",true,")) + assertFalse((session.policy as OptionalAuthPolicy).isAuthenticated()) + + // REQ works too — no CLOSED with auth-required. + session.receive("""["REQ","sub1",{"kinds":[1]}]""") + assertTrue(collector.rawMessagesContaining("EOSE").isNotEmpty()) + assertTrue(collector.rawMessagesContaining("CLOSED").isEmpty()) + + server.close() + } + + @Test + fun optionalAuthStillRecordsAuthenticatedUsers() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { OptionalAuthPolicy(relayUrl) }) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + val msg = OptimizedJsonMapper.fromJsonToMessage(collector.messages[0]) as AuthMessage + + // A client that DOES authenticate is still verified and recorded, so + // downstream policies can gate on identity. + session.receive(authJson(authEvent(challenge = msg.challenge))) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains(",true,")) + assertTrue((session.policy as OptionalAuthPolicy).isAuthenticated()) + assertTrue(session.requestContext.authenticatedUsers.contains(pubkey)) + + server.close() + } + + @Test + fun optionalAuthRejectsInvalidAuthButKeepsWorking() = + runTest { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val server = createServer(dispatcher = dispatcher, policyBuilder = { OptionalAuthPolicy(relayUrl) }) + val collector = MessageCollector() + + val session = server.connect(collector.sendCallback) + + // A bogus AUTH is rejected (challenge mismatch) but the connection is + // not authenticated and commands still flow. + session.receive(authJson(authEvent(challenge = "wrong-challenge"))) + + val okMessages = collector.rawMessagesContaining("OK") + assertEquals(1, okMessages.size) + assertTrue(okMessages[0].contains(",false,")) + assertTrue(okMessages[0].contains("challenge")) + assertFalse((session.policy as OptionalAuthPolicy).isAuthenticated()) + + val event = testEvent() + session.receive("""["EVENT",${event.toJson()}]""") + val allOk = collector.rawMessagesContaining("OK") + assertEquals(2, allOk.size) + assertTrue(allOk[1].contains(",true,")) + + server.close() + } + // -- Custom AuthPolicy tests ----------------------------------------------- @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt index 5e7c1b6865..b5863272e0 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt @@ -35,7 +35,7 @@ class LargeDBTests { companion object { fun getEventDB(): List = OptimizedJsonMapper.fromJsonToEventList( - TestResourceLoader().loadDecompressString("nostr_vitor_startup_data.json"), + TestResourceLoader().loadDecompressString("nostr_vitor_startup_data.json.gz"), ) val events by diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/NoFullTextSearchTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/NoFullTextSearchTest.kt new file mode 100644 index 0000000000..a3f7406e47 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/NoFullTextSearchTest.kt @@ -0,0 +1,213 @@ +/* + * 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.store.sqlite + +import androidx.sqlite.SQLiteConnection +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.runBlocking +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Exercises a [SQLiteEventStore] built with an [IndexingStrategy] whose + * `indexFullTextSearch` is `false`. + * + * The store must behave exactly like a normal store for every non-search + * operation while (a) never creating the `event_fts` table or its delete + * trigger and (b) treating any non-empty `search` term as matching nothing. + * This is the mode a relay uses when it offloads NIP-50 search to an + * external engine (e.g. Vespa) and doesn't want to pay the FTS index cost. + */ +class NoFullTextSearchTest { + private val signer = NostrSignerSync() + + @BeforeTest + fun setup() { + Secp256k1Instance + } + + private fun store() = + SQLiteEventStore( + dbName = null, + indexStrategy = DefaultIndexingStrategy(indexFullTextSearch = false), + ) + + private fun objectExists( + db: SQLiteConnection, + name: String, + ): Boolean = + db.prepare("SELECT name FROM sqlite_master WHERE name = ?").use { stmt -> + stmt.bindText(1, name) + stmt.step() + } + + @Test + fun testNoFtsTableOrTriggerIsCreated() = + runBlocking { + val store = store() + try { + // Force schema creation by opening the pool for a read. + store.pool.useReader { db -> + assertFalse(objectExists(db, "event_fts"), "event_fts table must not exist when FTS is disabled") + assertFalse(objectExists(db, "fts_foreign_key"), "fts_foreign_key trigger must not exist when FTS is disabled") + // Sanity: the canonical table is still there. + assertTrue(objectExists(db, "event_headers"), "event_headers must still be created") + } + } finally { + store.close() + } + } + + @Test + fun testInsertAndPlainQueriesStillWork() = + runBlocking { + val store = store() + try { + val note = signer.sign(TextNoteEvent.build("hello nofts world", createdAt = TimeUtils.now())) + store.insertEvent(note) + + // Query by id, kind, and author all resolve normally. + store.assertQuery(note, Filter(ids = listOf(note.id))) + store.assertQuery(note, Filter(kinds = listOf(TextNoteEvent.KIND))) + store.assertQuery(note, Filter(authors = listOf(note.pubKey))) + assertEquals(1, store.count(Filter(kinds = listOf(TextNoteEvent.KIND)))) + } finally { + store.close() + } + } + + @Test + fun testSearchFilterMatchesNothing() = + runBlocking { + val store = store() + try { + // Content clearly contains the term; with FTS off it must not match. + val note = signer.sign(TextNoteEvent.build("searchable bitcoin content", createdAt = TimeUtils.now())) + store.insertEvent(note) + + store.assertQuery(null, Filter(search = "bitcoin")) + store.assertQuery(null, Filter(kinds = listOf(TextNoteEvent.KIND), search = "bitcoin")) + store.assertQuery(null, Filter(authors = listOf(note.pubKey), search = "bitcoin")) + assertEquals(0, store.count(Filter(search = "bitcoin"))) + } finally { + store.close() + } + } + + @Test + fun testMultiFilterUnionDropsSearchBranchButKeepsOthers() = + runBlocking { + val store = store() + try { + val note = signer.sign(TextNoteEvent.build("multi filter bitcoin note", createdAt = TimeUtils.now())) + store.insertEvent(note) + + // The search branch contributes nothing; the kind branch still returns the note. + val results = + store.query( + listOf( + Filter(search = "bitcoin"), + Filter(kinds = listOf(TextNoteEvent.KIND)), + ), + ) + assertEquals(1, results.size) + assertEquals(note.id, results.first().id) + + assertEquals( + 1, + store.count( + listOf( + Filter(search = "bitcoin"), + Filter(kinds = listOf(TextNoteEvent.KIND)), + ), + ), + ) + } finally { + store.close() + } + } + + @Test + fun testDeleteWithSearchFilterRemovesNothing() = + runBlocking { + val store = store() + try { + val note = signer.sign(TextNoteEvent.build("delete me bitcoin", createdAt = TimeUtils.now())) + store.insertEvent(note) + + store.delete(Filter(search = "bitcoin")) + + // The event survives — a search delete cannot resolve to any rows. + store.assertQuery(note, Filter(ids = listOf(note.id))) + } finally { + store.close() + } + } + + @Test + fun testEmptySearchStringImposesNoConstraint() = + runBlocking { + val store = store() + try { + val note = signer.sign(TextNoteEvent.build("empty search body", createdAt = TimeUtils.now())) + store.insertEvent(note) + + // An empty search term is not a search; the filter still returns the note. + store.assertQuery(note, Filter(kinds = listOf(TextNoteEvent.KIND), search = "")) + } finally { + store.close() + } + } + + @Test + fun testReindexIsANoOp() = + runBlocking { + val store = store() + try { + val note = signer.sign(TextNoteEvent.build("reindex noop body", createdAt = TimeUtils.now())) + store.insertEvent(note) + + // Neither reindex entry point should throw or create an index. + store.reindexFullTextSearch() + + val progress = store.reindexFullTextSearch(resumeFrom = null, batchSize = 100) + assertTrue(progress.done, "batched reindex must report done immediately when FTS is disabled") + assertEquals(0, progress.processedThisBatch) + + store.pool.useReader { db -> + assertFalse(objectExists(db, "event_fts"), "reindex must not create event_fts when FTS is disabled") + } + + // Search still matches nothing after the no-op rebuild. + store.assertQuery(null, Filter(search = "reindex")) + } finally { + store.close() + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt new file mode 100644 index 0000000000..621b4aef9e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11RelayInformationBuilderTest.kt @@ -0,0 +1,174 @@ +/* + * 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.nip11RelayInfo + +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Nip11RelayInformationBuilderTest { + @Test + fun buildsTheSotExampleWithoutHandWrittenJson() { + val info = + relayInformation { + name = "sot" + description = "NIP-50 profile search ranked by Nostr web-of-trust" + software = "https://github.com/vitorpamplona/sot" + version = "0.1" + supports(1, 11, 42, 50) + } + + assertEquals("sot", info.name) + assertEquals(listOf("1", "11", "42", "50"), info.supported_nips) + + // Numeric NIPs serialize as JSON integers, exactly as the hand-written string had them. + val json = info.toJson() + assertTrue(json.contains("\"supported_nips\":[1,11,42,50]"), json) + assertTrue(json.contains("\"name\":\"sot\""), json) + // Unset fields stay out of the document. + assertTrue(!json.contains("\"limitation\""), json) + assertTrue(!json.contains("\"fees\""), json) + + // Round-trips back to the same document. + assertEquals(info, Nip11RelayInformation.fromJson(json)) + } + + @Test + fun emptyBuilderProducesAllNullFields() { + val info = relayInformation {} + + assertNull(info.name) + assertNull(info.supported_nips) + assertNull(info.limitation) + assertNull(info.fees) + assertEquals("{}", info.toJson()) + } + + @Test + fun nestedLimitationDsl() { + val info = + relayInformation { + name = "R" + limitation { + maxSubscriptions = 20 + maxFilters = 10 + maxLimit = 500 + authRequired = true + } + } + + assertEquals(20, info.limitation?.max_subscriptions) + assertEquals(10, info.limitation?.max_filters) + assertEquals(500, info.limitation?.max_limit) + assertEquals(true, info.limitation?.auth_required) + // Untouched limitation fields remain null (and are omitted from JSON). + assertNull(info.limitation?.max_message_length) + } + + @Test + fun limitationFromEnforcedRelayLimitsStaysInSync() { + val limits = RelayLimits(maxSubscriptions = 20, maxFilters = 10, maxLimit = 500, authRequired = true) + val info = + relayInformation { + name = "R" + limitation(limits) + } + + assertEquals(limits.toNip11Limitation(), info.limitation) + } + + @Test + fun feesDslAccumulatesEntries() { + val info = + relayInformation { + name = "Paid Relay" + fees { + admission(amount = 1000, unit = "msats") + publication(amount = 100, unit = "msats", kinds = listOf(1, 30023)) + } + } + + assertEquals( + 1000, + info.fees + ?.admission + ?.first() + ?.amount, + ) + assertEquals( + "msats", + info.fees + ?.admission + ?.first() + ?.unit, + ) + assertEquals( + listOf(1, 30023), + info.fees + ?.publication + ?.first() + ?.kinds, + ) + assertNull(info.fees?.subscription) + } + + @Test + fun retentionEntriesAreRepeatable() { + val info = + relayInformation { + name = "R" + retention(kinds = listOf(0, 3), count = 1) + retention(time = 3600) + } + + assertEquals(2, info.retention?.size) + assertEquals(arrayListOf(0, 3), info.retention?.get(0)?.kinds) + assertEquals(1, info.retention?.get(0)?.count) + assertEquals(3600, info.retention?.get(1)?.time) + } + + @Test + fun listHelpersAreRepeatableAndCollapseWhenEmpty() { + val info = + relayInformation { + name = "R" + supports(1) + supports(11, 42) + supports("custom-ext") + countries("US", "CA") + languages("en") + tags("search") + } + + assertEquals(listOf("1", "11", "42", "custom-ext"), info.supported_nips) + assertEquals(listOf("US", "CA"), info.relay_countries) + assertEquals(listOf("en"), info.language_tags) + assertEquals(listOf("search"), info.tags) + // Untouched lists collapse to null instead of empty arrays. + assertNull(info.nip50) + assertNull(info.supported_grasps) + + // Mixed numeric + string ids: numbers stay numbers, the extension stays a string. + assertTrue(info.toJson().contains("\"supported_nips\":[1,11,42,\"custom-ext\"]"), info.toJson()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastAuthorVerificationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastAuthorVerificationTest.kt new file mode 100644 index 0000000000..5f0de2fffd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastAuthorVerificationTest.kt @@ -0,0 +1,74 @@ +/* + * 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.nipF4Podcasts + +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.tags.AuthorTag +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The cross-check the UI uses to mark a podcast's claimed author "verified": a show (kind:10154) + * names authors via `p` tags (unverified), and an author's own counter-claim (kind:10064) lists the + * podcasts they actually author. Verified iff the author's 10064 lists this podcast's pubkey. + */ +class PodcastAuthorVerificationTest { + private val podcastSigner = + DeterministicSigner("nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair()) + private val authorSigner = + DeterministicSigner("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5".nsecToKeyPair()) + + @Test + fun `claimed author with a matching 10064 counter-claim verifies`() { + val authorPubkey = authorSigner.pubKey + val metadata = + podcastSigner.sign( + PodcastMetadataEvent.build( + title = "My Show", + image = "https://x/cover.jpg", + description = "desc", + authors = listOf(AuthorTag(authorPubkey, AuthorTag.ROLE_HOST)), + ), + ) + val podcastPubkey = metadata.pubKey + + val claimed = metadata.claimedAuthors().single() + assertEquals(authorPubkey, claimed.pubKey) + assertEquals(AuthorTag.ROLE_HOST, claimed.role) + + // The author's own 10064 lists this podcast -> the show's claim is verified. + val authored = authorSigner.sign(AuthoredPodcastsEvent.build(listOf(UserTag(podcastPubkey)))) + assertTrue(authored.authors(podcastPubkey)) + } + + @Test + fun `author whose 10064 omits the podcast is not verified`() { + val unrelated = "0000000000000000000000000000000000000000000000000000000000000001" + val authored = authorSigner.sign(AuthoredPodcastsEvent.build(listOf(UserTag(unrelated)))) + assertFalse(authored.authors(podcastSigner.pubKey)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMockSpamTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMockSpamTest.kt new file mode 100644 index 0000000000..4013c30977 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipF4Podcasts/PodcastMockSpamTest.kt @@ -0,0 +1,64 @@ +/* + * 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.nipF4Podcasts + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** Verifies the fingerprint used to drop the "Mock Podcast" spam flood, and that it spares real shows. */ +class PodcastMockSpamTest { + private fun event( + title: String?, + description: String?, + content: String, + ): PodcastMetadataEvent { + val tags = + buildList { + title?.let { add(arrayOf("title", it)) } + description?.let { add(arrayOf("description", it)) } + }.toTypedArray() + return PodcastMetadataEvent(EMPTY_ID, EMPTY_ID, 0, tags, content, EMPTY_ID) + } + + @Test + fun `the exact mock spam structure matches`() { + assertTrue(event("Mock Podcast", "Headless test feed", "Headless test feed").isMockSpam()) + } + + @Test + fun `real shows are not flagged`() { + // A genuine show that happens to share none of the three fields. + assertFalse(event("My Real Podcast", "A show about things", "Welcome!").isMockSpam()) + // Same title only — not enough. + assertFalse(event("Mock Podcast", "A different description", "Real content").isMockSpam()) + // Same content only — not enough. + assertFalse(event("Another Show", "Another desc", "Headless test feed").isMockSpam()) + // Missing tags entirely. + assertFalse(event(null, null, "Headless test feed").isMockSpam()) + } + + companion object { + private val EMPTY_ID: HexKey = "0".repeat(64) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt new file mode 100644 index 0000000000..74fec4309a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/PodcastPersonSoundbiteTest.kt @@ -0,0 +1,145 @@ +/* + * 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.nipXXPodcasting20 + +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.PersonTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.tags.SoundbiteTag +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata +import com.vitorpamplona.quartz.podcasts.PodcastPerson +import com.vitorpamplona.quartz.podcasts.PodcastSoundbite +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PodcastPersonSoundbiteTest { + @Test + fun `person tag parses all fields`() { + val person = PersonTag.parse(arrayOf("person", "Alice", "host", "https://img/a.png", "https://alice.example")) + assertEquals("Alice", person?.name) + assertEquals("host", person?.role) + assertEquals("https://img/a.png", person?.img) + assertEquals("https://alice.example", person?.href) + } + + @Test + fun `person tag with only a name`() { + val person = PersonTag.parse(arrayOf("person", "Bob")) + assertEquals("Bob", person?.name) + assertNull(person?.role) + assertNull(person?.img) + assertNull(person?.href) + } + + @Test + fun `person tag keeps href in slot 4 when role and img are blank`() { + val person = PersonTag.parse(arrayOf("person", "Carol", "", "", "https://carol.example")) + assertEquals("Carol", person?.name) + assertNull(person?.role) + assertNull(person?.img) + assertEquals("https://carol.example", person?.href) + } + + @Test + fun `person tag round-trips through assemble`() { + val original = PodcastPerson(name = "Dave", role = "guest", img = "https://img/d.png") + val reparsed = PersonTag.parse(PersonTag.assemble(original)) + assertEquals("Dave", reparsed?.name) + assertEquals("guest", reparsed?.role) + assertEquals("https://img/d.png", reparsed?.img) + assertNull(reparsed?.href) + } + + @Test + fun `nameless person tag is dropped`() { + assertNull(PersonTag.parse(arrayOf("person"))) + assertNull(PersonTag.parse(arrayOf("person", ""))) + } + + @Test + fun `person href resolves an npub to a pubkey`() { + val npub = "npub1hv7k2s755n697sptva8vkh9jz40lzfzklnwj6ekewfmxp5crwdjs27007y" + val hex = "bb3d6543d4a4f45f402b674ecb5cb2155ff12456fcdd2d66d9727660d3037365" + assertEquals(hex, PodcastPerson(name = "Alice", href = npub).nostrPubKey()) + assertEquals(hex, PodcastPerson(name = "Alice", href = "nostr:$npub").nostrPubKey()) + } + + @Test + fun `person href that is a plain web link has no pubkey`() { + assertNull(PodcastPerson(name = "Alice", href = "https://alice.example").nostrPubKey()) + assertNull(PodcastPerson(name = "Alice", href = null).nostrPubKey()) + } + + @Test + fun `soundbite tag parses times and optional title`() { + val soundbite = SoundbiteTag.parse(arrayOf("soundbite", "73.5", "60.0", "Best moment")) + assertEquals(73.5, soundbite?.startTimeSeconds) + assertEquals(60.0, soundbite?.durationSeconds) + assertEquals("Best moment", soundbite?.title) + assertEquals(73500L, soundbite?.startMillis()) + } + + @Test + fun `soundbite tag without title`() { + val soundbite = SoundbiteTag.parse(arrayOf("soundbite", "0", "30")) + assertEquals(0.0, soundbite?.startTimeSeconds) + assertEquals(30.0, soundbite?.durationSeconds) + assertNull(soundbite?.title) + } + + @Test + fun `soundbite with bad or non-positive numbers is dropped`() { + assertNull(SoundbiteTag.parse(arrayOf("soundbite", "abc", "60"))) + assertNull(SoundbiteTag.parse(arrayOf("soundbite", "10", "0"))) + assertNull(SoundbiteTag.parse(arrayOf("soundbite", "10"))) + } + + @Test + fun `soundbite round-trips through assemble`() { + val reparsed = SoundbiteTag.parse(SoundbiteTag.assemble(PodcastSoundbite(12.0, 45.0, "Clip"))) + assertEquals(12.0, reparsed?.startTimeSeconds) + assertEquals(45.0, reparsed?.durationSeconds) + assertEquals("Clip", reparsed?.title) + } + + @Test + fun `show metadata JSON parses a persons array`() { + val json = + """{"title":"My Show","persons":[{"name":"Alice","role":"host","img":"https://img/a.png"},{"name":"Bob","role":"guest"}]}""" + val event = AppSpecificDataEvent("id", "pk", 0, arrayOf(arrayOf("d", "podcast-metadata")), json, "sig") + val show = Podcasting20PodcastMetadata.parse(event) + val persons = show?.showPersons().orEmpty() + assertEquals(2, persons.size) + assertEquals("Alice", persons[0].name) + assertEquals("host", persons[0].role) + assertEquals("https://img/a.png", persons[0].img) + assertEquals("Bob", persons[1].name) + assertEquals("guest", persons[1].role) + } + + @Test + fun `show metadata without persons is empty rather than a crash`() { + val event = AppSpecificDataEvent("id", "pk", 0, arrayOf(arrayOf("d", "podcast-metadata")), """{"title":"Bare"}""", "sig") + val show = Podcasting20PodcastMetadata.parse(event) + assertTrue(show?.showPersons().orEmpty().isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt new file mode 100644 index 0000000000..5eeb714da2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20EpisodeEventTest.kt @@ -0,0 +1,219 @@ +/* + * 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.nipXXPodcasting20 + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.edit +import com.vitorpamplona.quartz.podcasts.PodcastAudio +import com.vitorpamplona.quartz.podcasts.PodcastEpisode +import com.vitorpamplona.quartz.podcasts.PodcastValue +import com.vitorpamplona.quartz.podcasts.PodcastValueRecipient +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Podcasting20EpisodeEventTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + @Test + fun `kind is 30054`() { + assertEquals(30054, Podcasting20EpisodeEvent.KIND) + } + + @Test + fun `build round-trips fields and is addressable by d tag`() { + val template = + Podcasting20EpisodeEvent.build( + dTag = "episode-1699123456-abc123def", + title = "The Future of Decentralized Social Media", + audios = listOf(PodcastAudio("https://example.com/episodes/episode-001.mp3", "audio/mpeg")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + description = "A deep dive into how protocols like Nostr are changing social media", + image = "https://example.com/artwork/episode-001.jpg", + durationInSeconds = 3600, + topics = listOf("technology", "decentralization"), + markdownContent = "In this episode, we discuss decentralized social media.", + ) + val event = signer.sign(template) + + assertEquals("episode-1699123456-abc123def", event.dTag()) + assertEquals("The Future of Decentralized Social Media", event.title()) + assertEquals("A deep dive into how protocols like Nostr are changing social media", event.description()) + assertEquals("https://example.com/artwork/episode-001.jpg", event.image()) + assertEquals(3600L, event.durationInSeconds()) + assertEquals("Thu, 04 Nov 2023 12:00:00 GMT", event.pubDate()) + assertEquals("In this episode, we discuss decentralized social media.", event.content) + + val audios = event.audios() + assertEquals(1, audios.size) + assertEquals("https://example.com/episodes/episode-001.mp3", audios[0].url) + assertEquals("audio/mpeg", audios[0].mediaType) + + assertTrue(event.topics().containsAll(listOf("technology", "decentralization"))) + assertEquals("Podcast episode: The Future of Decentralized Social Media", event.alt()) + } + + @Test + fun `optional fields default to null`() { + val template = + Podcasting20EpisodeEvent.build( + dTag = "ep-2", + title = "No extras", + audios = listOf(PodcastAudio("https://example.com/ep2.mp3")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + ) + val event = signer.sign(template) + + assertNull(event.image()) + assertNull(event.description()) + assertNull(event.durationInSeconds()) + assertNull(event.editsEventId()) + assertNull(event.video()) + assertNull(event.number()) + assertNull(event.season()) + assertNull(event.transcriptUrl()) + assertNull(event.chaptersUrl()) + assertNull(event.value()) + } + + @Test + fun `value-for-value split round-trips through the value tag and the interface`() { + val value = + PodcastValue( + enabled = true, + amount = 1000, + currency = "sat", + recipients = + listOf( + PodcastValueRecipient(name = "Host", type = "lnaddress", address = "host@ln.tips", split = 90), + PodcastValueRecipient(name = "Producer", type = "node", address = "02abcd", split = 10, fee = true), + ), + ) + val episode: PodcastEpisode = + signer.sign( + Podcasting20EpisodeEvent.build( + dTag = "ep-v4v", + title = "V4V", + audios = listOf(PodcastAudio("https://x/a.mp3")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + value = value, + ), + ) + + val parsed = episode.episodeValue() + assertTrue(parsed != null) + assertEquals(true, parsed.enabled) + assertEquals("sat", parsed.currency) + assertEquals(100, parsed.totalSplit()) + assertEquals(2, parsed.recipients.size) + assertEquals("Host", parsed.recipients[0].name) + assertEquals("lnaddress", parsed.recipients[0].type) + assertEquals("host@ln.tips", parsed.recipients[0].address) + assertEquals(90, parsed.recipients[0].split) + assertEquals(true, parsed.recipients[1].fee) + } + + @Test + fun `rich Podcasting 2 point 0 tags round-trip and surface through the interface`() { + val template = + Podcasting20EpisodeEvent.build( + dTag = "ep-rich", + title = "Rich Episode", + audios = listOf(PodcastAudio("https://example.com/ep.mp3", "audio/mpeg")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + video = PodcastAudio("https://example.com/ep.mp4", "video/mp4"), + episodeNumber = 5, + season = 2, + transcriptUrl = "https://example.com/ep.srt", + chaptersUrl = "https://example.com/ep.chapters.json", + ) + val episode: PodcastEpisode = signer.sign(template) + + assertEquals("https://example.com/ep.mp4", episode.episodeVideo()?.url) + assertEquals("video/mp4", episode.episodeVideo()?.mediaType) + assertEquals(5, episode.episodeNumber()) + assertEquals(2, episode.episodeSeason()) + assertEquals("https://example.com/ep.srt", episode.episodeTranscriptUrl()) + assertEquals("https://example.com/ep.chapters.json", episode.episodeChaptersUrl()) + // Audio still comes through independently of the video source. + assertEquals("https://example.com/ep.mp3", episode.episodeAudio().single().url) + } + + @Test + fun `edit tag tracks the original event id`() { + val template = + Podcasting20EpisodeEvent.build( + dTag = "ep-3", + title = "Corrected", + audios = listOf(PodcastAudio("https://example.com/ep3.mp3")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + ) { + edit("abababababababababababababababababababababababababababababababab") + } + val event = signer.sign(template) + + assertEquals("abababababababababababababababababababababababababababababababab", event.editsEventId()) + } + + @Test + fun `parses the spec example json into the typed event via EventFactory`() { + // Verbatim example from derekross/podstr NIP.md (id and sig elided are not + // required for parsing tags; we supply structurally valid placeholders). + val json = + """ + { + "kind": 30054, + "content": "In this episode, we discuss the latest developments in decentralized social media protocols.", + "tags": [ + ["d", "episode-1699123456-abc123def"], + ["title", "The Future of Decentralized Social Media"], + ["audio", "https://example.com/episodes/episode-001.mp3", "audio/mpeg"], + ["pubdate", "Thu, 04 Nov 2023 12:00:00 GMT"], + ["alt", "Podcast episode: The Future of Decentralized Social Media"], + ["description", "A deep dive into how protocols like Nostr are changing social media"], + ["image", "https://example.com/artwork/episode-001.jpg"], + ["duration", "3600"], + ["t", "technology"], + ["t", "decentralization"], + ["t", "social-media"] + ], + "created_at": 1699123456, + "pubkey": "0000000000000000000000000000000000000000000000000000000000000001", + "id": "0000000000000000000000000000000000000000000000000000000000000002", + "sig": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + """.trimIndent() + + val event = Event.fromJson(json) + assertTrue(event is Podcasting20EpisodeEvent) + assertEquals("The Future of Decentralized Social Media", event.title()) + assertEquals(3600L, event.durationInSeconds()) + assertEquals(3, event.topics().size) + assertEquals("https://example.com/episodes/episode-001.mp3", event.audios()[0].url) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt new file mode 100644 index 0000000000..774caf9533 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20PodcastMetadataTest.kt @@ -0,0 +1,181 @@ +/* + * 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.nipXXPodcasting20 + +import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent +import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.isPodcastShowEvent +import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.resolvePodcastShow +import com.vitorpamplona.quartz.podcasts.PodcastShow +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Podcasting20PodcastMetadataTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + // Verbatim content shape from derekross/podstr (kind 30078, d="podcast-metadata"). + private val metadataJson = + """ + { + "title": "My Podcast", + "description": "A podcast about interesting topics", + "author": "John Doe", + "email": "john@example.com", + "image": "https://example.com/artwork.jpg", + "language": "en", + "categories": ["Technology", "Science"], + "explicit": false, + "website": "https://example.com", + "copyright": "© 2025 John Doe", + "funding": ["https://example.com/donate", "https://example.com/tip"], + "locked": false, + "value": { + "amount": 100000, + "currency": "sat", + "recipients": [ + { "name": "Host", "type": "lnaddress", "address": "host@example.com", "split": 100 } + ] + }, + "type": "episodic", + "complete": true, + "guid": "abc-123" + } + """.trimIndent() + + private fun appDataEvent( + dTag: String, + content: String, + ): AppSpecificDataEvent = signer.sign(AppSpecificDataEvent.build(dTag = dTag, description = content)) + + @Test + fun `parses podcast-metadata json into PodcastShow fields`() { + val event = appDataEvent("podcast-metadata", metadataJson) + val show = Podcasting20PodcastMetadata.parse(event) + + assertTrue(show != null) + assertEquals("My Podcast", show.showTitle()) + assertEquals("A podcast about interesting topics", show.showDescription()) + assertEquals("https://example.com/artwork.jpg", show.showImage()) + assertEquals(listOf("https://example.com"), show.showWebsites()) + assertEquals("John Doe", show.showAuthor()) + assertEquals(listOf("Technology", "Science"), show.showCategories()) + assertEquals(listOf("https://example.com/donate", "https://example.com/tip"), show.showFundingUrls()) + assertEquals("© 2025 John Doe", show.showCopyright()) + assertFalse(show.showIsExplicit()) + assertTrue(show.showIsComplete()) + assertEquals("en", show.language()) + assertEquals("john@example.com", show.email()) + assertEquals("episodic", show.type()) + assertEquals("abc-123", show.guid()) + assertFalse(show.isLocked()) + + val value = show.showValue() + assertTrue(value != null) + assertEquals("sat", value.currency) + assertEquals(1, value.recipients.size) + assertEquals("Host", value.recipients[0].name) + assertEquals("host@example.com", value.recipients[0].address) + assertEquals(100, value.recipients[0].split) + } + + @Test + fun `build round-trips a Content through sign and parse`() { + val content = + Podcasting20PodcastMetadata.Content( + title = "Built Show", + description = "made in quartz", + author = "Jane", + image = "https://example.com/c.jpg", + categories = listOf("Tech"), + explicit = true, + funding = listOf("https://example.com/donate"), + complete = false, + ) + val event = signer.sign(Podcasting20PodcastMetadata.build(content)) + + assertEquals("podcast-metadata", event.dTag()) + val parsed = Podcasting20PodcastMetadata.parse(event) + assertTrue(parsed != null) + assertEquals("Built Show", parsed.showTitle()) + assertEquals("Jane", parsed.showAuthor()) + assertEquals(listOf("Tech"), parsed.showCategories()) + assertEquals(listOf("https://example.com/donate"), parsed.showFundingUrls()) + assertTrue(parsed.showIsExplicit()) + assertFalse(parsed.showIsComplete()) + } + + @Test + fun `optional rich fields default to empty or null when absent`() { + val event = appDataEvent("podcast-metadata", """{"title":"Bare","description":"d","image":"i"}""") + val show = Podcasting20PodcastMetadata.parse(event) + + assertTrue(show != null) + assertNull(show.showAuthor()) + assertTrue(show.showCategories().isEmpty()) + assertTrue(show.showFundingUrls().isEmpty()) + assertNull(show.showCopyright()) + assertFalse(show.showIsExplicit()) + assertFalse(show.showIsComplete()) + } + + @Test + fun `ignores app-data events with a different d tag`() { + val event = appDataEvent("amethyst-settings", metadataJson) + assertNull(Podcasting20PodcastMetadata.parse(event)) + assertFalse(isPodcastShowEvent(event)) + } + + @Test + fun `returns null when content is not valid metadata json`() { + val event = appDataEvent("podcast-metadata", "not-json") + assertNull(Podcasting20PodcastMetadata.parse(event)) + } + + @Test + fun `resolver unifies both show kinds into one list`() { + val f4 = + signer.sign( + PodcastMetadataEvent.build( + title = "F4 Show", + image = "https://example.com/f4.png", + description = "NIP-F4 show", + websites = listOf("https://f4.example.com"), + ), + ) + val pc20 = appDataEvent("podcast-metadata", metadataJson) + + val shows: List = listOf(f4, pc20).mapNotNull { resolvePodcastShow(it) } + + assertEquals(2, shows.size) + assertEquals(listOf("F4 Show", "My Podcast"), shows.map { it.showTitle() }) + assertTrue(isPodcastShowEvent(f4)) + assertTrue(isPodcastShowEvent(pc20)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20TrailerEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20TrailerEventTest.kt new file mode 100644 index 0000000000..d0e0de60f8 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipXXPodcasting20/Podcasting20TrailerEventTest.kt @@ -0,0 +1,81 @@ +/* + * 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.nipXXPodcasting20 + +import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class Podcasting20TrailerEventTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + @Test + fun `kind is 30055`() { + assertEquals(30055, Podcasting20TrailerEvent.KIND) + } + + @Test + fun `build round-trips fields`() { + val template = + Podcasting20TrailerEvent.build( + dTag = "trailer-1699123456-xyz789abc", + title = "Season 2 Preview", + url = "https://example.com/trailers/season-2-preview.mp3", + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + lengthInBytes = 1024000, + mimeType = "audio/mpeg", + season = 2, + ) + val event = signer.sign(template) + + assertEquals("trailer-1699123456-xyz789abc", event.dTag()) + assertEquals("Season 2 Preview", event.title()) + assertEquals("https://example.com/trailers/season-2-preview.mp3", event.url()) + assertEquals("Thu, 04 Nov 2023 12:00:00 GMT", event.pubDate()) + assertEquals(1024000L, event.lengthInBytes()) + assertEquals("audio/mpeg", event.mimeType()) + assertEquals(2, event.season()) + // Per spec the content SHOULD carry the trailer title. + assertEquals("Season 2 Preview", event.content) + } + + @Test + fun `optional fields default to null`() { + val template = + Podcasting20TrailerEvent.build( + dTag = "trailer-min", + title = "Teaser", + url = "https://example.com/teaser.mp3", + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + ) + val event = signer.sign(template) + + assertNull(event.lengthInBytes()) + assertNull(event.mimeType()) + assertNull(event.season()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagramTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagramTest.kt new file mode 100644 index 0000000000..da9e03d261 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastBoostagramTest.kt @@ -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.quartz.podcasts + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PodcastBoostagramTest { + @Test + fun `uses satoshis-stream field names and omits unset fields`() { + val json = + PodcastBoostagram( + podcast = "My Show", + episode = "Ep 1", + action = PodcastBoostagram.ACTION_BOOST, + appName = "Amethyst", + valueMsatTotal = 21_000_000L, + ).toJson() + + assertTrue(json.contains("\"podcast\":\"My Show\"")) + assertTrue(json.contains("\"app_name\":\"Amethyst\"")) + assertTrue(json.contains("\"value_msat_total\":21000000")) + assertTrue(json.contains("\"action\":\"boost\"")) + // Unset optionals (message, sender_name) must not appear. + assertFalse(json.contains("message")) + assertFalse(json.contains("sender_name")) + } + + @Test + fun `round-trips through json`() { + val original = + PodcastBoostagram( + podcast = "Show", + action = PodcastBoostagram.ACTION_STREAM, + valueMsatTotal = 1000L, + senderName = "alice", + ) + val parsed = JsonMapper.fromJson(original.toJson()) + + assertEquals("Show", parsed.podcast) + assertEquals(PodcastBoostagram.ACTION_STREAM, parsed.action) + assertEquals(1000L, parsed.valueMsatTotal) + assertEquals("alice", parsed.senderName) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChaptersTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChaptersTest.kt new file mode 100644 index 0000000000..470e2e310f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastChaptersTest.kt @@ -0,0 +1,58 @@ +/* + * 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.podcasts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PodcastChaptersTest { + @Test + fun `parses a podcast-namespace chapters json`() { + val json = + """ + { + "version": "1.2.0", + "chapters": [ + { "startTime": 0, "title": "Intro" }, + { "startTime": 73.5, "title": "Topic One", "img": "https://x/c1.jpg", "url": "https://x/ref" }, + { "startTime": 600, "title": "Wrap-up", "toc": false } + ] + } + """.trimIndent() + + val parsed = PodcastChapters.parse(json) + assertTrue(parsed != null) + assertEquals("1.2.0", parsed.version) + assertEquals(3, parsed.chapters.size) + assertEquals("Intro", parsed.chapters[0].title) + assertEquals(0L, parsed.chapters[0].startSeconds()) + assertEquals(73L, parsed.chapters[1].startSeconds()) + assertEquals("https://x/c1.jpg", parsed.chapters[1].img) + assertEquals(false, parsed.chapters[2].toc) + } + + @Test + fun `returns null on malformed json`() { + assertNull(PodcastChapters.parse("not-json")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastCommentScopeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastCommentScopeTest.kt new file mode 100644 index 0000000000..8333f24373 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastCommentScopeTest.kt @@ -0,0 +1,91 @@ +/* + * 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.podcasts + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Verifies that NIP-22 (kind:1111) comments scope correctly onto podcast episodes of both drafts. + * The app's comment composer always routes through [CommentEvent.replyBuilder], so this exercises + * exactly the path a "reply to this episode" tap takes — proving comments already work end-to-end. + */ +class PodcastCommentScopeTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + @Test + fun `episode events declare themselves as NIP-22 comment roots`() { + // Consistency with every other commentable content type (articles, videos, …). + val pc20 = + signer.sign( + Podcasting20EpisodeEvent.build("ep-1", "E", listOf(PodcastAudio("https://x/a.mp3")), "Thu, 04 Nov 2023 12:00:00 GMT"), + ) + val f4 = + signer.sign( + PodcastEpisodeEvent.build("E", "d", listOf(AudioTag("https://x/a.mp3"))), + ) + assertTrue(pc20 is RootScope) + assertTrue(f4 is RootScope) + } + + @Test + fun `comment on a Podcasting 2 point 0 episode roots on its address`() { + val episode = + signer.sign( + Podcasting20EpisodeEvent.build("ep-1", "Episode", listOf(PodcastAudio("https://x/a.mp3")), "Thu, 04 Nov 2023 12:00:00 GMT"), + ) + val comment = signer.sign(CommentEvent.replyBuilder("great episode", EventHintBundle(episode))) + + assertEquals(CommentEvent.KIND, comment.kind) + // Addressable root: the comment carries the episode's `a`/`A` address (30054:pubkey:d). + assertTrue(comment.rootAddressIds().contains(episode.addressTag())) + assertTrue(comment.hasRootScopeKind(Podcasting20EpisodeEvent.KIND.toString())) + assertTrue(comment.rootEventIds().contains(episode.id)) + } + + @Test + fun `comment on a NIP-F4 episode roots on its event id`() { + val episode = + signer.sign( + PodcastEpisodeEvent.build("Episode", "notes", listOf(AudioTag("https://x/a.mp3"))), + ) + val comment = signer.sign(CommentEvent.replyBuilder("great episode", EventHintBundle(episode))) + + assertEquals(CommentEvent.KIND, comment.kind) + // Regular (non-addressable) root: scoped by event id via the `e`/`E` tag, no address root. + assertTrue(comment.rootEventIds().contains(episode.id)) + assertTrue(comment.hasRootScopeKind(PodcastEpisodeEvent.KIND.toString())) + assertTrue(comment.rootAddressIds().isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrualTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrualTest.kt new file mode 100644 index 0000000000..2a5df005c3 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastStreamingAccrualTest.kt @@ -0,0 +1,73 @@ +/* + * 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.podcasts + +import kotlin.test.Test +import kotlin.test.assertEquals + +class PodcastStreamingAccrualTest { + @Test + fun `bills a whole minute only once it is fully played`() { + val accrual = PodcastStreamingAccrual() + assertEquals(0, accrual.accrue(30_000L)) // 30s played -> nothing billable yet + assertEquals(30_000L, accrual.pendingMillis()) + assertEquals(1, accrual.accrue(30_000L)) // another 30s -> one full minute + assertEquals(0L, accrual.pendingMillis()) + } + + @Test + fun `carries the partial remainder forward instead of rounding up`() { + val accrual = PodcastStreamingAccrual() + // 1m 25s played in one go -> bill 1 minute, keep 25s pending. + assertEquals(1, accrual.accrue(85_000L)) + assertEquals(25_000L, accrual.pendingMillis()) + // 35s more completes the second minute. + assertEquals(1, accrual.accrue(35_000L)) + assertEquals(0L, accrual.pendingMillis()) + } + + @Test + fun `many small playing ticks accumulate to a minute`() { + val accrual = PodcastStreamingAccrual() + var billed = 0 + repeat(59) { billed += accrual.accrue(1_000L) } + assertEquals(0, billed) // 59s, still short + billed += accrual.accrue(1_000L) + assertEquals(1, billed) // 60th second tips it over + } + + @Test + fun `a single long interval bills every full minute it contains`() { + val accrual = PodcastStreamingAccrual() + assertEquals(3, accrual.accrue(190_000L)) // 3m 10s -> 3 minutes + assertEquals(10_000L, accrual.pendingMillis()) + } + + @Test + fun `non-positive ticks never bill and an abandoned partial minute is simply dropped`() { + val accrual = PodcastStreamingAccrual() + assertEquals(0, accrual.accrue(0L)) + assertEquals(0, accrual.accrue(-5_000L)) + assertEquals(0, accrual.accrue(40_000L)) // 40s pending + assertEquals(40_000L, accrual.pendingMillis()) + // Session ends here: the caller discards the instance, so the 40s is never charged. + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt new file mode 100644 index 0000000000..391fac4952 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/PodcastValueShareTest.kt @@ -0,0 +1,93 @@ +/* + * 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.podcasts + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PodcastValueShareTest { + private fun node( + name: String, + split: Int, + fee: Boolean? = null, + ) = PodcastValueRecipient(name = name, type = PodcastValue.TYPE_NODE, address = "node-$name", split = split, fee = fee) + + @Test + fun `weighted split with no fees divides by relative weight`() { + val value = + PodcastValue( + recipients = listOf(node("host", 90), node("producer", 10)), + ) + // 100k sats total in millisats. + val shares = value.computeShares(100_000_000L).associate { it.recipient.name to it.amountMilliSats } + + assertEquals(90_000_000L, shares["host"]) + assertEquals(10_000_000L, shares["producer"]) + } + + @Test + fun `fee recipient takes its split as a percent off the top then remainder split by weight`() { + val value = + PodcastValue( + recipients = + listOf( + node("app", 5, fee = true), // 5% fee off the top + node("host", 80), + node("cohost", 20), + ), + ) + val shares = value.computeShares(1_000_000L).associate { it.recipient.name to it.amountMilliSats } + + // 5% of 1,000,000 = 50,000 fee. Remainder 950,000 split 80/20. + assertEquals(50_000L, shares["app"]) + assertEquals(760_000L, shares["host"]) + assertEquals(190_000L, shares["cohost"]) + // No more than the total is ever allocated. + assertTrue(shares.values.sum() <= 1_000_000L) + } + + @Test + fun `recipients without an address or with non-positive split are ignored`() { + val value = + PodcastValue( + recipients = + listOf( + node("host", 100), + PodcastValueRecipient(name = "noaddr", type = PodcastValue.TYPE_NODE, address = null, split = 50), + node("zero", 0), + ), + ) + val shares = value.computeShares(10_000L) + + assertEquals(1, shares.size) + assertEquals("host", shares.single().recipient.name) + assertEquals(10_000L, shares.single().amountMilliSats) + } + + @Test + fun `non-positive total or empty recipients yields no shares`() { + val value = PodcastValue(recipients = listOf(node("host", 100))) + assertTrue(value.computeShares(0L).isEmpty()) + assertTrue(value.computeShares(-5L).isEmpty()) + assertTrue(PodcastValue(recipients = emptyList()).computeShares(1_000L).isEmpty()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/UnifiedPodcastEpisodeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/UnifiedPodcastEpisodeTest.kt new file mode 100644 index 0000000000..8f7d5cd45e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/podcasts/UnifiedPodcastEpisodeTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.podcasts + +import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent +import com.vitorpamplona.quartz.nipF4Podcasts.episode.tags.AudioTag +import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent +import com.vitorpamplona.quartz.utils.DeterministicSigner +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * Proves the merge: a NIP-F4 episode (`kind:54`) and a Podcasting-2.0 episode + * (`kind:30054`) — built on different identity models and event kinds — flow into + * a single `List` and expose the same fields through the shared + * abstraction. + */ +class UnifiedPodcastEpisodeTest { + private val signer = + DeterministicSigner( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + + @Test + fun `both kinds satisfy the shared PodcastEpisode abstraction`() { + val f4 = + signer.sign( + PodcastEpisodeEvent.build( + title = "F4 Episode", + description = "Published under NIP-F4", + audios = listOf(AudioTag("https://example.com/f4.mp3", "audio/mpeg")), + image = "https://example.com/f4.png", + createdAt = 1000, + ), + ) + + val pc20 = + signer.sign( + Podcasting20EpisodeEvent.build( + dTag = "ep-1", + title = "Podcasting 2.0 Episode", + audios = listOf(PodcastAudio("https://example.com/pc20.mp3", "audio/mpeg")), + pubdate = "Thu, 04 Nov 2023 12:00:00 GMT", + description = "Published under kind 30054", + image = "https://example.com/pc20.png", + durationInSeconds = 1800, + createdAt = 2000, + ), + ) + + // The whole point: one list, mixed kinds, ordered newest-first. + val unified: List = listOf(f4, pc20).sortedByDescending { it.episodePublishedAt() } + + assertEquals(listOf("Podcasting 2.0 Episode", "F4 Episode"), unified.map { it.episodeTitle() }) + + unified.forEach { episode -> + assertEquals("audio/mpeg", episode.episodeAudio().single().mediaType) + } + + // F4 has no duration tag; the Podcasting-2.0 episode carries one. + val byTitle = unified.associateBy { it.episodeTitle() } + assertNull(byTitle["F4 Episode"]!!.episodeDurationInSeconds()) + assertEquals(1800L, byTitle["Podcasting 2.0 Episode"]!!.episodeDurationInSeconds()) + + assertEquals(1000L, byTitle["F4 Episode"]!!.episodePublishedAt()) + assertEquals(2000L, byTitle["Podcasting 2.0 Episode"]!!.episodePublishedAt()) + } +} diff --git a/quartz/src/commonTest/resources/nostr_vitor_startup_data.json b/quartz/src/commonTest/resources/nostr_vitor_startup_data.json.gz similarity index 100% rename from quartz/src/commonTest/resources/nostr_vitor_startup_data.json rename to quartz/src/commonTest/resources/nostr_vitor_startup_data.json.gz diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt index e65b2ca487..f041043069 100644 Binary files a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt and b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitObject.kt differ diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientNegentropySyncTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientNegentropySyncTest.kt new file mode 100644 index 0000000000..04734e6162 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientNegentropySyncTest.kt @@ -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() + 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() + 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() + 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() + 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 { + 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() + 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() + 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() + 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) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/PoolRequestsConcurrencyTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/PoolRequestsConcurrencyTest.kt new file mode 100644 index 0000000000..6d21d526e3 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/PoolRequestsConcurrencyTest.kt @@ -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", + ) + } + } +}