Commit Graph

1994 Commits

Author SHA1 Message Date
Claude 709e254c15 fix: periodic keep-alive to revive relays in long backoff
When a relay enters a long backoff (5 min, e.g. host unreachable or
a server returned an HTTP error during handshake), the per-relay
delayToConnectInSeconds blocks reconnect attempts for up to 5 minutes.
Without a wakeup, nothing inside NostrClient revisits that relay until
the next subscribe/count/publish.

Add a keep-alive coroutine that calls reconnectIfNeedsTo(false) every
60s while the client is active. The per-relay backoff still gates the
actual reconnect, so dead relays are not hammered, but a relay whose
backoff window has elapsed is reconnected within ~60s of becoming
eligible.

The job lives in scope and is cancelled by close().
2026-05-04 20:28:02 +00:00
Claude 575fe952c6 fix: auto-reconnect relays after server-initiated disconnect
Previously, when a relay closed the WebSocket (or the connection
dropped), NostrClient.onDisconnected only updated state and notified
listeners — it never tried to reconnect. The relay then stayed
disconnected until the next subscribe/count/publish call (which
triggers reconnect()) or an explicit reconnect() from the caller.

Now, if the client is still active and the relay is still in the
desired set (i.e. some sub/count/outbox still wants it),
onDisconnected schedules a debounced reconnect via reconnectIfNeedsTo,
which respects per-relay exponential backoff so we don't hammer dead
relays.
2026-05-04 20:17:29 +00:00
Vitor Pamplona 0210d608b5 Merge pull request #2719 from nrobi144/fix/bunker-timeout-and-decrypt
fix(nip46): fix bunker decrypt/encrypt parsing and increase timeout
2026-05-04 08:09:33 -04:00
Vitor Pamplona b1fb13cddc Merge pull request #2720 from davotoula/feat/log-lambda-overloads
chore: convert interpolated Log calls to lambda overload + restore throwables in Marmot catch blocks
2026-05-04 08:08:19 -04:00
Claude 76b69cd440 feat: bulk-remove for blocked users and hidden words
Long-press a row in Settings -> Security Filters to enter selection mode,
pick more rows, tap Unblock. The mute list (kind 10000) and block list
are rebuilt and re-broadcast once for all selected entries instead of
once per item.

- Quartz: add `MuteListEvent.removeAll` and `PeopleListEvent.removeAll`
  bulk overloads using the existing `TagArray.removeAny` primitive.
- Account / state classes: add `showUsers(List)` and `showWords(List)`
  that produce a single resigned event per list.
- AccountViewModel: thin wrappers `showUsers` / `showWords`.
- SecurityFiltersScreen: hoist per-tab selection sets, swap top bar
  in selection mode, add a local selectable user list (the shared
  `UserCompose` row was kept untouched).
2026-05-04 08:55:29 -03:00
davotoula 31dee7fe38 fix(log): pass throwable to Log.w in Marmot catch blocks
Previously the catch-block warnings interpolated ${e.message} but
dropped the throwable, losing the stack trace and showing nothing for
exceptions whose message is null. Switch to the (tag, msg, throwable)
overload so the cause and stack trace are logged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 10:52:39 +02:00
davotoula a22f63e42c refactor(log): convert interpolated Log calls to lambda overload
Avoid eager string interpolation when the log level is filtered out at
runtime. Covers Log.d/i calls and Log.w/e calls that did not pass a
throwable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 10:15:22 +02:00
nrobi144 bc2267d79a fix(nip46): fix bunker decrypt/encrypt response parsing and increase timeout
Two issues fixed:

