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.
Compose wrapper that consumes RoomTheme and applies it to the
audio-room body:
AudioRoomThemedScope(theme, content):
* Overrides Material3 colorScheme — background, onBackground,
primary, surface, onSurface — with the theme's argb values.
Each color override is independent: a room shipping a
primary tint but no background gets the primary swap and
default surface, no all-or-nothing.
* Optional background image via Coil's AsyncImage rendered
behind the content. backgroundMode=COVER → ContentScale.Crop;
TILE → FillBounds (true repeat-tile would need a custom
Modifier — FillBounds is a safe v1 fallback that doesn't
crop and visually approximates a tile when the image is
small + repeating).
* Fails OPEN — RoomTheme.Empty / all-null fields produces a
passthrough Box with no overrides.
AudioRoomFullScreen — wraps the existing scrolling Column in
AudioRoomThemedScope. The theme is materialised once via
RoomTheme.from(event) inside `remember(event)` so a recomposition
doesn't rebuild the colorScheme.
The font tag is still deferred — needs a per-pack FontFamily
loader; the v1 theme runs on the system default and renders
unchanged for un-themed rooms.
Lays the foundation for the moq-lite reconnect-with-backoff path:
NestsReconnectPolicy — exponential backoff settings.
(initial=1s, multiplier=2, max=30s) by
default, mirroring kixelated/moq's JS
reference (`delay: { initial: 1000,
multiplier: 2, max: 30000 }`).
maxAttempts defaults to Int.MAX_VALUE
since a long-running room should keep
trying as long as the user hasn't left;
the Composable's onDispose is the cancel
signal.
NoRetry sentinel for first-shot-or-fail
tests / single-room demos.
delayForAttempt(n) — 1-indexed; doubles per attempt; clamps at
maxDelayMs. n<1 returns 0.
isExhausted(n) — n >= maxAttempts (next retry forbidden).
init { require(...) } — ctor guards reject zero/negative initial,
multiplier <= 1, max < initial, attempts < 1.
NestsListenerState.Reconnecting / NestsSpeakerState.Reconnecting
— new state variants with (attempt, delayMs). UI consumes via
the existing NestsListenerState → ConnectionUiState mapper which
surfaces Reconnecting under OpeningTransport for the v1 chip;
a future commit can add a dedicated "Attempt N in Mms" UI.
Tests:
* First attempt = initialDelayMs
* Doubling via attempt index until maxDelayMs ceiling
* Non-standard multiplier still respects ceiling
* Zero/negative attempt → 0 delay
* isExhausted at the boundary
* NoRetry exhausts after attempt 1
* Constructor rejects invalid inputs
The orchestration layer (mint-fresh-JWT → reopen WT → re-issue
SubscribeHandles + the speaker-side equivalent) is the heavier
follow-up. Deliberately split because:
1. The state + policy can ship and be consumed by callers ready
to handle Reconnecting today — no behaviour change for those
who don't.
2. The full session-resurrection path needs `MutableSharedFlow`
buffering per SubscribeHandle so app code's `Flow<MoqObject>`
doesn't notice the swap. Substantial refactor; better as its
own commit with its own tests.
Materialised view of the kind-30312 theme tags into a small
renderer-friendly struct. Pure data + a `from(event)` projection
function — the Compose `AudioRoomThemedScope` wrapper consumes it
later.
RoomTheme — packs colors as `0xAARRGGBB` Long (full alpha)
so commons stays free of the Android Color type;
the Compose renderer recovers via `Color(argb)`.
`null` per field means "use the platform default" so
partial themes (background-only, primary-only, etc.)
fall back per-field.
RoomTheme.Empty — sentinel for un-themed rooms; renderer can pass
it unconditionally without an extra null branch.
RoomTheme.from(event) — picks the FIRST color per target (palette
fallbacks deferred to a later phase),
drops typo'd hex via Quartz's strict
ColorTag parser (returns null per field
rather than crashing), maps the
BackgroundTag mode to the renderer enum
(unknown wire modes fall back to COVER).
Tests:
* Empty event → empty theme
* Three color targets project to opaque ARGB Longs
* First-per-target wins (extra colors ignored in v1)
* Typo'd hex leaves null per field; OTHER colors still project
* Background URL + tile mode round-trip
* Unknown bg mode (a future "blur") → COVER fallback
* hexToOpaqueArgb always sets alpha=0xFF (no inadvertent
transparency for #000000)
Compose renderer + AudioRoomThemedScope wrapper come next.
Adds the Tier-3 minimum-viable theming primitives so a themed
nostrnests room renders without crashing the client:
ColorTag — `["c", "<hex6>", "background"|"text"|"primary"]`.
Strict 6-char hex parser; `#abc` shorthand and
named colors are REJECTED so a typo'd room
event can't crash the renderer. Output hex is
normalised uppercase, no `#` prefix.
BackgroundTag — `["bg", "<url>", "tile"|"cover"]`. Mode defaults
to COVER when missing; unknown modes (a future
"blur") fall back to COVER until the renderer
learns them. Empty URL is rejected.
MeetingSpaceEvent.colors() / .background() — tag accessors. The
font tag (`["f", family, optionalUrl]`) is intentionally NOT in
this commit; loading a custom FontFamily would need a per-pack
loader, and font-less rooms still render fine on the client.
Tests:
ColorTagTest — happy path, no-`#` prefix, rejects shorthand /
named / unknown target / missing target;
assemble normalisation; round-trip.
BackgroundTagTest — happy path, default-COVER, future-mode
fallback, rejects empty URL, round-trip.
The Compose `RoomTheme` projection + `AudioRoomThemedScope`
renderer come next.
Adds a "Share room" action to the room overflow menu (visible to
EVERYONE — share isn't host-restricted; the host-only "Edit room"
row is now gated alongside it). Builds an `nostr:naddr1...` URI
from the room's kind / hostPubkey / dTag via Quartz's
`ATag.toNAddr()` helper and hands it to the system share sheet
with the room title as a preview blurb.
shareRoomNaddr(context, event):
aTag = ATag(event.kind, event.pubKey, event.dTag(), null)
naddr = aTag.toNAddr()
text = "<title> — nostr:<naddr>"
Intent.ACTION_SEND → createChooser
AudioRoomFullScreen — overflow menu now visible to all members.
Share row appears for everyone; Edit row appears only when the
local user is the room's host.
Strings: audio_room_share_action.
Step 3 (zap entry points) is intentionally NOT in this commit —
the existing zap UI is built around the per-note ZapReaction in
ReactionsRow.kt with long-press-to-set-amount + click-to-zap, which
is heavy to embed inside the audio room. The reactions sub already
pulls kind 9735 receipts so paid zaps SURFACE in the floating
overlay; the SEND path can ship as a follow-up that wires the
existing `accountViewModel.zap(note, amount, ...)` flow with
ZapPaymentHandler from inside the participant context sheet.
Extends the host-only kick sheet from Tier 1 #6 into a full
context sheet that any room member (host or audience) can open by
long-pressing an avatar. Common rows on top, host-only rows
appended:
Audience-side (always shown):
* Follow / Unfollow — accountViewModel.follow(user) /
.unfollow(user). State derived from
accountViewModel.isFollowing(target).
* Mute / Unmute — accountViewModel.hide(user) / .show(user).
State derived from account.hiddenUsers.flow.value.
Host-only (appended if local user is the room host):
* Promote to speaker
* Demote to listener
* Kick
AudioRoomFullScreen — long-press is now wired for ALL
participants (previously only hosts). The sheet's internal
gating decides which rows to render. Long-pressing yourself is
blocked at the call site since the actions are all "for someone
else".
Strings: audio_room_participant_follow / unfollow / mute /
unmute. View profile is intentionally NOT in this
commit — AudioRoomActivity is a separate Activity
without an in-room nav stack to push a profile screen
onto. Adding it later means launching MainActivity with
a deep-link Intent.
The function name `ParticipantHostActionsSheet` is retained for
this commit to avoid a wide rename — its new behaviour is broader
than the original "host actions" wording. Renaming to
`ParticipantContextSheet` is a follow-up.
Data model + projection function for the participant grid:
RoomMember — one row in the grid. Combines the static `p`-tag
role (kind-30312) with the dynamic presence flags (kind-10312
aggregator). Carries an `absent` Boolean for "member never
joined" — nostrnests greys them out and we keep parity by
surfacing the flag for the UI to decide.
ParticipantGrid — onStage / audience split.
buildParticipantGrid(participants, presences) — pure projection.
Stage placement is `canSpeak() && onstage != false`, so a speaker
who explicitly emits `onstage=0` (Tier 1 Step 1's "step off the
stage" tag) drops to audience without losing their speaker role.
Pure-audience members (present in the ledger but not p-tagged)
show up in audience with `role = null`.
Tests:
* Host + speaker on stage with matching presence
* Speaker with onstage=0 drops to audience
* Pure listener (no p-tag) lands in audience with role=null
* P-tagged speaker without presence is on stage with absent=true
* Empty inputs produce an empty grid (no crash)
The actual `LazyVerticalGrid` rendering is a follow-up — current
`StagePeopleRow`s already cover the on-stage/audience split
visually; the data model is the substantive piece. Wiring the
VM's `participantGrid: StateFlow<ParticipantGrid>` and switching
the room screen to a grid layout can ship later without changing
the wire contract or tests.
End-to-end glue for the kick action and the broader per-participant
management surface:
AudioRoomViewModel.wasKicked: StateFlow<Boolean>
AudioRoomViewModel.onKick() — set-once flag, calls disconnect().
Idempotent. Authority enforcement (signer must be host/moderator)
is the platform layer's job — the relay does not enforce it.
RoomAdminCommandsFilterAssembler — REQs `kinds=[4312], #a=[room],
#p=[localPubkey]` so the relay only forwards commands actually
targeting the local user.
AudioRoomActivityContent — opens the wire sub on enter, observes
LocalCache for new AdminCommandEvents, and gates each kick on the
signer being either the room's pubkey OR a participant whose
current role is host/moderator. Calls vm.onKick() on a valid
match, then onLeave() once wasKicked flips.
ParticipantHostActionsSheet — bottom sheet with three rows:
Promote to speaker / Demote to listener / Kick. The destructive
Kick row is colored error. Promote + Demote use
RoomParticipantActions; Kick uses AdminCommandEvent.kick.
StagePeopleRow + AudioRoomFullScreen — long-press an avatar
(host-only, can't long-press the host themselves) opens the
ParticipantHostActionsSheet for that target.
RelaySubscriptionsCoordinator.roomAdminCommands registered
alongside the other audio-room subs.
Strings: audio_room_promote_speaker, audio_room_demote_listener,
audio_room_kick_action.
Tests:
* onKickFlipsWasKickedAndDisconnects — VM state transition
* onKickIsIdempotent — no double-disconnect
Adds the ephemeral host-issued admin command event nostrnests uses
for kick (and future moderation actions like mute/ban):
AdminCommandEvent — kind 4312. Carries one Action (currently just
KICK) in `content`, an `a`-tag pointing at the room (kind-30312
address), and a `p`-tag for the target. The verb-in-content shape
lets us extend with new actions without a wire schema bump.
AdminCommandEvent.kick(roomATag, target) — builder.
AdminCommandEvent.action() / .targetPubkey() / .room() — accessors
for the recipient side. Unknown actions return null (forward-compat
with verbs not yet in the enum).
EventFactory — registered so LocalCache + Filter.match can decode
incoming events properly.
Authority enforcement is the CLIENT'S job — the relay just stores
and forwards. Recipients filter on `kinds=[4312], #a=[room],
#p=[me]` and only honour commands whose signer is a participant
marked HOST or MODERATOR on the active kind-30312. The next commit
wires that gating + the disconnect side effect into AudioRoomViewModel.
Tests:
* Build a kick template and verify the action/address/target tags
* Round-trip parse exposes room, target pubkey and action
* Unknown verb in `content` returns null from action()
* Missing tags return null from accessors (no throw)
Surfaces audience members who raised their hand (kind-10312
presence with `hand=1`) and gives the host a one-tap "Approve"
button that promotes them to SPEAKER via
RoomParticipantActions.setRole.
HandRaiseQueueSection — Compose section visible host-only.
Filters the VM's `presences` map to entries where
handRaised=true AND the pubkey isn't already a host / moderator /
speaker (they wouldn't have raised their hand if they could
already speak). Hidden when the queue is empty so a quiet room
doesn't gain a placeholder.
AudioRoomFullScreen — gates the section on `isHost` and stitches
it in below the audience row.
Strings: `audio_room_hand_raise_queue_title`,
`audio_room_hand_raise_approve`.
Approve flow:
presence.hand=1 → RoomParticipantActions.setRole(event, target,
ROLE.SPEAKER) → account.signAndComputeBroadcast → relay sees
same `d`-tag → kind-30312 replaced. The promoted user's
participant tag now reports canSpeak() == true; their VM's
startBroadcast() gating in commons unblocks them automatically
on the next render.
Adds the host-side participant-list mutations as pure functions —
extracted to make every behaviour unit-testable without an
AccountViewModel / signer:
RoomParticipantActions.setRole(original, target, newRole)
RoomParticipantActions.demoteToListener(original, target)
Both republish kind-30312 with the same `d`-tag so the relay
treats it as a replacement of the original. Internally:
* If the target was already a participant, their tag is updated
in place — no duplicate `p`-rows.
* If they weren't (audience member promoting up from the room),
a fresh `p`-tag is appended.
* The host can NEVER be demoted — there's exactly one host per
NIP-53 audio room and the host's pubkey IS the event author,
so flipping their role would produce a corrupt event. Returns
null instead.
* Every other promoted participant is preserved verbatim — same
risk-mitigation as EditAudioRoomViewModel.buildEditTemplate.
6 unit tests covering:
* Promote-when-absent appends with the requested role
* Promote-when-present updates in place (no duplicate row)
* Host can't be demoted
* Demote flips a speaker to PARTICIPANT, leaves moderators alone
* Republish keeps the same `d`-tag
* Promoting one speaker doesn't disturb the other speakers
Sheet/menu wiring + the hand-raise queue UI come next.
Adds typed role accessors to make participant-list filtering
self-documenting:
ParticipantTag.effectiveRole(): ROLE? — case-insensitive parse;
null when the role string doesn't match any enum value (so an
unknown future "director" role doesn't accidentally pass canSpeak).
ParticipantTag.isHost() / isModerator() / isSpeaker() — single-role
predicates for per-row UI gating.
ParticipantTag.canSpeak() — true for HOST / MODERATOR / SPEAKER;
the audio-room VM uses this to gate startBroadcast() so anyone who
was promoted (not just the original host) can publish.
5 unit tests cover happy paths, case-insensitivity, the
unknown-role → null contract, the canSpeak union, and the
effectiveRole enum parse.
Tier 1 #5 + #6 consume these — promote/demote and kick both need
to render different rows depending on whether the local user is a
host or moderator (allowed to manage roles) and the target
participant's current role.
Adds the host-only "edit / close room" flow:
EditAudioRoomViewModel — pre-fills from the existing
MeetingSpaceEvent on bind. save() and
closeRoom() both republish kind-30312
with the SAME `d`-tag (relay treats it
as a replacement). Closing flips
status=CLOSED; everything else just
edits the form fields. Participants are
preserved verbatim — promoting a speaker
is a separate action (Tier 1 #5), so
edit must NEVER silently demote anyone.
buildEditTemplate — pure template builder extracted out so
it's unit-testable without an
AccountViewModel / signer (the
sign+broadcast path is the only side
effect).
EditAudioRoomSheet — bottom-sheet copy of CreateAudioRoomSheet
with a destructive "Close room" button
on the left and "Save" on the right.
Title, error and progress states wired
to the same VM.
AudioRoomFullScreen — host-only overflow menu (kebab) next to
the room title; "Edit room" opens the
sheet. Hidden when the local pubkey
isn't the room's pubkey.
EditAudioRoomViewModelTest — 4 unit tests covering:
* republish keeps the same d-tag and updates room/summary
* republish preserves all promoted participants (host + 2
speakers) — none lost on rename
* closeRoom flips status to CLOSED with the same d-tag
* blank summary / image OMIT the corresponding tags so a
"delete the summary" edit actually deletes it
Strings — `audio_room_edit_title`, `audio_room_edit_save`,
`audio_room_close_action`, `audio_room_overflow_menu`.
Scheduled rooms (#7) deliberately deferred to a follow-up — the
StatusTag enum currently has only OPEN/PRIVATE/CLOSED on
MeetingSpaceEvent, no PLANNED. That's an additive Quartz change
plus a date-time picker on the create flow; better to land in its
own commit.
End-to-end glue for the speaker-avatar reaction overlay (T1 #3):
RoomReactionsFilterAssembler — REQs `kinds=[7], #a=[roomATag]`
on the user's outbox relays. Mirror of RoomChatFilterAssembler
/ RoomPresenceFilterAssembler. Kind 9735 (zaps) deliberately not
in the v1 filter; the overlay only renders kind-7 reactions for
now and adding zaps later is a one-line filter change.
AudioRoomActivityContent — opens the wire sub on enter, observes
LocalCache for ReactionEvents matching this room's #a-tag, pipes
each event into vm.onReactionEvent. A separate 1-s tick drives
vm.evictReactions so the floating-up overlay's lifetime is set by
the eviction tick rather than per-component timers.
SpeakerReactionOverlay — small Composable: groups duplicate emojis
into "🔥 ×3" chips ordered by most-recent createdAt. Hidden when
the per-pubkey list is empty; the 30-s sliding window in the VM
takes care of clearing.
StagePeopleRow now accepts an optional `reactionsByPubkey` map
and renders the overlay below each speaker's avatar. The audience
row keeps the empty default — reactions land on speakers.
RoomReactionPickerSheet — bottom-sheet quick-picker with six
defaults (👏🔥😂👀❤️⚡). Tapping an emoji invokes
AccountViewModel.reactToOrDelete on the room note, which builds
+ signs + broadcasts the kind-7. Custom emoji-pack support is
deferred to a follow-up.
AudioRoomFullScreen — adds a "React" OutlinedButton next to the
hand-raise toggle that opens the picker. Now plumbs the room's
AddressableNote through (needed for reactToOrDelete).
strings.xml — `audio_room_reactions_title`,
`audio_room_reactions_button`.
Adds the listener-side fan-in for the speaker-avatar reaction
overlay (T1 #3):
recentReactions: StateFlow<Map<String, List<RoomReaction>>>
onReactionEvent(event, nowSec, windowSec = 30)
evictReactions(olderThanSec)
The platform layer is the source — amethyst observes LocalCache for
kind=7 with #a=[roomATag] and pipes events here. The 30-s window
constant lives next to SPEAKING_TIMEOUT_MS so the staleness
configuration is one place. Caller-driven tick (no internal timer
inside the VM) keeps the lifecycle aligned with the Composable.
Test:
* onReactionEventGroupsByTargetAndEvictsOnTick — two reactions on
bob arrive within the window; tick advances past the window;
overlay clears.
Data + dedup logic for the speaker-avatar reaction overlay (T1 #3):
RoomReaction — one ephemeral kind-7 reaction. Carries the
source pubkey, the target pubkey (null for
room-wide), the emoji/content, and the
createdAt second. Standard data-class equality
so the StateFlow can suppress no-op tick
re-emits via map.equals.
RoomReactionsAggregator
— `apply(event, nowSec, windowSec)` records a
fresh reaction and returns the post-evict
Map<targetPubkey, List<RoomReaction>>.
`evictAndSnapshot(olderThanSec)` is the
per-tick sweep — caller drives the cadence
(typically every second so the floating-up
animation frame rate is set by the eviction
tick rather than per-component timers).
Room-wide reactions (no `p` tag) land under
the empty-string key so the value-type stays
uniform.
Tests cover:
* Tag projection (with + without `p` target)
* Multi-source grouping by target speaker
* Window-edge eviction (a reaction at T=70 is gone at now=110,
window=30; a reaction at T=105 stays)
* Empty-string key for room-wide reactions
* Idempotent eviction snapshot — same input twice produces equal
Maps so the UI doesn't recompose on no-op ticks
Zap reactions (kind 9735) carry an amount + zapper that aren't in
the v1 RoomReaction shape; that's a follow-up if/when the UI grows
a "satoshi rain" treatment. The ledger stays generic on `content` so
adding it later is additive.
Wires up the kind-1311 chat end-to-end (T1 #2):
AudioRoomActivityContent — opens RoomChatFilterAssemblerSubscription
on enter, observes LocalCache for kind=1311
+ #a=[roomATag], pipes events into the
VM's chat ledger via onChatEvent.
AudioRoomChatPanel — new Composable. LazyColumn of messages
(auto-scroll-to-bottom on new arrival),
empty placeholder, OutlinedTextField +
Send button. Each row shows a
ClickableUserPicture by pubkey hex,
a truncated-pubkey label, and the
content. Rich author-name lookup is
intentionally deferred to keep the v1
panel's dependency surface tight.
Send path — `account.signAndComputeBroadcast(
LiveActivitiesChatMessageEvent.message(
post = trimmed,
activity = ATag(30312, host, dTag, null)))`
routed directly from the Composable so
the VM stays free of platform/signing
dependencies.
AudioRoomFullScreen — embeds the panel below the existing
hand-raise / leave row.
strings.xml — `audio_room_chat_send`,
`audio_room_chat_placeholder`,
`audio_room_chat_empty`.
Sub closes on Composable dispose; relay traffic naturally winds down
when the user leaves the room. Out-of-order arrivals are sorted to
chronological by the VM's ledger (already covered by the
AudioRoomViewModelTest unit tests).
Adds the relay-side wire sub for the live-chat panel — REQs
`kinds=[1311], #a=[roomATag]` against the user's outbox relays
while the room screen is composed.
RoomChatQueryState / RoomChatFilterSubAssembler /
RoomChatFilterAssembler — mirror of RoomPresenceFilterAssembler;
same shape so a future refactor can dedupe.
RoomChatFilterAssemblerSubscription — Composable lifecycle
wrapper. Single file (no separate "*Subscription.kt") since
the assembler + composable are tightly coupled to one event kind.
RelaySubscriptionsCoordinator.roomChat — registered alongside
`audioRooms` and `roomPresence` so accountViewModel.dataSources()
exposes it on the same surface the rest of the screen-scoped
subs use.
Events arriving in LocalCache are picked up by AudioRoomActivityContent
in the next commit, where they're piped into the VM's chat ledger.
Adds the listener-side state for the live-chat panel (T1 #2):
AudioRoomViewModel.chat: StateFlow<List<LiveActivitiesChatMessageEvent>>
AudioRoomViewModel.onChatEvent(event)
Same precedent as `presences` — the platform layer is the source
(amethyst observes LocalCache for kind=1311 + #a=[roomATag] and
pipes events here). The list is `created_at`-ascending so the chat
panel auto-scrolls newest at the bottom. Dedupes by event id so a
relay re-emit on reconnect doesn't double up the transcript.
Tests:
* onChatEventAccumulatesMessagesSortedByCreatedAt — out-of-order
arrivals end up in the right place on screen
* onChatEventDedupesByEventId — same event from two relays /
one reconnect produces ONE row
The amethyst-side wire sub + chat panel UI come next.
Renders "%d listeners" right under the room title once the kind-10312
aggregator has at least one entry. Counts every peer with a recent
presence event — hosts, speakers, and audience alike — matching what
the nostrnests web UI shows in its room header.
The badge uses a pluralized string resource so the wire copy matches
locale rules (one / many) without per-locale code paths.
Hidden when the count is zero so a brand-new room (before anyone's
presence has propagated) doesn't flash a misleading "0 listeners".
Counts can flicker briefly when a single peer drops a heartbeat —
the eviction window in AudioRoomActivityContent is 6 min so a short
relay hiccup doesn't flip the count, but the badge will move when
peers genuinely come and go.
Wires the listener-side aggregator end-to-end:
RoomPresenceFilterAssembler — REQs kind-10312 with #a=[roomATag]
across the user's outbox relays.
Mirrors ThreadFilterAssembler's
shape (PerUniqueIdEoseManager keyed
by the room a-tag so overlapping
screens stay separated).
RoomPresenceFilterAssemblerSubscription — Composable scope wrapper.
Opens the wire sub on enter, closes
on dispose, mirrors the
ThreadFilterAssemblerSubscription
pattern.
RelaySubscriptionsCoordinator — registers `roomPresence` alongside
`audioRooms` so accountViewModel.dataSources()
exposes it the same way every other
screen-scoped sub does.
AudioRoomActivityContent — calls the Composable subscription, then
runs two LaunchedEffects:
1. observe LocalCache for
MeetingRoomPresenceEvent matching
this room's a-tag, pipe each
event into vm.onPresenceEvent
2. evict peers whose last heartbeat
is older than 6 min on a 60-s tick
(one missed 30-s heartbeat +
5-min "still here" tolerance)
The presence map is now populated from the wire when entering an
audio room. Listener counter + participant grid UI consume
vm.presences in the next commit.
Adds the listener-side fan-in API the participant grid + listener
counter consume:
AudioRoomViewModel.presences: StateFlow<Map<String, RoomPresence>>
AudioRoomViewModel.onPresenceEvent(MeetingRoomPresenceEvent)
AudioRoomViewModel.evictStalePresences(olderThanSec: Long)
The platform layer (amethyst, next commit) observes LocalCache for
`kinds=[10312], #a=[roomATag]` and pipes events through onPresenceEvent;
that keeps the data source platform-specific and lets commons stay free
of Android-only LocalCache references. evictStalePresences runs on a
periodic tick driven by the platform layer.
Bug-fix while wiring this: the initial RoomPresence had a custom
pubkey-only equals() (intended to make Set<RoomPresence> dedup
straightforwardly). That broke Map<String, RoomPresence>.equals on a
heartbeat-only update — old.equals(new) returned true even when
handRaised / publishing flipped, so StateFlow suppressed the emission
and the UI never saw the change. Reverted to the data-class
all-fields equals; the Map is keyed by the pubkey String anyway, so
the custom equals never bought us anything. Test
`roomPresenceEqualityIsAllFields` pins the contract so it can't drift
back.
Tests:
* onPresenceEventPopulatesPresencesMapAndDedupesByPubkey
* evictStalePresencesDropsOldPeers
Introduces the data model + in-memory dedup/eviction logic the
participant grid (Tier 2 #1), hand-raise queue (T1 #5), and listener
counter (T1 #8) all consume.
RoomPresence — one peer's most recent kind-10312 snapshot.
Equality is by pubkey alone so a Set or Map
swap on heartbeat update doesn't double-count
when only the timestamp changes.
`RoomPresence.from(event)` projects every
presence tag, with conservative defaults for
absent ones (handRaised/publishing default
false; muted stays null to distinguish "didn't
say" from "explicitly false"; onstage defaults
TRUE so pre-onstage clients still render as
speakers).
RoomPresenceAggregator
— `apply(event)` dedupes by pubkey, keeps the
most recent createdAt (out-of-order events
can't overwrite newer state). `evictOlderThan`
is the staleness sweep — caller drives the
cadence (typically a 60 s tick passing
`now - 6*60` to evict on >6 min of silence).
Tests cover: tag projection, the absent-tag defaults, dedup on same
pubkey, out-of-order non-overwrite, multi-pubkey isolation, eviction
window, and the pubkey-only equality contract.
The amethyst-side LocalCache wiring + listener-counter UI come next
in their own commit.
Threads the new kind-10312 presence dimensions through the VM and the
heartbeat:
* AudioRoomUiState.onStageNow: explicit Boolean field, defaults to
true. Tier-2's "leave the stage" tap will flip it to false via
AudioRoomViewModel.setOnStage(...). Drives the
`["onstage", "0|1"]` tag.
* AudioRoomUiState.publishingNow: derived property, true only when
broadcast is Broadcasting AND not muted. Drives the
`["publishing", "0|1"]` tag. Matches the wire-tag semantics
"actually pushing audio packets" (vs holding a slot but silenced).
The heartbeat in AudioRoomActivityContent now reads both values and
passes them to MeetingRoomPresenceEvent.build. onstageTag IS a
LaunchedEffect key (so leaving the stage triggers an immediate
publish), publishingTag is NOT (mute toggles already publish via the
debounced effect).
The leave / dispose path now publishes publishing=false + onstage=false
explicitly, so aggregating peers can drop us from the grid immediately
instead of waiting out the staleness window.
Tests:
* AudioRoomViewModelTest::onStageNowDefaultsTrueAndSetOnStageFlipsIt
* AudioRoomViewModelTest::publishingNowDerivesFromBroadcastStateAndMute
(covers all four BroadcastUiState states + the muted-broadcasting
edge case where publishingNow must be false despite holding a slot)
Adds two NIP-53 presence tags used by nostrnests' room UI:
["publishing", "0|1"] — peer is actively pushing audio packets
["onstage", "0|1"] — peer holds a speaker slot vs audience
These complement the existing "hand" and "muted" tags. They're
independent: a speaker can be onstage but paused (onstage=1,
publishing=0), or broadcasting silently (onstage=1, publishing=1,
muted=1). The participant grid (Tier 2) and listener counter (Tier 1
#8) consume them.
- PublishingTag / OnstageTag classes mirror HandRaisedTag's parse +
assemble shape exactly so the DSL (TagArrayBuilderExt) extends
uniformly.
- MeetingRoomPresenceEvent.publishing() / onstage() accessor parsers
return Boolean? (null when the tag isn't present) so callers can
distinguish "explicitly false" from "absent".
- The MeetingSpaceEvent overload of build() now accepts both fields
as nullable params; the MeetingRoomEvent overload deliberately
doesn't (those are tier-2 video meetings, not Clubhouse rooms).
- Round-trip tests pin the wire format and the "absent → null"
behaviour.