14 KiB
commons — Shared Module Architecture & Goals
commons is the shared layer between every Amethyst front end:
| Consumer | Kind | Uses from commons |
|---|---|---|
amethyst |
Android app (touch-first) | everything (models, state, ViewModels, UI) |
desktopApp |
Desktop JVM app (mouse-first) | everything (models, state, ViewModels, UI) |
cli (amy) |
Headless JVM CLI (no UI) | non-UI only — models, actions, relay, services |
| iOS (future) | iOS app | everything; 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 a platform-native screen or navigation shell: domain models (Note,User), in-memory state holders, ViewModels, the relay-subscription client, shared business services, and the Compose UI components that more than one front end renders.amethyst/&desktopApp/— platform-native screens, navigation (bottom-nav vs sidebar), gestures, system integration. They assemblecommonspieces; they should not re-implement them.
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
commons is a single module that contains both Compose UI and headless
logic. That is deliberate (it keeps a feature's model, state, and UI together —
see §3), but it creates one hard constraint, because cli and any headless
consumer cannot use Compose:
CLI-safe code = does not depend on Compose UI. It may use the
androidx.compose.runtimeannotations@Stable/@Immutable(they are just stability tags) and snapshot state, but it must not importandroidx.compose.ui,androidx.compose.foundation,androidx.compose.material3, declare@Composablefunctions, or buildImageVectors.UI code = anything that does. It is only usable by the GUI front ends (Android, Desktop, iOS), never by
cli.
Compose is an implementation dependency of commonMain, so cli pulling in
commons does not force it to render anything — but a cli command must
only reach for CLI-safe packages. When you add code, know which side of this
line it is on, and put it in a package that matches (§2).
This boundary is not a top-level ui/ vs logic/ partition of the whole
module (we chose to stay feature-oriented, §3). It is a property of each file
that you keep track of via package placement and the table in §2.
2. Package taxonomy
Top-level packages under
commonMain/.../commons/, grouped by concern. UI? marks whether the
package contains Compose UI (and is therefore not CLI-safe).
Domain models & data
| Package | UI? | Purpose |
|---|---|---|
model |
no¹ | Core domain types (Note, User, Channel), thread assembly, and per-NIP event model extensions in model/nipNN… subpackages. model/cache holds the in-memory event-store interfaces + UserMetadataCache. model/account, model/observables. The largest package; keep it organized by NIP. |
defaults |
no | Static bootstrap data (default relays, channels). |
¹ 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 |
no | New-account bootstrap events. |
onchain |
no | On-chain zap splitting/broadcasting. |
marmot |
no | MLS group-chat event processing. |
nip53LiveActivities |
mixed | Live-activity zapper aggregation (logic) + the stream card in nip53LiveActivities/ui. |
search |
no | Event search filtering/ranking, kind registry. |
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 |
no | NIP-AC WebRTC call state machine + peer-session abstraction. See nipACWebRtcCalls/ARCHITECTURE.md. (Mirrors quartz/.../nipACWebRtcCalls.) |
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. |
feeds |
no | FeedDefinitionRepository — custom-feed definitions & ordering. |
profile |
mixed | ProfileBroadcastStatus (state) + EditProfileFields at the root; the ProfileBroadcastBanner composable lives in profile/ui. |
² may touch compose.runtime/foundation state types (e.g. LazyListState);
they are shared across the GUI apps. Treat as GUI-shared, not strictly headless.
Relay client
| Package | UI? | Purpose |
|---|---|---|
relayClient |
no | Compose-scoped subscription managers, filter assemblers, EOSE managers, preloaders. (Despite a composeSubscriptionManagers subpackage name, this is subscription-lifecycle logic, not UI.) |
relays |
no | Low-level EOSE/relay-timing bookkeeping (EOSECache, EOSERelayList). |
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 — not CLI-safe)
| Package | UI? | Purpose |
|---|---|---|
ui |
yes | Cross-cutting shared composables only, organized by area: ui/components, ui/theme, ui/signing, ui/thread, ui/feeds (feed DAL + filters — see debt §4), ui/notifications, ui/screens, ui/elements, ui/layouts, ui/markdown, plus Compose helpers in ui/state (cached-state) and ui/text (TextField extensions). Feature-specific UI lives in <feature>/ui, not here. |
nip23LongContent |
yes | Long-form (NIP-23) article UI: nip23LongContent/ui/article (reader) + …/ui/editor (authoring). The model lives in model/nip23LongContent. |
icons |
yes | ImageVector icon definitions + builders. |
hashtags |
yes | Custom hashtag ImageVectors. |
robohash |
yes | Procedural robohash avatar ImageVector assembly. |
Mixed (documented debt — see §4)
| Package | UI? | Purpose |
|---|---|---|
nip64Chess |
mixed | Live-chess feature: game/lobby/subscription logic and board/lobby composables in one flat package. Needs a nip64Chess/ui split. (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, Coil-OkHttp, markdown, viewModel() helper, NWC/LNURL). |
jvmMain |
Desktop-only (keyring, EXIF, service/upload). dependsOn(jvmAndroid). |
androidMain |
Android-only (Keystore, DataStore). dependsOn(jvmAndroid). |
iosMain |
iOS actuals. Compile-only spike today. |
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 rendered by ≥2 front ends, or that you want iOS to share? →
ui/<area>or<feature>/ui. Never incli. - 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.
nip64Chessis UI+logic in one flat package.LiveChessGame.ktmixes a state class with composables. Split intonip64Chess/(logic) +nip64Chess/ui/(composables); this needs file-level surgery (extracting composables out of logic files), not just moves, so it is deferred.ui/feedsholds the feed data-access layer (FeedFilter,ChangesFlowFilter,FeedContentState), which is logic, not UI, and overlaps conceptually with the top-levelfeeds(custom-feed definitions). Consider moving the DAL out ofui/.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.
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.