1. BunkerResponseDeserializer produces generic BunkerResponse for decrypt/encrypt
   results (plain strings that aren't pubkeys or JSON). The response parsers only
   matched typed subtypes (BunkerResponseDecrypt/Encrypt), causing all decrypt and
   encrypt operations through NIP-46 bunker to fail with ReceivedButCouldNotPerform.
   Fix: parsers now fall back to extracting response.result directly.

2. RemoteSignerManager timeout was 30s, shorter than Amber's 60s approval window.
   Fix: increased to 65s with safe retry (republishes same request, preserving UUID
   so bunker responses always match).

3. Desktop ChatPane showed raw ciphertext on decrypt failure instead of an error
   message. Fix: shows "Could not decrypt the message" in italic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-04 10:24:05 +03:00
Vitor Pamplona bf82ce454b Merge pull request #2707 from mstrofnone/feat/namecoin-import-nip05-only
feat(namecoin): resolve `import` items per ifa-0001 (NIP-05 only)
2026-05-03 12:20:41 -04:00
mstrofnone 07f6c352e8 feat(electrumx): add bare IP peer for relay.testls.bit (23.158.233.10)
Adds the underlying IP literal as a separate ElectrumX entry in both
the clearnet default list and the Tor list, mirroring how
46.229.238.187:57002 sits beside nmc2.bitcoins.sk:57002.

Why a bare IP entry alongside a hostname entry?

  - Robustness against unhealthy ICANN DNS paths. The relay.testls.bit
    name resolves through Namecoin (.bit), which most resolvers ship
    via a NIP-05 lookup against ElectrumX itself \u2014 chicken-and-egg if
    the only listed servers ARE .bit-resolved. A bare IP gives the
    resolver pool a completely-DNS-free anchor.
  - Same physical box, same self-signed cert, same SHA-256 (DER) pin
    (bb0b35e6\u20266cba) added in the prior commit, so no new cert pin is
    needed.

The .onion entry added in the prior commit already serves as the
DNS-free Tor anchor; this commit handles the equivalent for clearnet.

Verified end-to-end:
  node tls direct connect 23.158.233.10:50002 (no SNI) -> server.version
  reply ElectrumX 1.16.0; cert SHA-256 matches the pinned hash exactly.
2026-05-03 17:37:10 +10:00
mstrofnone 737464e528 feat(electrumx): add relay.testls.bit endpoints (clearnet TLS + Tor)
Adds the relay.testls.bit ElectrumX-NMC deployment as a second public
Namecoin name resolver. The same box also runs the Namecoin-anchored
Nostr relay at wss://relay.testls.bit/, so a single TLSA record on
d/testls anchors the trust chain for both services.

Default server set (clearnet):
  + relay.testls.bit:50002      (TLS, self-signed cert pinned)

Tor server set (onion-first):
  + 6cbn4rsk...onion:50001      (plaintext over Tor; re-uses the
                                 relay's existing v3 hidden service,
                                 onion key already authenticates)
  + relay.testls.bit:50002      (clearnet TLS fallback)

Cert pin (SHA-256 of DER):
  bb0b35e64235a794e157b69acd72da4ccc16a4d81d0ff6b1d8f7fdfc6cca6cba

The new self-signed cert is appended to PINNED_ELECTRUMX_CERTS so
strict-TLS Android devices (Samsung One UI 7, GrapheneOS) accept it
without a trust-all fallback.

Reference deployment also exposes wss://relay.testls.bit/electrumx
(WebSocket transport for browser-based Nostr clients), which inherits
the relay's existing TLSA-pinned ECDSA cert from d/testls. The current
JVM ElectrumXClient is TCP+TLS only; adding a WS variant of the client
is a larger change and tracked separately.
2026-05-03 17:23:53 +10:00
mstrofnone 3835622fc5 feat(namecoin): resolve import items per ifa-0001 (NIP-05 only)
Adds a new `NamecoinImportResolver` that follows the `import` field of a
Namecoin Domain Name Object value, recursively merging the imported
values into the importing object before `NamecoinNameResolver` reads
the `nostr` field.

Why
---
The 520-byte per-name limit on Namecoin is tight when a single record
needs `ip`, NIP-05 names, TLS, and per-subdomain map entries. A common
workaround (used by `testls.bit` and others) is to keep the apex record
small by delegating shared blocks into a sibling name via
`"import":"dd/<name>"` per ifa-0001 §"import". Without import support,
NIP-05 search for `testls.bit` (and the like) fails: the resolver sees
no `nostr` field at `d/testls` and gives up before consulting the
imported `dd/testls` that actually carries the names block.

What
----
Per ifa-0001 §"import":
  * Importer items take precedence over imported items (including `null`,
    which suppresses the imported counterpart).
  * Recursion is supported up to 4 levels (the spec minimum) by default.
    Cycles are broken by a visited-set; over-budget chains are silently
    truncated, with the importer's own items still taking effect.
  * Multiple imports in one array merge later-wins (the array's own
    order), with the importing object on top of all of them.
  * Subdomain selectors (the optional 2nd element of each inner array)
    are resolved through a `map` walk inside the imported value, with
    the standard exact-label > `*` wildcard > `""` default rules.
  * Three short-hand value forms are accepted alongside the canonical
    array-of-arrays: `"import":"d/foo"`, `"import":["d/foo"]`, and
    `"import":["d/foo","selector"]`. They map onto the canonical
    representation losslessly.
  * A failed import lookup (name not found, malformed JSON, network
    error) is treated as `{}` rather than failing the whole record, so
    transient ElectrumX hiccups don't kill resolution. The importing
    object's own items still apply.

Wires the expander into `NamecoinNameResolver.performLookup` and
`performLookupDetailed`. Records without an `import` key skip the
resolver entirely (zero extra I/O), so non-import names pay no cost.

Tests
-----
20 new tests in `NamecoinImportTest`:
  * Unit (resolver in isolation): no-import passthrough, all four
    shorthand value forms, importer precedence, null suppression,
    depth-4 recursion happy path, over-budget truncation, lookup
    failure, malformed JSON, malformed `import` value, cycle
    protection, multi-label selector descending in DNS order, wildcard
    fallback.
  * Integration with `NamecoinNameResolver`: bare and named NIP-05 across
    an import (mirroring the real-world `testls.bit -> dd/testls`
    deployment); regression guard that no-import records issue exactly
    one ElectrumX query; importer-wins on `nostr.names`; lenient
    failure when the imported boilerplate is unreachable;
    `resolveDetailed` returns `Success` and `NoNostrField` correctly
    across imports.

All 15 existing `NamecoinNameResolverTest` cases continue to pass.

Scope note
----------
This PR is intentionally scoped to the NIP-05 / search path. The
relay-resolution path (#2595) also benefits from import support; that
wiring is left to land alongside #2595 so the two PRs can move
independently.
2026-05-03 09:53:10 +10:00
Vitor Pamplona 3554ccf4c0 Merge pull request #2704 from davotoula/fix/quartz-nip19-tlv-bounds
fix(quartz): bound Tlv.parse against malformed naddr/nevent/nprofile
2026-05-02 10:00:50 -04:00
davotoula dc1ef53027 Code review:
- switch Tlv.parse to integer cursor
- tighten Tlv.parse loop + drop tautological fuzz assertion
2026-05-02 15:39:06 +02:00
davotoula ecc79192a4 bound Tlv.parse — guard short input + clamp truncated lengths 2026-05-02 15:38:34 +02:00
davotoula ca640df2c2 cover PoWRankEvaluator partial-rank branches explicitly
bound PoWRankEvaluator hex loop to id.indices
2026-05-02 09:27:39 +02:00
Vitor Pamplona 4a54ac6bee minimizes chance of failing tests 2026-05-01 16:16:14 -04:00
Vitor Pamplona cc194273a3 Passing the Note, not the event to the Nest viewmodel 2026-04-30 19:20:53 -04:00
Vitor Pamplona 4003b735dd Adds an isNewThread to the TextNote 2026-04-30 17:40:16 -04:00
Vitor Pamplona 2f9c6bddeb simplifying the readme 2026-04-30 17:37:29 -04:00
Vitor Pamplona 479f14cbd4 additional info 2026-04-30 17:28:20 -04:00
Vitor Pamplona acb36adfa3 Merge branch 'main' of https://github.com/vitorpamplona/amethyst
# Conflicts:
#	quartz/README.md
2026-04-30 17:26:28 -04:00
Vitor Pamplona 43dc925b74 Updates readme to look more like a guide 2026-04-30 17:22:58 -04:00
Claude c4a38bb899 docs(quartz): align tutorials with the layered patterns demoApp uses
The README's "Wiring relays into the store" tutorial used a
per-subscription SubscriptionListener.onEvent callback to feed the
store. EventCollector landed exactly to remove that boilerplate, so
update the example to register one connection-level collector at app
init and stop wiring listeners per subscribe() call.

The "Building a reactive feed UI" example showed project() ->
stateIn(WhileSubscribed) but never opened the relay subscription.
Bind it to the projection's collection lifecycle with .onStart {
client.subscribe } / .onCompletion { client.unsubscribe }, so the
relay subscription's lifetime equals the UI collector's. Also
demonstrate ProjectionState.filterItems for narrowing without a
re-query.

Drop the redundant client.connect() call from the wiring example
and add an explicit note (in both README and CLIENT) that
NostrClient connects on demand from subscribe()/publish() — connect()
is only useful after disconnect().

Other small fixes:
  - client.send(...) -> client.publish(...) (the actual API name).
  - Rename the variable from `observable` to `db` to match the demoApp
    code and the conceptual "this is the local database" framing.
  - Wrap the publish path in a viewModelScope.launch so the snippet
    is copy-pasteable into a ViewModel.
2026-04-30 20:50:00 +00:00
Vitor Pamplona 12ed2af028 Moves java.util.SortedSet to an arrayList. 2026-04-30 15:57:26 -04:00
Vitor Pamplona 1e4829626b Merge pull request #2664 from vitorpamplona/claude/quartz-eventstore-observer-o2f1O
Add event projection and interning for reactive event store queries
2026-04-30 13:46:40 -04:00
Claude c873385fd6 docs(quartz): add module-level README with layer overview + tutorials
Tutorial-style README covering the full pipeline:
relay → NostrClient → ObservableEventStore → InterningEventStore →
EventStore → project() → ViewModel → Compose.

Two worked examples:
1. Wiring NostrClient subscriptions into the store via
   SubscriptionListener.onEvent.
2. Building a reactive feed UI with project().stateIn(...) and
   Compose, showing how the three reactivity layers (Loading/Loaded
   state, list membership, per-event handles) map to Compose's
   recomposition model.

Cross-links the existing CLIENT.md, RELAY.md, and store/sqlite
README. Pointers to per-class KDoc for projection internals.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 17:28:17 +00:00
Claude 4cfd56da36 perf(quartz): cheaper hot paths in EventStoreProjection
Three small wins on paths the projection runs every event arrival
or every snapshot publish:

1. Cache `address` on Slot. Previously `removeIndexes(slot)` called
   `addressOf(slot.flow.value)` on every drop — for replaceables
   that's a fresh Address allocation every time. Now the address is
   computed once at slot construction and reused on removal.

2. Single-filter snapshot fast path. The deduped sorted union via
   TreeSet was unnecessary when there's only one per-filter set —
   that set is already sorted in the slot comparator's order. The
   common UI case (one filter per projection) now skips the
   TreeSet allocation + log-n inserts entirely.

3. Comparator singleton. `slotComparator()` was allocating a fresh
   Comparator lambda on every call (ctor + every snapshot).
   Replaced with a single `Comparator<Slot<*>>` cast at the use
   site — the comparator only reads sort keys that don't depend on
   the type parameter.

Also trimmed a verbose comment block in `project()` (3 lines → 1).
All 18 projection tests + 240 store + interner tests still pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 16:24:15 +00:00
Claude 058c56dc21 feat(quartz): re-evaluate filter membership on addressable supersession
When a replaceable / addressable update arrives in handleInsert, the
projection now re-runs Filter.match against the new event for every
filter. A v2 that no longer matches a filter (e.g. tag list changed)
is removed from that filter's set; a v2 that newly matches a filter
joins it. If no filter retains the slot afterwards, it's fully
dropped from byId / byAddress.

Closes the previous gap where v2 of an addressable kept a stale
filter membership inherited from v1. The slot's MutableStateFlow is
still updated in place — collectors of that handle still see the
content change before the membership update emits.

The cap-eviction-with-cleanup logic was extracted into a small
`admit` helper since the supersession path and the new-slot path
both need it.

For the common case (filter on `kinds + authors`, no tag/time
constraints), v2 always still matches — the new branches are no-ops
and the list reference stays stable, preserving the in-place update
guarantee. The behaviour change kicks in for tag, time-window, or
id-list filters.

New test addressableUpdateDropsSlotWhenFilterStopsMatching: v1 has
hashtag "nostr" and matches a `t = nostr` filter; v2 changes to
"bitcoin"; the projection drops the slot. 18/18 projection tests
pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 16:10:47 +00:00
Claude f759f44eea docs(quartz): trim verbose KDoc on cache + observable layers
Reviews before merge:

- EventInterner: drop stale "used by Event.fromJson" reference (we
  reverted that earlier). Tighten the class doc.
- ObservableEventStore: collapse the 30+ line class KDoc + duplicated
  StoreChange description into one focused doc each.
- EventStoreProjection: trim the 60+ line class doc; reactive
  semantics live on the project() extension. Add explicit caveat
  about in-place addressable updates not re-evaluating filter
  membership.

No behaviour change. All cache + store + projection tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 16:03:50 +00:00
Claude f4b94d6c6a feat(quartz): InterningEventStore now interns accepted events on insert
After the inner store accepts an event (insert succeeds), register
the caller's instance with the interner so subsequent reads of the
same id return the same `===` reference. This means an event that
was just inserted and is then queried back resolves to the original
in-memory object, not a freshly-deserialized clone.

Same in transaction { }: every accepted event is interned after the
inner transaction commits. If the body throws or inner rolls back,
nothing is interned (accepted list is discarded).

The decorator still does not *substitute* the caller's event with a
previously-cached one — same-id-different-sig collisions resolve
the new event into the cache without overwriting (intern() is
first-seen-wins). The caller keeps the instance they passed in.

All cache + store + projection tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 15:52:21 +00:00
Claude 5755ab90b2 refactor(quartz): switch project() builder from channelFlow to flow
channelFlow was overkill for a single-producer chain. The cost was
an extra Channel hop between every projection.snapshot() and the
downstream collector — pure overhead with no concurrency benefit.

flow { } needs the outer collector captured (because inside
changes.onSubscription { ... } and changes.collect { ... } the
implicit `this` is FlowCollector<StoreChange>, not
FlowCollector<ProjectionState<T>>). One `val outer = this` fixes
that.

Backpressure now flows directly: a slow collector suspends emit,
which suspends our changes.collect, which suspends the SharedFlow's
buffer drain — natural propagation, no decoupling Channel in the
middle.

17/17 projection tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 15:48:36 +00:00
Claude 8a349d0f2e fix(quartz): widen ExpirationTest insert window to dodge isExpired race
The pre-check in SQLiteEventStore.insertEvent rejects events whose
expiration is <= TimeUtils.now(). With expiration = time + 1, the
wall clock can tick past time + 1 between sampling `time` and the
insert call, intermittently failing testDeletingExpiredEvents.

https://claude.ai/code/session_01GoqXtcFnwgyjBict1EURyA
2026-04-30 15:36:05 +00:00
Claude f789fe4f53 refactor(quartz): EventStoreProjection becomes pure state machine; ObservableEventStore.project returns cold Flow
Removes scope ownership from EventStoreProjection. The class is now a
pure state machine — no Job, no AutoCloseable, no embedded
StateFlow:

  class EventStoreProjection(store, filters, nowProvider) {
      suspend fun seed()                         // run the seed query
      fun apply(change: StoreChange): Boolean    // apply one mutation
      fun snapshot(): ProjectionState.Loaded     // current view
  }

ObservableEventStore.observe(filter, scope) is renamed to
ObservableEventStore.project(filter) and now returns a cold
Flow<ProjectionState<T>>. Each collection allocates its own state
machine, runs its own seed, and unsubscribes when the collector
cancels. The flow is built using channelFlow + the state machine —
the class stays as the load-bearing primitive.

Standard caller pattern:

  val state = observable.project<TextNoteEvent>(filter)
      .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000),
               ProjectionState.Loading)

