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.
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.
The pre-check in SQLiteEventStore.insertEvent rejects events whose
expiration is <= TimeUtils.now(). With expiration = time + 1, the
wall clock can tick past time + 1 between sampling `time` and the
insert call, intermittently failing testDeletingExpiredEvents.
https://claude.ai/code/session_01GoqXtcFnwgyjBict1EURyA
ScriptedSpeaker mutated `handles` AFTER incrementing `_startCount`,
so a reader that polled startCount on a different thread (the test
runs on the runBlocking thread while the broadcast pump runs on
Dispatchers.Default) could see startCount==1 via the volatile
AtomicInteger read before the subsequent list write became visible,
then fail `assertEquals(1, first.handles.size)` with a stale 0.
Also `mutableListOf` itself is not thread-safe — concurrent reads
during a write are undefined.
Reorder so the list append happens BEFORE the increment (the
volatile write now publishes the list mutation under the same
happens-before edge tests already rely on for startCount), and
swap the backing store for CopyOnWriteArrayList so concurrent
reads of `handles[0]` are well-defined.
Observed as a flake on Ubuntu CI; passes locally.
Presence entries are valid for ~10 min (PRESENCE_FRESHNESS_WINDOW_SECONDS in
NestsFeedFilter). Without explicit cleanup, presenceNotes would grow unbounded:
every author who ever heartbeats in a room leaves an entry there forever.
The freshness check kept the filter result correct, but memory only ever grew.
Add LiveActivitiesChannel.pruneStalePresence(cutoff) and call it from
LocalCache.pruneOldMessagesChannel alongside the existing notes prune. Cutoff is
2x the freshness window (20 min) so a presence still inside any feed's window
can never be reaped.
Presence (kind-10312) was being stored in both `channel.notes` and the
`presenceNotes` index. The mixed-kind `notes` map is dominated by chat
in active rooms, and only HomeLiveFilter still read presence from it --
which is now migrated to scan presenceNotes directly.
- LocalCache.consume(MeetingRoomPresenceEvent): drop the `channel.addNote`
call; only addPresenceNote, plus addRelay so the channel's relay-counter
still tracks where presence arrived from.
- LiveActivitiesChannel: addPresenceNote / removePresenceNote emit on
flowSet.notes so reactive observers (NestsFeedLoaded) still update.
- HomeLiveFilter.shouldIncludeChannel: scan presenceNotes separately for
follow-broadcast detection in audio rooms (chat scan unchanged).
- HomeLiveFilter.followsThatParticipateOn: also count presenceNotes
authors so audio-room hosts/speakers factor into the participation
sort even when they haven't chatted.
- ChannelFeedFilter: delete the isChatEvent workaround that was excluding
presence from the chat feed -- presence no longer lands there.
kind-10312 is replaceable per author, but the room a presence points to
(`a`-tag) can change when a speaker hops between rooms. The replaceable
cache only swaps the addressable's content -- it doesn't know which channel
the old version was attached to, so the prior room kept the stale entry in
both `notes` and the new `presenceNotes` index. NestsFeedFilter would then
falsely surface the prior room as "live" via that author until the entry
dropped out of the freshness window.
Capture the prior room from the existing addressable before consumeBaseReplaceable
swaps it. When the new event is a true replacement (createdAt > prior) and
the room differs, drop the author from the prior channel's presenceNotes
and remove the prior version note from its main notes index.
LiveActivitiesChannel.notes is mixed-kind (chat, zaps, raids, clips, presence),
and chat dominates by volume in active rooms. The Nests feed filter scanned it
twice per room per recompute -- once for any-fresh-presence, once for fresh
on-stage presence -- doing an `is MeetingRoomPresenceEvent` cast on every chat
message just to find the speakers.
Add a presenceNotes index on LiveActivitiesChannel keyed by author pubkey
(presence is replaceable per author, so the key auto-collapses heartbeats).
Populate it from LocalCache.consume(MeetingRoomPresenceEvent). Switch
NestsFeedFilter to a single hasFreshSpeakers() pass over the index, dropping
the now-redundant isLiveByPresence() check (hasFreshSpeakers implies it).
Migrate NestsFeedLoaded's latest-presence flow to the same index.
Adds hasFreshSpeakers gate to NestsFeedFilter so OPEN/PRIVATE rooms
whose live speaker slate is empty are dropped — a room with no fresh
kind-10312 presence carrying onstage=1 has effectively ended even if
its kind-30312 status still says live.
Add a network_security_config.xml that keeps cleartext globally permitted
(so user-configured ws:// relays still work) but adds an explicit
domain-config for 127.0.0.1 and localhost. This stops StrictMode's
detectCleartextNetwork from flooding logcat with CleartextNetworkViolation
stacks each time the app talks to the local Tor SOCKS proxy
(220+ per benchmark session previously).
Verified on a playBenchmark build: zero CleartextNetworkViolation lines
to 127.0.0.1 even though the app continued attempting Tor connections
on port 9050.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Malformed `i` tags whose platform-identity field has no `:` (observed in
production logcat as e.g. `["i", " ", " "]`) made `create()` throw
`IndexOutOfBoundsException` from destructuring `split(':')`. The exception
was caught but each event spammed multiple stack traces. Validate the
separator up front in `parse()` and return null silently. Also fix the
diagnostic `joinToString { "," }` typo so the log shows real tag contents.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LoggedInPage hardcoded the Home/Messages/Video/Discover relay subscriptions,
so users who customised the bottom bar still paid bandwidth for the four
defaults while the icons they actually pinned never preloaded. Drive the
preloaders from uiSettingsFlow.bottomBarItems so subscriptions track the
chosen list reactively.
Adds a Home Tabs settings screen letting users pick which tabs appear on Home:
New Threads, Conversations, and a new combined Everything tab. The tab row is
hidden whenever only one tab is active, so users can keep a single feed view.
At least one tab always remains active.
The text Talk / Stop Talking buttons made it easy to misread the
broadcast state. Swap them for large filled mic-icon buttons so the
mic state is unmistakable: MicOff in primary color when idle (tap to
go live), Mic in error color when broadcasting (mic is open, tap to
stop).
The host's primary path to add an audience member to the stage was the
long-press → "Promote to Speaker" row, which is hard to discover and was
also being swallowed by ClickableUserPicture (which ignores onLongClick
when onClick is null). Wire a tap handler on audience cells so a single
tap opens the same per-participant sheet, surfacing Promote to Speaker
for hosts and View Profile / Follow / Mute for everyone else.
The publisher's inbound-bidi handler wrote SubscribeOk to the bidi
before calling registerInboundSubscription. The peer's first
publisher.send() after observing Ok could race the registration on
dispatchers that resume the peer's continuation before the handler's
(notably Windows under Dispatchers.Default), causing send to observe
an empty inboundSubs and return false. Reordering makes the peer's
view of Ok a happens-after of the registration.
Fixes the Windows-only failure in
MoqLiteSessionTest.publisher_acks_subscribe_and_pushes_group_data_on_uni_stream.
Two related bugs the user hit while testing against nostrnests:
1. Approving a hand-raise made the Hands tab disappear (the host's
local app saw the role grant) but the audience member did not
appear on stage anywhere else.
Cause: RoomParticipantActions.rebuild() rebuilt the kind-30312
without the original room's `relays` tag. The recently-added
broadcast fan-out path (Account.computeRelayListToBroadcast) keys
off `event.allRelayUrls()` for kind 30312, so a republished room
event with no relays tag only reaches the host's outbox — not
nostrnests's fixed five reads or the audience member subscribing
on those reads. The audience member never sees their SPEAKER tag
and stays absent from the participant list.
Fix: copy `original.relays()` into the rebuild so every republish
(approve, demote, kick → re-broadcast) keeps the relay routing
that lets the room reach its full audience.
2. Tapping "Leave Stage" as the host emptied the StageGrid
("Waiting for speakers…") but the action bar still showed the
Talk + Leave Stage cluster.
Cause: two different definitions of "on stage" were in play.
StageGrid uses ParticipantGrid (role + presence.onstage flag),
so flipping onStageNow=false drops the host out. The action bar
gated on the role-only `onStage: List<ParticipantTag>` (the
host's HOST tag never goes away), so the cluster stayed.
Fix: derive isOnStageMe from participantGrid.onStage so both
surfaces share the same "stepped off" semantics. The host who
taps Leave Stage now drops to the audience-style mute toggle
and can rejoin (kind-10312 onstage flips back to 1) without
the controls lying about their state.
https://claude.ai/code/session_016G7oP5BotPjUBgvMYrrrxh
Hand-raise toggles in Amethyst weren't appearing in nostrnests's UI.
nostrnests's NestsUI v2 routes reads against a fixed five-relay list
(NestsUI-v2/src/lib/const.ts: relay.snort.social, nos.lol, relay.damus.io,
relay.ditto.pub, relay.primal.net) — no outbox model. Its presence query
is just `kinds:[10312], "#a":[roomATag]` over those five relays.
Commit 637174ef already taught computeRelayListToBroadcast to fan a kind
30312 *room* event out to its `relays` tag. But kind 10312 presence (and
chat / reactions, etc.) is a different event type that *links* to the
room via an `a` tag. For those, broadcast was only reaching:
- the broadcaster's outbox relays
- the single firstOrNull() hint baked into the `a` tag
- the relay we happened to receive the room event from
If none of those overlap with nostrnests's fixed five reads, the
hand-raise update is silently dropped.
Extend the `linkedAddressIds` block in computeRelayListToBroadcast: when
the linked address resolves to a MeetingSpaceEvent / MeetingRoomEvent /
LiveActivitiesEvent, also add that linked event's allRelayUrls(). This
covers presence updates and any other room-scoped event that points at
a Nest via `#a`.
https://claude.ai/code/session_016G7oP5BotPjUBgvMYrrrxh
Hand-raise toggles in Amethyst weren't appearing in nostrnests's UI.
nostrnests's NestsUI v2 routes reads against a fixed five-relay list
(NestsUI-v2/src/lib/const.ts: relay.snort.social, nos.lol, relay.damus.io,
relay.ditto.pub, relay.primal.net) — no outbox model. Its presence query
is just `kinds:[10312], "#a":[roomATag]` over those five relays.
Commit 637174ef already taught computeRelayListToBroadcast to fan a kind
30312 *room* event out to its `relays` tag. But kind 10312 presence (and
chat / reactions, etc.) is a different event type that *links* to the
room via an `a` tag. For those, broadcast was only reaching:
- the broadcaster's outbox relays
- the single firstOrNull() hint baked into the `a` tag
- the relay we happened to receive the room event from
If none of those overlap with nostrnests's fixed five reads, the
hand-raise update is silently dropped.
Extend the `linkedAddressIds` block in computeRelayListToBroadcast: when
the linked address resolves to a MeetingSpaceEvent / MeetingRoomEvent /
LiveActivitiesEvent, also add that linked event's allRelayUrls(). This
covers presence updates and any other room-scoped event that points at
a Nest via `#a`.
https://claude.ai/code/session_016G7oP5BotPjUBgvMYrrrxh
The previous SendBuffer suite (FlowControlEnforcementTest) is entirely
single-threaded — every test calls enqueue and takeChunk sequentially
on the same coroutine, so the race that crashed the audio path in
production (NoSuchElementException from chunks.first() under
concurrent enqueue + takeChunk) stayed invisible. The whole :quic
commonTest tree had no concurrent test at all.
Three new tests run real-thread races on Dispatchers.Default:
- concurrent_enqueue_and_takeChunk_does_not_throw drives multiple
producer coroutines + a consumer coroutine and asserts the buffer
drains cleanly with no exception.
- concurrent_takeChunk_callers_never_double_drain_a_chunk fans out
multiple consumers against a pre-populated buffer; asserts the
sum of bytes handed out equals the bytes enqueued (i.e. no chunk
is double-counted by overlapping head-peel paths).
- concurrent_finish_with_inflight_enqueue_emits_correct_fin races
finish() against in-flight writes and asserts the FIN comes
AFTER every enqueued byte.
Tests pass against the synchronised SendBuffer; running them against
the pre-fix unsynchronised version corrupts state badly enough that
the consumer wedges (an explicit "this is what the bug looked like"
demonstration). With internal synchronisation in place the suite
finishes in <0.2 s.
Documents the concurrent-access contract so a future "let's drop the
sync, it's hot" refactor immediately fails CI.
QuicConnectionDriver.sendLoop holds the connection mutex while it calls
SendBuffer.takeChunk via QuicConnectionWriter.drainOutbound, but
WtPeerStreamDemux's per-stream `send` callback calls
SendBuffer.enqueue from arbitrary application coroutines without the
connection lock. The two paths concurrently mutate
(chunks, pendingBytes, headOffset, finPending, finSent, sentEnd,
nextOffset). Under load this surfaced as
java.util.NoSuchElementException: ArrayDeque is empty.
at kotlin.collections.ArrayDeque.first(ArrayDeque.kt:102)
at com.vitorpamplona.quic.stream.SendBuffer.takeChunk(SendBuffer.kt:85)
at com.vitorpamplona.quic.connection.QuicConnectionWriterKt.buildApplicationPacket(...)
at com.vitorpamplona.quic.connection.QuicConnectionDriver.sendLoop(...)
The writer saw `pendingBytes > 0` (incremented by an in-flight
enqueue on another thread) before the matching `chunks.addLast`
became visible, fell into the head-peel branch, and tripped on
chunks.first().
Wrap every read and write of SendBuffer state in `synchronized(this)`,
including the cheap `readableBytes` / `sentOffset` / `finPending` /
`finSent` getters used by the writer's pre-flight checks (so they
can't read torn state either). The lock is uncontended in the common
case and short-held in the rare race; we already use synchronized
blocks elsewhere in commonMain (QuicConnectionDriver.kt).
PrimaryTabRow's BadgedBox places the count badge in the top-end corner
of its anchor, which on tight tab labels lands directly on top of the
text. Lay them out as a Row instead so "Audience 3" reads cleanly,
matching the tabbar comment's "Hands · 3" intent.