Mirrors the quartz commonTest stub so :quic:testAndroidHostTest no longer
throws RuntimeException("Stub!") through PlatformLog.android on the
MAX_STREAMS_UNI emission path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Volatile resolves via kotlin.jvm on JVM but needs an explicit
kotlin.concurrent.Volatile import on Native; Dispatchers.IO is internal
on Native, so the in-process WebSocket switches to Dispatchers.Default
(no blocking I/O on that path); and Native's LinkedHashMap is final
without removeEldestEntry, so Nip98AuthVerifier's replay cache now caps
itself with an explicit insertion-order eviction loop (access-order was
unused — the cache is write-only).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces JVM-only @Volatile + synchronized double-checked locking with
kotlin.concurrent.atomics.AtomicBoolean.compareAndSet so the file builds
on all commonMain targets, mirroring the pattern in BasicRelayClient.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RelayEngine (224 → 185 lines):
- Extract BanStore <-> RuntimeConfigData mapping helpers
(seedInto, snapshotOf) into geode.config — bidirectional
conversion now has names instead of being inline boilerplate
repeated between the init block and snapshot().
- Collapse the standalone init block into a .apply on the
banStore declaration. Method reference ::snapshot for the
onMutation hook.
- Drop the effectiveAtBoot field's 12-line KDoc (rename to `boot`
locally, terse). Trim verbose property KDocs to the load-bearing
facts (why-not-what).
StaticConfig (255 → 182 lines):
- Drop resolveInfo's unused `advertisedUrl` parameter and the
workaround comment that justified keeping it for "future
fields". YAGNI — add back when actually needed. Two callers
updated.
- Trim trivial KDocs (fromToml/fromFile, field names that
document themselves like require_auth, reject_future_seconds,
supported_nips, database.file).
- Collapse NetworkSection's three thread-pool KDocs (~16 lines)
into a single section-level KDoc.
- Compact parallel_verify, AdminSection, AdminSection.state_file,
and advertisedUrl companion KDocs while keeping the
load-bearing facts (invariants, strfry interop strings,
surprising defaults).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
KtorRelay was carrying state that wasn't HTTP-specific: the
Nip86Server, its InfoHolder adapter, and the adminPubkeys allow-list.
Push them into RelayEngine — it already owns the info doc, ban store,
and event store the Nip86Server consults; the allow-list is a
relay-level "who is admin?" decision, not a transport-level one.
RelayEngine gains adminPubkeys: Set<HexKey> = emptySet() and exposes
nip86Server as a public property. Future non-HTTP admin transports
(in-process tools, hypothetical NIP-86-over-WS) read it directly
without re-deriving the wiring.
KtorRelay shrinks to mostly Ktor structures — routing block, engine
config, lifecycle. It just builds the Nip86HttpHandler around
relay.nip86Server.
Main.kt and Nip86EndToEndTest pass adminPubkeys to RelayEngine
instead of KtorRelay.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two simplifications:
1. Derive admin URL from RelayEngine.url. NIP-86 spec mandates that
the admin endpoint is "the same URI as ws(s)://, called via
http(s)://" — so KtorRelay derives the NIP-98 binding URL by
calling relay.url.toHttp() instead of accepting publicUrl as a
separate config. Single source of truth (info.relay_url),
accidental misconfiguration impossible, no Host-header fallback.
StaticConfig.AdminSection.public_url is gone.
2. Uniform code path for admin-enabled vs disabled. Empty allow-list
isn't a special case anywhere: Nip86Server.isAuthorized returns
false for everyone, dispatch rejects, Nip86HttpHandler returns
NotAdmin → 403. KtorRelay always assembles the Nip86HttpRoute
and registers the POST endpoint; Nip86Server.isEnabled() and the
"is admin on?" branches in the handler and route are deleted.
Nip86EndToEndTest's admin-disabled case is now a behavioral test:
sign a valid token, expect 403 NotAdmin (was 405 with the no-route
variant, was 403 with the fake-disabled-route variant).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the NIP-86-over-HTTP orchestration (NIP-98 verify → admin
allow-list gate → parse → dispatch → serialize) into quartz so any
relay implementation that wants the standard transport can plug its
HTTP framework in without re-deriving the sequence — and without
accidentally skipping NIP-98 verification or the admin check.
• Nip86Server (quartz) gains allowList + isEnabled() + isAuthorized();
dispatch becomes dispatch(pubkey, req) and enforces the allow-list
itself, so in-process callers can't bypass it either.
• Nip86HttpHandler (new, quartz) takes only primitives (authHeader,
url, body) and returns a sealed Response — Disabled, PayloadTooLarge,
MissingAuth, BadAuth, NotAdmin, BadRequest, Ok(pubkey, req, resp,
json). Constants for the 1 MiB body cap, application/nostr+json+rpc
content type, and WWW-Authenticate scheme live here.
• Nip86HttpRoute (geode) shrinks to a Ktor adapter: bounded read,
extract URL/auth, call handler, map Response → Ktor status codes.
Audit logging stays in geode — not a quartz concern.
External relay authors: import quartz, build a Nip86HttpHandler with
their Nip86Server + Nip98AuthVerifier, and write a ~30-line adapter
for their framework. The full security-critical flow is in the box.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nip86Server used to take an optional IEventStore and call store.delete()
only on banevent — banpubkey and disallowkind would update the BanStore
but leave existing matching events served by REQ. That's inconsistent
(an operator who bans a spam pubkey expects the spam to be gone) and
a real safety issue (banning a CSAM event wouldn't actually remove
existing copies if the store was null).
Replace the IEventStore param with onBan: suspend (Filter) -> Unit. The
dispatcher fires it after each ban method with the Filter that selects
the now-banned events:
banpubkey → Filter(authors = [pk])
banevent → Filter(ids = [id])
disallowkind→ Filter(kinds = [k])
KtorRelay wires onBan = { f -> relay.store.delete(f) }, so the existing
SQLite delete-by-filter path handles every shape without the caller
having to know which field to populate. The default no-op keeps the
unit test for the dispatcher dependency-free.
withInt is now suspend so disallowKind's handler can call the suspending
onBan inline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RelayEngine used to take three params that were really RuntimeConfig
construction details — info: RelayInfo, stateFile: File?,
seedAuthorization: AuthorizationSeed — and assembled the RuntimeConfig
internally. Replace that with a single runtimeConfig: RuntimeConfig
param (with an in-memory default), matching how store: IEventStore is
already passed in. Main.kt builds the RuntimeConfig from StaticConfig
at the assembly layer where the TOML→runtime mapping is most explicit.
AuthorizationSeed is dropped — it only existed to shuttle data into
the now-removed seedAuthorization param. Callers build RuntimeConfigData
directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ktor's WebSocket layer is unbounded by default, so [limits].max_ws_frame_bytes
was a strfry-parity tuning knob nobody actually used — drop the whole
LimitsSection from StaticConfig and the matching constructor param from
KtorRelay. The negentropy-large-corpus plan note is updated accordingly.
maxAdminBodyBytes is a real defense-in-depth cap (NIP-98 forces us to
read the body for the payload sha256 before we can authenticate, so an
unbounded read is a pre-auth DoS vector). Keep the cap, but stop
exposing it as an operator knob — it was never plumbed through to
StaticConfig and 1 MiB is ~1000× any plausible NIP-86 RPC payload.
Hardcoded at the Nip86HttpRoute construction site with the rationale
inline. Nip86HttpRoute.maxBodyBytes stays so tests can drive the 413
boundary.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
KtorRelay was resolving NIP-11 inline in the routing block while NIP-86
already had a dedicated Nip86HttpRoute with a handle(call) entry point.
Lift NIP-11 to the same shape so both endpoints look symmetric and the
routing block is just dispatch. The `liveJson` callback (rather than a
snapshot string) keeps NIP-86 changerelay* admin mutations visible on
the next GET without re-wiring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rename RelayConfig → StaticConfig and the persistence/RelayStateStore →
config/RuntimeConfig so the two configuration tiers — boot-time TOML
versus operator-mutable runtime state — are obvious from the class
names. The `persistence/` package is gone; everything related to config
lives in `config/`.
For overlapping fields (NIP-11 info doc, pubkey / kind allow-deny
lists), StaticConfig now seeds RuntimeConfig on first boot via the new
RuntimeConfig(seed = …) constructor and an AuthorizationSeed type. The
runtime file wins from then on: later edits to [authorization] in the
TOML are ignored once an admin RPC has written the snapshot. As a
consequence, KindAllowDenyPolicy and PubkeyAllowDenyPolicy are no
longer stacked in geode's policy chain — BanListPolicy already reads
the same lists from the BanStore, and double-stacking would silently
diverge after the first admin mutation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to the previous geode rename — quartz's jvmAndroid tests
extend the geode testFixtures `RelayClientTest` and were calling
`defaultRelay.preload(...)`. After moving `preload` off `RelayEngine`
and into an extension function in `geode.testing`, these tests need
the import explicitly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renames in the geode module to make naming honest:
- Relay -> RelayEngine (the transport-agnostic core)
- LocalRelayServer -> KtorRelay (Ktor/CIO transport; defaults to 127.0.0.1
but supports public deployment, so "Local" was misleading)
- RelayHub -> InProcessRelays (URL-keyed registry that doubles as a
WebsocketBuilder for in-JVM tests; "Hub"/"Pool" implied substitutable
resources, but each URL maps to a distinct relay)
Also moves the test-only helpers `preload` and `publish` out of
RelayEngine and into a testFixtures extension file. As part of that,
`publish` now waits for the relay's OK reply before returning so that
publish-then-subscribe is deterministic — previously the fire-and-forget
submit let a subscription register after publish returned but before the
ingest queue fanned out, causing ephemeral kinds (not persisted) to leak
to late subscribers. Fixes the flaky
`Nip01ComplianceTest.ephemeralEventIsNotStoredAndDoesNotShowOnFollowupReq`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-review cleanup of the previous fix commit. No behavior change:
1. Removed nullability from the `nestViewModel` parameter — the only
call site always passes it, and the previous chain
`nestViewModel?.uiState?.collectAsState()?.value` was a
conditional composable that only worked by accident.
2. Replaced the hand-rolled `targetCanSpeakByRole` with
`ParticipantTag.canSpeak()` so future role additions stay
consistent.
3. Split the monolithic sheet into ParticipantSheetHeader,
AudienceActions, HostActions, ParticipantZapDialog and
DestructiveConfirmDialog so each piece recomposes on its own
state and has a single responsibility.
4. Factored the `broadcast(...) + toast + onDismiss()` triplet into
a `hostAction(...)` helper inside HostActions and a top-level
`broadcast(...)` utility for the destructive dialogs.
5. Collapsed the 13 pre-resolved toast strings into a
`ParticipantToasts` data class produced once by
`rememberParticipantToasts(displayName)`.
6. Made displayName reactive via `observeUserInfo` so the header
and toast text always agree (previously the toast used a
snapshot while the header observed the metadata flow).
7. Replaced the deeply qualified types in the signature with
normal imports.
8. Consolidated three `rememberSaveable<Boolean>` dialog flags
into a single `SheetDialog` enum with a name-based Saver, so
the dialog states are mutually exclusive by construction.
https://claude.ai/code/session_013Aj1h3epWkvucKFLdunjcv
A heavily-edited note can fire `edits.stateFlow` hundreds of times
during initial relay sync; without throttling, each emission still
hits the IO scan even though `distinctUntilChanged` would collapse
most of them downstream. `sample(500)` keeps only the most recent
state per ~half second, so the IO `findLatestModificationForNote`
runs at most ~twice a second per note instead of once per arrival.
- Gate the new notificationsFollowing / notificationsEveryone fan-out in
updateFeedsWith / deleteNotes on splitNotificationsEnabled.value so the
default-off case stops doing two extra filter scans per event bundle.
- Lazy-init NotificationFeedFilter.overrideFollowLists so the
SharingStarted.Eagerly topNavFilter pipeline only starts when the
split UI actually opens the filter.
- Switch SplitNotificationsScaffold to rememberForeverPagerState so the
active tab survives recomposition, matching HomeScreen/DiscoverScreen.
- Promote the duplicated "Notification" last-read key string to a
NOTIFICATION_LAST_READ_KEY const.
- Move the opaque-background fix into SummaryBar itself so callers don't
need a band-aid Modifier.background on their topBar Column.
- Trim narrating / WHAT comments; keep only the deep-link scroll WHY.
Adds an opt-in toggle under Settings → Notifications that splits the
notifications screen into two pinned-mode tabs — Following (kind:3
follow list only) and Everyone (global). The bottom-bar unread dot
glows only for Following so it behaves like the DM-style indicator
the bounty asks for. Default is off; existing single-feed behavior
and the FeedFilterSpinner are unchanged when the toggle is off.
Fixes ten issues found while reviewing the per-participant ModalBottomSheet
on the Nest full-screen UI:
1. demoteToListener used to fall through to setRole(PARTICIPANT) when the
target had no participant row, silently adding a pure-audience listener
as PARTICIPANT to the kind-30312. Now returns null in that case and
when the target is already PARTICIPANT.
2. Kick and Force-mute now require an AlertDialog confirmation before
firing, matching the host's own Leave-room flow.
3. Sheet header switched from a raw hex stub to UsernameDisplay so the
user knows who they're acting on.
4. Local Hush is hidden when the target isn't currently broadcasting —
the volume gate attaches to an active subscription, so a hush against
a non-speaker was a silent no-op.
5. Follow/Mute/Promote/Demote/Force-mute/Kick now emit success toasts
(launchSigner already surfaces errors).
6. isFollowing and isHidden moved to collectAsState so labels update
live if the kind-3 / mute-list publish lands while the sheet is open.
7. Force-mute row carries a "May be ignored by clients that don't honor
the verb" subtitle (the verb is an Amethyst extension over nostrnests).
8. Tap on a stage avatar now opens the same sheet as long-press — the
audience grid already worked this way; the stage was long-press only.
9. Hand-raise queue avatars are now click+long-press → sheet, so a host
can profile / kick a raised-hand audience member without switching tabs.
10. Promote-to-Speaker / Promote-to-Moderator / Demote-to-Listener rows
are hidden when the target already has that role.
https://claude.ai/code/session_013Aj1h3epWkvucKFLdunjcv
`observeEdits` in NoteCompose previously listened to the raw
`note.flow().edits.stateFlow` and re-ran `findModificationEventsForNote`
(an IO scan) inside a `LaunchedEffect` on every emission — even when
the emission didn't change the actual list of modifications. That
mutated `editState` repeatedly with the same value during scroll on
any active TextNote.
Add `observeNoteModifications` in EventObservers.kt: it does the IO
resolution via `mapLatest { LocalCache.findLatestModificationForNote(note) }`
on Dispatchers.IO, then `distinctUntilChanged()` — so the State only
updates (and the consumer's LaunchedEffect only re-keys) when the
modification list truly changes. The State is `null` until the first
IO resolution completes; consumers treat that as "still loading" and
don't flip the UI to "no edits" prematurely.
Drop the now-unused `observeNoteEdits` and the
`AccountViewModel.findModificationEventsForNote` wrapper.
`TimeAgo`, `NormalTimeAgo`, `ChatTimeAgo`, and the chatroom header's
private `TimeAgo` previously formatted the timestamp once inside
`remember(time)` and never refreshed — a note shown when it was 59 s
old would keep saying "59s" forever, even when it had been minutes.
Introduce a single app-wide ticker:
* `LocalNowSeconds` is a `CompositionLocal<State<Long>>` whose value
is refreshed every 30 s by a single `produceState` coroutine inside
`NowProvider` (mounted once at the app root in `MainActivity`).
* Each `TimeAgo` composable reads the ticker inside `derivedStateOf`,
so it only triggers a Text recomposition when the formatted string
actually crosses a threshold (e.g. 1m → 2m). Ticks that don't change
the displayed string are filtered by `derivedStateOf` equality.
One coroutine total, no per-item timers, and recompositions are
proportional to "strings that actually need to update" rather than
"items on screen × ticks/second".
Also drop DLNA from the cast description (functionality removed) in English and Polish.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the raw viewModelScope.launch + runCatching with launchSigner,
matching the rest of the codebase. Same survival-past-dismiss
guarantee, plus signer errors (read-only key, NIP-46 unauthorized /
timeout, signer not found) now surface as toasts instead of being
silently swallowed.
The HomeTabsSettingsScreen toggles (New Threads / Conversations /
Everything) wrote to UiSettingsFlow but UiSharedPreferences had no
DataStore keys for them, so the debounced save was a no-op and startup
always reloaded the defaults. Add the missing keys, reads, and writes.