The Pause action called stopSelf(), but onDestroy() then triggered the
auto-restart broadcast (because alwaysOnNotificationService was still
enabled), so the notification reappeared seconds later. There was no way
to actually pause without toggling the setting, so the button was just
confusing.
Drops the ACTION_STOP intent, the notification action, and the
always_on_notif_stop string from all locales. Also includes incidental
spotless fixes the pre-commit hook required.
When the host taps "Leave" we now show a 3-button confirmation:
- Close room → re-publish kind:30312 with status="closed", then leave
- Just leave → leave; room stays open until the 8h staleness window
- Cancel → dismiss
Audience-side leave flow unchanged. Without this prompt, hosts who tap
Leave silently abandoned the room — listeners saw it as "live" with no
audio until the EGG-01 rule 7 staleness timeout, eight hours later.
Implementation:
- `closeMeetingSpace(accountViewModel, event)` builds a verbatim
FormState from the event and reuses
EditAudioRoomViewModel.buildEditTemplate(... STATUS.CLOSED). Bypasses
the VM's mutable state so unsaved edits in an open Edit sheet can't
leak into the close payload.
- On publish failure we still leave (the user asked to) and surface a
toast — the room will auto-close at the 8h timeout.
Process death (host backgrounded + Android-killed) is still handled by
the same 8h fallback; no extra wire changes.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
The first-party nostrnests web client (NestsUI-v2) emits kind-30312
events using NIP-53 *streaming-event* tag names rather than the
kind-30312 names defined by NIP-53 itself. Confirmed against
`nostrnests/nests/NestsUI-v2/src/components/EditRoomDialog.tsx` and
`useRoomList.ts`:
- room name: `title` (NIP-53 spec for 30312: `room`)
- MoQ relay: `streaming` (NIP-53 spec for 30312: `endpoint`)
- moq-auth: `auth` (NIP-53 spec for 30312: `service`)
- status: `live` (NIP-53 spec for 30312: `open` / `private`
/ `closed` / `planned`)
Our parsers followed the NIP-53 spec, so events from the production
nostrnests web app fell through to status=null, which `checkStatus`
then surfaced as "Ended" in the live-rooms UI. Add legacy-alias
acceptance to all four parsers — read-side only; we still emit the
canonical NIP-53 forms, matching EGG-01.
Per-tag changes:
StatusTag: "live" → OPEN, "ended" → CLOSED
RoomNameTag: "title" alias → room
EndpointUrlTag: "streaming" alias → endpoint
ServiceUrlTag: "auth" alias → service
No emit-side changes; no spec change.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
When the + FAB is tapped on Audio Rooms and the user's kind:10112
nests-server list is empty (or has no http(s) entries), show an
AlertDialog offering to add the built-in default
(CreateAudioRoomViewModel.DEFAULT_SERVICE_URL — moq.nostrnests.com)
to their list, then continue into the create-room sheet.
Wired through the existing Account.sendNestsServersList path used by
the Settings screen's NestsServersViewModel.save(), so the resulting
kind:10112 propagates to the user's outbox identically. On signer/
network failure surface a toast and keep the dialog dismissed (the
user can retry from Settings).
If the user already has a usable server, behavior is unchanged — the
FAB opens the create-room sheet directly.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
ChannelCardCompose wraps content in ClickableNote, whose default onClick
calls routeFor(note, account). For MeetingSpaceEvent (kind:30312) that
falls through routeForInner's `is AddressableEvent` branch
(RouteMaker.kt:159-161) and returns Route.Note(addressTag) — the thread
view, which is the bug.
Bypass ChannelCardCompose for the rooms feed: AudioRoomFeedCard
renders RenderLiveActivityThumb directly inside a Column.clickable that
launches AudioRoomActivity, mirroring the home live-bubble pattern in
RenderLiveActivityBubble.kt:70-95. Falls back to the thread route when
service/endpoint/d are missing, same as the bubble.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
- Cache the AICore FeatureStatus check per service instance — was
re-running an RPC on every image attach.
- Two-pass decode with inSampleSize so 12 MP camera shots become
~1024 px before we hand them to the describer (avoids 40+ MB
ARGB_8888 allocations and the GC churn that follows).
- Switch ML Kit clients to var + lazy-on-first-use so close() no
longer triggers init for clients we never invoked.
- Wrap the composable's suggestAltText call in try/finally so a
cancellation mid-inference resets the spinner state.
Adds com.google.mlkit:genai-image-description as the primary alt-text
source — Gemini Nano via AICore produces full descriptive sentences on
supported devices. When checkFeatureStatus reports anything other than
AVAILABLE (or AICore is missing), the service falls back to the legacy
play-services-mlkit-image-labeling keyword join. Both paths sit behind
the same MLKitImageLabelService.suggestAltText API; the F-Droid stub is
unchanged.
Wire ML Kit image labeling into the media-attach dialog so the alt-text
field is prefilled with a confidence-filtered, comma-separated label
list when the user picks an image and the field is still empty. A
spinner shows during labeling and a dismissible "AI-suggested, edit me"
chip lets the user revert. Play flavor uses
play-services-mlkit-image-labeling; F-Droid ships a no-op stub.
Define the host-side flow that was previously implicit. A new-room
implementer can now go from "I want to start broadcasting" to first
audio frame without reading our source.
README — new "Hosting a new room" section:
- 8-step ASCII walkthrough symmetrical to "Joining sequence".
- Covers service/endpoint selection, kind:30312 compose + publish, JWT
mint with publish:true, WT/Setup, Announce, presence, Opus stream.
- Lists ongoing host duties (add speaker, kick, edit, close, recording).
EGG-01 — two new behavior rules:
- Rule 12 "Publish-before-mint ordering": peers MUST publish the
kind:30312 to relays BEFORE requesting a JWT for it, since the auth
sidecar reads the most-recent event by (pubkey, kind, d) to validate
existence / status. 410 unknown_room → retry 1s/2s/4s. Closes the
silent footgun where a host could mint a token before their event
has propagated.
- Rule 13 "Service/endpoint selection (host-side guidance)": pre-fill
from kind:10112 first entry, fall back to client default with user
override, both MUST be https://.
EGG-07 — new "Audio publish authorisation" section:
- Spells out who gets a publish:true JWT: host (by authorship,
implicitly — no need for ["p", _, _, "speaker"] on the host's own
p-tag), explicit "speaker" role, or "admin" role. All others 403
publish_forbidden per EGG-02.
- Relay does NOT re-read kind:30312; demoting a speaker mid-session
does NOT terminate their stream — host MUST kick (kind:4312) to
end an active broadcast.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
Edits across all 13 EGGs to remove implementer guesswork. After this an
implementer can build a listener and speaker without reading our source.
README:
- Conventions section: hex casing rule (lowercase, 64 chars, no 0x),
NIP-01 foundations, `a`-tag form, created_at tie-break, JSON / time.
- Joining sequence: numbered end-to-end walkthrough from `kind:30312`
to first audio frame, referencing each EGG.
EGG-01 (room event):
- `relays` tag is one tag with multiple values (not multi-tag).
- `service` URL trailing-slash normalization.
- `private` status: gate is implementation-defined, not "render as open".
- `d`-tag charset locked to [A-Za-z0-9._-] so it interpolates safely
into the moq-auth namespace.
- Relay-discovery rule: publish to `relays` tag ∪ NIP-65 outbox.
- Tie-break on identical created_at via smallest event id.
EGG-02 (auth):
- JWT signing pinned: ES256 over P-256, JWKS at /.well-known/jwks.json,
5-minute relay cache.
- NIP-98 tags pinned: u / method / payload, base64 RFC 4648 standard
(not base64url).
- Error taxonomy: full HTTP status + `error` slug table for /auth, plus
WebTransport CONNECT 200/401/403/404 table.
EGG-03 (audio):
- Pin moq-lite Lite-03 to kixelated/moq-rs `rs/moq-lite/src/lite/`.
- One Opus packet per moq Frame (no container, no timestamp).
- Pubkey hex casing reaffirmed at the suffix / broadcast slots.
- AnnouncePlease prefix="" for speaker discovery.
- Mute = stop publishing (not silence frames, not Announce Ended).
- Mid-stream join: discard pre-skip per RFC 7845.
EGG-04 (presence):
- Heartbeat jitter ±5 s required (anti-thundering-herd).
- "0"/"1" are strings, not booleans.
- Single-room rule via replaceable-event semantics.
EGG-05 (chat): 8 KB suggested, 64 KB hard cap, 3 msg/s render rate.
EGG-06 (reactions): 30s window measured against created_at, drop-on-arrival
if already stale.
EGG-07 (moderation): replay protection — dedupe kicks by id within 120 s.
EGG-08 (scheduling): planned rooms MUST 403 at the auth sidecar.
EGG-10 (theming): bg image caps (1 MB / 4096 px soft, 8 MB / 8192 px hard).
Deferred (per review): EGG-00 (Conventions as a standalone spec), EGG-13
(capability advertisement), EGG-14 (discovery), test vectors corpus.
The "Conventions" section in README covers EGG-00's most urgent content
inline.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
11 cases driving the predicate matrix against a real TextFieldState on
device:
- mention-free text passes through
- pure delete fully covering a mention is allowed
- partial overlap (at start, at end, inside) collapses atomically
- scope-exact replace with non-empty text collapses (SwiftKey case)
- scope-broader replace passes through
- append after mention preserves it
- trailing space and trailing newline are consumed during atomic collapse
- multiple mentions: only the touched one collapses
- cheap-gate path (mention-free original) is verified
All 11 pass on Pixel 9a; gives the predicate a regression net so future
predicate-tuning doesn't reintroduce the @Vitor Pamplona bug.
Run via:
./gradlew :amethyst:connectedPlayDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=\
com.vitorpamplona.amethyst.MentionPreservingInputTransformationTest
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(compose): tighten @OptIn scope from @file to the object
fix(compose): allow full-cover changes through; collapse only on partial overlap
refactor(compose): hoist MENTION_REGEX, fast-path mention-free text, drop redundant scaffolding
fix(compose): also collapse mention atomically on full-range non-empty replaces
fix(compose): atomically delete the whole mention on partial-overlap edits
fix(compose): opt-in ExperimentalFoundationApi in MentionPreservingInputTransformation
Address review findings:
- Add trap for temp dir cleanup on error
- Use -Zxz for max distro compatibility (older dpkg lacks zstd)
- Use --root-owner-group for correct file ownership
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wire-protocol specs for nostrnests-style audio rooms, in the
style of Nostr NIPs and Blossom BUDs. Each spec documents one
self-contained capability that a client or relay can implement;
two compliant peers implementing the same set of EGGs round-trip
without further coordination.
Layout:
README.md cover, status table, conformance levels
EGG-01.md Room event (kind:30312) required
EGG-02.md Auth + WebTransport handshake required
EGG-03.md Audio plane (moq-lite) required
EGG-04.md Presence (kind:10312) required
EGG-05.md In-room chat (kind:1311) optional
EGG-06.md Reactions (kind:7) optional
EGG-07.md Roles & moderation (kind:4312) optional
EGG-08.md Scheduling (status=planned) optional
EGG-09.md User server list (kind:10112) optional
EGG-10.md Theming (c/f/bg tags) decorative
EGG-11.md Recording decorative
EGG-12.md Catalog track (catalog.json) optional
Conformance levels (Listener / Speaker / Host) defined in the
README so a deployment can declare "we implement EGG-01..EGG-04"
and other peers know exactly what to expect.
Each spec follows the same shape (Summary / Wire format /
Behavior numbered MUST/SHOULD/MAY rules / Example /
Compatibility) and fits on a single printed page. Wire formats
are documented exactly as nostrnests + amethyst implement them
on this branch — no hypothetical capabilities, no "future" tags
without an EGG number.
Companion path to the chat-driven inclusion landed in the
previous commit. The home filter now ALSO pulls a kind-30312
audio room into the live-bubble row when a follow is actively
broadcasting in it (kind-10312 presence with `publishing=1`).
Wire-up:
* LocalCache gains a dedicated consume(MeetingRoomPresenceEvent)
that, in addition to the addressable storage, attaches the
version note to the room's LiveActivitiesChannel keyed by
the kind-30312 address from the `["a", ...]` tag. The same
fan-out kind-1311 chat already gets, applied to presence.
Without this, channel.notes never sees presence events and
the bubble can't pick them up via the chat-channel pump.
* HomeLiveFilter.acceptableChatEvent extends to recognize
MeetingRoomPresenceEvent. We only accept publishing=true
presences from a follow targeting an OPEN/PRIVATE room —
hand-raise / mute / pure-listener heartbeats are noise that
would flood the bubble with everyone in the room every 30 s.
Status check shares the new isMeetingSpaceLive() helper with
the chat-driven path.
* HomeLiveFilter.updateListWith resolves the room channel for
incoming MeetingRoomPresenceEvents via the same
`interactiveRoom().address` pointer.
End user effect: a follow opening their mic in an audio room is
now indistinguishable from them chatting in it — both pull the
room into the live-bubble row, both surface the room name + tap
straight into AudioRoomActivity. Bubble disappears within 15 min
of the last qualifying event (chat or presence) per the existing
window.
When a follow chats in a kind-30312 audio room, the room now
appears in the live-bubble row at the top of home — same place
streaming kind-30311 + ephemeral chats already show up.
The plumbing already exists below the surface:
LocalCache.consume(LiveActivitiesChatMessageEvent) calls
getOrCreateLiveChannel(activityAddress) regardless of whether
the address is a kind-30311 or kind-30312, so liveChatChannels
already grows entries for audio rooms whenever a follow chats
in one. The home filter just rejected them at the
acceptableChatEvent guard because it required `info.status() ==
LIVE` — and `info` is hard-typed to LiveActivitiesEvent so it's
null for audio rooms.
Three changes:
* HomeLiveFilter.acceptableChatEvent — branch on the chat's
activityAddress.kind. For kind-30311 keep the existing
info.status() == LIVE path; for kind-30312 read the
addressable's MeetingSpaceEvent and accept OPEN/PRIVATE.
"Closed" rooms drop out of the bubble even if a follow is
chatting in the (now-archived) chat.
* RenderLiveActivityBubble — when channel.address.kind ==
30312, pull room title from the addressable so the bubble
label reads "Lounge" instead of "naddr1abc…", and tap-launch
AudioRoomActivity directly (one-tap into the room) instead
of routing to ChannelView. Falls back to the channel route
if the address is malformed.
* LiveStatusIndicator.checkChannelIsOnline — the red live-dot
surfaces for kind-30312 channels whenever their addressable
is OPEN/PRIVATE, mirroring what status==LIVE means for
streaming.
Audio rooms with no chat yet still don't surface (would require
populating channel.info, which would mean widening the channel
model — deferred to a later cycle per the architectural
discussion). The chat-driven case covers nostrnests' typical UX:
a host opens a room, audience members start chatting, the bubble
pulls in their followers.
Pre-existing bug surfaced during the user-walkthrough audit:
WatchAccountForAudioRoomsScreen used `val hiddenUsers =
hiddenUsers.flow.collectAsStateWithLifecycle()` (no `by`), so the
LaunchedEffect captured a State<T> object as a key rather than
the unwrapped value. State equality is reference-based and the
remembered State instance is stable across recompositions, so the
effect never re-fired when the user muted someone — the rooms
feed showed stale results until a manual refresh.
Switching to `val hiddenUsers by ...` unwraps the value so
LaunchedEffect re-keys when the hidden-users set changes.
Four user-visible quirks the walkthrough surfaced:
5. EditAudioRoomSheet's "Close room" button was one tap with no
confirm. A misclick destroyed the room. Added an AlertDialog
gate ("All attendees will be disconnected. The room will
show as CLOSED in the feed.") with a destructive primary
action and a Cancel.
6. Silent ActivityNotFoundException in ParticipantHostActionsSheet
(View Profile) and MeetingSpace.kt (Listen to Recording).
Both were `runCatching { startActivity(...) }` with no toast
on failure — user taps, nothing happens, no feedback. Now
they toast "No app installed to open this link." through
the standard toastManager.
7. ScheduleStartPicker dismiss didn't revert in-dialog state.
User picks Dec 13, taps Cancel, reopens the dialog → still
pre-selected to Dec 13 (the cancelled choice) instead of the
committed value. Added `resetPickersToCommitted()` that
rewinds both picker states to the committed unixSeconds on
every dismiss / cancel path.
8. CreateAudioRoomViewModel.publishAndBuildLaunchInfo accepted
past start times. Added a `scheduledStartUnix < now` guard so
the host gets "Pick a future start time." instead of publishing
a kind-30312 with `status=planned` + a backdated `starts` tag.
Three rough edges in the in-room chat panel:
3. Send swallowed broadcast failures and cleared the draft
unconditionally. An offline user typed, tapped Send, watched
the text vanish, and never saw a toast — the message landed
nowhere. Now the draft clears ONLY on success; failure toasts
`audio_room_chat_send_failed_title` with the exception
message and keeps the text in the field for retry. Composer
also disables itself for the brief in-flight window so a
double-tap can't fire two sends.
4. Auto-scroll forced to bottom on every new message, even
while the user was reading older messages. Switched to the
standard "only scroll if pinned near the bottom" pattern via
a derivedStateOf gate over `listState.firstVisibleItemIndex`.
+ Replaced the v1 placeholder (truncated 8-hex of the pubkey)
with the canonical Amethyst chat author-name pattern:
LoadUser kicks the metadata fetch, UsernameDisplay observes
the kind-0 flow, and the result falls back to a truncated
npub while the metadata is in flight or missing. Same code
path RenderChatClip / RenderChatRaid use.
Two reliability bugs the user-walkthrough audit caught:
1. Foreground service flickered on every transport blip. The
LaunchedEffect keyed on `isConnected = ui.connection is
Connected`, so a Reconnecting state stopped the service
mid-blip and restarted it on recovery. Beyond the audible
dropout from losing the wake-lock, Android 14+ doesn't
reliably let us re-promote to FOREGROUND_SERVICE_TYPE_MICROPHONE
after losing it — risking a permanent broadcast-side regression
after a single relay hiccup. Treat Reconnecting as "still
live" for the service decision.
2. TalkRow disappeared mid-broadcast on transient listener
disconnect. The speaker session is INDEPENDENT of the
listener — the user could be broadcasting cleanly while the
listener is Reconnecting, but our `if (ui.connection !is
Connected) return` early-exited the entire row, so the user
could neither mute their mic nor stop their broadcast until
the listener recovered. Switch the gate to "user can act on
broadcast state" — Connected listener OR an in-flight
broadcast handle (Connecting/Broadcasting).
Same logic applied to the PIP-entry gate so onUserLeaveHint
during a transient blip doesn't lock the user out of PIP.
Each cell of the participant grid was allocating fresh Modifier
chains and lambdas on every recompose. With a 50-speaker room
and the grid recomposing on every connectingSpeakers /
speakingNow / reactions flip, the per-frame allocation count
adds up.
Hoist the constants:
* gridModifier (fillMaxWidth + height + padding)
* cellWidthModifier (avatarSize + 16.dp)
* absentAlphaModifier (alpha 0.5f)
* speakingBorderModifier (border + CircleShape)
* spinnerModifier (size avatarSize - 8.dp)
Replace the `.let { ... }.let { ... }` Modifier chain on the
avatar with a `when` over the four (isSpeaking × absent) cases —
each case picks a pre-built Modifier rather than synthesising a
fresh chain.
Cache the per-pubkey long-click adapter via remember(pubkey,
onLongPressParticipant) so the `{ hex -> cb(hex) }` wrapper
isn't re-allocated on every recompose.
* announces() now throws UnsupportedOperationException at CALL
time (not the first collect) on the IETF default — matches
subscribeCatalog's timing so both can be guarded with one
`runCatching { listener.announces() }` per session rather than
a runCatching around every collect site (audit #6).
* KDoc on announces() documents the deliberate hot-vs-cold
asymmetry with subscribeSpeaker/subscribeCatalog: announce
data is room-state with a single VM consumer (cold flow is
sufficient), audio is per-frame playout with multi-collect
resilience needs (hot SharedFlow with DROP_OLDEST). The
different shapes are intentional, not accidental (audit #5).
* MoqLiteNestsListener.wrapSubscription's "IETF SubscribeHandle
path conventionally surfaces" comment was scoped to the audio
track when first written; now the same body serves both audio
and catalog. Re-frame so the comment applies to either track
(audit #7).
Test: NestsListenerCatalogTest's announces-throws case now
asserts the call itself throws, not the collect.
The re-issuing pump's MutableSharedFlow used the default
onBufferOverflow = SUSPEND. A stalled consumer (decoder, player,
or anything downstream) back-pressured the pump → the inner
handle.objects.collect → the underlying transport → the relay.
Effect: the room caches up to 64 frames (~1.3s) on the consumer
side, then the relay slows down sending us audio.
For Opus audio the desired behavior is the inverse: drop OLD
frames so playback stays live after a UI hiccup or a foreground
transition. Switch to BufferOverflow.DROP_OLDEST so the relay
keeps streaming at full rate and the consumer's audio stays
synchronized with the speaker, at the cost of dropped frames
during stalls (which would have been late anyway).
Four related state-leak bugs surfaced by the new-interface audit:
1. fetchSpeakerCatalog launched a fire-and-forget coroutine that
wasn't tracked per-pubkey. With the wrapper's re-issuing
handle the catalog subscription survives session swaps —
and a removed speaker's collector kept re-adding the
catalog map entry on every emission. Fix: per-pubkey job map
(catalogJobs); cancel on closeSubscription before dropping
the map entry.
2. teardown() cleared activeSpeakers / speakingNow / announces
but not _speakerCatalogs. Stale catalog data accreted
across reconnects + room swaps. Fix: cancel every catalog
job and reset the map alongside _announcedSpeakers.
3. teardown()'s _uiState.copy(...) reset activeSpeakers and
speakingNow but not connectingSpeakers. The pre-roll spinner
could persist on stale pubkeys after a disconnect. Fix:
include connectingSpeakers in the same copy.
4. fetchSpeakerCatalog ran BEFORE the abandoned-subscription
re-check in openSubscription, so a removed speaker's catalog
subscription opened anyway. Fix: move the catalog kick-off
to after the re-check + after slot.attach (the catalog needs
a confirmed-attached audio slot to be cancellable via
closeSubscription).
Surface moq-lite's ANNOUNCE flow on NestsListener so the audio
room can render an authoritative "actively broadcasting"
indicator independent of kind-10312 presence's `publishing`
flag. Same channel nostrnests' web client uses for its live
badges (`useRoomAnnouncements` in the JS reference).
Wire-up:
* RoomAnnouncement(pubkey, active) data class — one update
per publisher transition (Active → broadcast came up,
inactive → broadcast went down).
* NestsListener.announces(): Flow<RoomAnnouncement> with a
default body that throws UnsupportedOperationException on
the IETF reference path.
* MoqLiteNestsListener implements via session.announce("")
against the room's namespace; the suffix carries the
speaker pubkey hex straight through.
* ReconnectingNestsListener routes via collectLatest so the
consumer-facing flow restarts against each new session
after a refresh / reconnect (no SubscribeHandle re-issuance
pump needed — announces is a cold per-collect stream).
* AudioRoomViewModel.announcedSpeakers: StateFlow<Set<String>>
populated by observeAnnounces. Active emissions add the
pubkey, inactive emissions remove it. Cleared on teardown.
IETF listeners leave the set empty; UI falls back to
presence's publishingNow flag.
Tests:
* NestsListenerCatalogTest — adds the announces() default-
throws-on-collect case.
Closes the audit's Tier-4 #17b gap. UI integration (e.g. a green
"live" dot driven by announcedSpeakers) is a follow-up — the data
flow is in place and downstream consumers can opt in.
moq-auth issues bearer tokens with a 600 s lifetime. When a token
expires the relay tears down the WebTransport session and the
wrapper recovers via the regular Failed → Reconnecting → Connected
path with a brief audible dropout (smoothed by the SubscribeHandle
buffer pump but still user-visible as a Reconnecting chip).
Add `tokenRefreshAfterMs` to connectReconnectingNestsListener
(default 540 s — 1 min before the relay's 600 s expiry). The
orchestrator now waits for either a terminal listener state OR
the refresh deadline; whichever fires first wins. On refresh:
* close the still-healthy listener,
* skip the reconnect-schedule path (refresh is a planned
cutover, not a backoff event),
* loop straight to openOnce() which mints a fresh JWT and
establishes a new session.
The SubscribeHandle re-issuance pump cuts subs over to the new
session, and the wrapper's outward state never enters Reconnecting
during the cutover — the user-facing UI stays Connected end-to-end.
Setting tokenRefreshAfterMs <= 0 disables the behavior.
Tests:
* ReconnectingNestsListenerTest gains
`proactive_token_refresh_recycles_listener_without_failure_state`
— drives the refresh path with a 50 ms window, captures every
state emission, asserts neither Reconnecting nor Failed appear
during a clean recycle.
Tracks the window between SUBSCRIBE_OK and the first decoded audio
frame (typically 0.5-2s on a fresh subscription) so the user can
see audio is on its way rather than wonder why a "live" speaker
is silent.
Wire-up:
* AudioRoomUiState gains `connectingSpeakers: ImmutableSet<String>`,
a set-once-per-subscription field.
* VM enters the set on `slot.attach(...)` (right after SUBSCRIBE_OK
succeeds), exits on the first `onSpeakerActivity` callback (first
decoded packet), and clears on speaker removal. Set membership
survives subsequent silence — once a speaker has delivered a
frame, we know the pipeline works.
* ParticipantsGrid renders a small CircularProgressIndicator
overlaid on the avatar (sized smaller than the picture so the
user stays recognisable underneath).
Audience-side avatars don't get the overlay — they have no MoQ
subscription. Only on-stage speakers in the connectingSpeakers set
show it.
The catalog subscription API shipped two commits ago but nothing
read it. This wires it up:
* RoomSpeakerCatalog data class (commons) — kotlinx.serialization
parser for moq-lite's `catalog.json` payload (version, audio
track list with codec / sample_rate / channel_count / bitrate).
Forward-compat: ignoreUnknownKeys + nullable fields tolerate
new keys and partial publishers.
* AudioRoomViewModel.speakerCatalogs: StateFlow<Map<String,
RoomSpeakerCatalog>> populated lazily as per-speaker
subscriptions land. Catalog fetch piggybacks on openSubscription
via subscribeCatalog — best-effort, doesn't gate audio.
* Participant context sheet renders a single-line summary
("OPUS · 48kHz · 2ch") below the pubkey when the catalog is
available.
* Enabled kotlinx.serialization plugin on :commons (was already
a transitive dep, just not wired for @Serializable codegen).
Tests:
* RoomSpeakerCatalogTest — 7 cases covering the canonical shape,
describe() formatting (codec uppercased, kHz / channel
short-formed), all-null fallback, unknown-key tolerance,
garbage-bytes fallback, and empty-audio-list.
Wire MeetingSpaceEvent's existing RecordingTag into the
note-view renderer. Closed rooms (status=CLOSED) that ship a
`["recording", url]` tag get an OutlinedButton that hands the
URL to the system media player via ACTION_VIEW — most users have
a podcast / audio app registered for HTTPS audio URLs.
Live and scheduled rooms keep the Join button. Closed rooms
without a recording render no CTA — there's nothing to enter.
Adds:
* MeetingSpaceEvent.recording() accessor
* TagArrayBuilder<MeetingSpaceEvent>.recording(url) DSL builder
* ListenToRecordingButton composable in the note renderer
Note: nostrnests' moq-lite refactor dropped the LiveKit-era
recording HTTP surface, but the kind-30312 `recording` tag is
still spec-allowed and individual hosts can populate it via
out-of-band capture. This commit makes Amethyst the receiving
end for any host who does.
The MoqLiteNestsListener KDoc still pointed to "we'll add a
parallel subscribe in a follow-up". The follow-up shipped in
the previous commit; refresh the doc to describe the actual
behavior so anyone reading the listener doesn't think catalog
support is still missing.
Surface moq-lite's `catalog.json` track on the NestsListener API.
The catalog publishes one JSON object per group describing the
broadcast (codec, sample rate, optional speaker-side hints) and
is the canonical channel a watcher reads first to discover
available tracks.
Wire-up:
* NestsListener.subscribeCatalog(pubkey) — interface method with
a default body that throws UnsupportedOperationException, so
the IETF DefaultNestsListener fails loud rather than returning
a flow that never delivers data.
* MoqLiteNestsListener overrides it to wrap
`session.subscribe(broadcast = pubkey, track = "catalog.json")`
through the same MoqObject mapping the audio path uses.
* ReconnectingNestsListener routes both subscribeSpeaker and
subscribeCatalog through a shared `reissuingSubscribe` helper —
catalog handles also survive session swaps via the
MutableSharedFlow re-issuance pump.
Tests:
* NestsListenerCatalogTest — verifies the interface default
rejects with UnsupportedOperationException so a caller wired
against the IETF reference path fails fast.
VM/UI consumers (e.g. "speaker codec" tooltip) are intentionally
out of scope for this commit; this exposes the capability so a
follow-up can plug in a JSON parser + per-pubkey catalog cache.
Add a per-cell UsernameDisplay below each ClickableUserPicture in
the participant grid. Picks up the existing display-name + nip-05
crossfade behaviour and caches one User reference per pubkey.
Cell width is bumped to avatarSize + 16dp so longer names
ellipsize cleanly rather than spilling into the neighbour.
Closes the visible follow-up flagged in the LazyHorizontalGrid
participant-renderer commit.
Replace the FillBounds fallback for RoomTheme.BackgroundMode.TILE
with a real ImageShader+ShaderBrush pipeline. Compose's AsyncImage
has no tile-mode option; the canonical path is to fetch the bitmap
through Coil into an ImageBitmap and wrap it as a ShaderBrush with
TileMode.Repeated on both axes, then paint a Box-sized background.
While the load is in flight (or on failure) the scope paints
nothing so the underlying material surface shows through — same
fail-open behavior as the rest of the theming layer.
A `nostr:naddr1...` for a kind-30312 routes through MainActivity's
URI handler to Route.Note(addressTag) and lands on the
RenderMeetingSpaceEvent renderer in the note view. Until now that
renderer only showed the room name + summary + participant list —
there was no way to actually enter the room without going through
the live-activity ChannelView lobby (which is for kind-30311).
Extract the Join button from AudioRoomJoinCard into a standalone
JoinAudioRoomButton composable, then drop it into the note-view
renderer. Hidden when status=CLOSED (you can't join a finished
room) or when the event lacks service/endpoint/d-tag. Single
helper means the lobby card and the in-feed button share the
same launch logic; future surfaces (room list, search results)
can add the same button trivially.
Switch the production NestsListenerConnector default to wrap each
session in connectReconnectingNestsListener, then retire the VM's
own scheduleAutoRetry / autoRetryAttempts / connectInternal /
retryPending state machine.
A single retry policy now lives in the transport layer (the
wrapper's NestsReconnectPolicy) rather than racing two of them —
the VM's previous retry would tear down + recreate subscriptions
on every attempt, dropping audio. With the wrapper:
* transport drops auto-retry with exponential backoff,
* existing SubscribeHandle objects keep emitting via the
MutableSharedFlow re-issuance pump (already shipped in T4 #2's
earlier commit),
* the existing speaker set survives through Reconnecting →
Connected without per-attempt re-subscription.
UI side: new ConnectionUiState.Reconnecting(attempt, delayMs)
surfaces the wait. AudioRoomFullScreen shows a "Reconnecting…"
chip during the transient window — the user typically doesn't
need to act, the wrapper recovers on its own.
Existing AudioRoomViewModelTest covers connect/disconnect/teardown
unchanged; ReconnectingNestsListenerTest in :nestsClient owns the
retry-policy contract.
Replace the placeholder OPEN-flag fallback with a dedicated
MeetingSpacePlannedFlag for kind-30312 rooms in `status=planned`.
The badge:
* renders "SCHEDULED" on a primary-blue chip (distinct from the
green OPEN, orange PRIVATE, and black CLOSED variants)
* adds a localized "Starts <date+time>" subline when the event
ships a `["starts", "<unix-seconds>"]` tag and the moment is
still in the future
* falls back to just "SCHEDULED" once the start time has passed
(nostrnests pivots to "Live now" in that case; we'll match
that once the kind-30312 typically flips to status=open at
start-time)
Closes the visible loop on the scheduled-rooms feature for
audience members — host-side picker + emit was already in place,
this is the receiving-side render.
Two new opt-in interop cases drive the production
connectReconnectingNestsListener against the real nostrnests
relay (set -DnestsInterop=true to run):
* reconnecting_wrapper_round_trips_frames_via_real_relay —
happy-path validation that the new MutableSharedFlow-backed
pump doesn't drop frames against real moq-lite framing.
* reconnecting_wrapper_keeps_handle_alive_across_session_swap —
custom connector opens two real sessions; we close the first
mid-stream, observe the orchestrator's automatic reconnect,
and confirm that frames published after the swap arrive on
the SAME consumer-facing SubscribeHandle. Real-wire version
of the in-process test added in the previous commit.
Wire the deferred font-URL path of the kind-30312 `["f", family,
url]` tag. New RoomFontLoader downloads the font asset via the
account's image-channel OkHttpClient (so it picks up the same
Tor / privacy posture as image fetches), caches under
`cacheDir/audio-room-fonts/<sha256-of-url>`, and wraps the
local file as a Compose FontFamily via `Font(file = ..., ...)`.
Same URL → same cache key → no repeat downloads across launches.
Failures (404, malformed font, Tor circuit timeout) fall back to
the system-font-name mapping or platform default — themes never
fail loud.
AudioRoomThemedScope subscribes via `produceState`; while the
download is in flight the typography stays on the system-font
fallback so the room renders immediately. Once the file lands,
the URL-loaded family takes over.
Two related bugs in connectReconnectingNestsListener:
1. Orchestrator never advanced past the first Failed. The reconnect
loop used `listener.state.collect { ... return@collect }` to
"exit" on a terminal state, but `return@collect` only returns
from the lambda — a StateFlow's collect keeps running. As a
result, after the first transport failure the orchestrator
re-entered the lambda for every subsequent emission and never
reached the outer `delay(delayMs)` / openOnce() that opens the
next session. Switch to `onEach { mirror } + first { terminal }`
so the upstream completes deterministically.
2. SubscribeHandle stopped emitting after a reconnect (the
long-deferred Tier-4 follow-up). Reissue the subscription on
every activeListener swap by feeding a wrapper-side
MutableSharedFlow from a `collectLatest` pump that re-calls
`listener.subscribeSpeaker(...)` against each fresh session.
The buffer (64 frames ≈ 1.3 s of Opus) covers a typical
re-handshake without dropping speech. Unsubscribing the
logical handle cancels the pump and forwards a best-effort
unsubscribe to the live underlying handle.
Tests: ReconnectingNestsListenerTest covers both happy-path
session swap (frames keep arriving) and the unsubscribe-before-swap
path. Uses real coroutines + Dispatchers.Default rather than
runTest because the test scheduler doesn't auto-advance the
StateFlow + delay chain reliably under UnconfinedTestDispatcher.
Chain Material3 DatePicker → TimePickerDialog in
ScheduleStartPicker, mirroring the ExpirationDatePicker pattern
used in ShortNotePostScreen. Hour + minute are picked in the
local zone and stitched into UTC unix seconds via the system
zone offset (the DatePicker hands back UTC midnight, so we add
the local hh:mm and subtract the offset).
Closes the time-of-day follow-up from the scheduled-rooms commit.
Closes the deferred font tag from the Tier-3 plan. Wire-up:
* quartz: FontTag(family, optionalUrl) parser/assembler with
blank-rejection on family + blank-URL-becomes-null normalisation.
* MeetingSpaceEvent.font(): FontTag? accessor.
* TagArrayBuilder.font(family, url) DSL.
* RoomTheme gains fontFamily / fontUrl fields.
* AudioRoomThemedScope maps `family` to a Compose FontFamily for
the four CSS-style generic-family names ("sans-serif", "serif",
"monospace", "cursive") — each maps to the corresponding system
fallback. The whole Material3 typography is rebuilt with that
family so headlines / body / labels all swap together.
URL-based font loading (RoomTheme.fontUrl) is the natural follow-up:
fetch + cache via OkHttp, then build a FontFamily from a local
file. Until then, an unknown family is silently a no-op — the room
renders in the platform default rather than crashing or fetching
on the UI thread.
Tests:
* FontTagTest — 9 cases covering family-only, family+URL,
blank rejection, missing family, wrong tag name, blank-URL
normalisation, assembler shapes (with + without URL), and
the assembler's blank-family rejection.
* RoomThemeTest gains 3 cases for font projection (family-only /
family+URL / empty event).
Add a "Send zap" row to the participant context sheet. Reuses the
standard ZapCustomDialog targeting the user's kind-0 metadata
addressable note (the canonical "zap a user" base note used
throughout amethyst, e.g. profile screen).
Single-payable invoices hand off to a wallet via payViaIntent —
the same path ReactionsRow uses for in-feed zaps. Split-payee
zaps (uncommon for a personal LN address but theoretically
possible if the metadata has a zap-split tag) toast a "use the
profile screen" message rather than wire a Compose nav host into
ModalBottomSheet — the audio-room activity has no nav host of its
own and routing back to MainActivity for the rare case isn't
worth the plumbing.
The participant context sheet's "View profile" row was previously a
TODO comment — AudioRoomActivity is a separate Android activity
without its own nav stack. Wire it through MainActivity's existing
nostr: URI handler:
* encode the target hex as npub via NPub.create(...)
* fire ACTION_VIEW on `nostr:npub1...` with explicit MainActivity
component (FLAG_ACTIVITY_REORDER_TO_FRONT brings the running
instance forward instead of spawning a duplicate)
* the audio-room foreground service keeps audio alive while the
user is on the profile screen
Reuses the existing audio_room_participant_view_profile string.
Replace the two LazyRow stage/audience sections with a single
ParticipantsGrid composable backed by buildParticipantGrid in
commons. The grid:
* derives on-stage from kind-30312 role + kind-10312 onstage flag
* renders absent members (promoted but never emitted presence) at
50 % alpha — matches nostrnests' grey-out for "promoted but
never joined"
* uses LazyHorizontalGrid with a 1-row Fixed cell so adding a
second row (e.g. names below avatars) is a one-line change
Audience derivation moves from AudioRoomActivityContent into the
projection function — the activity-side filter for "neither host
nor speaker" was only used by the now-removed StagePeopleRow call,
so deleting it shrinks the activity composable and keeps both
audience and absence logic in commons.
Tier-3 background-audio audit (item #4 found the only gap):
AudioTrackPlayer.start() never called requestAudioFocus, so an
inbound phone call mixed on top of the room audio instead of
ducking it. Fixed by acquiring focus at the SERVICE level (one
owner per room session, not per player) in
AudioRoomForegroundService.requestAudioFocus():
* AUDIOFOCUS_GAIN with USAGE_VOICE_COMMUNICATION +
CONTENT_TYPE_SPEECH so the OS treats the room like a phone
call.
* setAcceptsDelayedFocusGain(false) — immediate focus or
nothing.
* On-change listener is a no-op; the OS handles duck-on-loss.
* abandonAudioFocusRequest in onDestroy.
* Best-effort acquire — runCatching swallows denials so a
refused request doesn't fail service start (the OS will
duck/pause us by its own policy, which is degraded but
acceptable).
Audit notes file under nestsClient/plans/ documents items 1-5
with commit-time citations:
1. PARTIAL_WAKE_LOCK — already correct
2. FG type microphone (14+) — already correct
3. FG type media-playback for listen-only — already correct
4. Audio focus — fixed in THIS commit
5. PIP keep-alive — already correct
Wraps `connectNestsListener` with a transport-loss reconnect loop
that consumes [NestsReconnectPolicy] for exponential backoff.
Mirrors the JS reference's `Connection.Reload` behaviour.
connectReconnectingNestsListener(httpClient, transport, scope,
room, signer, policy = default,
connector = real)
Behaviour:
* Returned [NestsListener] forwards the underlying listener's
state while a session is alive.
* On Failed (transport / handshake error), the orchestrator
increments the attempt counter, surfaces
[NestsListenerState.Reconnecting(attempt, delayMs)] for that
delay, then opens a fresh session via the injected connector.
* On Closed (user-driven disconnect), the loop exits — Closed
is terminal.
* `policy.isExhausted(attempt+1)` halts the loop when
[NestsReconnectPolicy.maxAttempts] hits. Default policy is
Int.MAX_VALUE so a long-running room keeps trying.
Subscribe-handle preservation across a reconnect is intentionally
NOT in this commit. Caller-owned [SubscribeHandle]s bind to the
SESSION; once the session is replaced, the handle's flow stops
emitting. The KDoc spells this out so callers can either:
1. Re-subscribe inside their own
`state.collectLatest { if (Connected) sub() }` loop
2. Wait for the future MutableSharedFlow-buffered upgrade flagged
in the Tier-4 plan ("MutableSharedFlow per handle that the
per-session pump emits into").
Test seam: the `connector: suspend () -> NestsListener` parameter
defaults to the real connect call. Tests can pass a scripted fake
that returns a sequence of (Failed, Connected, Failed, Connected)
listeners and verify the orchestrator walks the policy correctly
— production path is unchanged.
Shipped the deferred slice of Tier 1 Step 4: hosts can now
schedule a room for a future time instead of going live
immediately.
Quartz:
StatusTag.STATUS.PLANNED — new enum value alongside OPEN /
PRIVATE / CLOSED. Pre-Lite-03
clients that don't know the value
get null from the parser; the room
renderer's existing fallback handles
that. The amethyst feed renders
PLANNED with the OPEN badge in v1
(a "Scheduled — starts at HH:MM"
chip is a visual follow-up).
StartsTag — `["starts", "<unix-seconds>"]` parser + assembler.
Strict numeric — non-numeric values return null so
a malformed event can't crash the room-list
renderer. Negative values are rejected at assemble.
MeetingSpaceEvent.starts() — accessor.
TagArrayBuilderExt.starts(unixSeconds) — DSL helper.
Amethyst:
CreateAudioRoomViewModel —
onScheduledToggle(scheduled) — flips PLANNED vs OPEN.
onScheduledStartChange(unixSeconds) — picker callback.
FormState.scheduled / .scheduledStartUnix — additive fields.
canSubmit gates on a picked time when scheduled = true.
publishAndBuildLaunchInfo() — emits status=PLANNED +
`["starts", <unix>]` when
scheduled; otherwise the
existing OPEN path runs.
CreateAudioRoomSheet —
Schedule toggle (Material3 Switch) above the picker.
ScheduleStartPicker — OutlinedButton that opens a Material3
DatePickerDialog. The selected date is
saved as 00:00 of that day in the
local time zone (TimePicker stitching
is a follow-up; nostrnests' web UI
does the same date-only flow).
MeetingSpace.kt feed item — added the PLANNED branch to the
exhaustive when so the build passes.
Tests:
StartsTagTest — numeric parse, malformed-rejected, missing /
wrong-name rejection, assemble shape, negative rejected,
STATUS.PLANNED parse round-trip.