Port the Nostr Observer's relay pull into commons and lay the paper out on the device instead of with a model: - commons/observer: ObserverDesk (14 desks), ObserverReadiness (no fallback lens), ObserverPull (observer: lens on the search relay, one REQ per desk, the lens's reactions/reposts/replies as the ranking signal, bylines), ObserverEditor (pure, deterministic layout), ObserverPress (account-scoped background job with step progress). - commonsUI/observer/ui: newspaper screen + PoW-style progress banner. - Route.Observer, NavBarItem.OBSERVER, drawer + bottom-bar catalog entries. - amy observer [USER]: prints the same edition from the terminal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HSjTU2HWoVbg6ttC1uB1f8
23 KiB
commons — Shared Module Architecture & Goals
commons is the shared layer between every Amethyst front end:
| Consumer | Kind | Uses from commons |
|---|---|---|
amethyst |
Android app (phones, tablets, laptops) | everything (models, state, ViewModels) + commonsUI |
desktopApp |
Desktop JVM app | everything (models, state, ViewModels) + commonsUI; today's mouse-first screens are to be replaced by the shared UI |
cli (amy) |
Headless JVM CLI (no UI) | everything — commons is headless by construction; it never sees commonsUI |
nappletHost |
Android WebView sandbox | napplet contract + commonsUI (for the shell/shim Compose resources) |
| iOS (future) | iOS app | everything + commonsUI; expected to share most UI with Android |
commons sits above quartz (the protocol-only Nostr KMP library) and
below the apps. The split between the three is:
quartz/— Nostr protocol: events, NIPs, crypto, relay framing. No app state, no UI, no caches of "what this user follows."commons/— everything an Amethyst client needs that isn't platform-specific or Compose UI: domain models (Note,User), in-memory state holders, ViewModels, the relay-subscription client, shared business services.commonsUI/— the Compose UI components that more than one front end renders, plus everything only they need (icons, theme, Coil fetchers, markdown, thecomposeResourcesstrings/fonts and the generatedRes). Depends oncommonsasapi. SeecommonsUI/ARCHITECTURE.md.amethyst/&desktopApp/— platform shims: the Activity / Window entry points, services, system integration, and the platform implementations of shared ports. They assemblecommonspieces; they should not re-implement them. Screens and navigation are being moved intocommonsUIso Android (which now also runs on laptops) and a new JVM Desktop app render one UI; seeplans/2026-09-27-one-ui-android-desktop.md.
The goal is "write it once in
commons". Before adding a manager, cache, filter, ViewModel, or composable to an app module, check whether it already exists here or belongs here. See the "Where does my code go?" guide below.
1. The one rule that shapes the package tree: the UI / non-UI boundary
The shared layer is two modules with one package tree:
commons= CLI-safe code. It does not depend on Compose UI. It may use theandroidx.compose.runtimeannotations@Stable/@Immutable(they are just stability tags) and snapshot state (mutableStateOf,State), but it must not importandroidx.compose.ui,androidx.compose.foundation,androidx.compose.material3, Coil, the generatedRes, declare@Composablefunctions, or buildImageVectors. Itsbuild.gradle.ktssimply has none of those dependencies, so a violation fails to compile.
commonsUI= UI code. Anything that does the above. It is only usable by the GUI front ends (Android, Desktop, iOS), never bycli.
Both modules share the same com.vitorpamplona.amethyst.commons.* package
tree — the split is a module boundary, not a package rename, so a file moves
between commons/src/… and commonsUI/src/… without changing its package or
any consumer's imports. Kotlin resolves same-package declarations across
modules without imports; the only thing that stops working across the boundary
is internal visibility (a UI file cannot see an internal declaration in
commons — make it public or move it).
This boundary is not a top-level ui/ vs logic/ partition of the package
tree (we chose to stay feature-oriented, §3). It is a property of each file:
a feature keeps its logic in commons/…/<feature>/ and its composables in
commonsUI/…/<feature>/ui/ (or commonsUI/…/<feature>/ for the historical
flat packages), and the table in §2 says which module each package lives in.
2. Package taxonomy
Top-level packages under
commonMain/.../commons/, grouped by concern. UI? marks whether the
package contains Compose UI (and therefore lives in commonsUI, not
here). "mixed" means the feature's logic is in commons and its composables
in commonsUI, under the same package.
Domain models & data
| Package | UI? | Purpose |
|---|---|---|
model |
no¹ | Core domain types (Note, User, Channel), thread assembly (ThreadAssembler, ThreadLevelCalculator, ReplyContext, replyingDirectlyTo), and per-NIP event model extensions in model/nipNN… subpackages. model/cache holds the in-memory event store: the ICacheProvider / ILocalCache ports and UserMetadataCache in commonMain, and the concrete LocalCache (plus AntiSpamFilter, CachePruner, CacheSearch and the LocalCacheHost app-shell port) in jvmAndroid. model/account, model/observables. The largest package; keep it organized by NIP. |
defaults |
no | Static bootstrap data (default relays, channels). |
model/navigation also holds the headless navigation identifiers: NavBarItem, BottomBarEntry and the app's @Serializable Route catalog (Routes.kt). model/composer holds post-composer state that is not UI (AudienceSelection, SplitBuilder, IZapRaiser).
¹ model uses only the @Stable/@Immutable runtime annotations — CLI-safe.
Protocol-adjacent business logic (CLI-safe)
| Package | UI? | Purpose |
|---|---|---|
actions |
no | Event builders for user actions (follow, zap…). The canonical entry point for non-UI callers. |
account |
mixed | New-account bootstrap events; the logged-off login/sign-up buttons in commonsUI account/ui/login and account/ui/signup. |
onchain |
mixed | On-chain zap splitting/broadcasting; user-facing failure strings in commonsUI onchain/ui. |
marmot |
mixed | MLS group-chat event processing; group-chat composables (retention picker, agent stream banner) in commonsUI marmot/ui. |
cyberspace |
no | CYBERSPACE_V2 §7.7 region-bag search: the free quote off a bag's hint/h tags, the device-measured budget, and the cold sweep flow over quartz's RegionSweep. The protocol itself (coordinates, Cantor trees, keys, the bag) is quartz/.../cyberspace. |
sno |
mixed | DECK-0003 object rendering math — rasterizer, lighting, face winding, default avatar — here; the Compose viewer/thumbnail and the Coil fetcher in commonsUI under sno and sno/ui. |
nip53LiveActivities |
mixed | Live-activity zapper aggregation (logic) + the stream card in nip53LiveActivities/ui. |
search |
no | Event search filtering/ranking, kind registry. |
observer |
mixed | The Nostr Observer, native: ObserverPull reads the reader's day off the search relay through their observer: web-of-trust lens (readiness probe, one REQ per desk, the lens's reactions/reposts/replies), ObserverEditor lays out an ObserverEdition on the device with no model, and ObserverPress runs it on the account scope. The newspaper screen and the progress banner are in commonsUI observer/ui; amy observer prints the same edition. |
preview |
no | OpenGraph / meta-tag link-preview parsing. |
emojicoder |
no | Variation-selector emoji encode/decode. |
richtext |
no | URL/media/pattern parsing for rich text. |
blurhash |
no | BlurHash encode/decode (pure math; platform image bridge in platform sets). |
thumbhash |
no | ThumbHash encode/decode. |
nipACWebRtcCalls |
mixed | NIP-AC WebRTC call state machine + peer-session abstraction. See nipACWebRtcCalls/ARCHITECTURE.md. (Mirrors quartz/.../nipACWebRtcCalls.) Call-screen previews in commonsUI nipACWebRtcCalls/ui. |
qrcode |
mixed | Scanned-payload classification (classifyScannedPayload: NIP-19, nostr: URIs, relays, NWC/LN) here; scanner sheets in commonsUI qrcode/ui. |
scheduledposts |
mixed | Scheduled-post model + signed-event parsers (extractFirstMediaUrl, extractEventId, extractContentPreview); the MediaThumbnail composable in commonsUI scheduledposts/ui. |
State holders & ViewModels
| Package | UI? | Purpose |
|---|---|---|
state |
no² | Small feature StateFlow machines (FollowState, UserMetadataState, LoadingState). |
viewmodels |
no² | Larger list/feed-backed ViewModels (androidx.lifecycle.ViewModel). Shared by all GUI front ends; cli usually drives the layers below instead. The few that hold Compose UI state (ChatNewMessageState — TextFieldValue; thread/LevelFeedViewModel — LazyListState) live in commonsUI under the same package. |
feeds |
no | The feed data-access layer at the root (FeedFilter, AdditiveFeedFilter, AdditiveComplexFeedFilter, ChangesFlowFilter, FeedContentState, FeedState, InvalidatableContent, DefaultFeedOrder, RepostRenderability…) plus feeds/custom (FeedDefinitionRepository — custom-feed definitions & ordering) and feeds/related. See the feed-patterns skill. |
profile |
mixed | ProfileBroadcastStatus (state) + EditProfileFields at the root; the ProfileBroadcastBanner composable lives in commonsUI profile/ui. |
privacylock |
mixed | Lock state machine + settings here; LocalPrivacyLockState/lockStateFor (CompositionLocal accessor) in commonsUI. |
² may touch compose.runtime state types (snapshot state, @Stable); they
are shared across the GUI apps. A state holder that needs a foundation/ui
type (LazyListState, TextFieldValue, TextFieldState) goes to commonsUI.
Relay client
| Package | UI? | Purpose |
|---|---|---|
relayClient |
mixed | Compose-scoped subscription managers, filter assemblers, EOSE managers, preloaders. (Despite a composeSubscriptionManagers subpackage name, this is subscription-lifecycle logic, not UI.) The @Composable entry points — relayClient/user/ (observeUser* — kind-0 metadata), relayClient/event/ (EventFinderFilterAssemblerSubscription/observeNote*), the other *FilterAssemblerSubscriptions, KeyDataSourceSubscription, auth/AuthApprovalBanner — are in commonsUI under the same packages. See the relay-client skill. |
relays |
mixed | Low-level EOSE/relay-timing bookkeeping (EOSECache, EOSERelayList); relay-list settings composables (DraggableRelayList, RelayEventCountRow, the NIP-45 count result types) in commonsUI relays/ui. |
Platform abstractions (expect/actual)
| Package | UI? | Purpose |
|---|---|---|
util |
no | KMP primitives: KmpLock, WeakReference, number/URL/codepoint helpers, list/debug helpers. This is the only general-utility package — there is no utils. |
keystorage |
no | Secure key storage interface (Keystore / keychain / keyring actuals). |
tor |
no | Tor manager interface + settings. |
service |
no | Cross-cutting services: BundledUpdate batching (common); service/upload (JVM), service/nwc, service/lnurl (jvmAndroid). Singular service — there is no services. |
UI (Compose — lives in commonsUI)
| Package | UI? | Purpose |
|---|---|---|
ui |
yes | Cross-cutting shared composables only, organized by area: ui/components, ui/theme, ui/signing, ui/thread, ui/note, ui/richtext, ui/search, ui/notifications, ui/screens, ui/layouts, ui/feeds (feed shell: empty/error/loading states, refresh box, remembered scroll states), ui/navigation (INav, EmptyNav, ObservableNav/TwoPaneNav/ShareToDMNav, top bars, drawer swipe), ui/settings (the searchable settings catalog), ui/markdown, ui/privacylock, plus Compose helpers in ui/state (cached-state) and ui/text (TextField extensions). Feature-specific UI lives in <feature>/ui, not here. Nothing under ui.* lives in this module any more: the feed DAL that used to sit in ui/feeds is now feeds/, and the reply-context logic that sat in ui/note (replyingDirectlyTo, ReplyContext) is now in model/ next to ThreadAssembler. |
nip23LongContent |
yes | Long-form (NIP-23) article UI: nip23LongContent/ui/article (reader) + …/ui/editor (authoring). The model lives in model/nip23LongContent (here). |
icons |
yes | ImageVector icon definitions + builders, Material Symbols codepoints, the icon-font glyph tables. |
hashtags |
yes | Custom hashtag ImageVectors. |
robohash |
yes | Procedural robohash avatar ImageVector assembly. |
audio |
mixed | Spectrum/visualizer data (AudioSpectrum, SpectrumAnalyzer…) here; the VisualizerRenderers, VisualizerRegistry and the canvas composables in commonsUI. |
service/image |
mixed | CoilImageBridge + the BlurHash/ThumbHash/Base64/Blossom Coil fetchers are commonsUI (they are Coil); the headless image helpers stay here. |
napplet |
mixed | Protocol/permission logic here; NappletWebContract (serves the shell/shim from composeResources) in commonsUI. |
favorites, nip30CustomEmojis, nip34Git, nip85TrustedAssertions, nip53LiveActivities |
mixed | Logic here; each feature's ui/ (or the flat FavoriteAppIcon, EmojiSuggestionState) in commonsUI. |
chats |
yes | Composables shared by every chat kind (DMs, public chats, relay groups, concord): unread badge, divisors, system messages, author line, send button, reply toggle, new-conversation screen. Chat models are in model/chats. |
nip17Dm, nip23LongContent, nip28PublicChat, nip29RelayGroups, nip32Labeling, nip43RelayMembers, nip51Lists, nip52Calendar, nip56Reports, nip72ModCommunities, nip73ExternalIds, nip99Classifieds, nipC0CodeSnippets, nipCCGeocaching, birdstar, nipsOnNostr, buzz, cashu, clink, concord, music, mediaServers, nip46RemoteSigner, browser, profile, napplet |
yes / mixed | Single-feature UI under <feature>/ui (named after the quartz package, or its concern name when quartz has none). Where the package also has logic (cashu, browser, napplet, profile) that part stays here; the buzz and concord models are in model/buzz and model/concord. |
Mixed (documented debt — see §4)
| Package | UI? | Purpose |
|---|---|---|
nip64Chess |
mixed | Live-chess feature: game/lobby/subscription logic here; board/lobby composables in commonsUI under nip64Chess/ui. (Mirrors quartz/.../nip64Chess.) |
domain |
no | Currently only domain/nip46 (Nostr Connect signer flows). Sparse; candidate to fold into a clearer home. |
3. Conventions
Feature-oriented, not layer-partitioned
We keep a feature's model, state, and UI together under one feature
package rather than splitting the whole module into top-level ui/ /
viewmodels/ / model/ layers. Within a feature, separate UI from logic with a
ui subpackage (e.g. profile/ProfileBroadcastStatus vs
profile/ui/ProfileBroadcastBanner) so the CLI-safe boundary (§1) stays
visible.
The big shared cross-feature packages (model, ui, relayClient, util,
icons) are the exception — they are organized by layer because many features
share them.
Naming
- Singular, no synonyms.
util(notutils),service(notservices). One concept → one package name. - Feature UI vs cross-cutting UI — the deciding test. A composable goes in
<feature>/uiif it renders/edits one feature's content (it would make no sense outside that feature) — e.g.profile/ui,nip53LiveActivities/ui,nip23LongContent/ui. It goes inui/<area>only if it is reusable across features (theme, avatars, buttons, layouts, markdown rendering, shimmer…). When in doubt, ask "could a second, unrelated feature reuse this as-is?" — yes →ui/<area>, no →<feature>/ui. The top-levelui/package holds no feature-specific composables.
NIP as the second axis (mirror quartz)
quartz is ~94% organized by NIP (nipNN<slug> per spec), and that is correct
there — the protocol layer is naturally NIP-partitioned. commons is not
organized by NIP at the top level, and should not be: most of it is
cross-cutting infrastructure (ui, relayClient, viewmodels, feeds,
util…) that serves many NIPs at once, and the load-bearing UI/non-UI boundary
(§1) cuts across NIPs, so a NIP-first top level would just nest the same
problem one level down.
Instead, layer is the primary axis, NIP is the secondary axis:
- The big shared layers stay layer-organized (
model,relayClient, the cross-cuttingui, …). - Inside a layer, NIP-specific code goes in a
nipNN<slug>subpackage whose name matchesquartzexactly — e.g.model/nip57Zaps. This gives a clean trace:quartz/nip57Zaps→commons/model/nip57Zaps. - A top-level package that is a single self-contained NIP feature takes the
same name as its
quartzcounterpart, and owns its own UI under<feature>/ui:nip64Chess,nipACWebRtcCalls,nip53LiveActivities,nip23LongContent,marmot. (marmotis un-numbered inquartztoo.) Generic/multi-NIP packages keep their concern name (search,preview,actions,richtext…). - Feature UI is never under
ui/. A single-NIP feature's composables live in<feature>/ui(e.g.nip53LiveActivities/ui), notui/nip53LiveActivities.ui/is exclusively cross-cutting (§2, Naming).
Source sets
| Source set | For |
|---|---|
commonMain |
KMP code for all targets (Android, JVM, iOS). Gated by verifyKmpPurity — no Jackson/OkHttp/System.currentTimeMillis/java.util.UUID/JVM @Synchronized/@Volatile. Use the KMP replacements. |
jvmAndroid |
Shared by Android + Desktop, not iOS. Where JVM-bound deps live (nestsClient, OkHttp, NWC/LNURL). |
jvmMain |
Desktop-only (keyring, EXIF, service/upload, OS notifications). dependsOn(jvmAndroid). |
androidMain |
Android-only (Keystore, DataStore, the Android R string resources used by the napplet host). dependsOn(jvmAndroid). |
iosMain |
iOS actuals. Compile-only spike today. |
commonsUI mirrors the same source-set layout (plus skikoMain, shared by
desktop JVM + iOS for org.jetbrains.skia pixel helpers); Coil-OkHttp,
markdown and the viewModel() helper live in its jvmAndroid.
When adding platform code, prefer the most common source set that still
compiles: commonMain → jvmAndroid → platform-specific. See
/kotlin-multiplatform.
Where does my code go? (quick guide)
- Pure Nostr protocol (events/NIPs/crypto)? → not here, it's
quartz. - A composable (screens and navigation chrome included)? →
commonsUI, inui/<area>or<feature>/ui(same package tree as here). Also anything that imports Coil,Res, or afoundation/uistate type. Only the platform entry points and system integrations stay inamethyst//desktopApp/. - A ViewModel /
StateFlowstate holder? →viewmodelsorstate(or<feature>if feature-scoped). Keep it CLI-safe where practical. - Relay subscription / filter assembly? →
relayClient. - A domain model or per-NIP event wrapper? →
model(model/nipNN…). - A platform capability behind
expect/actual(storage, crypto)? → the matching abstraction package + actuals in platform sets. - A generic helper? →
util. (Resist creating a new top-level package for one file.)
4. Known debt / follow-ups
These are intentionally documented, not silently tolerated. Fix opportunistically.
commonsapplies the Compose compiler plugin without declaring any composable. Deliberate, not debt: the plugin's@StabilityInferredstamps are what keep unannotated commons classes stable from the apps' point of view. Measured on full recompiles (2026-09-12): without it, unstable composable params go 20→28 incommonsUI, 33→65 indesktopApp, 90→149 inamethyst. Don't remove it; if a class must be stable for a hot path, annotate it explicitly as well.- Same package tree in two modules. Intentional (zero-import-churn split),
but it means a package's module is not visible from its name. Rule of
thumb: if it imports Compose UI it is in
commonsUI; check §2 when unsure. domainis sparse (onlynip46). Either grow it as the home for use-case/flow types or rename it to the matchingnip46RemoteSignerper the NIP-second-axis rule.relaysvsrelayClientare coherent but close in name;relaysis low-level EOSE bookkeeping,relayClientis the subscription client. Keep the distinction in mind when adding files.- Several single-file feature packages (
account,marmot,nip53LiveActivities,keystorage) are kept as feature/abstraction namespaces expected to grow; do not fold them intoutiljust for size. onchain(on-chain zap splitting) isquartz-adjacent but un-numbered; leave readable unless a clear NIP number lands.model/cache/EventCacheisjvmAndroid, notcommonMain. The NIP-95java.io.Filespill is the obvious blocker but not the binding one: the cache's own storage,LargeSoftCache, isjvmAndroid(WeakReference+ConcurrentSkipListMap), as are the two*ListMatchingFilterobservables,MintDirectoryIndexandNwcPaymentTracker. Promoting the cache means promoting those first. The binding constraint isLargeSoftCache's sorted store: it backs the rangedforEach(from, to, …)that all ofLargeSoftCacheAddressExtuses to scan one kind's slice of theAddresskey space, and a hash map turns each of those into a full scan.quartz/linuxTest/LargeCacheRangeFallbackTestdocuments the matching invariant from the other side — the native range overloads fall back to full scans, and that is deemed safe precisely because the range callers live in the JVM-onlyLargeSoftCache. Moving this needs a sorted KMP store first, not just the okio blob sink and thejava.util.SortedSetsignature change. See the audit incommons/plans/2026-08-30-commons-migration-sweep.md.
5. See also
nipACWebRtcCalls/ARCHITECTURE.md— WebRTC call state machine deep-dive.- Root
.claude/CLAUDE.md— module overview, sharing philosophy, build commands. /kotlin-multiplatform,/compose-expert,/feed-patterns,/relay-client,/account-stateskills for the patterns referenced above.