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).
Material3 expressive defaults the FilledTonalIconToggleButton container
shorter than ButtonDefaults.MinHeight, leaving the audience listen-mute
and on-stage mic-mute toggles visibly shorter than the neighboring
"Leave the Stage" / "Leave" outlined buttons. Pin the toggle height to
ButtonDefaults.MinHeight so the row aligns.
When Tor stays in Connecting for >60s, prompt the user to drop the proxy
for the rest of the session. The choice is persisted as a 1-hour window
so subsequent failures (across cold starts, network changes) silently
fall back without re-prompting; after the hour the dialog returns.
Bypass clears on user-initiated TorType changes and on network identity
changes so Tor gets a fresh attempt under new conditions.
Adds Route.NestLobby — a regular Compose destination — between every
Nests entry point (feed card, channel-view card, in-feed CTA, naddr
deep links) and NestActivity. The lobby is read-only: it warms cached
chat, host info, listener count, and recent messages via the existing
NestRoomFilterAssemblerSubscription, but never opens a NestViewModel,
publishes kind-10312 presence, or launches MoQ. Only the new
OpenNestRoomButton inside the lobby launches NestActivity, so a user
coming back to peek at an old room no longer triggers the audio
pipeline (or the host-side kind-30312 republish path) by accident.
JoinNestButton now navigates to the lobby instead of launching the
activity; OpenNestRoomButton owns the actual launch and lives only
on the lobby screen.
Tutorial-style README covering the full pipeline:
relay → NostrClient → ObservableEventStore → InterningEventStore →
EventStore → project() → ViewModel → Compose.
Two worked examples:
1. Wiring NostrClient subscriptions into the store via
SubscriptionListener.onEvent.
2. Building a reactive feed UI with project().stateIn(...) and
Compose, showing how the three reactivity layers (Loading/Loaded
state, list membership, per-event handles) map to Compose's
recomposition model.
Cross-links the existing CLIENT.md, RELAY.md, and store/sqlite
README. Pointers to per-class KDoc for projection internals.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Three small wins on paths the projection runs every event arrival
or every snapshot publish:
1. Cache `address` on Slot. Previously `removeIndexes(slot)` called
`addressOf(slot.flow.value)` on every drop — for replaceables
that's a fresh Address allocation every time. Now the address is
computed once at slot construction and reused on removal.
2. Single-filter snapshot fast path. The deduped sorted union via
TreeSet was unnecessary when there's only one per-filter set —
that set is already sorted in the slot comparator's order. The
common UI case (one filter per projection) now skips the
TreeSet allocation + log-n inserts entirely.
3. Comparator singleton. `slotComparator()` was allocating a fresh
Comparator lambda on every call (ctor + every snapshot).
Replaced with a single `Comparator<Slot<*>>` cast at the use
site — the comparator only reads sort keys that don't depend on
the type parameter.
Also trimmed a verbose comment block in `project()` (3 lines → 1).
All 18 projection tests + 240 store + interner tests still pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
When a replaceable / addressable update arrives in handleInsert, the
projection now re-runs Filter.match against the new event for every
filter. A v2 that no longer matches a filter (e.g. tag list changed)
is removed from that filter's set; a v2 that newly matches a filter
joins it. If no filter retains the slot afterwards, it's fully
dropped from byId / byAddress.
Closes the previous gap where v2 of an addressable kept a stale
filter membership inherited from v1. The slot's MutableStateFlow is
still updated in place — collectors of that handle still see the
content change before the membership update emits.
The cap-eviction-with-cleanup logic was extracted into a small
`admit` helper since the supersession path and the new-slot path
both need it.
For the common case (filter on `kinds + authors`, no tag/time
constraints), v2 always still matches — the new branches are no-ops
and the list reference stays stable, preserving the in-place update
guarantee. The behaviour change kicks in for tag, time-window, or
id-list filters.
New test addressableUpdateDropsSlotWhenFilterStopsMatching: v1 has
hashtag "nostr" and matches a `t = nostr` filter; v2 changes to
"bitcoin"; the projection drops the slot. 18/18 projection tests
pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5
Android's RootTrustManager throws when an app has Network Security Config
domain-specific entries and the 2-arg checkServerTrusted overload is used,
crashing Nest room joins on relays covered by such config. Discover the
3-arg checkServerTrusted(chain, authType, hostname) overload via reflection
and invoke it with the SNI host; fall back to the standard 2-arg form on
plain JVM where that overload doesn't exist.
Reviews before merge:
- EventInterner: drop stale "used by Event.fromJson" reference (we
reverted that earlier). Tighten the class doc.
- ObservableEventStore: collapse the 30+ line class KDoc + duplicated
StoreChange description into one focused doc each.
- EventStoreProjection: trim the 60+ line class doc; reactive
semantics live on the project() extension. Add explicit caveat
about in-place addressable updates not re-evaluating filter
membership.
No behaviour change. All cache + store + projection tests pass.
https://claude.ai/code/session_01Jny85MTu1ynKgFBgysfWu5