Tradeoffs:
- Cold flow → multiple collectors each re-seed. ViewModel pattern
  with stateIn shares one collection across observers (the standard
  Android approach).
- close() goes away. Cancelling the collecting scope tears down the
  projection.
- closeStopsListening test renamed to cancellingScopeStopsListening
  and now verifies the StateFlow's value freezes after its scope is
  cancelled.

17/17 projection + 240/240 store tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 15:35:30 +00:00
Claude 8517bae7a4 feat(quartz): replace items + ready with sealed ProjectionState
EventStoreProjection now exposes `state: StateFlow<ProjectionState<T>>`
instead of separate `items` + `ready` signals. ProjectionState is a
sealed interface with two cases:

  data object Loading                                  // initial state
  data class Loaded(items: List<MutableStateFlow<T>>)  // post-seed view

This lets the UI distinguish "still seeding" from "seeded but empty"
— the previous `emptyList()` initial value conflated the two. Future
extension to `Failed(throwable)` is a single case addition.

The `ready: CompletableDeferred<Unit>` signal is gone; callers use
`state.first { it is Loaded }` (or the test `awaitReady()` helper).

Test fixtures gain two private extensions on EventStoreProjection<T>:
- val items: List<MutableStateFlow<T>> — terse access to the loaded
  list (returns empty if still seeding).
