Phase 4 (codec + audio capture slice). Adds the publisher-side MoQ
control messages and the Opus encode + microphone capture pieces a
speaker needs. The host-grants-speaker UI flow is deferred — that's
multi-screen UX that should be designed before being implemented.
commonMain (nestsclient.moq):
- 5 new MoqMessageType entries with codec round-trip + tests:
* Announce (0x06): publisher offers a track namespace.
* AnnounceOk (0x07): subscriber acknowledges.
* AnnounceError (0x08): subscriber rejects with error code +
reason phrase.
* Unannounce (0x09): publisher withdraws a previously-announced
namespace.
* SubscribeDone (0x0B): publisher tells the subscriber no more
objects are coming for this subscription, with stream count and
reason.
commonMain (nestsclient.audio):
- New `OpusEncoder` interface — symmetric to `OpusDecoder`, one
instance per outgoing track since Opus state is per-stream.
- New `AudioCapture` interface — `start()`, `readFrame()` returns a
PCM frame or null when stopped, `stop()` releases the mic.
androidMain (nestsclient.audio):
- `MediaCodecOpusEncoder` — wraps `MediaCodec("audio/opus")` encoder
variant (API 29+). 48 kHz mono in, 32 kbit/s VBR Opus out, 20 ms
frames. Drains output queue per encode call.
- `AudioRecordCapture` — wraps `AudioRecord` from
`MediaRecorder.AudioSource.VOICE_COMMUNICATION` so the platform's
echo-cancellation + noise-suppression filters apply when available.
Reads exactly one PCM frame per readFrame() call, retries on
underrun, throws AudioException(DeviceUnavailable) on permission/
resource failures.
commonTest:
- `AnnounceCodecTest` — 7 cases covering each message round-trip,
concatenated decode in sequence, and a guard against accidentally
reordering MoqMessageType enum codes.
Permission already declared:
- `RECORD_AUDIO` is already in amethyst/AndroidManifest.xml — no
manifest change needed.
What this does NOT include:
- A publisher loop class (analogous to AudioRoomPlayer for publish
direction) — can be added when the speak-button wiring lands.
- The host-grants-speaker UI — needs design input.
- STREAM_HEADER_SUBGROUP for stream-based object delivery — datagrams
cover the listener happy-path; streams add reliability that nests
may or may not require.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Phase 3d-3 of the Clubhouse/nests integration. Wires the
nestsClient.connectNestsListener() facade into AudioRoomStage via a
dedicated Android ViewModel, and surfaces the listener's StateFlow
through a small assist chip so the user sees connection progress and
failure modes.
amethyst/audiorooms/room:
- New `AudioRoomConnectionViewModel` (Android `ViewModel`):
* Owns one `NestsListener` + one `AudioRoomPlayer` per host/speaker.
* `connect(event, signer)` resolves the room HTTP-side, opens
WebTransport, runs MoQ SETUP, then subscribes to every host +
speaker pubkey from the 30312 event and wires their Opus stream
through `MediaCodecOpusDecoder` -> `AudioTrackPlayer`.
* `disconnect()` is idempotent, also called from `onCleared()`.
* Mirrors the underlying NestsListener.state into its own
StateFlow so callers observe one source.
* Audience members are skipped — they don't publish audio.
- `AudioRoomStage` now mounts the ViewModel keyed by the room
address, auto-calls `connect()` in a LaunchedEffect, and
disconnects in a DisposableEffect.
- New `ConnectionChip` composable renders the listener state with
retry-on-tap for Idle / Failed / Closed, and color-codes Connected
(primary) and Failed (error) states.
amethyst:
- Added `:nestsClient` to the project's dependencies.
res:
- New strings for the five connection-chip states.
Behavior today: on opening an audio-room, the chip will report
`Connecting (OpeningTransport)` then immediately
`Failed: WebTransport NotImplemented: ...` because the Kwik handshake
in `KwikWebTransportFactory` is the next phase. Tapping the chip
retries — same outcome until 3b-2 ships. Everything else (presence,
chat, hand-raise, mute) is unaffected.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Phase 3d-2 of the Clubhouse/nests integration. Ties HTTP auth (3a) +
WebTransport (3b-1 stub) + MoQ session (3c) under a single
`connectNestsListener()` entry point with an observable state
machine, so audio-room callers see one resource and one StateFlow
instead of three layered handshakes. Pure commonMain — fully tested
against fakes; no network or Android needed.
commonMain (nestsclient):
- `NestsListener` interface — `state: StateFlow<NestsListenerState>`,
`subscribeSpeaker(pubkeyHex)`, `close()`. nests namespaces each
speaker's track as `["nests", <roomId>]` with the speaker's pubkey
hex as track name; subscribeSpeaker fills that in.
- `NestsListenerState` sealed class:
* Idle
* Connecting(step = ResolvingRoom | OpeningTransport | MoqHandshake)
* Connected(roomInfo, negotiatedMoqVersion)
* Failed(reason, cause?)
* Closed
- `DefaultNestsListener` — straight delegation to MoqSession once
connected.
- `connectNestsListener(httpClient, transport, scope, serviceBase,
roomId, signer, supportedMoqVersions)` — walks the three handshake
steps. Each failure short-circuits to Failed with a clear reason
string and the underlying cause attached; transport is torn down on
partial-handshake failure.
- `parseEndpoint(url)` — hand-rolled URL splitter so commonMain stays
free of a URL-library dependency. Handles default-port stripping,
rejects userinfo, preserves query strings.
commonTest:
- `NestsConnectTest` (5 cases, all using fakes):
* Happy path: walks resolveRoom -> transport.connect -> MoQ SETUP,
asserts state lands on Connected with the right roomInfo +
negotiated version, and verifies the transport saw the parsed
authority/path/bearer.
* resolveRoom failure short-circuits without touching transport.
* Transport handshake failure short-circuits with the right kind in
the reason.
* Malformed endpoint URL short-circuits.
* parseEndpoint covers default-port stripping, explicit port,
pathless URL, query string preservation.
This completes the pure-Kotlin integration. The only seam left
between `connectNestsListener()` and audible audio is the Kwik-backed
WebTransportFactory implementation (Phase 3b-2). When that lands, an
Amethyst AudioRoomViewModel can wire the existing AudioRoomStage to:
state = MutableStateFlow(NestsListenerState.Idle)
listener = connectNestsListener(...)
for each speaker p: AudioRoomPlayer(MediaCodecOpusDecoder(),
AudioTrackPlayer(), scope).play(listener.subscribeSpeaker(p).objects)
…and audio plays.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Phase 3d-1 of the Clubhouse/nests integration. Adds the listener-side
audio pipeline that turns a SubscribeHandle's Flow<MoqObject> into
audible PCM through Android's MediaCodec + AudioTrack. Encoder /
AudioRecord (speaker publishing) lands in Phase 4.
commonMain (nestsclient.audio):
- `AudioFormat` constants — 48 kHz mono signed-16-bit, 20 ms frames
(960 samples), matching the nests Opus profile.
- `OpusDecoder` interface — stateful per-track decoder.
- `AudioPlayer` interface — start/enqueue/stop, suspend on backpressure.
- `AudioRoomPlayer` — wires a Flow<MoqObject> through OpusDecoder into
AudioPlayer. Decoder errors are surfaced via an `onError` callback
but don't tear down the loop (one bad packet shouldn't kill the
room); player errors are fatal. play()/stop() are single-shot per
instance, with stop() idempotent and double-release-safe.
- `AudioException` with three canonical kinds (DecoderError,
DeviceUnavailable, PlaybackFailed).
androidMain:
- `MediaCodecOpusDecoder` — wraps `MediaCodec("audio/opus")` with the
RFC 7845 §5.1 Opus identification header in csd-0 and zeroed
pre-skip / seek-pre-roll in csd-1 / csd-2. Drains the output queue
per-packet, handles INFO_OUTPUT_FORMAT_CHANGED gracefully.
- `AudioTrackPlayer` — wraps `AudioTrack` in MODE_STREAM with USAGE_
VOICE_COMMUNICATION / CONTENT_TYPE_SPEECH so the OS treats audio-
room playback like a phone call (call-volume rocker, ducks
notifications). Buffer = 4× minimum so the producer can fall behind
~80 ms (roughly the WebTransport datagram jitter on mobile).
Tests (commonTest, fake-based):
- `AudioRoomPlayerTest` — 7 cases covering happy-path decode +
enqueue, decoder failure with continued loop, stop idempotency,
double-play rejection, play-after-stop rejection, empty-PCM
skip-enqueue, and live channel-backed flow.
The real MediaCodec / AudioTrack code is thin glue against Android
APIs and is validated manually on device — no Robolectric here, the
seams are the Fake* doubles.
Next: Phase 3d-2 wires NestsClient end-to-end (HTTP auth → MoqSession
listener subscribe → AudioRoomPlayer) plus the Amethyst-side
AudioRoomViewModel. After that the only remaining pure-MoQ work is
the Kwik handshake (Phase 3b-2).
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Phase 3c-3 of the Clubhouse/nests integration. Promotes MoqSession
from a one-shot SETUP runner to a real concurrent session: the
control-stream and datagram pumps run on a caller-supplied scope
after the handshake, and a public subscribe()/unsubscribe() API
delivers per-track OBJECT_DATAGRAMs as a Flow<MoqObject>.
commonMain (nestsclient.moq):
- MoqSession now takes a `pumpScope: CoroutineScope` so callers can
bind the pumps to their lifecycle (test backgroundScope, viewmodel
scope, etc.).
- After setup() succeeds, two background coroutines start:
* Control-stream pump — buffers chunks across multiple writes,
decodes complete frames, dispatches SUBSCRIBE_OK / SUBSCRIBE_ERROR
to the matching CompletableDeferred, drops malformed frames
without killing the session.
* Datagram pump — decodes OBJECT_DATAGRAMs and routes each to the
matching per-track sink keyed by track_alias.
- `subscribe(namespace, trackName, priority, filter)` sends SUBSCRIBE,
awaits SUBSCRIBE_OK (throws MoqProtocolException on SUBSCRIBE_ERROR
with the publisher's reason), and returns a SubscribeHandle whose
`objects` flow emits inbound OBJECT_DATAGRAMs.
- `unsubscribe(subscribeId)` is idempotent; second call is a no-op.
close() cancels pumps + tears down all in-flight subscribes cleanly.
- Per-track sink uses a bounded Channel with DROP_OLDEST overflow
rather than MutableSharedFlow, so objects buffered between
subscribe() returning and the consumer attaching are preserved
(SharedFlow with replay=0 silently drops pre-subscription emissions).
transport (FakeWebTransport):
- Switched from `consumeAsFlow()` to `receiveAsFlow()` so a `.first()`
during setup followed by a long-running pump `.collect{}` works on
the same channel without the first call closing it.
Tests:
- Updated existing setup tests for the new `pumpScope` parameter.
- New `subscribe_completes_on_subscribe_ok_then_delivers_datagrams_in_order`
end-to-end: client subscribes, raw server peer (no MoqSession to
avoid pump-vs-test contention) replies SUBSCRIBE_OK + 3 datagrams,
client's flow yields all three in order with correct payloads.
- `subscribe_throws_MoqProtocolException_when_publisher_replies_with_subscribe_error`.
- `datagrams_for_unknown_track_alias_are_dropped_silently` ensures
the pump doesn't crash on orphan datagrams and the session stays
closeable.
This completes the pure-Kotlin MoQ work. Remaining 3.x phases all
touch real audio (MediaCodec Opus, AudioTrack, AudioRecord) or the
real WebTransport handshake (Phase 3b-2 with Kwik).
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Phase 3c-2 of the Clubhouse/nests integration. Extends the MoQ
control-plane codec with the subscribe lifecycle messages (SUBSCRIBE,
SUBSCRIBE_OK, SUBSCRIBE_ERROR, UNSUBSCRIBE) and adds the
OBJECT_DATAGRAM wire format the listener uses to receive low-latency
Opus audio. Pure codec layer — no session-pump or network changes;
the subscribe/object delivery Flow arrives in Phase 3c-3.
commonMain (nestsclient.moq):
- New message types in `MoqMessageType`: Subscribe (0x03),
SubscribeOk (0x04), SubscribeError (0x05), Unsubscribe (0x0A).
- New data classes in MoqMessage.kt:
* `TrackNamespace` — tuple of byte strings, with `of(vararg String)`
helper for the common UTF-8 case.
* `SubscribeFilter` enum. Phase 3c-2 supports LatestGroup /
LatestObject; AbsoluteStart/AbsoluteRange throw at construction
(they need extra wire fields we'll add when nests needs them).
* `Subscribe`, `SubscribeOk`, `SubscribeError`, `Unsubscribe` data
classes. `SubscribeOk` validates contentExists + largest-id
coupling at construction.
- New codecs in `MoqCodec` (namespace tuple + all four messages).
Unknown filter codes on the wire are rejected.
- `MoqObject` data class + `MoqObjectDatagram` encode/decode for the
OBJECT_DATAGRAM wire format (track_alias, group_id, object_id,
publisher_priority, status, payload).
Tests:
- `SubscribeCodecTest` — round-trips for each message with multi-
segment namespaces + parameters, SUBSCRIBE_OK with and without
contentExists, concatenated decode in sequence, unknown-filter
rejection, construction-time validation.
- `MoqObjectDatagramTest` — Opus-sized payload round-trip, zero-
length payload with OBJECT_DOES_NOT_EXIST status, 8-byte-varint
boundary coverage, truncated datagram rejection, out-of-range
priority rejection.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Phase 3c-1 of the Clubhouse/nests integration. Lands a MoQ-transport
control-plane codec (draft-ietf-moq-transport) and a session wrapper
that runs the SETUP handshake over any WebTransportSession. Purely
commonMain + fully tested end-to-end against FakeWebTransport — no
network or Kwik dependency required.
commonMain (nestsclient.moq):
- `Varint` — QUIC variable-length integer codec per RFC 9000 §16.
Encode/decode/size, with typed truncation handling (decode returns
null so callers can buffer more and retry).
- `MoqWriter` / `MoqReader` — append-only byte writer + bounds-checked
reader used by the message codec.
- `MoqCodecException` — typed error for malformed frames.
- `MoqMessage` sealed class + `MoqMessageType` enum + `SetupParameter`.
Phase 3c-1 covers just `ClientSetup` / `ServerSetup` (message types
0x40 / 0x41).
- `MoqCodec.encode/decode` — wraps payload with `type (varint) +
length (varint)`. Rejects unknown types and trailing bytes inside a
declared payload window.
- `MoqSession.client/server` — attaches to a WebTransportSession and
runs the CLIENT_SETUP / SERVER_SETUP handshake with version
negotiation + configurable timeout.
- `MoqVersion` constants for draft-11 and draft-17.
Tests:
- `VarintTest` — all four RFC 9000 §A.1 sample vectors (1/2/4/8 byte),
boundary round-trips, negative/overflow rejection, short-buffer
returns null, bytesConsumed accuracy.
- `MoqCodecTest` — CLIENT_SETUP / SERVER_SETUP round-trip (empty and
multi-param), multi-version negotiation, exact wire layout for
ServerSetup(1L), truncated-frame returns null, concatenated frames
decode with offset, unknown type rejected, trailing bytes rejected,
zero-length parameter values allowed.
- `MoqSessionTest` — full SETUP exchange over FakeWebTransport (both
sides happy path, client falling back to older version, server
rejecting when no version overlap + client timing out cleanly).
SUBSCRIBE / ANNOUNCE / OBJECT_STREAM / OBJECT_DATAGRAM land in
Phase 3c-2 on top of the same MoqSession.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Phase 3b-1 of the Clubhouse/nests integration. Lands the
[WebTransportSession] abstraction the MoQ layer (Phase 3c) will code
against, so MoQ framing + tests can develop in parallel with the real
Kwik-based transport integration (deferred to Phase 3b-2).
commonMain:
- `WebTransportSession` — bidi/uni stream access, datagrams, close.
- `WebTransportBidiStream` / `WebTransportReadStream` /
`WebTransportWriteStream` — minimal read/write surface.
- `WebTransportFactory.connect(authority, path, bearerToken)` for
opening sessions.
- `WebTransportException(kind, ...)` with four canonical failure modes
(HandshakeFailed, ConnectRejected, PeerClosed, NotImplemented) so UI
code doesn't need to know about library-specific exceptions.
- `FakeWebTransport.pair()` — in-memory, fully-wired client/server
pair for unit-testing MoQ framing without touching a real QUIC stack.
jvmAndroid:
- `KwikWebTransportFactory` stub that throws
`WebTransportException(NotImplemented)` at `connect()`. Doc spells
out the handshake sequence (QUIC dial → H3 SETTINGS →
`:method=CONNECT :protocol=webtransport` Extended CONNECT → 2xx) so
Phase 3b-2 can drop the real implementation in without touching
callers.
Tests:
- `FakeWebTransportTest` — datagram round-trip both directions,
bidi-stream write visible on peer side, close() flips isOpen.
- `WebTransportExceptionTest` — kind + message + cause preserved.
- `KwikWebTransportFactoryTest` — `connect()` currently fails with the
NotImplemented sentinel, so Phase 3b-2 can replace this test when
the real handshake lands.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Phase 3a of the Clubhouse/nests integration. Adds a new KMP module
`nestsClient` (Android + JVM targets) that owns the HTTP control plane
for talking to a nests audio-room backend. No transport/audio yet —
that arrives in 3b with WebTransport + MoQ.
Surface:
- `NestsAuth.header(signer, url, method)` signs a kind 27235 event via
Quartz's existing HTTPAuthorizationEvent and returns a ready-to-use
`Authorization: Nostr <base64>` header value.
- `NestsRoomInfo` data class + tolerant JSON parser (ignores unknown
fields so newer nests server revisions don't break older clients).
- `NestsClient.resolveRoom(serviceBase, roomId, signer)` calls
`<serviceBase>/<roomId>` with the signed NIP-98 header and returns
the MoQ endpoint + token the audio layer will need.
- `OkHttpNestsClient` (jvmAndroid) is the default implementation
shared between Android and desktop JVM.
Tests:
- `NestsRoomInfoTest` (commonTest) covers full/minimal payloads,
unknown-field tolerance, missing-endpoint rejection, and URL
construction edge cases.
- `NestsAuthTest` (jvmTest) signs a real 27235 event, decodes the
base64 back through Quartz's JacksonMapper, and asserts the
signature verifies and the url+method tags bind correctly.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Phase 2 of the Clubhouse/nests integration: when the live-activity
channel screen is opened on a kind 30312 MeetingSpaceEvent, render an
audio-room "stage" above the chat that shows host/speaker/audience
avatars and exposes hand-raise + mute toggles backed by NIP-53 kind
10312 presence events.
Quartz:
- New MutedTag (`["muted","1"|"0"]`) and `muted()` builder helper for
MeetingRoomPresenceEvent.
- New MeetingRoomPresenceEvent.build() overload that accepts a 30312
MeetingSpaceEvent root (matching the existing 30313 overload) and
optionally encodes hand-raise + mute flags in one call.
Amethyst:
- AudioRoomStage composable: filters participants from the 30312 event
into hosts / speakers / audience, renders avatars, and publishes
presence on enter + every 30 s while composed (on dispose it pushes
one final lowered-hand presence so peers drop us before timeout).
- ChannelView wires AudioRoomStage in next to ShowVideoStreaming; the
latter is a no-op for non-30311 events so non-audio rooms are
unaffected.
No audio is captured yet — the mic toggle is a Nostr-only signal
until Phase 3 brings the MoQ/WebTransport transport.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
Adds a new "Audio Rooms" entry in the drawer feeds section that lists
NIP-53 kind 30312 (Interactive Rooms / audio spaces) events, mirroring
the existing Live Streams architecture.
- AudioRoomsFeedFilter narrows LocalCache.liveChatChannels to
MeetingSpaceEvent (30312) and MeetingRoomEvent (30313), sorting by
OPEN > PRIVATE > CLOSED then by follow participation.
- AudioRoomsFilterAssembler/SubAssembler reuse makeLiveActivitiesFilter
so the wire-level REQs are shared with the Live Streams screen.
- AudioRoomsScreen/TopBar/FeedLoaded follow the Live Streams layout and
render via ChannelCardCompose.
This is phase 1 of the Clubhouse/nests integration plan: it only adds
the discovery surface. Joining, presence, audio transport (MoQ) and
room creation will arrive in subsequent PRs.
https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
- MeetingRoomPresenceEvent (kind 10312) now extends BaseReplaceableEvent
instead of BaseAddressableEvent. NIP-53 states this kind is a regular
replaceable event ("presence can only be indicated in one room at a
time"), and LocalCache already routes it through consumeBaseReplaceable.
createAddress/createAddressATag/createAddressTag now take only pubKey.
- Add TagArrayBuilder extensions for streaming (30311), meeting space
(30312) and meeting room (30313) tags, plus build() helpers that assemble
the required tags per NIP-53.
- Add ProofOfAgreement utility to sign and verify the schnorr proof that
NIP-53 places in the 5th element of a participant p-tag
(SHA256 of kind:pubkey:dTag signed by the participant's private key).
Includes LiveActivitiesEvent.hasValidProof() convenience.
- Follow Packs was piggybacking on the Discovery top nav filter; give it a
dedicated defaultFollowPacksFollowList StateFlow, liveFollowPacksFollowLists,
and a DEFAULT_FOLLOW_PACKS_FOLLOW_LIST preference key so its top nav filter
no longer mutates Discovery (and vice versa).
- Persist Communities, Badges, Browse Emoji Sets, and Follow Packs top nav
filters in encrypted SharedPreferences so they survive app restart.
- Longs top nav was restricted to kind3GlobalPeople; switch it to
kind3GlobalPeopleRoutes to match the other media/reading feeds and expose
hashtag/geohash/relay/interest-set filters.
Previously the drawer hand-coded label + icon + route for every item,
duplicating what NavBarCatalog already stored. Adding a new screen
meant updating both files (and forgetting the catalog silently broke
the bottom-bar settings, as happened with Polls).
- Add POLLS to the catalog (was missing).
- Add three ordered id lists in NavBarItem.kt: DrawerNavigateItems,
DrawerYouItems, DrawerFeedsItems. Each drawer section iterates its
list and looks each id up in NavBarCatalog.
- Add CatalogSection + CatalogNavigationRow helpers in DrawerContent
that render a NavBarItemDef using the existing NavigationRow
overloads (Drawable vs Vector icon).
- Profile keeps its primary-color tint via a special case inside
CatalogSection (the only item with a non-default tint).
- Create (Share HLS Video / Chess) and System (IconRowRelays +
Settings) stay hand-coded: they contain non-catalog items or
custom composables.
Net: DrawerContent.kt shrinks by ~160 lines; adding a new drawer
screen is now a single catalog entry plus one list membership.
On the JVM, Log.d / .i / .w / .e were printing through \`println()\` which
lands on stdout. The amy CLI uses stdout as its JSON contract — any
Marmot/MLS command would restore state, emit a burst of DEBUG lines,
then emit the JSON object, and every caller piping through jq failed
to parse because the first non-empty line was a log line.
Switch to \`System.err.println\` inside PlatformLog.jvm.kt. Conventional
for CLIs (data on stdout, diagnostics on stderr), and the desktop app
already redirects as part of packaging. Callers who want everything
mixed can still \`2>&1\` at the shell level.
Fixes amy marmot group create / group add / message send etc. when
their output is captured by a shell script.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
Jackson was writing the computed \`hasPrivateKey\` into identity.json
and then refusing to read it back ("Unrecognized field hasPrivateKey")
on the next invocation. All amy commands that reload the data-dir
(\`marmot group create\`, \`marmot key-package publish\`, …) exited 1 at
the Context.open step. Pure boolean getter, no persistence needed.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
wn now wraps account listings in {"result": [{...}]} so extract_pubkey's
object + array fallbacks both missed it and ensure_identity bailed with
"could not determine \$who npub" immediately after create-identity.
Also: create-identity on a fresh box can exit non-zero with
"failed to connect to any relays" even when the account *was* created
locally. Probe --json whoami afterwards before calling that a fatal.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
Users can now pick any subset of drawer items, in any order, to appear
in the bottom bar. If zero items are selected the bottom bar is hidden.
- NavBarItem enum + NavBarCatalog: every drawer destination gets an id,
label, icon, and route resolver so items can be looked up by id.
- UiSettings gains bottomBarItems: List<NavBarItem>; persisted in the
existing UiSharedPreferences DataStore as a comma-separated enum list.
- AppBottomBar reads the list from UiSettingsFlow and renders a zero-
height spacer (preserving nav-bar insets) when the list is empty.
- New Navigate section at the top of the drawer exposes Home, Messages,
Media, Discovery, Notifications as drawer destinations.
- New BottomBarSettingsScreen under All Settings lets users pin/unpin
entries and drag to reorder (same UX as ReactionsSettingsScreen).
Containers / CI often block the keyutils syscalls wnd uses by default
to store secret keys (add_key returns EACCES, keyctl_search returns
ENOSYS). The integration-tests feature ships a mock keyring store, but
the stock wnd binary doesn't wire it up. Add a tiny opt-in patch.
Changes:
- headless/patches/whitenoise-mock-keyring.patch: if built with the
integration-tests feature and \$WHITENOISE_MOCK_KEYRING is set, wnd
calls Whitenoise::initialize_mock_keyring_store() before the normal
init path. Harmless on production builds.
- headless/setup.sh: apply both harness patches generically from a
list; build wn/wnd with --features cli,integration-tests; export
WHITENOISE_MOCK_KEYRING=1 alongside WHITENOISE_DISCOVERY_RELAYS when
launching each daemon.
- preflight swaps keyctl for patch in required tools — we no longer
need a real session keyring.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
Introduces a dedicated "Live Streams" screen that mirrors the Shorts
architecture (FeedContentState + FilterAssembler + SubAssembler) and
reuses the Discovery/LiveStreams rendering (ChannelCardCompose) and
relay filter builders (makeLiveActivitiesFilter) for the same top-nav
filters available on Shorts: Following, Global, Hashtag, Location,
Authors, Community, etc.
Refactors LocalPreferences.innerLoadCurrentAccountFromEncryptedStorage
to extract follow-list pref reads into a small helper so the method
stays under the JVM 65KB method size limit after adding the new
defaultLiveStreamsFollowList setting.
Without this, wnd exits on startup with NoRelayConnections in any
environment that can't reach wnd's baked-in public discovery relay
set (nos.lol, relay.damus.io, …). The headless harness runs entirely
on a loopback relay, so hitting the public internet is never OK.
Changes:
- headless/patches/whitenoise-discovery-env.patch: adds an env-var
override at the top of DiscoveryPlaneConfig::curated_default_relays.
When \$WHITENOISE_DISCOVERY_RELAYS is set (comma-separated) we use
that list instead of the hardcoded public one.
- headless/setup.sh: applies the patch in preflight (idempotent —
checks for a .headless-discovery-patched marker), invalidates the
previous wn/wnd build on first patch, rebuilds, and threads
\$WHITENOISE_DISCOVERY_RELAYS=\$RELAY_URL into every start_daemon
invocation.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
Introduces a dedicated Public Chats screen accessible from the left drawer,
mirroring the architecture of the Shorts screen. It reuses the existing
Discover public-chats renderer (ChannelCardCompose + RenderPublicChatChannelThumb)
and its relay-filter helpers (makePublicChatsFilter), but with its own feed
content state, filter assembler/sub-assembler, and top-nav follow-list filter
setting so the selection is independent from Discover.
- New Route.PublicChats, drawer entry, screen/top-bar/feed composables
- PublicChatsFeedFilter scans LocalCache.publicChatChannels
- defaultPublicChatsFollowList persisted in AccountSettings / LocalPreferences
- Live per-relay follow list flow on Account for relay subscription
Introduces a standalone Follow Packs screen (Route.FollowPacks)
accessible from the drawer, rendering FollowListEvent (kind 39089)
entries from LocalCache with the same top-nav filter as Shorts and
the Discovery tab. Reuses the existing defaultDiscoveryFollowList
setting and DiscoverFollowSetsFeedFilter logic so the two surfaces
stay in sync. Item rendering goes through ChannelCardCompose pinned
to FollowListEvent.KIND, matching the Discovery follow-packs tab.
nostr-rs-relay's build.rs needs protoc to compile the nauthz gRPC
definitions. Without it the build fails deep in prost-build with a
panic that's hard to read, so fail up front with a pointer to
`apt-get install protobuf-compiler`.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
Every test now flows through one loopback nostr-rs-relay on
ws://127.0.0.1:8080 — no public relays, no egress. Addresses the
repeated 503s we saw from the sandbox and the kind:445 rejections we
hit on public relays even with internet.
Changes:
- Preflight clones https://github.com/scsibug/nostr-rs-relay into
state-headless/nostr-rs-relay and runs `cargo build --release`
(~3 min first run). Cache hits on subsequent runs.
- New start_local_relay / stop_local_relay functions render a minimal
config.toml each run (binds to 127.0.0.1, no kind blacklist, data
under state-headless/relay) and wait up to 20s for the port.
- configure_relays collapsed to a single-relay path — A/B/C all point
at \$RELAY_URL. Kept publish-lists + key-package publish as smoke
signals for the advertise path.
- Flags pared down: drop --local-relays (always local now), add
--port N for when 8080 is taken. --no-build still honoured.
- trap wires relay shutdown alongside daemon shutdown.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
Move topic chips below the author meta row and shrink them
(10sp gray text on a subtle fill, no border, tighter padding)
so the title, summary, and author are the primary focus.
Move the "defaults a new Amethyst account gets seeded with" into commons
so the UI and amy agree byte-for-byte on what a fresh account looks like.
commons additions:
- commons/defaults/Constants.kt — relay URL constants (moved from amethyst/)
- commons/defaults/AmethystDefaults.kt — DefaultChannels, DefaultNIP65RelaySet,
DefaultNIP65List, DefaultGlobalRelays,
DefaultDMRelayList, DefaultSearchRelayList,
DefaultIndexerRelayList (moved from
amethyst/model/AccountSettings.kt)
- commons/account/AccountBootstrapEvents.kt — data class + bootstrapAccountEvents(signer, name)
that builds the nine events a new Amethyst account publishes: kind:0, 3,
10002, 10050, 10051, 10099, 50, 51, + channel list. One source of truth.
Retrofits:
- AccountSessionManager.createNewAccount now delegates event construction to
the commons helper; it still packages them into AccountSettings for the
Android storage path.
- DefaultSignerPermissions stays in AccountSettings.kt — it uses Android
NIP-55 Permission/CommandType types.
- 19 import updates across amethyst/ to point at the new commons paths.
New CLI verbs:
- amy create [--name NAME]: mint a keypair, write identity.json, seed
relays.json with the Amethyst defaults, publish all nine bootstrap events
to DefaultNIP65RelaySet via NostrClient.publishAndConfirmDetailed.
- amy login KEY [--password X]: accept any identifier form Amethyst's login
screen accepts — nsec1, ncryptsec (+ --password, NIP-49), BIP-39 mnemonic
(NIP-06), npub1, nprofile1, 64-hex pubkey (default) or 64-hex privkey
(with --private), NIP-05 (name@domain.tld, HTTP lookup). Read-only keys
land with privKeyHex=null; Identity now carries that cleanly.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
Matches the 13 scenarios in marmot-interop.sh but runs A via the new
`amy` CLI instead of prompting the human. Identities B and C still go
through `wn`/`wnd` from whitenoise-rs.
Layout:
- marmot-interop-headless.sh — top-level entrypoint
- headless/setup.sh — preflight, daemon lifecycle, identities
- headless/helpers.sh — amy wrappers + assertion helpers
- headless/tests-create.sh — tests 01..05
- headless/tests-manage.sh — tests 06, 07, 08, 11
- headless/tests-extras.sh — tests 09, 10, 12, 13
Reuses lib.sh for colours, record_result, wait_for_invite/message,
jq_group_id, and dump_daemon_diagnostics.
Keeps the interactive marmot-interop.sh intact for final UI sign-off.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
CollapsibleSectionPreview shows two sections with representative rows so
header styling, expand state, and chevron alignment can be verified
against light/dark themes. ListContentPreview wires mockAccountViewModel
and EmptyNav to render the whole reorganized drawer body.
CollapsibleSection was re-allocating a Modifier chain, uppercasing the
title String, and formatting an accessibility label on every
recomposition. Move the static padding/fillMaxWidth into
DrawerSectionHeaderModifier in Shape.kt, use the section title as-is,
and rely on the chevron's contentDescription instead of a per-frame
onClickLabel format call.
Five extractions so the Amethyst UI and the new amy CLI share one
implementation for each piece of Marmot/identity behaviour instead of
reimplementing it per platform.
quartz additions:
- MarmotGroupData.bootstrap(..) + withMergedRelays(..): factory for the
metadata shape every inviter stamps on a fresh group and the outbox-
merge rule every admin commit carries forward.
- KeyPackageFetcher: (a) pure fetchRelaysFor union of target kind:10051,
target outbox, my outbox; (b) one-shot fetchKeyPackage; (c) publish-
side resolveKeyPackagePublishRelays.
- resolveUserHexOrNull: single entry point for npub / nprofile / 64-hex
/ NIP-05 identifiers. Uses the existing Nip19Parser + Nip05Client
infra so NIP-05 resolution is live over HTTP.
commons additions:
- MarmotManager.leafIndexOf(..): pubkey -> leaf index lookup used by
every removeMember caller.
- MarmotManager.addMember(nostrGroupId, keyPackageEvent, relays):
convenience overload that lifts the base64 decode into the manager.
- MarmotManager.buildTextMessage(..) returning a TextMessageBundle;
optionally persists the outbound inner event (CLI needs persist,
Amethyst relies on LocalCache loopback).
- MarmotIngest.ingest(event): routes kind:1059 / kind:445 through
unwrap -> processWelcome / processGroupEvent -> persist the decrypted
application message. Deliberately platform-agnostic.
Retrofits:
- Account.addMarmotGroupMember and fetchKeyPackageAndAddMember now take
a KeyPackageEvent directly; Account.keyPackagePublishRelays delegates
to KeyPackageFetcher.
- AccountViewModel.updateMarmotGroupMetadata uses bootstrap + merged.
- AccountViewModel.sendMarmotGroupMessage builds the kind:9 template via
buildTextMessage(persistOwn = false) to avoid double-persisting.
- AccountSessionManager's NIP-05 login branch delegates to
resolveUserHexOrNull; no more hand-rolled Nip05Id / nip05Client.get.
- CLI Context exposes nip05Client and requireUserHex; syncIncoming now
calls MarmotManager.ingest; all command sites use KeyPackageFetcher
+ the shared identifier resolver. Npubs util deleted.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
Separates personal lists (profile, bookmarks, my emoji packs, drafts,
wallet, etc.) from discovery feeds (pictures, videos, emoji packs feed,
communities, etc.) so the two uses of an emoji icon and the general mix
of ownership vs. browsing are no longer visually merged into one flat
list.
Each section is collapsible via a chevron header but expanded by default
for quick access.
New :cli JVM module producing an `amy` binary that drives Amethyst
functionality headlessly. Identity and relay configuration sit at the
root (`amy init`, `amy whoami`, `amy relay …`); Marmot/MLS lives under
`amy marmot …` so future verbs (dm, feed, profile) can slot in cleanly.
Marmot surface covered:
- key-package publish / check
- group create / list / show / members / admins / add / rename /
promote / demote / remove / leave
- message send / list (kind:9 inner events)
- await key-package / group / member / admin / message / rename / epoch
(non-interactive polling with --timeout; exit 124 on timeout)
Wiring:
- NostrClient + BasicOkHttpWebSocket.Builder, reusing the existing
publishAndConfirmDetailed and fetchFirst accessories
- MarmotManager from :commons with file-backed MlsGroupStateStore,
KeyPackageBundleStore, and MarmotMessageStore (unencrypted — scratch
harness use only)
- each invocation is one-shot: prepare → syncIncoming → run command →
persist → disconnect
All commands emit one JSON object on stdout; diagnostics on stderr.
Designed so shell harnesses can pipe through jq without bespoke parsing.
https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq