Files
Claude 9afa8127da refactor!: take quartz from vitorpamplona/quartz
Quartz moves to its own repo, the way the crypto primitives moved to
crypto-kmp. Every module depends on com.vitorpamplona.quartz:quartz
2.0.0 (libs.quartz) instead of :quartz, and -PquartzDir=../quartz
builds a local checkout instead of the artifact, for every module and
for quic's own quartz dependency (which replaces the root build's
substitution of the published quartz with :quartz).

What moved with quartz: its CI jobs (lint tasks, test-quartz-linux-native,
test-quartz-ios), the Maven Central publish step of the release
workflow, jitpack.yml, tools/unicode-nfkc, the nostr-expert and
event-store-semantics skills, the agent-skill check, and the README's
library usage docs.

What stays here:
- The four quartz client tests that need a real Ktor socket relay
  (TrustNetworkSync*, GiantReqStreamTest, BulkDownloadBenchmark) move to
  geode's tests; geode forwards -PprodRelayBench for the latter. quartz's
  other relay-client tests now use a test relay of its own, which ends
  quartz's test dependency on geode.
- The benchmark module's resources (previously read from quartz's
  androidDeviceTest resources) move into benchmark/; relayBench --real
  reads the corpus from there.
- NoteComposeKindCoverageTest reads EventFactory.kt from quartz's JVM
  sources jar, or from the -PquartzDir checkout, passed by commonsUI's
  build.
- commons and commonsUI declare crypto-kmp (and commons mls-kmp), which
  they import directly. Through quartz's api alone, a -PcryptoKmpDir
  checkout never reached the common metadata compile.
- The catalog drops what only quartz used (kchesslib, negentropy,
  jackson-databind, sqlite-bundled, secp256k1-kmp common, compose
  runtime-annotation, the maven-publish plugin).

agent_config_check checks a skill's quartz/... paths against a ../quartz
checkout when there is one and skips them otherwise. AGENTS.md, the
skills, CONTRIBUTING, BUILDING, RELEASE_OPS, PLANS and the READMEs point
at the new repo.

Needs quartz 2.0.0 (and crypto-kmp 1.0.0) on Maven Central before CI can
resolve it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEkJaDWYrcTbGvT8JLQCrG
2026-10-21 16:32:25 +00:00

7.3 KiB


name: account-state description: Account, LocalCache, User/Note and feeds in commons/model + commons/feeds: per-user XState objects (.flow), FeedFilter/AdditiveFeedFilter, FeedViewModel. Use when adding an account setting or list, a feed, or choosing LocalCache vs account flows.

Account, LocalCache and Feeds

The client state backbone, all headless in commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ (abbreviated commons/…/ below):

  • model/cache/LocalCache.kt — object LocalCache : EventCache(). Every Note / User the client has seen. All logic is in model/cache/EventCache.kt (an open class so tests can build their own); Android and Desktop both use the LocalCache singleton (AppModules.kt, DesktopAppModules.kt).
  • model/Account.kt — one Account per signed-in user: ~50 per-feature state objects (kind3FollowList, nip65RelayList, muteList, bookmarkState, …), each exposing .flow.
  • feeds/ + viewmodels/ + <feature>/dal/ — feeds: a FeedFilter scans LocalCache, a FeedContentState / FeedViewModel keeps the result live.
relay EVENT ─► EventCache.justConsume ─► Note/User updated, indexes, live.newNote(...)
                     │                                   │
                     ▼                                   ▼
   XState pins its addressable note,            LocalCacheFlow.newEventBundles
   note.flow().metadata → XState.flow           → FeedViewModel → FeedFilter.applyFilter
                     │                                   │
                     └────────► ViewModels / composables ◄┘

Account state objects

Pattern, copied from model/nip51Lists/muteList/MuteListState.kt:

  • Pin the note. val muteListNote = cache.getOrCreateAddressableNote(MuteListEvent.createAddress(signer.pubKey)). LocalCache's notes / addressables are LargeSoftCache (weak references), so the state object's field is what keeps its note from being collected.
  • Expose one StateFlow. Map muteListNote.flow().metadata.stateFlow → decrypt (private lists go through a per-feature …DecryptionCache such as MuteListDecryptionCache, held next to it in Account as muteListDecryptionCache) → fall back to the AccountSettings backup (settings.backupMuteList) when the event is absent → stateIn(scope, SharingStarted.Eagerly, …).
  • Mutation helpers return a signed event — publishing it is the caller's job. MuteListState.hideUser(pubkey) returns a MuteListEvent; Account.hideUser does sendMyPublicAndPrivateOutbox(muteList.hideUser(pubkeyHex)). A state change you never send is invisible to every other client and is lost when the next relay copy arrives.
  • Consumers read account.<feature>.flow, never a MutableStateFlow on Account.

Full catalog of properties, classes, kinds and packages: references/account-state-flow.md.

Adding an account-scoped setting or list

  1. Persisted as a Nostr event? Pick the kind (NIP-51 list, NIP-78 app data, a relay list) and make sure quartz has the event class (see quartz's nostr-expert skill; a new one is a quartz change, built here with -PquartzDir=../quartz).
  2. Add commons/…/model/nipXX…/XState.kt modeled on MuteListState (encrypted) or nip51Lists/BookmarkListState.kt (plain): pin, flow, suspend mutation helpers that build the event with the quartz class and sign it.
  3. Instantiate it as a val in Account.kt (plus its DecryptionCache if private) and add an Account method that sends the returned event (sendMyPublicAndPrivateOutbox, sendAutomatic, …). If the list must survive relay loss, add a backupX field to AccountSettings.
  4. Make sure EventCache.justConsumeInnerInner lists the event class — a kind that is not listed falls into the else branch and is rejected, so the state object never sees it, even though EventFactory parses it. Replaceable kinds without custom logic go in the consumeBaseReplaceable group.
  5. Relay subscription: relay-client. UI: settings screens are shared in commonsUI/…/commons/ui/screen/loggedIn/settings/; strings follow the AGENTS.md Strings rules.

LocalCache vs Account flow vs feed

You render… Read
one note / user you have an id for LocalCache.getOrCreateNote(id) / getOrCreateUser(pubkey), then note.flow().metadata (or replies, reactions, zaps, …)
"my follows / mutes / relays / bookmarks" account.kind3FollowList.flow, account.muteList.flow, account.nip65RelayList.flow, account.hiddenUsers.flow, …
a list of notes a FeedFilter + FeedContentState / FeedViewModel — references/feeds.md
ad-hoc filter results LocalCache.observeNotes(filter: Filter): Flow<List<Note>>

Never scan LocalCache.notes inside a composable.

Feeds in one paragraph

Bases are in commons/…/feeds/: IFeedFilter / FeedFilter<T> (feed(), feedKey(), limit() default 500, showHiddenKey()), AdditiveFeedFilter<T> (adds applyFilter + sort for incremental merges), AdditiveComplexFeedFilter<T, U> (incoming type differs from row type, e.g. home/dal/HomeLiveFilter.kt), ChangesFlowFilter<T>, FilterByListParams, DefaultFeedOrder / sortedByDefaultFeedOrder(), FeedContentState, FeedState. Concrete filters are shared, in commons/…/<feature>/dal/ (home/dal/, hashtag/dal/, notifications/dal/, ~55 of them). The account's main-tab feeds are long-lived FeedContentStates in viewmodels/AccountFeedContentStates.kt (owned by BaseAccountViewModel.feedStates); per-screen feeds use a FeedViewModel subclass. Recipe, real code and traps: references/feeds.md.

Gotchas

  • LocalCache is shared across accounts. Switching accounts doesn't wipe it; every Account derives its flows from the same cache. Filters that depend on the account must put the account pubkey in feedKey().
  • Hold the Note, not the Event. getOrCreate* returns the same wrapper per id; for replaceable/addressable notes note.event is swapped in place when a newer version arrives.
  • Flows on a Note are not durable. CachePruner (model/cache/CachePruner.kt) calls clearFlow() on notes during cleanup, and anything not strongly held can be collected. Re-fetch via note.flow(); don't stash flow references.
  • Observers can fire before derived state exists. EventCache notifies observers (refreshNewNoteObservers) as part of consuming an event, before some of what it derives from it (channels, indexes) is filled in. Re-read state in the collector rather than trusting it is complete.
  • Memory pressure trims. MemoryTrimmingService (model/cache/) is run by both app modules, and AccountFeedContentStates trims every feed to 200 items on memoryPressure. Never assume a previously seen note is still resident.

References

  • references/account-state-flow.md — catalog of Account state objects, kinds and packages.
  • references/local-cache.md — EventCache structures, insertion path, lookups, pruning.
  • references/feeds.md — adding a feed: filter, state holder, invalidation, rendering, tests.
  • Related skills: quartz's nostr-expert (event classes, EventFactory), relay-client (subscriptions), auth-signers (how mutations sign), compose-expert (rendering).