- suspend awaitReady() — replaces ready.await().

awaitItems(predicate) now matches against the Loaded state. 17/17
projection + 240/240 store + interner tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 15:20:45 +00:00
Claude f23537a49c refactor(quartz): rename InternedEventStore → InterningEventStore
Past-participle "Interned" suggested an attribute of the events
flowing through; present-participle "Interning" describes what the
decorator actively does. Read clearer.

File renamed, no consumers needed updating (this commit is the only
external reference, since callers compose the decorator inline).
All 240 store + projection + interner tests still pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 15:02:30 +00:00
Claude ecbb7ea10c refactor(quartz): tighten projection package layout + relay flows from store
Three changes wrapped together:

1. relay moves up to IEventStore. The interface gains an abstract
   `val relay: NormalizedRelayUrl?` and SQLiteEventStore, EventStore,
   FsEventStore, InternedEventStore, and ObservableEventStore all
   override it (decorators forward inner.relay). EventStoreProjection
   loses its `relay` ctor param and uses `store.relay` for NIP-62
   vanish scoping; ObservableEventStore.observe overloads lose their
   relay arg. The relay was always a property of the store anyway —
   threading it through projection construction was redundant.

2. StoreChange inlines into ObservableEventStore. The sealed type is
   now ObservableEventStore.StoreChange (with Insert / DeleteByFilter /
   DeleteExpired as nested cases) since it's strictly a contract of
   the change stream, never used independently. StoreChange.kt deleted.

