Audit-driven fixes for the audio-room reactions overlay:
1. RoomReactionsAggregator now dedups by event id. The previous code
appended every event to a flat list, but LocalCache.observeEvents
re-emits the full matching list on every cache mutation — so an
N-reaction window grew quadratically and the overlay rendered the
same emoji once per replay. Keyed by event id collapses re-emits.
2. RoomPresenceAggregator gains applyOrNull that returns null when an
incoming heartbeat is older-or-equal to the cached presence; the VM
skips the StateFlow write in that case. In a 200-peer room, every
replay used to copy a 200-entry map plus run an O(N) StateFlow
equality check 200 times per emission. Now it's one-and-skip.
3. ReactionsEvictionTicker only ticks while the aggregator is non-empty.
Once the last reaction expires the loop self-cancels until the next
reaction lands — a quiet room costs no scheduled work.
4. REACTION_WINDOW_SEC dropped 30s -> 10s. Reactions are about what
the speaker is saying right now; a 10s window keeps the overlay
timely instead of bleeding into the next paragraph.
5. SpeakerReactionOverlay drives a per-chip lifecycle animation: each
chip drifts upward ~16dp and fades over the 10s window. A fresh
reaction (same emoji) restarts the chip's animation. The
AnimatedVisibility outer entry/exit still smooths first-arrival and
final disappearance.
Tests: added a re-emit dedup case to RoomReactionsStateTest plus an
end-to-end replay assertion in NestViewModelTest. Existing tests
updated to use unique event ids.
https://claude.ai/code/session_01DMeCvWyBYVVVPez2hwqCs4
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.
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).
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.
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 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.
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.
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)
The Phase-1 interop harness exposed a substantial mismatch between our
production HTTP client and what the nostrnests reference server actually
exposes. This commit refactors `:nestsClient` and the wiring above it so
the production code path can talk to a real moq-auth + moq-relay.
| Aspect | Before | After (matches nostrnests/moq-auth/src/index.ts) |
|-----------|-------------------------------------------|--------------------------------------------------|
| Method | GET | POST |
| URL | `<base>/<roomId>` | `<base>/auth` |
| Body | none | `{"namespace":"nests/<kind>:<host>:<roomId>","publish":bool}` |
| Response | `{endpoint, token, codec, sample_rate}` | `{token}` only |
| Endpoint | from response | from event's `endpoint` tag (passed via `NestsRoomConfig.endpoint`) |
| NIP-98 | bound to GET URL | bound to POST URL + body hash |
Type changes:
- New `NestsRoomConfig` data class bundling (authBaseUrl, endpoint,
hostPubkey, roomId, kind). Built by the caller (UI / VM) from the
NIP-53 kind 30312 event before invoking connectNests*.
- `NestsRoomConfig.moqNamespace()` produces the exact format
moq-auth's NAMESPACE_REGEX expects: `nests/<kind>:<hex64>:<roomId>`.
- `NestsRoomInfo` deleted; replaced with a tiny `NestsTokenResponse(token)`
matching the real response shape.
- `NestsClient.resolveRoom(serviceBase, roomId, signer): NestsRoomInfo`
→ `NestsClient.mintToken(room, publish, signer): String`. The
publish flag drives the JWT claims (`get` for listeners, `put`
for speakers).
Wire path:
- `OkHttpNestsClient` now POSTs `<authBase>/auth` with a JSON body
and a NIP-98 Authorization header bound to (POST, url, body-hash).
- `connectNestsListener` / `connectNestsSpeaker` take `room:
NestsRoomConfig` instead of split (serviceBase, roomId), pass
`publish=false` / `publish=true` respectively, and use the room's
`endpoint` (not a server-returned one) for the WebTransport
connect. The minted JWT is the bearer token.
- `NestsListenerState.Connected` / `NestsSpeakerState.Connected` /
`Broadcasting` carry the `room: NestsRoomConfig` instead of the old
`roomInfo: NestsRoomInfo`.
- MoQ TrackNamespace for the room is now a single segment whose
bytes are `room.moqNamespace()` — the simplest mapping to the
relay's JWT claim check (`root: "<namespace>"`); Phase-3 round-trip
test will confirm and adjust if the relay expects a multi-segment
tuple.
Wiring above:
- `AudioRoomViewModel` constructor: replaces `(serviceBase, roomId)`
with `(room: NestsRoomConfig)`. Connector seam interfaces
(NestsListenerConnector, NestsSpeakerConnector) follow the same
shape.
- `AudioRoomViewModelFactory` (Android) takes `room: NestsRoomConfig`.
- `AudioRoomActivity` adds `EXTRA_AUTH_BASE_URL`, `EXTRA_ENDPOINT`,
`EXTRA_HOST_PUBKEY`, `EXTRA_KIND` Intent extras (was just service
+ roomId) and reconstructs `NestsRoomConfig` in onCreate. Drops
`EXTRA_SERVICE_BASE`.
- `AudioRoomJoinCard` reads `event.endpoint()` + `event.pubKey` +
`event.kind` in addition to `event.service()`; rooms missing any
of those are silently un-joinable (the event author didn't host
on a nests-compatible relay).
- `AudioRoomActivityContent` takes `room: NestsRoomConfig` in place
of (serviceBase, roomId) and threads it down.
Phase-1 ping test rewired to use the production `OkHttpNestsClient`
end-to-end against the real `/auth`, asserting we get back a
3-segment JWT.
Existing in-process tests updated for the new types: NestsConnectTest,
NestsSpeakerTest, AudioRoomViewModelTest. NestsRoomInfoTest renamed to
NestsRoomConfigTest with new cases for the namespace formatter and the
auth-URL helper. All 80 in-process tests still green.
Phase 3 (next) will add the full round-trip interop test that runs
production `connectNestsListener` + `connectNestsSpeaker` through the
real moq-relay — that's where MoQ wire-format assumptions (draft
revision, OBJECT_DATAGRAM layout, namespace tuple shape) get verified
or get followup audit findings.
Adds an end-to-end MoQ round-trip test and lands the highest-severity
findings from a 3-agent audit (protocol / ViewModel / Android lifecycle)
of the M5–M7 + Activity work.
Round-trip test (`:nestsClient` MoqRoundTripTest):
- Two MoqSession.client() instances (publisher + subscriber) talk
through a hand-rolled in-test relay coroutine that mirrors a real
MoQ relay's wire behavior (forwards CLIENT_SETUP / SERVER_SETUP /
ANNOUNCE / SUBSCRIBE / SUBSCRIBE_OK / OBJECT_DATAGRAM).
- 100-Opus-frame test exercises the full publisher → wire →
subscriber path, asserting payload bytes + monotonic group/object
ids round-trip correctly. Catches any drift between our publisher
and our own subscriber's wire format.
- Second test verifies SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST) flows
back as MoqProtocolException when the publisher hasn't openTrack'd.
MoQ protocol fixes (CRITICAL audit findings):
- SubscribeDoneStatus constants were inverted: had UNSUBSCRIBED=0x01
(peer reads as INTERNAL_ERROR) and TRACK_ENDED=0x00 (peer reads as
UNSUBSCRIBED). Swapped to draft-stable values: UNSUBSCRIBED=0x00,
TRACK_ENDED=0x03.
- Pending CompletableDeferreds for in-flight SUBSCRIBE / ANNOUNCE on
session close were `cancel()`-ed, which propagates as
CancellationException — caller's entire scope cancels instead of
catching a domain MoqProtocolException. Switched all sites to
`completeExceptionally(MoqProtocolException("session closed"))`
including unsubscribe-while-pending-OK.
ViewModel fixes:
- openSubscription race: re-check `activeSubscriptions[pubkey] === slot
&& !closed` AFTER the suspending `subscribeSpeaker` returns; if the
user removed the speaker mid-flight, fire-and-forget UNSUBSCRIBE
rather than attaching a leaked SubscribeHandle + AudioRoomPlayer to
a discarded slot.
- Server-initiated `Closed` listener state now triggers
`teardown(targetState=Closed)` and resets the auto-retry counter,
so a transport-died-mid-handshake doesn't leave stale subscriptions
in the VM map until the Activity finishes.
- Cleanup-scope split: `disconnect()` (user-driven) routes the close
through `viewModelScope` (still alive); `onCleared()` routes it
through a process-lived `cleanupScope` (default GlobalScope, tests
pass backgroundScope) so MoQ control frames (UNSUBSCRIBE,
UNANNOUNCE, SUBSCRIBE_DONE) actually land before the QUIC
transport drops, instead of being eaten by the cancelled
viewModelScope.
Android lifecycle fixes:
- AudioRoomBridge.clear() wired into AccountViewModel.onCleared next
to the existing CallSessionBridge.clear() — no more cross-account
AccountViewModel leak after logout/switch.
- registerReceiver flag now gated on Build.VERSION.SDK_INT
TIRAMISU+ — RECEIVER_NOT_EXPORTED on pre-33 devices collides with
RECEIVER_VISIBLE_TO_INSTANT_APPS. PendingIntents stay
package-scoped via setPackage(packageName).
- onUserLeaveHint no longer enters PIP unconditionally: gated on
ui.connection == Connected AND PackageManager
FEATURE_PICTURE_IN_PICTURE present, so PIP-from-lobby /
PIP-on-incompatible-device doesn't leave a frozen full-screen
card in Recents.
- Replaced the process-wide singleton AudioRoomPipActions toggle
Boolean with a per-Activity MutableSharedFlow<Unit> so a stale
emission from a torn-down Activity can't leak into a new one.
ViewModel test was extended with `cleanupScope = backgroundScope`
plumbing so the post-disconnect `listener.close()` assertion remains
observable in the test scheduler.
Verified: spotlessApply clean; :commons:jvmTest (9 tests),
:nestsClient:jvmTest (80 tests including 2 new round-trip), and
:amethyst:compilePlayDebugKotlin all green.
Audit findings deferred to follow-up commits (none ship-blocking):
- Wire layout pinning vs draft-17 vs draft-11 (currently emits a
draft-11-style OBJECT_DATAGRAM; we advertise draft-17). Resolve
via the M4 manual interop pass against nostrnests.
- UNANNOUNCE racing inbound SUBSCRIBE; control-pump cancel half-write
(audit MoQ #5, #6) — small ordering tweaks.
- Two retry coroutines stacking under fast Failed bursts (audit VM #4).
- AudioRoomForegroundService startForeground always-first contract
(audit Android #8).
Listener side now subscribes to every host AND speaker on the stage (was
just hosts) and exposes a `speakingNow: ImmutableSet<String>` derived
from MoQ object arrival. Each on-stage avatar gets a primary-color ring
while its track is delivering audio (debounced 250 ms — ~12 Opus
frames — so packet jitter doesn't make the ring flicker).
ViewModel:
- `AudioRoomViewModel.uiState` gains `speakingNow`.
- New `onSpeakerActivity(pubkey)` is invoked once per received MoQ object
via a `Flow.onEach` tap on `SubscribeHandle.objects` (before the
AudioRoomPlayer consumes it). Each invocation (re)arms a per-speaker
expiry coroutine that clears the entry after `SPEAKING_TIMEOUT_MS`.
- speakingNow is cleared whenever the underlying subscription closes
(speaker removed from room) or the listener tears down (disconnect /
onCleared).
Screen:
- AudioRoomViewModel is now hoisted up to AudioRoomStageContent so the
speaker rows can read speakingNow alongside the connection UI.
- StagePeopleRow wraps each ClickableUserPicture in a 2 dp circular
border when the participant is in `speakingNow`.
- updateSpeakers now receives `(hosts + speakers).pubKeys` so dynamic
speaker promotion via NIP-53 event updates flows through to the
reconcile loop already in M1.
Test:
- Adds `speakingNowClearsOnTeardown` to AudioRoomViewModelTest. The
per-frame activity path is exercised end-to-end by the existing
AudioRoomPlayerTest in :nestsClient (which uses the in-memory pipe).
Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:amethyst:compilePlayDebugKotlin` green.
Wires `connectNestsListener` into the existing NIP-53 audio-room "stage" so
a user can tap Connect, see the live connection state, and hear hosts
speaking. Per the audio-rooms completion plan
(`nestsClient/plans/2026-04-26-audio-rooms-completion.md`) phase M1.
UI (amethyst):
- AudioRoomStage gets a state-chip / Connect / Disconnect / Mute row
above the existing hand-raise. State chip walks ResolvingRoom →
OpeningTransport → MoQ-handshake → Connected; Failed surfaces the
reason inline with a Retry button. The 30312 `service` tag drives
visibility — rooms hosted on non-nests servers show "Audio not
available" and the rest of the stage UI continues to work.
ViewModel (commons):
- New `AudioRoomViewModel` in commons/.../viewmodels/ owns one
`NestsListener` and one `AudioRoomPlayer` per active speaker, exposes
a single `StateFlow<AudioRoomUiState>`, and reconciles subscriptions
whenever the screen pushes a new speaker set via `updateSpeakers`.
Audio-pipeline construction is injected via `decoderFactory` /
`playerFactory` so a future desktop port can reuse the orchestration
once Compose Desktop has WebTransport. Unit-tested with a fake
`NestsListenerConnector` driving state transitions directly.
nestsClient:
- `AudioPlayer.setMuted(Boolean)` joins the interface; `AudioTrackPlayer`
routes it through `AudioTrack.setVolume(0f/1f)`. Mute keeps the
decode + network pipeline running so unmute is sample-accurate.
Build:
- `:commons` commonMain now `implementation(project(":nestsClient"))` so
the shared VM can see `NestsListener` / `AudioRoomPlayer` / interfaces.
Concrete OkHttp / Quic / MediaCodec / AudioTrack actuals stay in
`:nestsClient`'s platform source sets and are bound by an
Android-side `AudioRoomViewModelFactory` co-located with the screen.
Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:quic:jvmTest :amethyst:compilePlayDebugKotlin` all green.
Address the architectural critique from self-review. The leaderboard
math and the shared system card are now platform-neutral primitives in
commons, the on-UI-thread aggregation is gone, and two latent
correctness bugs around subscription lifecycle and zap routing are
fixed.
- Introduce a pure LiveActivityTopZappersAggregator in commons that
takes plain ZapContribution values and returns a sorted, deduped,
anon-bucketed top-N list. Covered by a unit-test suite that is
Compose/Android-independent.
- Introduce LiveStreamTopZappersViewModel in commons/commonMain. It
owns two partitioned contribution maps (stream-scoped and
goal-scoped), mutates them under a Mutex on the IO dispatcher in
response to channel.changesFlow and Note.zaps.stateFlow emissions,
and publishes the aggregator result via a StateFlow<List<TopZapperEntry>>.
UI is now a dumb consumer with no aggregation logic left in the
composable.
- Move StreamSystemCard to commons/commonMain/compose/ so Desktop can
consume it alongside Android.
- Fix attachZapToLiveActivityChannel to run even when a zap receipt was
already consumed by another subscription. Previously a zap first seen
by notifications/profile would never reach the stream's channel
cache; addNote is already idempotent so this is a safe retro-route.
- Fix ChannelFilterAssemblerSubscription to re-invalidate filters when
a live-activity channel's metadata arrives. The 30311 stream event
usually lands after the initial assembler run, so the goal-tag-driven
subscription never fired. Now it re-runs as metadata resolves and the
goal id is discovered.
https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
The reference port recomputed the inverse DCT cosine tables once per output
pixel — for a 32x24 decode that's ~43k redundant cos() calls. This change
lifts the tables out of the inner loop and caches them across decodes
keyed by (size, componentCount) so subsequent placeholders at the same
target size skip cosine evaluation entirely.
Additional wins:
- AC coefficients unpack into pre-sized DoubleArrays (no ArrayList<Double>
boxing, no post-hoc copy)
- truncated hashes are rejected up-front via a single length check instead
of mid-stream
- LPQA -> sRGB uses an inline branch clamp instead of a
min/max/round/coerceIn chain
Tests: 12 unit tests cover determinism across repeated decodes, cache
clearing, alpha preservation, aspect-ratio preservation both directions,
average-color drift bounds, truncated input rejection, and round-tripping
base64 vs. raw bytes. All green.
Bench: new ThumbHashBenchmark exercises opaque decode, alpha decode,
warm- and cold-cache decode, aspect-ratio probe, and the full
decodeKeepAspectRatio pipeline so the perf delta is visible on device.
Adds a parallel ThumbHash placeholder everywhere BlurHash is already used.
Remote events now carry both hashes; renderers prefer thumbhash when
available and fall back to blurhash. The MIP-04 `thumbhash` imeta field
that was previously reserved-but-unused is now wired end-to-end.
- New ThumbHash encoder/decoder in commons/commonMain (no new Gradle dep)
- New ThumbhashTag and thumbhash accessors/builders across NIP-17, 68,
71, 94, 95, 99, the experimental profile gallery, and MIP-04
- RichTextParser + MediaContentModels carry a thumbhash field alongside
blurhash so every downstream composable can pick its preferred placeholder
- New ThumbHashFetcher (Android + Desktop) registered with Coil, plus a
small placeholderModel(thumbhash, blurhash) helper centralising the
"prefer thumbhash, fall back to blurhash" rule
- Rename BlurhashMetadataCalculator -> PreviewMetadataCalculator; the
calculator decodes the bitmap / video thumbnail once and runs both
encoders on the same pixels to keep upload cost flat
- DesktopMediaMetadata, MediaUploadResult, and FileHeader now surface
both hashes through the NIP-96, Blossom, NIP-95, MIP-04, NIP-17 DM,
Classifieds, picture, video, and long-form upload paths
- Round-trip unit test for the ThumbHash port
Adds MediaUrlPdf / PdfSegment to the rich-text pipeline so PDF URLs
(detected by .pdf extension, NIP-92 imeta m tag, or application/pdf
Content-Type) render a card showing the first page, filename, and page
count. Tapping opens a full-screen HorizontalPager over every page
rendered on demand with PdfRenderer. Long-press surfaces the existing
share menu. Uses only the built-in Android PdfRenderer; no new
dependencies. Desktop continues to fall back to a clickable link.
Convert all 5 mutable `var` callback fields on CallManager
(onAnswerReceived, onIceCandidateReceived, onNewPeerInGroupCall,
onMidCallOfferReceived, onPeerLeft) into a single
`sessionEvents: SharedFlow<CallSessionEvent>`.
Benefits:
- Eliminates the stale-callback window between Activity destroy and
recreate. No more nulling callbacks in onDestroy — the `closed`
flag on CallSession is sufficient.
- No mutable state shared between Activity and background code.
- CallActivity no longer wires or tears down callbacks — CallSession
subscribes to the flow in its init block.
- Type-safe sealed interface (CallSessionEvent) replaces 5 separate
lambda types.
The renegotiationEvents SharedFlow remains separate because
renegotiation has its own glare-resolution logic in CallSession.
Updated all 11 test sites in CallManagerTest to collect from
sessionEvents instead of setting callback vars.
https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
When the user is logged in on two devices and receives a call, both
ring. When one picks up, the other should stop ringing immediately —
but crucially, the device that answered must not lose its WebRTC
connection when the silenced device reacts.
The "answered elsewhere" state machine (onCallAnswered at the self-
pubkey branch) was already in place and covered by a unit test, but the
signal it relies on was never actually published: acceptCall wrapped the
CallAnswerEvent only for `groupMembers - signer.pubKey` so in a 1:1 call
it only reached the caller. Sibling devices subscribed to gift wraps
for their own pubkey never saw it and kept ringing until the 60 s local
timeout.
Fixes:
- acceptCall / rejectCall publish an extra gift wrap of the *same*
signed answer/reject event addressed to `signer.pubKey`, so sibling
devices in IncomingCall observe the echo and transition to
Ended(ANSWERED_ELSEWHERE / REJECTED) locally. Neither path publishes
any further signaling, so the device that picked up is never
disturbed.
- onCallRejected: add a self-pubkey early-return mirroring
onCallAnswered. Without it, a self-reject echoing back to a device
already in Connecting/Connected hit the peer-rejection branches,
firing onPeerLeft(signer.pubKey) — which would tell CallController to
dispose its own PeerSession and tear down the live audio.
- onSignalingEvent: record self-answer call-ids in completedCallIds
alongside hangups and rejects, so an out-of-order relay replay
delivering the self-answer before the original offer does not make a
sibling device start ringing for a call it already knows was answered.
Adds multi-device tests covering: the new self-addressed wraps in
acceptCall/rejectCall, the end-to-end "two phones, one answers"
scenario, the Connected / Connecting self-reject echo guards, and the
out-of-order offer-after-self-answer replay case.
https://claude.ai/code/session_01QVUnhr79hYqQuXFiEb8puk
Scenario: Alice calls {bob, carol} as a group. Bob answers, Bob and
Alice are talking, Carol never joins. Before Carol's 30 s watchdog
fires, Alice hangs up. Bob's state had peerPubKeys={alice} and
pendingPeerPubKeys={carol}. The old onPeerHangup handler only ended
the call when BOTH sets became empty, so Bob stayed in Connected
alone staring at a black screen until Carol's watchdog finally
dropped her — and even then the state machine didn't terminate
because "no peers, no pending" wasn't true at the exact same step.
Change the termination rule in onPeerHangup (both Connected and
Connecting states) to end the call as soon as connectedRemaining
becomes empty, regardless of how many pending peers are still
tracked. Without a single connected peer the call has no one to
talk to and can't meaningfully continue.
Also publish a CallHangup to any peers in the pending set that WE
personally invited (peersInvitedByUs ∩ pendingPeerPubKeys) so their
devices stop ringing. Peers we did not invite (e.g. group members
the caller originally included) are the caller's responsibility —
the caller's own group hangup already reaches them — so we leave
them alone.
Adds three regression tests covering:
- Connected state: caller hangs up while one pending member remains
- Connecting state: same, before onPeerConnected fires
- Callee had invited someone mid-call: call ends AND Bob notifies
his invitee to stop ringing
When a caller invites a group and one of the members never answers,
the caller already drops the unresponsive peer after 30 s (via the
per-peer watchdog in CallManager). The hangup the caller publishes is
addressed only to the unresponsive peer, so the OTHER participants
never learn that the peer is out and keep waiting for them forever.
Mirror the caller's 30 s budget on the callee side. In acceptCall,
split the group members into:
- peerPubKeys: the caller (they sent us the offer) plus any peers
whose answer we observed while still ringing. These are confirmed
to be in the call.
- pendingPeerPubKeys: everyone else we haven't heard from yet. Each
gets a local watchdog timer; if we never observe them (via a mesh
answer or a mid-call offer) within 30 s they are silently dropped
from our local state.
Because the callee is not the peer's inviter, handlePeerTimeout must
NOT publish a CallHangup in this path — terminating the invitee's
ringing is the caller's responsibility. Track "who we invited" in a
peersInvitedByUs set so the caller-side path (beginOffering,
initiateCall, invitePeer) still publishes the hangup, while the new
callee-side watchdog path drops the peer silently.
Also move a pending peer into peerPubKeys and cancel their watchdog
when they send us a mid-call mesh offer — the offer is proof they're
in the call, so we should not wait further.
Adds three new tests (callee watchdog drops pending peer, mid-call
offer transitions pending peer, watchdog is cancelled on answer from
pending peer) and updates two existing tests for the new Connecting
state shape when a group call is accepted.
New "Enable voice and video calls" switch at the top of the Call
Settings screen. Default is ON (no behavior change for existing users).
When a user turns it OFF:
- Call buttons in the chat room top bar are hidden. ChatroomScreen
observes account.settings.callsEnabled as a StateFlow and flips
onCallClick / onVideoCallClick to null, which RenderRoomTopBar
already treats as "no buttons".
- Incoming CallOfferEvents are silently dropped in CallManager.
A new isCallsEnabled: () -> Boolean hook on the CallManager
constructor gates onIncomingCallEvent *before* the followed-user
check, so the device never rings and no state transition occurs.
Existing in-flight call signaling (answers/hangups/ICE) still flows
through so a call that was active when the user flipped the toggle
continues to clean up normally.
- The rest of the call-related settings (video quality, max bitrate,
TURN servers) are hidden on the settings screen since they have no
effect while calls are disabled — the screen becomes just the single
meaningful toggle.
The setting is persisted per account via SharedPreferences
(PrefKeys.CALLS_ENABLED = "calls_enabled"), loaded in
LocalPreferences.loadFromEncryptedStorageSync, and exposed as a
MutableStateFlow<Boolean> on AccountSettings so both the chat top bar
and the settings screen react to changes without needing to navigate
away and back.
AccountViewModel wires
isCallsEnabled = { account.settings.callsEnabled.value }
into the CallManager constructor so the flag is read lazily on every
incoming event.
Tests in CallManagerTest:
- incomingOfferIgnoredWhenCallsDisabledInSettings — disabled flag
drops the offer, no state change, no published events.
- disablingCallsAfterStartDoesNotTearDownInProgressCall — an active
call keeps working after the toggle flips; only new offers are
ignored.
- incomingOfferProcessedWhenCallsEnabled — regression guard for the
default-enabled path.
https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB
Two changes that tighten how the caller-side UX handles slow or
unresponsive callees in a group call:
1. Per-peer 30-second invite timeout
Previously the caller used a single 60-second call-wide timeout: if
*any* peer answered within the window the timer was cancelled and
slow peers could remain "ringing" indefinitely. For a mid-call
invite there was no timeout at all — the invitee stayed in
pendingPeerPubKeys forever, burning a PeerConnection on the caller
side and keeping the invitee's device ringing.
CallManager now schedules an independent 30-second timer for every
peer in pendingPeerPubKeys (or Offering.peerPubKeys in the initial
offer phase). The timer is started when the peer is added to
pending — in beginOffering, initiateCall and invitePeer — and is
cancelled when the peer answers (onCallAnswered), rejects
(onCallRejected) or hangs up (onPeerHangup). On expiry the peer is
dropped from the group, a CallHangup is published to them so their
device stops ringing, and onPeerLeft fires so CallController can
dispose the per-peer PeerConnection. If the drop leaves the caller
with zero connected and zero pending peers, the call ends with
EndReason.TIMEOUT.
Callee ringing still uses the 60-second CALL_TIMEOUT_MS in
onIncomingCallEvent; the two timers now serve distinct roles.
New constant CallManager.PEER_INVITE_TIMEOUT_MS = 30_000L.
NIP-AC.md "Event Lifecycle" documents both timers.
2. Per-peer "Calling..." status in the video grid
The shared "Waiting for others to join…" banner across the top of
ConnectedCallUI is removed. PeerVideoGrid now takes a
pendingPeerPubKeys set and, for each peer still pending, routes
rendering through PeerAvatarCell with a "Calling…" status line
under the username — so it's obvious *which* participants the call
is waiting on, not just *that* it's waiting. A peer in pending
never shows as a video cell even if a stale track is still in the
map, because they haven't actually answered yet.
The voice-only path in ConnectedCallUI now also uses PeerVideoGrid
(with empty tracks/active-video) instead of the single
GroupCallPictures + GroupCallNames stack, so the per-peer status
behavior is consistent for audio calls.
Tests added in CallManagerTest:
- perPeerTimeoutEndsP2PCallWhenBobNeverAnswers
- perPeerTimeoutDropsSlowCalleeFromGroupCall
- perPeerTimeoutIsCancelledOnAnswer
- perPeerTimeoutDropsMidCallInviteeWhenNoAnswer
- perPeerTimeoutIsCancelledOnReject
- perPeerTimeoutEndsCallWhenAllCalleesIgnore
All existing CallManagerTest cases still pass unchanged.
https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB
When an existing group call participant invites a new peer via
CallController.invitePeer(), the caller connects to the invitee via the
normal offer/answer flow, but the other existing callees were never
establishing their own PeerConnections to the new peer — breaking the
full-mesh invariant.
Root cause: the NIP-AC "callee-to-callee mesh setup" mechanism relies on
each callee observing the others' CallAnswers during its own ringing
window and applying a symmetric "lower pubkey initiates" tiebreaker.
A mid-call invitee's ringing window happens after all existing callees
have already answered, so the invitee's discoveredCalleePeers set is
empty. Worse, if the invitee's pubkey is higher than an existing
callee's, that callee also stays passive (waiting for the invitee to
initiate), and no mesh connection is ever attempted between them.
Fix — break the symmetry on mid-call invites:
- CallManager.onCallAnswered now expands peerPubKeys when an answer
arrives in Connected (or Connecting) state from a peer that is not
yet in the tracked group membership. This keeps the UI and state
consistent with the expanded group and gives CallController a clear
hook via onAnswerReceived.
- CallController.onCallAnswerReceived splits the NO_SESSION case:
* Connected state → mid-call invite. Unconditionally initiate a
mesh CallOffer to the new peer. The invitee stays passive, so
exactly one side initiates per pair and glare is structurally
impossible.
* Connecting state → initial-call mesh observation. Keep the
existing lower-pubkey tiebreaker via onNewPeerInGroupCall to
avoid glare with the symmetric peer.
This fix uses the existing event kinds (CallOffer 25050 and CallAnswer
25051) — no new kinds or tags are required. NIP-AC.md is updated with
a new "Mid-Call Mesh Expansion" subsection under "Inviting New Peers"
documenting the asymmetric rule and a full-flow diagram.
Tests in CallManagerTest cover:
- Mid-call invitee's broadcast answer expands existing callee's
peerPubKeys in Connected state.
- Same expansion works in Connecting state (existing callee still
handshaking with caller when the invitee joins).
- Initial-call answers from already-tracked peers do NOT trigger the
expansion branch (regression guard).
- Full end-to-end 3-way flow exercising Alice, Bob, Carol managers.
https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB
Mirror TorSettingsTest and TorRelayEvaluationTest in commons/commonTest
using kotlin.test for KMP compatibility. Tests verify the shared types
directly without going through Android typealiases.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Split PeerSession.kt out of PeerSessionManager.kt (types, interface,
manager are now in separate files)
- Remove webRtcSessions duplication in CallController — PeerSessionManager
is now the single source of truth for session tracking; WebRtcCallSession
is retrieved via the adapter cast when WebRTC-specific APIs are needed
- Initialize PeerSessionManager eagerly with localPubKey (passed to
CallController constructor) instead of lazy suspend init — fixes early
ICE candidates being silently dropped before first suspend call
- Extract FakePeerSession into its own file for reuse across test files
- Remove assertion-only glare tiebreaker tests from NipACStateMachineTest
(now properly tested with real logic in PeerSessionManagerTest)
https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
Extract the ICE candidate buffering, answer routing, and renegotiation
glare logic from CallController into a platform-independent
PeerSessionManager in commons/. This makes the three most critical
NIP-AC spec requirements testable as JVM unit tests with FakePeerSession:
1. Two-layer ICE candidate buffering (global + per-session)
- Global buffer: candidates arriving before any PeerConnection exists
- Per-session buffer: candidates arriving before setRemoteDescription
- Candidates buffered while ringing are preserved when accepting
2. Renegotiation glare handling (pubkey comparison tiebreaker)
- Higher pubkey wins, lower pubkey rolls back local offer
- Full glare scenario test with two PeerSessionManagers
3. Callee-to-callee mesh initiation (lower pubkey initiates)
CallController now delegates to PeerSessionManager via a PeerSession
interface, with WebRtcPeerSessionAdapter bridging to WebRtcCallSession.
https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
Replace manual tag assembly in test helpers (makeOffer, makeAnswer, etc.)
with the existing build() methods from quartz event classes. This keeps
test tag structures in sync with production code automatically.
https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
Add 81 tests across two test suites covering the full NIP-AC spec:
- quartz/NipACStateMachineTest (31 tests): Protocol compliance test vectors
for event structure, tags, P2P/group flows, ICE serialization, staleness,
renegotiation glare rules, and multi-device support
- commons/CallManagerTest (50 tests): State machine integration tests using
real NostrSignerInternal with actual crypto, covering:
* Full call lifecycle (Idle → Offering/IncomingCall → Connecting → Connected → Ended → Idle)
* Call rejection, busy auto-reject, hangup from any state
* Self-event filtering (ICE, hangup, answer-elsewhere)
* Mid-call renegotiation (voice ↔ video)
* Group calls (mesh discovery, partial disconnect, invite peer)
* Interface-level tests with real signing + gift wrapping pipeline
* Full end-to-end P2P flow with two CallManager instances
Also adds test vector tables to NIP-AC.md spec for other implementers.
https://claude.ai/code/session_01AfRYTRCvtKqqDxeKQujUrx
Tests cover the StringIndexOutOfBoundsException in Url.getPart() that
occurred when readEnd() trimmed trailing characters (e.g. ':') from a
detected URL but urlMarker indices remained pointing past the trimmed
string's end.
- UrlMarkerTest: three cases testing PORT/QUERY index at or beyond
string length (the boundary conditions fixed by minOf clamping and
the startIndex >= length guard)
- UrlsDetectorTest: regression for "今北産業" (no crash, 0 URLs) and
for a bare "example.com:" host
- UrlParserTest: end-to-end regression for "今北産業" and "example.com:"
https://claude.ai/code/session_013rJ9iYndYVJgpYK2VazxL2