Give LoadingReplyNote the same chrome as DmHistoryLoadingCard - rounded
translucent surface, spinner-in-a-box, status line - so an unloaded reply
reads as the same 'reaching back into history' state, just inline in the
quote instead of at the oldest end.
Reply quotes inside a conversation rendered the same 'post not found'
BlankNote as the main feeds when the target message had not been paged in
yet. But a reply target in a DM is not missing - it is simply older than
the loaded window, and for NIP-17 the inner rumor id is not even queryable
on relays (only the outer gift-wrap id is), so the only way to surface it
is to keep paging gift-wrap history until the wrap carrying it decrypts.
Add LoadingReplyNote: a custom inner-quote placeholder that shows a small
spinner + 'looking for the original message' and drives the matching
history pager's loadMore in a loop (gated on its own loadingMore/exhausted)
until the target decrypts - at which point WatchNoteEvent crossfades the
real message in and disposes the loader - or the protocol's history runs
dry, settling into the terminal 'not found' text. It runs regardless of
scroll position so opening a thread pulls an off-screen reply target in on
its own; the loop is idempotent so multiple loaders and the scroll loader
coalesce onto one paging window.
Wire it in via a new optional onBlank slot on ChatroomMessageCompose,
chosen by the parent message's protocol (gift-wraps vs NIP-04). Public
chats and marmot groups keep the default blank.
LocalCache must hold a single Note per event id/address and must never
remove a Note from the cache map while another Note still strongly
references it — a dangling reference both leaks the shell and lets a
relay echo mint a second Note with the same id.
Note.onchainZaps (NIP-BC) and Note.nutzaps (NIP-61) were added after the
removal/migration routines were written and were never wired into them,
so a pruned zap-source Note leaked through its target's maps:
- removeNote() only detached reply/boost/reaction/zap/zapPayment/report/
label, leaving the target's onchainZaps/nutzaps entry dangling when the
source note was pruned. Now also calls removeNutzap + a new
source-keyed removeOnchainZap (unconditional cache removal, distinct
from the verdict-respecting removeOnchainZapForSource).
- removeAllChildNotes() cleared onchainZaps but never returned the source
notes for removal from the cache map (asymmetric with nutzaps), so they
lingered orphaned. Now included.
- moveAllReferencesTo() dropped labels, zapPayments, and onchainZaps when
a replaceable's old version was superseded — silent data loss plus
orphaned onchain sources. Now migrated and cleared like the rest.
Adds NotePruningReferenceTest covering all three paths.
https://claude.ai/code/session_01RqJPYzmjb1pR3NBeH2yY3s
Keep the universal policy interface free of NIP-42: instead of a default
authenticatedUsers on IRelayPolicy, add an opt-in AuthScopedPolicy mixin that
only FullAuthPolicy (and PolicyStack, which unions its auth-tracking members)
implements. RequestContext.authenticatedUsers resolves it via an `as?
AuthScopedPolicy` downcast, defaulting to empty — so non-auth relays carry no
auth concept, and the accessor now earns its keep by encapsulating that cast
(ctx.policy is IRelayPolicy and no longer exposes the set directly).
Non-storage relays answer a REQ purely from filters: EventSource.events()
got no session/auth context, even though the connection's policy already
knows the authenticated pubkey(s). That walled the auth state off from the
code that produces events, forcing a shared mutable holder + hand-wired
RelaySession to build any caller-aware relay (NIP-50 search scored from the
viewer, DM-style restricted content, paid/allow-listed sets, per-connection
tenancy).
Introduce RequestContext (connectionId, authenticatedUsers, policy) and pass
it through the read path: RelaySession -> SessionBackend.query/count/
countResult -> EventSourceBackend -> EventSource. authenticatedUsers is now
exposed on IRelayPolicy (default empty, overridden by FullAuthPolicy and
unioned by PolicyStack) and read live, so a REQ after AUTH sees the freshly
authenticated pubkey(s). For richer per-connection state, downcast ctx.policy.
EventSourceServer.serve { } is now usable for auth-scoped relays without a
side channel. Adds a test proving ctx.authenticatedUsers reaches the source
after a NIP-42 handshake; updates RELAY.md.
The additive predicate in ChatroomListNewFeedFilter required
room.senderIntersects(followingKeySet) to be true, the exact inverse of
the full feed() rebuild, which includes a room only when the sender is
NOT followed. So new gift wraps / NIP-04 DMs from strangers streaming in
through the additive update path were all rejected, and the New Requests
list never grew past whatever feed() had computed at screen-open time.
On a heavy account this showed as the list freezing at a handful of rooms
while tens of thousands of events decrypted and history exhausted -
'loading but not changing the screen'. Negate the senderIntersects term
so the additive path matches feed().
The round-model history cards computed reachedBack = deepestUntil over
the ACTIVE relays only. When the deepest relay finished paging and
dropped out of the active set, the min jumped to the next-active (newer)
relay's cursor, so 'back to X' lurched FORWARD to a more recent date —
un-reaching history it had already loaded. Visible in the giftwrap trace:
the deepest relays reach ~2023, then once they finish and only the
shallow inbox.nostr.wine (~70d) remains, deepestUntil(active) snaps back
to ~70d.
Compute it over ALL relays (including finished ones, which keep their
deep cursor), matching the convo path. Now it only ever moves older.
Applied to both round-model managers (giftwrap + rooms NIP-04).
Note: a large *forward* jump (e.g. ~3 years in one step) is still
expected and correct — a dense relay can return a full 10k-event page
spanning years, so the oldest-loaded date legitimately leaps. No
messages are skipped; each relay pages contiguously.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
On a flaky network Arti records circuit failures past the first hop as
"indeterminate" and, once a guard's indeterminate ratio crosses 0.7,
permanently disables it. Disabled guards are never re-enabled nor removed
from the sample (60-day lifetime), and the sample is capped at 60. Arti
normally refills usable guards when they fall below 20, but a full sample
of unusable guards leaves no room — replenishment wedges and every circuit
returns AllGuardsDown. The state persists in guards.json and bootstrap
still "succeeds", so no existing self-heal path fires: Tor stays broken
across restarts. This is the long-standing, hard-to-reproduce production
"can't connect to Tor" bug.
On init, scan guards.json and, if any non-empty guard selection has zero
usable guards (disabled or unlisted_since set), wipe Arti state so the
next bootstrap rebuilds a fresh sample. A single usable guard is enough to
build circuits, so recovery only triggers at the last resort to preserve
guard-set stability (anonymity) and avoid pointless churn on bad networks.
Verified on emulator: poisoned 60/60 -> wipe -> fresh 20/20 usable sample
-> .onion OnOpen, zero AllGuardsDown.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the no-progress retry: in this trace vitor/mom/nos.lol never
connect at all (only damus gets a REQ — the other three cannot-connect
for both live and history). The retry correctly re-attempts, but it can't
reach relays that won't connect, so every round is 0 events, no relay is
ever 'done', exhausted never completes, and it retries forever — a cold,
empty feed stays on the spinner.
The round-model history had no give-up for repeated cannot-connect (only
for CLOSE), unlike the convo path. Route onCannotConnect through the same
pager give-up as onClosed, so a relay that's unreachable for a few rounds
is abandoned, exhaustion completes, and the screen resolves (to the
loaded rooms, or empty + retry) instead of spinning forever. The streak
resets on any contact, so a merely slow relay that connects within a few
attempts isn't dropped. Applied to both round-model managers.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
On a cold start with no cached rooms and no recent (<7d) DMs, the rooms
list relies entirely on history paging. If a round settles via
cannot-connect / CLOSE (relays dropped during a connect storm) instead of
a clean empty-EOSE, no relay is marked done, exhausted stays false, and
the no-progress guard then refuses to retry — recovery would only come if
the relays happened to reconnect AND re-EOSE the open subscription on
their own. Meanwhile the empty feed shows LoadingFeed() forever (it needs
BOTH protocols exhausted to show 'no conversations').
When a round makes no progress but isn't exhausted, actively retry after
a 5s backoff (clearing the no-progress gate) instead of waiting
passively. Paced so a fast-CLOSE / rate-limited relay isn't hammered, and
it stops once the protocol exhausts or a round makes progress. Applied to
both round-model history managers (giftwrap + rooms NIP-04).
Not caused by the stall-gate drop — the empty-feed search path is
unchanged by that commit; this is a pre-existing fragility the severe
76s connect storm in this cold start exposed.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The SPI name leaned on "Req" (the NIP-01 command), which isn't self-explanatory.
Rename to read as what it is — a source of events for a query:
- ReqResponder -> EventSource (method respond() -> events())
- ReqResponderBackend -> EventSourceBackend (param responder -> source)
- ReqResponderServer -> EventSourceServer
- *Test + RELAY.md + KDoc references updated to match.
Also drop the JwtAuthPolicy snippet from RELAY.md (it was only an illustrative
doc example, never a real class); the surrounding prose still documents the
`authorize` bridge hook.
Pure rename + doc edit, no behavior change; full suite green.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
The stall-gate stopped widening once a load surfaced older messages but
no new conversation row, leaving the boundary card in a confusing paused
state (not loading, not caught-up). It existed to brake the OLD
pagination model, where every widen re-downloaded the whole window; the
per-relay until+limit paging doesn't re-download, so the brake is
obsolete — and a visible boundary card means the user is waiting for
more, so pausing there made no sense.
Now while the boundary is in view each protocol pages round after round
until genuinely exhausted (empty page), then shows 'all caught up'. The
card is only ever loading or caught-up, never paused. Removes the
autoFillRoomMark mark plumbing and the getMark/setMark gate.
Tradeoff: a user with few conversations but deep message history pages
that history to the end on reaching the bottom — bounded and efficient
now (no re-download), terminated by exhaustion + the no-progress guard.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Cleanup pass (4 review angles), behaviour-preserving, full suite green:
- Extract RelayServerBase: NostrServer and ReqResponderServer duplicated
connect/serve/buildPolicy/activeConnections and the connection scope verbatim
(ConnectionRegistry only unified the bookkeeping). They now share one engine
base and contribute only their backend + teardown; ReqResponderServer is ~10
lines, NostrServer just its ingest/store wiring.
- HyperLogLog.leadingZeroBits: replace the hand-rolled per-byte bit loop with
stdlib Int.countLeadingZeroBits() (- 24 for the 0..255 byte).
- LimitsPolicy.clampLimits: drop the `var changed` + throwaway-list map for a
`none{} -> map{}` that's simpler and allocates nothing when no filter is
clamped (the common case once maxLimit is set).
- PolicyStack: collapse the two first-non-null hook loops to firstNotNullOfOrNull.
- RelaySession.handleAuth: use OkMessage.rejected(MachineReadablePrefix.ERROR, …)
instead of hand-writing the "error:" prefix, matching the COUNT path.
Skipped: rewriting FullAuthPolicy's pre-existing auth-required:/invalid: reason
strings to MachineReadablePrefix — unchanged context lines, out of this diff's
scope.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
The bare glyph markers (✓ 8 · ↓ 1) had no context. Prefix each with a
translatable 'Relay sync:' label and add '·' separators between states,
so a marker reads e.g. 'Relay sync: ✓ 8 · ↓ 1' or 'Relay sync: ↓ nostr.wine'.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
The pendingNewlyAdded field was a rollback token faked through instance state:
FullAuthPolicy.accept(AuthCmd) committed the pubkey eagerly, so a later
rejection (composed policy or a throwing hook) had to be undone, and the field
existed only to avoid dropping a pubkey that was already authenticated.
Fix the root cause — the eager commit. accept(AuthCmd) now only validates; the
pubkey is recorded in FullAuthPolicy.onAuthenticated (made final), which the
engine calls only after accept AND the whole policy chain approve the AUTH.
External-auth bridges override a new open `authorize` hook that runs before the
commit; throwing rejects the login with nothing committed to undo.
This deletes pendingNewlyAdded, IRelayPolicy.onAuthenticationFailed, its
PolicyStack override, and both rollback call-sites in RelaySession.handleAuth —
and makes the prior compose-after-reject / failed-re-AUTH cases correct by
construction (no rollback to get wrong). Tests updated to override `authorize`;
the two regression tests pass unchanged.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
The status card's icon slot only handled two states — caught-up (✓) and
loading (spinner) — leaving it blank in the third: not exhausted but not
actively loading (the rooms-list auto-fill stops short of exhaustion via
the no-new-rooms stall-gate, or between round-model pages). The card then
read 'Older … messages · N relays' with nothing on the left. Fill that
paused state with a static '⋯' glyph so the slot is never empty.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Behaviour-preserving readability improvements (full suite green):
- LimitsPolicy: drop the <T : Command> generic reject helpers (which forced
rejectSubId<ReqCmd>(...) call-site type args). Each check is now a
"rejection reason or null" function (eventRejection / subscriptionRejection)
and the accept overloads just wrap a non-null reason — the three overloads
read almost identically.
- Extract ConnectionRegistry: NostrServer and ReqResponderServer duplicated
the connection bookkeeping (stable-id keying, active gauge, once-only
teardown accounting). That subtle logic now lives in one named class both
servers delegate to; their connect()/close() shrink to the parts that
actually differ (the backend and what else teardown closes).
- HyperLogLog.addPubKey: rename `ri` -> `registerIndex`.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Final-review findings on the relay tooling:
- Auth bypass (security): FullAuthPolicy.accept(AuthCmd) commits the pubkey,
but handleAuth only rolled back on the hook-throw path — a policy composed
AFTER FullAuthPolicy that rejects the AuthCmd left the connection
authenticated behind an OK false. handleAuth now also rolls back on the
reject path, preserving OK-true-iff-authenticated for any composition order.
- Failed re-AUTH no longer drops a prior valid auth: onAuthenticationFailed
removes only the pubkey THIS AUTH newly added (tracked via Set.add's return),
not one already authenticated earlier on the connection.
- Rename the server-side RelayConnectionListener -> RelayServerListener to
avoid colliding with the existing client-side
relay.client.listeners.RelayConnectionListener (published-API clarity).
- RelayLimits.toNip11Limitation clamps created_at bounds to Int range so a
post-2038 epoch second can't wrap negative in the NIP-11 document.
- Add regression tests for both auth cases (reject-after-FullAuth; failed
re-AUTH keeps prior auth).
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Nip11RelayInformation modeled `icon` but not `banner` (NIP-11's wide
promotional image, distinct from the square icon), so relays advertising a
banner couldn't round-trip it. Add the field + a round-trip test.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Previously max_message_length and max_subscriptions were hard-coded in
RelaySession behind a parallel `limits` param, while the per-command limits
went through LimitsPolicy — two mechanisms, and a custom policy couldn't
influence the session-level ones.
Unify them: add two default-noop hooks to IRelayPolicy —
acceptMessage(raw) (pre-parse) and acceptSubscription(subId, openCount) —
chained through PolicyStack so they compose across multiple policies.
LimitsPolicy now implements all limit checks; RelaySession just invokes the
hooks and no longer takes a `limits` param. Servers compose LimitsPolicy
whenever `limits` is set and keep `limits` only to advertise via NIP-11.
Behaviour is unchanged (oversized -> NOTICE invalid:, sub cap -> CLOSED
rate-limited:); the enforcement now lives in the policy layer. Adds direct
hook unit tests; existing end-to-end limit tests still pass.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Two UI papercuts in the per-relay DM history surface:
- RelayReachMarker listed every relay name comma-joined per state, so a
gap shared by many relays (e.g. all nine clustered at the live-tail
floor on first open) overflowed into an unreadable line. Now each state
shows the relay's host name only when it is the sole one of its state at
that depth (the usual converged case); otherwise just a count, with
maxLines/ellipsis as a backstop.
- The history status card briefly read 'loading from 0 relays': the count
populated a beat after loadingMore flipped true (and again as the last
relay settled). Set the relay count before raising the spinner in
loadMore, and defensively drop the relay clause from the card subtitle
when the count is 0.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
A relay author no longer hand-rolls limit policies or wires NIP-11 twice.
- RelayLimits: one config that is both enforced and advertised. Pass it to
NostrServer/ReqResponderServer and every limit is applied; toNip11Limitation()
renders the same numbers into the NIP-11 limitation block so they can't drift.
- LimitsPolicy: per-command enforcement (max_content_length, max_event_tags,
created_at bounds reject EVENT; max_filters / max_subid_length reject
REQ/COUNT; max_limit clamps, default_limit fills) with invalid: prefixes.
Servers prepend it automatically when limits declares command caps.
- RelaySession: session-level caps that a policy can't see — max_message_length
(NOTICE before parse) and max_subscriptions (rate-limited: CLOSED on new sub).
- NIP-11 serving: Nip11RelayInformation.toJson() + CONTENT_TYPE
(application/nostr+json); the existing model was parse-only.
- Tests for the policy, the session-level caps end-to-end, and the
limits->NIP-11 round trip; RELAY.md Limits + Serving NIP-11 sections.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
The HLL aggregation side (estimate/merge/encode) existed but the construction
side did not, and the Jackson wire (de)serializer silently dropped the `hll`
field — so a JVM/Android relay could not actually answer COUNT with HLL.
- HyperLogLog.addPubKey(): the NIP-45 construction (register index = pubkey
byte at the filter offset; value = leading-zero-bits from offset+1, +1),
KMP-safe. Plus HyperLogLog.builderFor(filter) and an HllBuilder that streams
event pubkeys into registers and yields an approximate CountResult.
- Plumb CountResult through the count path: SessionBackend.countResult /
ReqResponder.countResult (default = exact count(); override for approximate/
hll); RelaySession sends the returned CountResult.
- Fix CountResultSerializer/Deserializer (jvmAndroid) to write/read the `hll`
hex field, matching the kotlinx serializer the native targets already use.
- Tests for construction (index/value/merge-idempotence) and the wire path;
RELAY.md Approximate COUNT section.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Gives relay operators metrics/logging hooks without patching the engine, and
removes the hashCode()-keyed connection registry the audit flagged.
- RelaySession gains a stable, process-unique `id` (monotonic counter).
- RelayConnectionListener (onConnect/onDisconnect, no-op default) is accepted
by NostrServer and ReqResponderServer; both now key their connection
registry by `id` instead of hashCode() (no more identity-collision hole).
- Both servers expose a live `activeConnections` gauge. Teardown accounting is
idempotent (double close counted once) and onDisconnect fires for any
connections still open at server close.
- Tests + RELAY.md Observability section.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Add cs-rCZ, de-rDE, sv-rSE and pt-rBR translations for the new settings
search field (placeholder + no-results) and the share-to-DM flow strings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audit finding: FullAuthPolicy.accept(AuthCmd) added the pubkey to the
authenticated set before onAuthenticated ran, so a bridge that threw from
onAuthenticated (its whole point — reject when e.g. a JWT exchange fails)
produced an OK false while the connection stayed authenticated server-side.
Subsequent REQ/EVENT/COUNT were then allowed despite the failed login — an
auth bypass.
- Add IRelayPolicy.onAuthenticationFailed(pubKey) (default no-op), forwarded
by PolicyStack and overridden by FullAuthPolicy to drop the pubkey.
- RelaySession.handleAuth calls it when onAuthenticated throws, restoring the
invariant that a client treated as authenticated is exactly one that got
OK true. The rollback is itself guarded so a misbehaving policy can't also
swallow the failing OK.
- Tests: failed-hook now asserts the connection is NOT authenticated and that
a follow-up REQ is rejected with auth-required.
- RELAY.md: note that throwing from onAuthenticated rolls auth back.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
startUntil() is 'now - 1week' and was recomputed on every updateFilter,
so it drifted forward in real time. A relay that hadn't advanced its
cursor (first page in flight, or empty) has until = that floor, so its
filter changed every time ANY other relay's EOSE triggered
invalidateFilters — the subscription saw a 'new' filter and re-REQed it.
The trace showed nostr.oxtr.dev asked twice (until 1779903206 then
...207, +1s) and reaching the bottom (done) twice, and it fed extra
ditto REQ->CLOSE churn and rate-limiting on the busy relays.
Pin the floor once when the window starts and reuse it for the window's
life (which ends in ~2s once every relay is done or stalled), so an
un-advanced relay's filter stays stable and only a relay whose cursor
genuinely advanced is re-REQed.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Lets non-storage relays (search, redirector, computed/projected data) answer
REQs without implementing the heavy IEventStore or hand-writing the
readFrame -> parse -> policy -> EVENT/EOSE loop.
- ReqResponder: the public Flow<Event> SPI — respond(filters): Flow<Event>
(+ a count() default). EOSE is sent when the flow completes.
- SessionBackend: the seam RelaySession now depends on (query/count/submit/
negentropy-snapshot). submit + snapshot default to reject / empty so a
responder only implements the read path. LiveEventStore implements it
(storage path unchanged); ReqResponderBackend adapts a ReqResponder.
- ReqResponderServer: storage-free dispatch engine mirroring NostrServer's
connect/serve/close, reusing RelaySession for the full wire protocol.
- RelaySession now frames backend failures as CLOSED error: <msg> (REQ) and
count failures likewise, instead of dropping the coroutine — useful for
responders doing network I/O.
- RELAY.md: Non-Storage Relays section + engine/source-map updates.
Storage path (NostrServer + IEventStore, live tail, negentropy) is unchanged;
existing server/auth/negentropy tests pass alongside the new responder tests.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
Review prep for the DM pagination branch:
- Delete commons TimeWindowPagination + its test: the early since-based
time-window approach, referenced only by its own test and fully
superseded by UntilLimitPager (until+limit, gap-proof). 212 lines a
reviewer would otherwise study for nothing.
- Bring the design doc up to the final architecture: NIP-04 per-relay
filter scoping, per-relay independent paging (no rounds) + in-stream
markers for the convo, the round model still used by rooms/gift-wrap,
the WindowLoadTracker backstops and tracksReqSends gating, the
loadingMore-starts-false fix, and the DMPagination diagnostics map.
Marks the obsolete time-slice section as superseded.
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Addresses the self-contained, low-risk items from the relay-ergonomics
request:
- NIP-50: add SearchQuery to parse Filter.search into free-text terms and
the typed key:value extensions (domain/language/sentiment/nsfw/include),
preserving unknown extensions and offering a canonical toSearchString().
- NIP-42: add a suspend IRelayPolicy.onAuthenticated(pubKey, event) hook
(chained through PolicyStack) so external-auth bridges (e.g. JWT exchange)
can live inside FullAuthPolicy instead of leaking into transport code.
RelaySession invokes it after the AUTH passes; a throw becomes OK false.
- Ergonomics: Command.fromJson/toJson and Message.fromJson/toJson mirroring
Event, plus MachineReadablePrefix + OkMessage/ClosedMessage factories for
standardized OK/CLOSED reason prefixes.
- Docs: RELAY.md sections for the external-auth bridge, NIP-50 search, and
the wire helpers.
https://claude.ai/code/session_016YBS2pWCBSDgAMthHzfCTr
loadingMore was wired straight to windowLoad.loading, which starts true
(the tracker assumes a load is in flight from construction). On the
first conversation open — before any paging window has run — that true
wedged the scroll-driven loader: its gate is '!loading', so loadMore
never fired, and even if it had, the 'if (!windowLoad.loading.value)
startLoading' guard would have skipped startLoading (value was the
construction-time true), leaving no watchdog to ever settle it. Result:
permanent spinner, 0 relays. Earlier opens only worked because a prior
conversation had left the shared tracker at false.
Expose a _loadingMore that starts false and is mirrored from the window
by the done collector, and track windowActive ourselves so the first
loadMore actually starts the window (and a re-entrant loadMore mid-page
doesn't reset it and forget finished relays).
https://claude.ai/code/session_01B1fmmmX8JjQWH3amMLdvcW
Kind 34551 (CommunityRulesEvent) was missing from EventFactory.create, so
signing a community-rules template produced a generic Event. Returning it
as CommunityRulesEvent in Account.sendCommunityRules threw a
ClassCastException when publishing community rules.
Register the kind in the factory and add a regression test.
- Mark all *_search_keywords translatable="false" (English concept/protocol
index; stops Crowdin translating protocol terms and breaking locale search) [#1]
- Collapse ~23 symbol+nav rows via a local symEntry() helper [#3]
- Reword keywordsRes KDoc to match the actual word-prefix tokenization [#6]
- Add search keywords to the Legal rows (privacy_policy, child_safety) [#7]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add keyword blobs so settings resolve by concept/protocol name, not just
title — e.g. "blossom" -> Media Servers, "audio rooms" -> Nests Servers,
"negentropy" -> Event Sync, "nsec" -> Backup Keys. NIP numbers omitted by
preference.