3. Package reshuffle:
     store/projection/ObservableEventStore.kt → store/ObservableEventStore.kt
     store/projection/EventStoreProjection.kt → cache/projection/EventStoreProjection.kt
     store/projection/StoreChange.kt → inlined into ObservableEventStore

   ObservableEventStore is a store decorator and lives next to
   IEventStore. EventStoreProjection is a cache primitive (alongside
   EventInterner / InternedEventStore) and lives under cache/. The
   `observe(filters, scope)` convenience moved from a method on
   ObservableEventStore to a top-level extension function in
   cache/projection/, which avoids the otherwise-circular
   store → cache/projection → store dependency.

7/7 interner + 17/17 projection + all other store tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 14:51:16 +00:00
Claude e126ca679a refactor(quartz): move interning classes into nip01Core/cache/interning
Groups EventInterner + InternedEventStore + the platform actuals + the
test under a focused interning sub-package. Leaves nip01Core/cache/ as
the namespace for cache primitives generally; future cache types
(LargeWeakCache, etc.) get sibling sub-packages.

Files moved:
  cache/EventInterner.kt          → cache/interning/EventInterner.kt
  cache/InternedEventStore.kt     → cache/interning/InternedEventStore.kt
  cache/EventInterner.jvmAndroid  → cache/interning/EventInterner.jvmAndroid
  cache/EventInterner.apple       → cache/interning/EventInterner.apple
  cache/EventInterner.linux       → cache/interning/EventInterner.linux
  cache/EventInternerTest.kt      → cache/interning/EventInternerTest.kt

No consumers to update — the previous decorator refactor already
removed the interner from all the store ctors. 7/7 interner +
240/240 store + projection tests still pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 14:40:15 +00:00
Claude 3792c98ec5 refactor(quartz): rename StoreEvent → StoreChange, events flow → changes
The "Event" name was overloaded — the store-mutation type lived
right next to Nostr Event, so StoreEvent.Insert(event: Event) read
awkwardly. Renaming to StoreChange disambiguates and matches
reactive vocabulary ("store changes").

  - StoreEvent → StoreChange (file, sealed type, all references).
  - ObservableEventStore.events → ObservableEventStore.changes.
  - Internal field _events → _changes.
  - KDoc cross-references updated.

