Tapping Restore Default mid-drag would replace `items` with the default
list while `draggedItemIndex` still pointed into the old list. If the
old index exceeded the new list's lastIndex, the next pointer event
indexed `items` out of bounds. Clear drag state before save().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets users who have over-customized the bottom navigation bar
return to the fresh-install layout (Home, Messages, Video, Discover,
Notifications) without manually toggling each item.
Mirror NostrnestsProdAudioTransmissionTest.sustained_per_frame_send_outcomes_two_users
but drive the local Docker-backed kixelated/moq reference relay via
NostrNestsHarness. Lets us reproduce the production "82/100" cliff
without nostrnests.com so we can attribute the loss to client code
(`:nestsClient` / `:quic`) vs. the production deployment.
Same per-frame instrumentation: bypass NestMoqLiteBroadcaster, call
publisher.send() / publisher.endGroup() directly, record the boolean
return per frame plus any endGroup exception, dump a per-frame
send/recv table on failure.
Test 4 showed a hard 82/100 cliff at 20 ms cadence two-users, all 18
trailing groups lost permanently. Root-cause-narrowing requires knowing
whether MoqLitePublisherHandle.send returned false (frame dropped at the
moq-lite layer due to inboundSubs.isEmpty() / publisherClosed) or true
(frame queued by moq-lite and lost downstream — uni-stream write error
swallowed by runCatching, QUIC flow control wedged, or relay drop).
The production NestMoqLiteBroadcaster wraps everything in
runCatching {…}.onFailure {…} and ignores the boolean, so frame loss is
structurally invisible to the app. This new test bypasses the broadcaster
and calls publisher.send / publisher.endGroup directly while mirroring
the auth + transport + session setup that connectNestsSpeaker performs.
Records sendOutcomes[i] and endGroupErrors[i] per frame; on failure
prints a per-frame send/recv table so we can see exactly when send
flips false (or whether all 100 sends report true and the loss is
strictly downstream of moq-lite).
Previously test 4 used .take(N).toList() inside withTimeoutOrNull, so a
timeout discarded every received frame and the only failure signal was
"only got partial audio". When the production run flagged frame loss,
we couldn't tell whether the stream stalled mid-flight, never started
fanning out, or dropped scattered groups.
Drain into an external CopyOnWriteArrayList via .onEach { } so the
partial contents survive the timeout, then on failure surface:
- received N/100 with first/last arrival timestamps
- missingGroups summary as run-length ranges (e.g. [3-7,42,90-99])
- first/last 20 arrivals so the front and tail of the sequence are
visible without flooding stdout
The gap pattern alone narrows the diagnosis: front-loaded losses point
to subscribe-settle being too short, tail-loaded losses point to a
broadcaster shutdown race, scattered drops point to per-subscription
buffer overflow / datagram loss.
Four progressively-narrower JVM tests that drive the production
connectNestsSpeaker / connectNestsListener / OkHttpNestsClient /
QuicWebTransportFactory / NestMoqLiteBroadcaster code paths against
the real moq.nostrnests.com + moq-auth.nostrnests.com infrastructure
(no Docker harness, no Nostr events) so we can isolate why audio is
not flowing between two real users:
1. auth_minting_works_for_publish_and_listen_paths
2. same_user_speaker_and_listener_round_trip
3. two_users_speaker_publishes_listener_subscribes
4. sustained_real_time_cadence_two_users (~2s @ 20ms cadence)
Skipped by default; opt in with -DnestsProd=true and (optionally)
override URLs via -DnestsProdEndpoint=... / -DnestsProdAuth=...
Both NestFullScreen and NestLobbyScreen render the kind-1311
transcript with LazyColumn(reverseLayout = true). When a new message
is prepended, LazyColumn preserves visual position by shifting
firstVisibleItemIndex from 0 to 1, leaving the new message just below
the viewport — the user has to scroll down to see it.
If the user is at (or one item from) the bottom when a new message
arrives, scroll back to item 0 so the newest message is visible. If
they are reading older history, leave them where they are.
Holds FLAG_KEEP_SCREEN_ON via the existing KeepScreenOn composable
while ConnectionUiState is Connected, so the device doesn't lock
and interrupt an active audio-room session.
NestFeedCard previously routed every tap on a joinable MeetingSpaceEvent
through NestLobbyScreen. The lobby exists to keep an accidental tap on
a stale room from booting the audio pipeline (and the host-side
kind-30312 republish path) — once the badge says the room is live with
fresh presence, that gating is just extra friction. Mirror the badge's
liveness heuristic in the click handler and launch NestActivity
directly for cards rendering as LIVE / PLANNED / PRIVATE; only EndedFlag
cards (status = ENDED, status = LIVE with stale presence, or unknown
status) keep flowing through the read-only lobby.
Move the workaround out of ChatroomList: use the DisappearingScaffold's
padding lambda parameter (previously unused) to position the left pane
below the top bar and above the bottom bar. Reset
LocalDisappearingScaffoldPadding to zero only at that boundary so the
inner LazyColumn inside ChatroomListFeedView doesn't double-count the
bar inset and produce a gap below the TabRow.
The right pane keeps systemBarsPadding because ChatroomView /
PublicChatChannelView bring their own DisappearingScaffold; layering the
outer padding there would stack the chat header below the outer search
bar.
ChatroomList stays a flat Column with no scaffold awareness.
In MessagesSinglePane the MessagesTabHeader sits inside the
DisappearingScaffold's topBar slot, so LocalDisappearingScaffoldPadding's
top component already covers the bar + tabs and the inner LazyColumn's
contentPadding lines the first row up flush below the tabs.
In MessagesTwoPane the tabs are part of the column content rather than
the scaffold's top slot. The scaffold still publishes its top-app-bar
height as the top inset, so rememberFeedContentPadding inside the feed
double-counts it and renders a top-app-bar-tall gap between the TabRow
and the first chatroom row.
Override LocalDisappearingScaffoldPadding around MessagesPager to zero
the top component while preserving start/end/bottom, so the feed still
clears the bottom bar.
The room metadata (RoomHeader) and live listener count were items
inside the reverse-layout LazyColumn, so they scrolled with the chat
and forced the chat to share its viewport. Move them to a regular
Column above the LazyColumn so the chat list owns most of the screen
and the header sits as a fixed row on top.
Drop the redundant "Recent chat" label that was only there to
separate the in-list header from messages.
The README's "Wiring relays into the store" tutorial used a
per-subscription SubscriptionListener.onEvent callback to feed the
store. EventCollector landed exactly to remove that boilerplate, so
update the example to register one connection-level collector at app
init and stop wiring listeners per subscribe() call.
The "Building a reactive feed UI" example showed project() ->
stateIn(WhileSubscribed) but never opened the relay subscription.
Bind it to the projection's collection lifecycle with .onStart {
client.subscribe } / .onCompletion { client.unsubscribe }, so the
relay subscription's lifetime equals the UI collector's. Also
demonstrate ProjectionState.filterItems for narrowing without a
re-query.
Drop the redundant client.connect() call from the wiring example
and add an explicit note (in both README and CLIENT) that
NostrClient connects on demand from subscribe()/publish() — connect()
is only useful after disconnect().
Other small fixes:
- client.send(...) -> client.publish(...) (the actual API name).
- Rename the variable from `observable` to `db` to match the demoApp
code and the conceptual "this is the local database" framing.
- Wrap the publish path in a viewModelScope.launch so the snippet
is copy-pasteable into a ViewModel.
A TextButton in the AppBar actions slot read as too subtle and small
next to the title. Switch to a filled Button so the primary CTA is
unmistakable. The actions Row already centers children vertically;
trim the trailing modifier padding so the button sits flush with
the bar's edge.
Replace the static Column-based CachedChatList with the same
LazyColumn(reverseLayout = true) the in-room screen uses
(NestChatPanel.NestChatMessageList). Items are keyed by note id,
and the room metadata header / listener count / "Recent chat"
label are stacked above the oldest message so the user can scroll
up through history into the room info.
Wire onSendNewMessage to animateScrollToItem(0) so a freshly sent
message snaps the list back to the newest entry — same behaviour
the in-room composer has.
- Replace bottom Open Room button with a TextButton "Open" action in
the top app bar, freeing the screen bottom for the chat composer.
- Mount NestEditFieldRow + NestNewMessageViewModel on the lobby so
users can post kind-1311 messages without joining the audio plane;
in-room composer features (mentions, file upload, drafts, emojis)
carry over.
- Switch the Scaffold to contentWindowInsets=0 and use imePadding +
navigation-bar inset on the composer so the chat list draws behind
the 3-button system navigation while the composer stays reachable
above it (and rises above the IME when the keyboard opens).
- Drop the now-unused OpenNestRoomButton helper and its string.
NestActionBar.kt had grown to ~470 lines with most of the volume in
OnStageControls, where four broadcast-state branches each inlined a
56dp filled icon button, a tonal toggle button, and an outlined
"Leave the Stage" button. The result was hard to scan for state
coverage, and the top-level KDoc still described the pre-cleanup
layout.
Changes:
* Rewrote the top-level KDoc as a state-by-state table matching the
current UI.
* Extracted reusable affordances:
ConnectButton, StatusChip, LeaveStageButton, TalkButton,
StopBroadcastButton, MicMuteToggle, HandRaiseToggle,
LeaveRoomButton.
* Merged the duplicate Connect button branches in StartCluster
(Idle / Closed / Failed all share one). The failure reason already
appears in the status strip above.
* Split OnStageControls: kept the dispatcher thin and moved the
permission state + Settings deep-link into OnStageIdleControls,
which is the only state that needs them.
* Replaced the inline qualified Settings Intent with a
Context.openAppSettings() helper, plus a Context.hasMicPermission()
helper for the two RECORD_AUDIO checks.
* Pulled the status-strip text resolution into a small
NestUiState.statusStripText() so ActionBarStatusStrip is one Text.
No behavior changes — just structure, naming, and docs.
The red Mic-on FilledIconButton already conveys "you are live, tap
to stop" — the AssistChip labelled "Live" beside it was duplicate
visual noise. Removed the chip and its now-orphan string. Mute toggle
+ red Mic + Leave the Stage are sufficient to express the broadcasting
state.
Several gaps in the action bar were causing dead-end UI states or
making non-actionable controls visible:
* Hand-raise was rendered while disconnected / connecting / failed.
Raising can't be delivered to the room until we're Connected, so
hide it until the connection state agrees. Threaded `isConnected`
into `EndCluster` from the root composable.
* Audience listen-mute toggle (Volume Up/Off) removed. System volume
keys cover local volume; the on-screen control was redundant and
shared the same speaker glyph as the broadcasting mic-mute toggle,
which made the two affordances easy to confuse.
* On-stage user without `canBroadcast` had no way to step down without
leaving the whole room. Show a "Leave the Stage" outlined button
in that branch so the speaker slot can be released.
* Broadcast `Connecting` and `Failed` substates also had no
step-down affordance — forcing the user to either retry the mic or
leave the room. Added "Leave the Stage" to both. `stopBroadcast()`
cancels the in-flight `speakerConnectJob` (NestViewModel
`teardownBroadcast`), so cancelling mid-handshake is safe.
* `BroadcastUiState.Broadcasting.muteError` was tracked in state but
never surfaced. Route it through `ActionBarStatusStrip`.
* Permission denial pill said "Open settings"; renamed to
"No permissions" so the label communicates the *state* (the tap
still deep-links to the system settings page).