Move the LIVE chip and listener count out of the header strip and into
the right side of the Stage card's title row (listeners first, then
chip). The remaining summary now hides by default and toggles when the
user taps the room title in the top bar, reclaiming vertical space for
the stage and chat.
Lets a clustered burst of mint calls (typical interop test scenario,
also any moq-auth deployment with stricter rate limits) outlast the
60 s rate-limit window instead of cascading into a hard failure.
- Retry up to 7 times on HTTP 429. Worst-case total wait at 1 s
initial + 16 s cap is 1+2+4+8+16+16+16 = 63 s — just past the
reference moq-auth 60 s/IP window.
- Respect the `Retry-After` header (delta-seconds OR HTTP-date)
when present; fall back to capped exponential backoff otherwise.
- Re-sign the NIP-98 auth event on every attempt. The reference
validator accepts a 60 s validity window; without re-signing,
any retry that waits past that window fails 401 "Event too old".
- Suspending `delay` so coroutine cancellation tears the loop down
cleanly.
`./gradlew :nestsClient:jvmTest -DnestsInterop=true -DnestsInteropExternal=true`
now goes green in a single invocation: 157 tests across 33 classes,
0 failures. Previously the same command cascaded 11 failures once
the moq-auth 20/min/IP bucket filled.
Unit tests: 7 new cases covering Retry-After parsing (seconds,
HTTP-date, garbage, missing), exponential cap, the 429-then-200
retry loop, max-retries exhaustion, and that non-429 4xx is NOT
retried. Backed by a tiny ServerSocket-based HTTP fake so no new
test deps.
Captures the group-rotation, orchestrator-break-on-Closed removal,
ensureAnnounceWatchStarted, handleInboundBidi refactor, and
removeInboundSubscription changes that landed in d8ab4fd9. All
three reconnecting-listener interop scenarios now pass.
To make a SubscribeHandle survive a publisher session swap on the same
relay, three things had to change together:
1. Broadcaster emits one Opus frame per moq-lite group
(`publisher.send` + `publisher.endGroup`). moq-lite's "from-latest"
subscribe semantics deliver a new subscriber the NEXT group's
frames; without per-frame rotation a subscriber that attaches
mid-broadcast waits forever for the (single, never-ending) group
to end.
2. MoqLiteSession opens its announce-watch bidi synchronously before
the first subscribe, dispatches subscribe + announce frames over a
single long-running collector (varint type code hoisted outside
`collect`), and FINs the publisher's currentGroup when an inbound
subscribe bidi closes — so the next send opens a fresh group keyed
off the live subscriber instead of the recycled one.
3. ReconnectingNestsListener no longer breaks on terminal=Closed in
the orchestrator; the user-driven stop path goes through
`orchestrator.cancel()`, so any other Closed (peer-driven
transport close, half-broken session, publisher recycle) is a
reconnect trigger.
Round-trip interop test updated to assert `groupId == idx` (one group
per frame) rather than `groupId == 0`. All three reconnecting-listener
interop scenarios now pass against the real moq-rs relay:
happy-path, session-swap, and listener-survives-publisher-recycle.
The takeWhile/collect pattern only re-checks its predicate when the
NEXT upstream value emits, so once nextSubscribeBidi found its
target Subscribe bidi, the helper sat blocked waiting for a
follow-up bidi that may never come — turning groups_are_demuxed_by_
subscribeId into a 5-minute hang on tests that open exactly two
subscribes (the announce-watch bidi happened to land between, but
relying on it to nudge the flow forward is fragile).
Switched to transformWhile { … emit(…); false }.firstOrNull() so
the upstream collection terminates synchronously after the first
match. Warm-daemon test time drops from multi-minute timeout back
to the expected ~10 ms range.
https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
The session-layer fix in 851045c6 lazy-launches a single shared
announce-watch bidi on first subscribe (publisher-disconnect
detection). The unit test groups_are_demuxed_by_subscribeId
assumed each session.subscribe() opened exactly one peer-side bidi
and grabbed them by raw `peerOpenedBidiStreams().first()`, which
races with the announce-watch bidi.
New helper nextSubscribeBidi(serverSide) iterates accepted bidis,
peeks the control-byte varint, and skips any that aren't Subscribe.
The race window (announce-watch bidi between subscribe #1 and
subscribe #2) is now handled gracefully — the test reliably
finds both subscribe bidis regardless of the announce-watch's
launch ordering.
Also extends the listener-survives-publisher-recycle plan doc
with the full diagnosis of why
reconnecting_wrapper_keeps_handle_alive_across_session_swap
remains a separate, narrower failure (publisher single-group
architecture: NestMoqLiteBroadcaster never rotates groups, so
mid-stream listener resubscribes have nothing to attach to).
https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
The custom C secp256k1 implementation under quartz/src/main/c/ was never
wired into Gradle (no externalNativeBuild block) and only existed for the
3-way benchmark + cross-validation test against ACINQ on JVM/Android.
The C library has been split out to vitorpamplona/libschnorr256k1{,-kmp},
so the in-repo copy is dead weight.
This change replaces it with the published Maven Central artifact
`com.vitorpamplona:schnorr256k1-kmp:1.0.0`, scoped to test/benchmark
configurations only — production crypto continues to use ACINQ
secp256k1-kmp on JVM/Android and the pure-Kotlin Secp256k1 on native.
The Android AAR ships libschnorr256k1_jni.so for arm64-v8a and x86_64,
so the Android benchmark now exercises the C row automatically (no more
manual build_android.sh). On JVM the .so still has to be installed by the
developer; the cross-validation test and triple-benchmark gracefully skip
the C row when System.loadLibrary fails, matching prior behavior.
Verified on JVM: all 13 secp256k1 jvmTest classes pass (188 tests), and
ACINQ vs pure-Kotlin benchmark numbers match the documented baseline
within sandbox noise (verifySchnorr ~15k ops/s, signSchnorr cached
~33k ops/s, pubkeyCreate+Compress ~36k ops/s).
Removes ~5,100 LOC: quartz/src/main/c/ (4,500), Secp256k1InstanceC.*
expect/actual shim (560), Secp256k1C JNI declaration object.
https://claude.ai/code/session_01KnvpK2amcVZKfFiZJvHjVe
Pulls three high-signal features from NestScreen into the PIP layout
without crowding the small floating window:
- Audio-level pulsing rings on active speakers, mirroring the
full-screen StageGrid contract. The ring width tracks live audio
level so the PIP visibly responds to who is talking, instead of
showing static borders.
- Adaptive speaker selection: when anyone is speaking, PIP shows only
the active speakers (up to 4); when exactly one person is talking
the avatar bumps to 56dp for a clear focus mode. With no one
speaking, falls back to the first 4 on-stage participants so the
PIP isn't blank during silence. Fixes the 'same 4 faces while
someone else talks off-screen' problem in big rooms.
- Self-status row (mic broadcasting / muted, hand-raised), so a
glance at the PIP answers 'am I muted?' without expanding back.
Hidden for pure audience listeners with no hand to keep clutter low.
- Reconnecting / Failed caption over a dimmed body so audio drops
are obvious — previously the avatars stayed lit even with no audio
arriving.
https://claude.ai/code/session_01BpwAxN9gs79CJuWKqf3BEB
Two layered fixes for the gap captured in
nestsClient/plans/2026-04-28-listener-survives-publisher-recycle.md.
Session layer (MoqLiteSession.kt):
- Lazy single shared announce-watch pump per session, opened on
first subscribe. moq-lite Lite-03 has no explicit "publisher
gone" message on the subscribe bidi (the relay keeps that
bidi open across publisher cycles in case a fresh publisher
takes over the suffix), so the announce stream's Ended event
is the only reliable signal.
- On Announce(Ended) for a broadcast suffix, close the
matching ListenerSubscription's frames Channel and remove it
from the map. The wrapper-level frames.consumeAsFlow() flow
ends naturally — same shape as a user-driven
handle.unsubscribe() — so the wrapper pump's collect-
completion path drives the re-issue.
Wrapper layer (ReconnectingNestsListener.kt):
- Inner re-subscribe `while (currentCoroutineContext().isActive)`
loop in reissuingSubscribe. When the underlying frames flow
completes (publisher cycled, signalled by the session layer
above), re-issue subscribe against the same listener with a
100 ms backoff. moq-lite supports subscribe-before-announce so
a re-subscribe issued during the gap attaches cleanly when
the next publisher comes up under the same suffix.
Verified against the real moq-rs relay (host build, external
mode): the new
NostrNestsReconnectingListenerInteropTest.subscribe_handle_survives_publisher_recycle
test passes — single SubscribeHandle keeps emitting frames across
multiple speaker JWT-refresh cycles. Speaker reconnect tests
still pass too.
In production, this closes the audio dropout that would have
fired every 9 minutes per speaker JWT refresh on long Nest calls
(see the plan doc for the original diagnosis).
https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
The only way to keep audio playing while leaving the room screen was the
Home/Recents gesture (onUserLeaveHint). Back swiped the user out of the
session entirely, and there was no in-UI affordance signalling that PiP
existed. Users wanting to step away had no obvious path that preserved
audio.
- BackHandler in NestActivityContent: while connected and PIP is
supported, back minimizes to PIP. Disconnected/Connecting/Failed
fall through to the default finish() so cancelling still works.
- Minimize IconButton in the room TopAppBar (hidden when the device
doesn't advertise FEATURE_PICTURE_IN_PICTURE).
- Public NestActivity.enterPip() consolidates the connected + feature
guards so onUserLeaveHint, BackHandler, and the Minimize button all
share one entry point.
https://claude.ai/code/session_01BpwAxN9gs79CJuWKqf3BEB
Seven coordinated visual changes so the grid feels less prototype-y
and more native to the user's Material 3 theme:
#1 Colour harmonisation. Drops the hardcoded Tailwind hexes for
host (purple-500), moderator (sky-500) and hand-raise (yellow-500)
in favour of MaterialTheme.colorScheme tones — host → primary,
moderator → secondary, hand → tertiary, each paired with the
matching onContainer for the icon tint. Speaking-green is kept as
a hardcoded brand colour because "green = mic active" is a
universal convention (Spaces, Clubhouse, Discord); a primary tint
would lose that signal.
#2 Stage strip as a tonal card. StageGrid now wraps its label + grid
in a Surface(color = surfaceContainerLow, shape = 20.dp rounded)
so the active speakers visually live in their own zone, separated
from the chat / audience tab below.
#3 Breathing room. GRID_SPACING goes from 6.dp to 8.dp — still keeps
4 cells per row on a 411.dp Pixel (4*96 + 3*8 = 408 ≤ 411). The
stage card adds 12.dp internal padding.
#4 Empty-stage hint with icon. Adds an HourglassEmpty glyph above
the existing "Waiting for speakers…" copy so the empty state reads
as intentional rather than as a layout glitch.
#5 Ring color crossfade. animateColorAsState on the speaking-ring
color in addition to the existing animateDpAsState on width — the
transition from idle → speaking → muted → idle no longer snaps.
#6 Soft outer halo. New drawBehind layer paints a transparent
speaking-green circle scaled by the live audio level. Uses
drawBehind so the glow extends beyond the avatar's layout bounds
without affecting cell measurement; alpha animates to 0 when the
speaker stops, so it dims rather than disappears.
#7 Reaction chips. SpeakerReactionOverlay wraps in AnimatedVisibility
with fade + scale-in/out so a 👏 burst pops in and dims out
instead of snapping. Chips swap secondaryContainer fill for a
tonal Surface (tonalElevation 2dp + shadowElevation 1dp) — softer
in light mode, properly tinted in dark mode.
Discovered while validating connectReconnectingNestsSpeaker against
the real moq-rs relay: when a publisher cycles its session (e.g. via
JWT refresh), an existing listener-side SubscribeHandle does not
auto-reattach to the new publisher under the same broadcast suffix.
Frames stop until the listener's own JWT refresh fires.
Root cause is multi-layer:
- moq-lite session: SubscribeHandle.frames is a
Channel.consumeAsFlow() that the session never closes on remote
disconnect.
- moq-lite Lite-03: graceful close emits Announce(Ended), which
the wrapper would need to observe to react.
- ReconnectingNestsListener: reissuingSubscribe only re-issues on
listener-session swaps, not on announce-Ended.
A wrapper-layer announce-driven re-subscribe was attempted (race
collect vs announce-Ended.first(), cancel loser, unsubscribe, retry).
It compiled and the unit tests passed, but interop against the real
relay still showed listener silence after the recycle — the listener's
QUIC session terminated 4 ms after the publisher's "subscribe
cancelled" log line, suggesting the announce-bidi cleanup or
sub-unsubscribe path is being interpreted as session close at the
moq-lite layer. Did not pin down the exact chain.
Reverted the wrapper change to keep the branch clean. Speaker-side
connectReconnectingNestsSpeaker is unaffected and continues to pass
its interop tests. Plan doc captures the diagnosis and three
candidate fix paths for follow-up — preferred direction is
session-layer (have MoqLiteSession close the frames Channel when the
relay forwards Announce(Ended) or FINs the subscribe bidi) so the
existing wrapper pump's natural collect-completion handles it.
https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
#1 Local user's cell tints the username in colorScheme.primary so you
can find yourself in a busy stage. Threads myPubkey through StageGrid
and AudienceGrid; cells receive isSelf and pass MaterialTheme primary
to UsernameDisplay's textColor.
#2 SpeakerReactionOverlay moves from below the username (which reflowed
the column when reactions arrived and shoved neighbors down) to
inside the avatar Box, anchored bottom-end with a small outward
offset so it overlaps the corner without colliding with the
bottom-center mic badge. Cell height stays constant whether the
speaker has 0 or 5 active reactions.
#3 Kick now mirrors nostrnests' two-step path: the existing ephemeral
kind-4312 ['action','kick'] kicks the target off the audio plane,
followed by a re-published kind-30312 with their p-tag dropped so
the participant grid stops rendering them. New
RoomParticipantActions.removeParticipant builds the second event;
refuses to drop the host (same protection as setRole demoting host).
#7 Tap-to-mute on your own avatar. Self-cell receives onTapSelf which,
when broadcasting, toggles BroadcastUiState.Broadcasting.isMuted via
the existing setMicMuted path. Falls back to no-op when not
broadcasting so a non-broadcasting tap doesn't surface a useless
action — the avatar simply isn't tappable.
The first cut of the JWT-refresh interop test held a single listener-
side SubscribeHandle open across the speaker's session recycle and
asserted both pre- and post-recycle frames arrived on it. That fails
because moq-lite's relay doesn't auto-route a vanilla SubscribeHandle
to a fresh publisher session under the same suffix — the
listener-survival-across-publisher-recycle is a separate concern,
NOT a speaker-reconnect bug. Verified against a real moq-rs relay
(host-built, external mode, --auth-key-dir + per-kid JWK file).
Reworked to validate just what the speaker reconnect should
guarantee:
- Phase 1: pre-refresh frames round-trip on the first session.
- Phase 2: orchestrator recycles (openCount → 2+, wrapper state
reaches Broadcasting on the new session).
- Phase 3: post-refresh frames round-trip on a FRESH listener
subscription against the new session.
- Wrapper invariant: outward state never surfaced
Reconnecting / Failed during the recycle.
Both speaker interop tests pass against the real relay.
The "listener subscription survives publisher recycle" gap is a
real production concern for >9-min stage time but lives outside
this PR — it would need either listener-side announce-watching or
a coupled refresh-on-publisher-cycle hook in the listener wrapper.
https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
After verifying the nostrnests reference (NestsUI-v2 @ main):
- ProfileCard.tsx writes kicks as ['action','kick'] tags with empty content
- useAdminCommands.ts reads action via tags.find(t => t==='action') and
applies a 60-s relay since plus a processedRef Set to dedup re-deliveries
- p-tag role marker for moderators is 'admin', not 'moderator'
Amethyst was diverging on every one of those, which means our outbound
admin commands were invisible to nostrnests, theirs to us, and any
nostrnests admin (role='admin') failed our isModerator() / canSpeak()
gates entirely — kicks and force-mutes signed by them were silently
dropped.
Changes:
quartz/AdminCommandEvent.kt
- Emit ['action', '<verb>'] tag with empty content
- Reader prefers the tag, falls back to content for any in-flight
Amethyst-built kick from before this commit
- kick() and forceMute() share a common build() helper
quartz/ParticipantTag.kt
- ROLE.MODERATOR.code = 'admin' (matches nostrnests + EGG-07)
- Adds legacyCodes = ['moderator'] so older Amethyst-emitted
kind-30312 events still parse as MODERATOR
- effectiveRole() walks both code + legacyCodes
amethyst/AdminCommandsCollector
- Filter carries since = now - 60 (EGG-07 #7)
- Defensive per-event freshness re-check for cached events / clock skew
- mutableSetOf<String>() processed-id dedup for the lifetime of the
collector, mirroring useAdminCommands.ts's processedRef
EGG-07.md
- Documents the Amethyst-only ['action','mute'] extension under a new
'Implemented extensions' section. nostrnests doesn't emit or honour
it today; cross-client force-mutes only work between Amethyst peers
- 'warn' stays in the future-actions list — nostrnests has no plans
for it either
Tests:
- ParticipantTagTest: new asserts that 'admin' / 'Admin' / 'ADMIN'
parse as ROLE.MODERATOR; pins the wire string to 'admin' and the
legacy alias to 'moderator'
- AdminCommandEventTest: kick/forceMute templates carry ['action', _]
tag with empty content; legacy content-form still parses; tag wins
over content when both are present
Three improvements stacked into one commit since they share files:
#3 Empty-stage hint: StageGrid no longer disappears when nobody is on
stage. The "Stage" label stays and a quiet "Waiting for speakers…"
line keeps the strip visible so the room doesn't look broken before
the first speaker arrives.
#5 Local per-speaker hush: AudioPlayer gains a setVolume(Float)
default-no-op method; AudioTrackPlayer composes mute and volume
multiplicatively into AudioTrack.setVolume so a hushed stream stays
silent regardless of mute state. NestViewModel exposes
locallyHushed: ImmutableSet<String> in NestUiState plus
setLocalHushed(pubkey, hushed) — applied at attach time so a
re-subscribe of an already-hushed speaker stays silent. New "Hush
this speaker" / "Restore this speaker" row in
ParticipantHostActionsSheet, available to anyone (it affects only
our own playback, nothing on the wire).
#4 Host moderation gaps: wires Promote-to-Moderator using the existing
RoomParticipantActions.setRole(ROLE.MODERATOR) builder — UI gap
only, no protocol change. Adds a new AdminCommandEvent.Action.MUTE
variant + AdminCommandEvent.forceMute(room, target) builder; the
sheet emits it on "Force-mute speaker" and the
AdminCommandsCollector dispatches incoming MUTE actions to a new
NestViewModel.onForceMuted() that routes through the existing
setMicMuted(true) path. Honor-based, same trust model as KICK —
relays don't enforce signer authority, the client checks the
signer is host or moderator on the active kind-30312.
The previous emitter ran a permanent while(true){delay} loop on the
viewModelScope from connect-time onward. Cheap in production but
catastrophic for any test that calls runTest's advanceUntilIdle()
after vm.connect() — virtual time never reached idle, hanging
NestViewModelTest's existing connection-state cases for ~15 minutes
before the worker timeout killed them.
Now the emitter is started lazily by onAudioLevel() and exits the
loop the first tick after rawAudioLevels empties, so an idle room
schedules nothing. The next decoded frame restarts it. Idempotent,
so the launch path doesn't need to call it any more — removed the
boot from launchConnect().
Adds an end-to-end audio-amplitude pipeline so on-stage speakers'
green ring throbs in time with their voice (closer to Spaces /
Clubhouse than the previous binary "is in speakingNow" indicator).
NestPlayer.play() now takes an onLevel callback and computes the
normalized peak of each decoded 16-bit PCM frame. NestViewModel
exposes audioLevels: StateFlow<Map<String, Float>>; raw 50 Hz
updates from the decode loop are coalesced into a 10 Hz publish
tick (LEVEL_TICK_MS) so the StateFlow doesn't spam recompositions
across a busy stage. The map is cleared on speaker close, on the
speaking-timeout sweep, and on teardown.
In MemberCell the speaking ring's width animates between
AVATAR_RING_WIDTH (3 dp) and MAX_RING_WIDTH (7 dp) via
animateDpAsState, smoothing the tick into a continuous halo.
Muted-publisher and idle states are unchanged.
Adds NestPlayerTest coverage for the new callback (level math +
empty-PCM short-circuit).
- Cap mic, hand-raise, and role badges at 28.dp so they no longer
dominate the new 100.dp avatars.
- Surface HOST and MODERATOR with a small badge anchored top-left
(purple medal / sky shield), mirroring nostrnests at-a-glance.
- TalkBack: MicStateBadge announces "Speaking now" / "Microphone
muted" / "Microphone open" instead of going silent.
- StageGrid floats currently-speaking members to the top so the
listener doesn't scroll to find who they're hearing; AudienceGrid
floats raised hands so moderators can approve the queue without
hunting. Both use stable sorts and Modifier.animateItem() for a
smooth shuffle.
Two ship-readiness items.
1. Status: CLOSED handler. The NestActivityBody never observed kind-30312
status flips, so when a host ended the room every other listener and
speaker stayed connected to the relay until they manually backed out
or the JWT expired — silent room with stale "you're in" UI. New
LeaveOnRoomClosed composable in NestRoomLifecycle.kt watches
event.isLive() and calls onLeave() when the live event flips to
CLOSED (or hits the 8 h auto-close cutoff in MeetingSpaceEvent.
checkStatus). Same teardown path as kick — VM.onCleared() releases
the listener + speaker when the activity finishes.
2. Speaker-reconnect interop test against the real nostrnests stack,
mirroring NostrNestsReconnectingListenerInteropTest. Two cases:
- Happy path: wrapper drives a single real session, frames round-trip.
- Forced JWT refresh (4 s window): orchestrator recycles the
underlying speaker mid-stream; frames pre- AND post-recycle must
all land on the same listener-side SubscribeHandle. Validates the
production 540 s ↔ 600 s JWT-TTL relationship against the real
moq-rs relay. Gated by -DnestsInterop=true.
https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
Adds cli/tests/nests/nests-interop.sh — a 47-test operator-driven
interop script that walks a tester through every audio-room edge case
between Amethyst Android and the nostrnests.com NestsUI-v2 web client.
Coverage: bidirectional host/listener flows, audio round-trip on
moq-lite Lite-03, hand-raise + role promotion/demotion, mute, kind:7
reactions (including NIP-30 custom emoji + 30 s overlay clear), kind:1311
in-room chat (text + emoji + link + image upload + history backfill),
kind:4312 kick, room edit + close, scheduled rooms (status=planned),
multi-speaker, background audio + PIP, network drop reconnect, 10-min
JWT refresh, custom moq servers (kind:10112), naddr deep links, and
edge cases (long titles, empty rooms, leave-stage, dedupe, race).
The script follows the marmot/marmot-interop.sh pattern: shared
logging + result helpers, color-coded prompts (yellow=Amethyst,
magenta=web, cyan=optional 3rd identity), p/f/s confirms recorded to a
TSV, and a final summary table. Skip / only / keep-state flags are
supported for iteration.
Pure manual harness — amy does not yet ship `amy nests <verb>` so
nothing is automatable from the CLI side.
Mirror the listener's ReconnectingNestsListener for the publish side.
moq-auth issues 600 s bearer tokens; without proactive refresh, a user
holding the stage past 10 minutes silently drops when the relay tears
down the WebTransport session and stays off the air until they manually
re-tap Talk. The new wrapper recycles the session at 540 s so the relay
never sees an expired token, and re-issues publishing onto each fresh
session with the user's mute intent replayed on the new handle.
VM swap is a one-line change to DefaultNestsSpeakerConnector. The
caller-owned BroadcastHandle is now the wrapper's stable handle that
survives every refresh.
Six unit tests cover happy path, refresh-without-failure-state, mute
replay across recycle, close idempotence, first-attempt-failure
exception propagation, and post-close startBroadcasting guard.
https://claude.ai/code/session_01HXf3zG3F2ev2ASeQju7Y5S
Drops STAGE_CELL_MIN and AUDIENCE_CELL_MIN from 112.dp to 96.dp, which
lets a Pixel-class 411.dp phone fit 4 cells per row instead of 3 (and
unlocks 5/6 columns earlier on tablets). The 100.dp avatars overflow
~2.dp into the 6.dp horizontal arrangement on each side, so neighbors
still show a ~2.dp visible gap. Names ellipsize to the cell width as
before.
100dp avatars across stage and audience tabs, names centered under each
cell, and the avatar border now distinguishes actively speaking (green)
from publishing-but-muted (red) at a glance — the existing mic-pill
keeps its semantics underneath.
UsernameDisplay gains an optional textAlign so the grid can opt in to
centered labels without disturbing other call sites.
The activity class lives in `…nests.room.activity.NestActivity` but the
manifest entry was missing the `.activity` segment, so tapping a Nests
feed card crashed with ActivityNotFoundException.
AppBottomBar hides itself on canPop entries by emitting nothing into the
bar slot. DisappearingScaffold previously read the slot's measured
height as 0 and skipped applying navigationBarsPadding (its
`bottomBar == null` branch only fires when the lambda itself is null),
which let the FAB and feed content slide under the system navigation
bar. Fall back to the system-nav-bar inset when the slot measures zero
so the FAB lines up with the bar-visible position and content stays
clear of the gesture/3-button nav.
https://claude.ai/code/session_01QSk7CnjNtbcgS3XBD5WEZy