Case names (Insert, DeleteByFilter, DeleteExpired) unchanged. All
240 store + projection + interner tests still pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 14:38:23 +00:00
Claude 3fc4781d12 refactor(quartz): encapsulate interning as InternedEventStore decorator
Replaces the ctor-injected interner inside SQLiteEventStore /
QueryBuilder / EventStore / FsEventStore with a standalone
InternedEventStore decorator in nip01Core/cache/.

Composition is now explicit at the call site:

  val sqlite = EventStore(...)
  val cached = InternedEventStore(sqlite)
  val observable = ObservableEventStore(cached)

Each layer has one job: EventStore persists, InternedEventStore
canonicalises read results, ObservableEventStore publishes the bus.
Stores no longer carry an interner field; passing one through three
layers of constructor params is gone.

InternedEventStore wraps every IEventStore.query variant (list +
streaming, single filter + multi) and pipes results through
interner.intern. Writes (insert, transaction, delete*,
deleteExpiredEvents) and counts pass through untouched.

assertQuery test helpers generalized from EventStore /
SQLiteEventStore to IEventStore so the decorator works with the
existing fixtures.

BaseDBTest reverts to plain EventStore — the basic store tests
don't read through the projection / interning layer, so they don't
need decoration. Tests that DO want canonical-instance reads can
wrap explicitly: `InternedEventStore(eventStore)`.

7/7 interner + 240/240 store + projection tests still pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 14:30:48 +00:00
Claude 03e9c9d2b2 refactor(quartz): move EventInterner into nip01Core/cache package
Splits the cache primitives off into their own package. Files moved:

  nip01Core/core/EventInterner.kt          → nip01Core/cache/EventInterner.kt
  nip01Core/core/EventInterner.jvmAndroid  → nip01Core/cache/EventInterner.jvmAndroid
  nip01Core/core/EventInterner.apple       → nip01Core/cache/EventInterner.apple
  nip01Core/core/EventInterner.linux       → nip01Core/cache/EventInterner.linux
  nip01Core/core/EventInternerTest.kt      → nip01Core/cache/EventInternerTest.kt

Imports updated in SQLiteEventStore, QueryBuilder, EventStore (sqlite),
FsEventStore, BaseDBTest. Each platform actual now explicitly imports
nip01Core.core.Event / HexKey since we crossed package boundaries.

7/7 interner + 240/240 store + projection tests still pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 14:19:22 +00:00
Claude 7f613d6003 refactor(quartz): intern only on store reads, inject per-store interner
The previous commit interned at Event.fromJson, which is too early —
the deserializer can produce events with manipulated id fields that
don't match their content. Move interning to the post-validation
boundary: events are only canonicalised when read back from durable
storage, where they were valid when written.

- Revert Event.fromJson to plain OptimizedJsonMapper.fromJson.
- Revert ObservableEventStore.insert interning. Substituting the
  caller's event for a previously-cached one with the same id but
  potentially different sig was a write-side surprise; we leave the
  caller's instance alone.
- Intern at SQLiteStatement.toEvent (read deserialization).
- Intern at FsEventStore.readEvent (FS reads).

The interner is also now constructor-injected through every store
layer (SQLiteEventStore, EventStore, QueryBuilder, FsEventStore)
defaulting to EventInterner.Default. BaseDBTest gives each parallel
forEachDB store its own EventInterner — without this, parallel test
runs that sign "same id, different sig" events through the shared
Default would see the first run's sig leak across all DBs.

All 240 store + projection + interner tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 13:45:01 +00:00
Claude e909866d26 feat(quartz): add EventInterner so deserialized events share canonical instances
Process-wide interner that canonicalises Event instances by id —
whenever the same event id is decoded twice (relay duplicates,
projection seeds, FS re-reads, etc.), every consumer sees the same
object reference. Backed by weak references so entries vanish once
no live consumer (projection slot, UI state) holds the event.

Shape:
- expect class EventInterner in nip01Core/core/, with a Default
  process-wide instance.
- jvmAndroid actual: ConcurrentHashMap<HexKey, WeakReference<Event>>
  pre-sized to 5_000 (load factor 0.75). On-access cleanup atomically
  removes dead entries via map.remove(key, ref). intern() uses
  putIfAbsent + retry-on-dead-canonical to be race-safe.
- apple / linux actuals: passthrough (no canonicalisation). Kotlin/
  Native has weak refs but no built-in concurrent map; rather than
  ship a half-baked impl on Apple targets we skip canonicalisation
  there. Can be revisited if iOS wants the memory savings.

Wire-up:
- Event.fromJson now routes through EventInterner.Default.intern,
  so every deserialised event becomes canonical for free. In-process
  events (signer.sign(...) etc.) aren't auto-interned — the
  canonicaliser pays off for events that re-occur from multiple
  sources, which signed-locally events don't.

Tests (jvmTest):
- internReturnsFirstInstance / internCollapsesDuplicates:
  identity is preserved across equivalent decodes.
- getReturnsLiveEntry / getReturnsNullForUnknownId.
- getEvictsDeadEntries: weak ref cleared by GC, on-access
  cleanup drops the entry.
- draftChurnDoesNotLeak: 100 distinct drafts churn through the
  interner with no strong refs; map shrinks back to ~0 after GC.
- defaultInstanceIsShared: the global Default interner is process-wide.
- 7/7 pass; 240/240 store + projection tests still green.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 13:27:33 +00:00
davotoula 0c2614fbf5 fix(nip39): reject identity claim tags without a platform separator
Malformed `i` tags whose platform-identity field has no `:` (observed in
production logcat as e.g. `["i", " ", " "]`) made `create()` throw
`IndexOutOfBoundsException` from destructuring `split(':')`. The exception
was caught but each event spammed multiple stack traces. Validate the
separator up front in `parse()` and return null silently. Also fix the
diagnostic `joinToString { "," }` typo so the log shows real tag contents.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:21:23 +02:00
Claude ae6343cd73 refactor(quartz): simplify EventStoreProjection (-50 lines, -1 bug)
Review of the projection found one real bug and four redundancies:

Bug fix: handleInsert tracked `retained` inline as it walked filters,
flipping it to false when the slot self-evicted from a filter's cap.
But if filter A retained the slot (cap=null or with room) and filter B
later cap-evicted it, retained ended up false even though the slot was
still in A's set — leaking a slot into a per-filter set with no
byId/byAddress entry. Fix: compute retention after the loop with
`perFilter.values.any { it.contains(slot) }`.

Simplifications:
- Drop the redundant `ordered: SortedSet<Slot>` field. It was only
  read by publish(); it's the deduped sorted union of perFilter
  values. Compute it lazily at publish time instead. Removes the
  field, all the ordered.add/ordered.remove calls, and one helper.
- Collapse `dropSlotIndexes` and `removeSlot` paths into
  `removeIndexes` (id+address) + `removeSlot` (also clears per-filter
  sets). The eviction loop calls removeIndexes; everywhere else
  calls removeSlot.
- Drop the defensive `byAddress[address] === slot` identity check —
  with byId as the primary index, removeSlot is only called once per
  slot, and rekey only ever moves byId, never byAddress.
- Pull the iterate-and-drop pattern into a single `dropWhere(predicate)`
  inline helper. handleVanish, applyDeleteByFilter, and
  applyDeleteExpired all collapse to one-liners.
- Apply now returns Boolean from each branch and publish() runs once
  at the end, instead of three separate `if-publish` blocks.

Net: 233 → 142 lines. Same behaviour, plus the retained bug fix.
17/17 projection tests + all other store tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-30 00:03:03 +00:00
Claude 544d26d9ec refactor(quartz): tidy projection package, flatten StoreEvent, per-filter limits
Five changes wrapped together:

1. Move ObservableEventStore + StoreEvent from store/observable/ into
   store/projection/. One package owns the projection bus + projection
   itself; consumers import only `store.projection.*`.

2. Flatten DeleteRule into the StoreEvent sealed type:
     StoreEvent.Insert(event)
     StoreEvent.DeleteByFilter(filters)
     StoreEvent.DeleteExpired(asOf?)
   ObservableEventStore.delete(filter|filters) emits DeleteByFilter;
   deleteExpiredEvents() emits DeleteExpired with the pinned cutoff.

3. Use event.address() for AddressableEvent and event.isExpirationBefore(t)
   for NIP-40 checks; drop the local addressOf(kind, pubKey, dTag) and
   isExpiredAt helpers. Plain replaceables (kind 0/3, 10000-19999) still
   build Address(kind, pubKey, "") manually since they don't implement
   AddressableEvent. NIP-09 delete-by-address now looks up the projection's
   byAddress index with the deletion's Address directly (it's already an
   Address, no conversion needed).

4. Per-filter limit. Each filter retains its own capped TreeSet<Slot>
   (sorted created_at DESC, id ASC). A slot is "live" iff at least one
   filter retains it. The projection's items is the deduped union, so
   when filter A and filter B match disjoint events the union can
   exceed any single filter's limit. New tests confirm both behaviours:
   per-filter eviction + union-larger-than-cap.

5. Drop the redundant + 1 in expiration checks; use isExpirationBefore
   directly with TimeUtils.now() for arrival-time guards. The sweep
   path subtracts 1 because the store's SQL uses strict `<` while the
   helper is `<=`.

17/17 projection tests + all other store tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-29 22:43:46 +00:00
Claude f666fe2002 refactor(quartz): key projection's address index by Address instead of String
Renames `byStableKey: HashMap<String, Slot>` to
`byAddress: HashMap<Address, Slot>` in EventStoreProjection. Address
is a data class on every platform so equals/hashCode are derived
from (kind, pubKeyHex, dTag) — same identity as the synthetic string
key, but typed.

`stableKey(event)` / `stableKey(kind, pubKeyHex, dTag)` become
`addressOf(...)` returning `Address?`. Plain replaceables map to
`Address(kind, pubKey, "")`, addressables to
`Address(kind, pubKey, dTag)`, regular events to null.

`limit` was already used at insertNew() to enforce the cap on
inserts; left unchanged.

15/15 projection tests still pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-29 21:42:46 +00:00
Claude d394a38f2b feat(quartz): emit StoreEvent.Delete from observable for filter / expired sweeps
Replaces ObservableEventStore.events: SharedFlow<Event> with
SharedFlow<StoreEvent>, where StoreEvent is:

  Insert(event)
  Delete(rule: DeleteRule)
    DeleteRule.Filtered(filters)   ← from delete(filter) / delete(filters)
    DeleteRule.Expired(asOf)       ← from deleteExpiredEvents()

ObservableEventStore now emits Insert for accepted events, and Delete
for every out-of-band removal that went through it. The Expired rule
carries the cutoff timestamp (TimeUtils.now() at call time) so the
projection's in-memory drop matches the store's on-disk drop without
clock skew between collectors.

EventStoreProjection no longer runs its own NIP-40 ticker. Expired
events linger in [items] until the application calls
deleteExpiredEvents() on the observable — at which point the
projection drops everything whose expiration is past `asOf`. The
ticker, expirationTickMs constructor arg, and sweepExpired helper are
all removed.

For DeleteRule.Filtered the projection iterates current slots and
drops any whose event matches any of the rule's filters via
Filter.match — same predicate the store uses.

Tests:
- nip40ExpirationDroppedByTicker → nip40ExpirationDroppedOnStoreSweep:
  drives a deleteExpiredEvents() call instead of waiting on a ticker.
- New deleteByFilterRemovesMatchingSlots: confirms delete(filter) on
  the observable removes exactly the matching slots from the projection
  without touching others.
- 15/15 projection tests + all other store tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-29 21:20:31 +00:00
Claude 32850c3d40 refactor(quartz): make ObservableEventStore an external composition layer
Reverts the previous round's choice of embedding an ObservableEventStore
inside EventStore. The wrapper now lives strictly outside any specific
store: callers compose `ObservableEventStore(EventStore(...))` (or
`ObservableEventStore(FsEventStore(...))`, or any IEventStore) and use
that as their projection bus.

EventStore is back to a plain IEventStore implementation over
SQLiteEventStore — no `observable`, `events`, or `observe()` on it.

ObservableEventStore gains the `observe(filters, relay, scope)` and
`observe(filter, relay, scope)` convenience methods. NIP-62 vanish
scoping is passed in per-projection (rather than a constructor field
on the wrapper) so the same observable can feed projections with
different relay scopes.

Tests construct `observable = ObservableEventStore(store)` in
`setUp` and route inserts through `observable.insert(...)` so the
projection bus actually publishes them. 14/14 projection + 219/219
other store tests pass.

https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
2026-04-29 21:06:27 +00:00
Claude 0fafd34537 fix(nests): emit canonical title tag and keep mute badge visible while muted
Two interrelated bugs from the deployed-schema flip:

1. Rooms created in Amethyst didn't appear in nostrnests's "Live Now"
   lobby. The lobby filter in NestsUI's skeleton.js requires a `title`
   tag on every kind:30312 — events without one are dropped from
   `live`/`planned`/`ended`. RoomNameTag still emitted the legacy
   `room` name, so our rooms passed our own readers (which tolerate
   both names) but were invisible to NestsUI. Flip canonical to
   `title`, keep `room` as legacy on read.

2. The mute badge over the avatar disappeared shortly after toggling
   mute, even though the broadcaster was still muted. Two causes:

   - ParticipantsGrid gated both the muted ring and the MicStateBadge
     on `member.publishing`. But per the deployed kind-10312 semantics
     `publishing=0` whenever `muted=1` (NestRoomPresencePublisher
     mirrors this — `publishingNow = broadcasting && !isMuted`), so
     the badge that was supposed to *indicate* mute was hidden
     exactly when it became relevant. Show the badge when on stage
     and either publishing OR muted; gate the muted ring on `muted`
     alone.

   - The presence heartbeat captured `micMutedTag` / `publishingTag`
     / `onstageTag` / `handRaised` at LaunchedEffect launch and never
     refreshed them. The 500 ms debounced LaunchedEffect did publish
     the new mute state, but ~30 s later the heartbeat overwrote it
     with the captured pre-toggle values, flipping `publishing`
     back to 1 and `muted` back to 0 server-side. Wrap the dynamic
     fields in `rememberUpdatedState` so the heartbeat reads the
     latest snapshot on every refresh; keep the existing key set
     (event/handRaised/onstage) for prompt re-emission on those
     transitions.

Tests updated for the new canonical `title` emission; both jvmTest
suites pass.
2026-04-29 20:45:32 +00:00