Compose wrapper that consumes RoomTheme and applies it to the
audio-room body:
AudioRoomThemedScope(theme, content):
* Overrides Material3 colorScheme — background, onBackground,
primary, surface, onSurface — with the theme's argb values.
Each color override is independent: a room shipping a
primary tint but no background gets the primary swap and
default surface, no all-or-nothing.
* Optional background image via Coil's AsyncImage rendered
behind the content. backgroundMode=COVER → ContentScale.Crop;
TILE → FillBounds (true repeat-tile would need a custom
Modifier — FillBounds is a safe v1 fallback that doesn't
crop and visually approximates a tile when the image is
small + repeating).
* Fails OPEN — RoomTheme.Empty / all-null fields produces a
passthrough Box with no overrides.
AudioRoomFullScreen — wraps the existing scrolling Column in
AudioRoomThemedScope. The theme is materialised once via
RoomTheme.from(event) inside `remember(event)` so a recomposition
doesn't rebuild the colorScheme.
The font tag is still deferred — needs a per-pack FontFamily
loader; the v1 theme runs on the system default and renders
unchanged for un-themed rooms.
Lays the foundation for the moq-lite reconnect-with-backoff path:
NestsReconnectPolicy — exponential backoff settings.
(initial=1s, multiplier=2, max=30s) by
default, mirroring kixelated/moq's JS
reference (`delay: { initial: 1000,
multiplier: 2, max: 30000 }`).
maxAttempts defaults to Int.MAX_VALUE
since a long-running room should keep
trying as long as the user hasn't left;
the Composable's onDispose is the cancel
signal.
NoRetry sentinel for first-shot-or-fail
tests / single-room demos.
delayForAttempt(n) — 1-indexed; doubles per attempt; clamps at
maxDelayMs. n<1 returns 0.
isExhausted(n) — n >= maxAttempts (next retry forbidden).
init { require(...) } — ctor guards reject zero/negative initial,
multiplier <= 1, max < initial, attempts < 1.
NestsListenerState.Reconnecting / NestsSpeakerState.Reconnecting
— new state variants with (attempt, delayMs). UI consumes via
the existing NestsListenerState → ConnectionUiState mapper which
surfaces Reconnecting under OpeningTransport for the v1 chip;
a future commit can add a dedicated "Attempt N in Mms" UI.
Tests:
* First attempt = initialDelayMs
* Doubling via attempt index until maxDelayMs ceiling
* Non-standard multiplier still respects ceiling
* Zero/negative attempt → 0 delay
* isExhausted at the boundary
* NoRetry exhausts after attempt 1
* Constructor rejects invalid inputs
The orchestration layer (mint-fresh-JWT → reopen WT → re-issue
SubscribeHandles + the speaker-side equivalent) is the heavier
follow-up. Deliberately split because:
1. The state + policy can ship and be consumed by callers ready
to handle Reconnecting today — no behaviour change for those
who don't.
2. The full session-resurrection path needs `MutableSharedFlow`
buffering per SubscribeHandle so app code's `Flow<MoqObject>`
doesn't notice the swap. Substantial refactor; better as its
own commit with its own tests.
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.
Adds the Tier-3 minimum-viable theming primitives so a themed
nostrnests room renders without crashing the client:
ColorTag — `["c", "<hex6>", "background"|"text"|"primary"]`.
Strict 6-char hex parser; `#abc` shorthand and
named colors are REJECTED so a typo'd room
event can't crash the renderer. Output hex is
normalised uppercase, no `#` prefix.
BackgroundTag — `["bg", "<url>", "tile"|"cover"]`. Mode defaults
to COVER when missing; unknown modes (a future
"blur") fall back to COVER until the renderer
learns them. Empty URL is rejected.
MeetingSpaceEvent.colors() / .background() — tag accessors. The
font tag (`["f", family, optionalUrl]`) is intentionally NOT in
this commit; loading a custom FontFamily would need a per-pack
loader, and font-less rooms still render fine on the client.
Tests:
ColorTagTest — happy path, no-`#` prefix, rejects shorthand /
named / unknown target / missing target;
assemble normalisation; round-trip.
BackgroundTagTest — happy path, default-COVER, future-mode
fallback, rejects empty URL, round-trip.
The Compose `RoomTheme` projection + `AudioRoomThemedScope`
renderer come next.
Adds a "Share room" action to the room overflow menu (visible to
EVERYONE — share isn't host-restricted; the host-only "Edit room"
row is now gated alongside it). Builds an `nostr:naddr1...` URI
from the room's kind / hostPubkey / dTag via Quartz's
`ATag.toNAddr()` helper and hands it to the system share sheet
with the room title as a preview blurb.
shareRoomNaddr(context, event):
aTag = ATag(event.kind, event.pubKey, event.dTag(), null)
naddr = aTag.toNAddr()
text = "<title> — nostr:<naddr>"
Intent.ACTION_SEND → createChooser
AudioRoomFullScreen — overflow menu now visible to all members.
Share row appears for everyone; Edit row appears only when the
local user is the room's host.
Strings: audio_room_share_action.
Step 3 (zap entry points) is intentionally NOT in this commit —
the existing zap UI is built around the per-note ZapReaction in
ReactionsRow.kt with long-press-to-set-amount + click-to-zap, which
is heavy to embed inside the audio room. The reactions sub already
pulls kind 9735 receipts so paid zaps SURFACE in the floating
overlay; the SEND path can ship as a follow-up that wires the
existing `accountViewModel.zap(note, amount, ...)` flow with
ZapPaymentHandler from inside the participant context sheet.
Extends the host-only kick sheet from Tier 1 #6 into a full
context sheet that any room member (host or audience) can open by
long-pressing an avatar. Common rows on top, host-only rows
appended:
Audience-side (always shown):
* Follow / Unfollow — accountViewModel.follow(user) /
.unfollow(user). State derived from
accountViewModel.isFollowing(target).
* Mute / Unmute — accountViewModel.hide(user) / .show(user).
State derived from account.hiddenUsers.flow.value.
Host-only (appended if local user is the room host):
* Promote to speaker
* Demote to listener
* Kick
AudioRoomFullScreen — long-press is now wired for ALL
participants (previously only hosts). The sheet's internal
gating decides which rows to render. Long-pressing yourself is
blocked at the call site since the actions are all "for someone
else".
Strings: audio_room_participant_follow / unfollow / mute /
unmute. View profile is intentionally NOT in this
commit — AudioRoomActivity is a separate Activity
without an in-room nav stack to push a profile screen
onto. Adding it later means launching MainActivity with
a deep-link Intent.
The function name `ParticipantHostActionsSheet` is retained for
this commit to avoid a wide rename — its new behaviour is broader
than the original "host actions" wording. Renaming to
`ParticipantContextSheet` is a follow-up.
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 ephemeral host-issued admin command event nostrnests uses
for kick (and future moderation actions like mute/ban):
AdminCommandEvent — kind 4312. Carries one Action (currently just
KICK) in `content`, an `a`-tag pointing at the room (kind-30312
address), and a `p`-tag for the target. The verb-in-content shape
lets us extend with new actions without a wire schema bump.
AdminCommandEvent.kick(roomATag, target) — builder.
AdminCommandEvent.action() / .targetPubkey() / .room() — accessors
for the recipient side. Unknown actions return null (forward-compat
with verbs not yet in the enum).
EventFactory — registered so LocalCache + Filter.match can decode
incoming events properly.
Authority enforcement is the CLIENT'S job — the relay just stores
and forwards. Recipients filter on `kinds=[4312], #a=[room],
#p=[me]` and only honour commands whose signer is a participant
marked HOST or MODERATOR on the active kind-30312. The next commit
wires that gating + the disconnect side effect into AudioRoomViewModel.
Tests:
* Build a kick template and verify the action/address/target tags
* Round-trip parse exposes room, target pubkey and action
* Unknown verb in `content` returns null from action()
* Missing tags return null from accessors (no throw)
Surfaces audience members who raised their hand (kind-10312
presence with `hand=1`) and gives the host a one-tap "Approve"
button that promotes them to SPEAKER via
RoomParticipantActions.setRole.
HandRaiseQueueSection — Compose section visible host-only.
Filters the VM's `presences` map to entries where
handRaised=true AND the pubkey isn't already a host / moderator /
speaker (they wouldn't have raised their hand if they could
already speak). Hidden when the queue is empty so a quiet room
doesn't gain a placeholder.
AudioRoomFullScreen — gates the section on `isHost` and stitches
it in below the audience row.
Strings: `audio_room_hand_raise_queue_title`,
`audio_room_hand_raise_approve`.
Approve flow:
presence.hand=1 → RoomParticipantActions.setRole(event, target,
ROLE.SPEAKER) → account.signAndComputeBroadcast → relay sees
same `d`-tag → kind-30312 replaced. The promoted user's
participant tag now reports canSpeak() == true; their VM's
startBroadcast() gating in commons unblocks them automatically
on the next render.
Adds the host-side participant-list mutations as pure functions —
extracted to make every behaviour unit-testable without an
AccountViewModel / signer:
RoomParticipantActions.setRole(original, target, newRole)
RoomParticipantActions.demoteToListener(original, target)
Both republish kind-30312 with the same `d`-tag so the relay
treats it as a replacement of the original. Internally:
* If the target was already a participant, their tag is updated
in place — no duplicate `p`-rows.
* If they weren't (audience member promoting up from the room),
a fresh `p`-tag is appended.
* The host can NEVER be demoted — there's exactly one host per
NIP-53 audio room and the host's pubkey IS the event author,
so flipping their role would produce a corrupt event. Returns
null instead.
* Every other promoted participant is preserved verbatim — same
risk-mitigation as EditAudioRoomViewModel.buildEditTemplate.
6 unit tests covering:
* Promote-when-absent appends with the requested role
* Promote-when-present updates in place (no duplicate row)
* Host can't be demoted
* Demote flips a speaker to PARTICIPANT, leaves moderators alone
* Republish keeps the same `d`-tag
* Promoting one speaker doesn't disturb the other speakers
Sheet/menu wiring + the hand-raise queue UI come next.
Adds typed role accessors to make participant-list filtering
self-documenting:
ParticipantTag.effectiveRole(): ROLE? — case-insensitive parse;
null when the role string doesn't match any enum value (so an
unknown future "director" role doesn't accidentally pass canSpeak).
ParticipantTag.isHost() / isModerator() / isSpeaker() — single-role
predicates for per-row UI gating.
ParticipantTag.canSpeak() — true for HOST / MODERATOR / SPEAKER;
the audio-room VM uses this to gate startBroadcast() so anyone who
was promoted (not just the original host) can publish.
5 unit tests cover happy paths, case-insensitivity, the
unknown-role → null contract, the canSpeak union, and the
effectiveRole enum parse.
Tier 1 #5 + #6 consume these — promote/demote and kick both need
to render different rows depending on whether the local user is a
host or moderator (allowed to manage roles) and the target
participant's current role.
Adds the host-only "edit / close room" flow:
EditAudioRoomViewModel — pre-fills from the existing
MeetingSpaceEvent on bind. save() and
closeRoom() both republish kind-30312
with the SAME `d`-tag (relay treats it
as a replacement). Closing flips
status=CLOSED; everything else just
edits the form fields. Participants are
preserved verbatim — promoting a speaker
is a separate action (Tier 1 #5), so
edit must NEVER silently demote anyone.
buildEditTemplate — pure template builder extracted out so
it's unit-testable without an
AccountViewModel / signer (the
sign+broadcast path is the only side
effect).
EditAudioRoomSheet — bottom-sheet copy of CreateAudioRoomSheet
with a destructive "Close room" button
on the left and "Save" on the right.
Title, error and progress states wired
to the same VM.
AudioRoomFullScreen — host-only overflow menu (kebab) next to
the room title; "Edit room" opens the
sheet. Hidden when the local pubkey
isn't the room's pubkey.
EditAudioRoomViewModelTest — 4 unit tests covering:
* republish keeps the same d-tag and updates room/summary
* republish preserves all promoted participants (host + 2
speakers) — none lost on rename
* closeRoom flips status to CLOSED with the same d-tag
* blank summary / image OMIT the corresponding tags so a
"delete the summary" edit actually deletes it
Strings — `audio_room_edit_title`, `audio_room_edit_save`,
`audio_room_close_action`, `audio_room_overflow_menu`.
Scheduled rooms (#7) deliberately deferred to a follow-up — the
StatusTag enum currently has only OPEN/PRIVATE/CLOSED on
MeetingSpaceEvent, no PLANNED. That's an additive Quartz change
plus a date-time picker on the create flow; better to land in its
own commit.
End-to-end glue for the speaker-avatar reaction overlay (T1 #3):
RoomReactionsFilterAssembler — REQs `kinds=[7], #a=[roomATag]`
on the user's outbox relays. Mirror of RoomChatFilterAssembler
/ RoomPresenceFilterAssembler. Kind 9735 (zaps) deliberately not
in the v1 filter; the overlay only renders kind-7 reactions for
now and adding zaps later is a one-line filter change.
AudioRoomActivityContent — opens the wire sub on enter, observes
LocalCache for ReactionEvents matching this room's #a-tag, pipes
each event into vm.onReactionEvent. A separate 1-s tick drives
vm.evictReactions so the floating-up overlay's lifetime is set by
the eviction tick rather than per-component timers.
SpeakerReactionOverlay — small Composable: groups duplicate emojis
into "🔥 ×3" chips ordered by most-recent createdAt. Hidden when
the per-pubkey list is empty; the 30-s sliding window in the VM
takes care of clearing.
StagePeopleRow now accepts an optional `reactionsByPubkey` map
and renders the overlay below each speaker's avatar. The audience
row keeps the empty default — reactions land on speakers.
RoomReactionPickerSheet — bottom-sheet quick-picker with six
defaults (👏🔥😂👀❤️⚡). Tapping an emoji invokes
AccountViewModel.reactToOrDelete on the room note, which builds
+ signs + broadcasts the kind-7. Custom emoji-pack support is
deferred to a follow-up.
AudioRoomFullScreen — adds a "React" OutlinedButton next to the
hand-raise toggle that opens the picker. Now plumbs the room's
AddressableNote through (needed for reactToOrDelete).
strings.xml — `audio_room_reactions_title`,
`audio_room_reactions_button`.
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.
Wires up the kind-1311 chat end-to-end (T1 #2):
AudioRoomActivityContent — opens RoomChatFilterAssemblerSubscription
on enter, observes LocalCache for kind=1311
+ #a=[roomATag], pipes events into the
VM's chat ledger via onChatEvent.
AudioRoomChatPanel — new Composable. LazyColumn of messages
(auto-scroll-to-bottom on new arrival),
empty placeholder, OutlinedTextField +
Send button. Each row shows a
ClickableUserPicture by pubkey hex,
a truncated-pubkey label, and the
content. Rich author-name lookup is
intentionally deferred to keep the v1
panel's dependency surface tight.
Send path — `account.signAndComputeBroadcast(
LiveActivitiesChatMessageEvent.message(
post = trimmed,
activity = ATag(30312, host, dTag, null)))`
routed directly from the Composable so
the VM stays free of platform/signing
dependencies.
AudioRoomFullScreen — embeds the panel below the existing
hand-raise / leave row.
strings.xml — `audio_room_chat_send`,
`audio_room_chat_placeholder`,
`audio_room_chat_empty`.
Sub closes on Composable dispose; relay traffic naturally winds down
when the user leaves the room. Out-of-order arrivals are sorted to
chronological by the VM's ledger (already covered by the
AudioRoomViewModelTest unit tests).
Adds the relay-side wire sub for the live-chat panel — REQs
`kinds=[1311], #a=[roomATag]` against the user's outbox relays
while the room screen is composed.
RoomChatQueryState / RoomChatFilterSubAssembler /
RoomChatFilterAssembler — mirror of RoomPresenceFilterAssembler;
same shape so a future refactor can dedupe.
RoomChatFilterAssemblerSubscription — Composable lifecycle
wrapper. Single file (no separate "*Subscription.kt") since
the assembler + composable are tightly coupled to one event kind.
RelaySubscriptionsCoordinator.roomChat — registered alongside
`audioRooms` and `roomPresence` so accountViewModel.dataSources()
exposes it on the same surface the rest of the screen-scoped
subs use.
Events arriving in LocalCache are picked up by AudioRoomActivityContent
in the next commit, where they're piped into the VM's chat ledger.
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.
Renders "%d listeners" right under the room title once the kind-10312
aggregator has at least one entry. Counts every peer with a recent
presence event — hosts, speakers, and audience alike — matching what
the nostrnests web UI shows in its room header.
The badge uses a pluralized string resource so the wire copy matches
locale rules (one / many) without per-locale code paths.
Hidden when the count is zero so a brand-new room (before anyone's
presence has propagated) doesn't flash a misleading "0 listeners".
Counts can flicker briefly when a single peer drops a heartbeat —
the eviction window in AudioRoomActivityContent is 6 min so a short
relay hiccup doesn't flip the count, but the badge will move when
peers genuinely come and go.
Wires the listener-side aggregator end-to-end:
RoomPresenceFilterAssembler — REQs kind-10312 with #a=[roomATag]
across the user's outbox relays.
Mirrors ThreadFilterAssembler's
shape (PerUniqueIdEoseManager keyed
by the room a-tag so overlapping
screens stay separated).
RoomPresenceFilterAssemblerSubscription — Composable scope wrapper.
Opens the wire sub on enter, closes
on dispose, mirrors the
ThreadFilterAssemblerSubscription
pattern.
RelaySubscriptionsCoordinator — registers `roomPresence` alongside
`audioRooms` so accountViewModel.dataSources()
exposes it the same way every other
screen-scoped sub does.
AudioRoomActivityContent — calls the Composable subscription, then
runs two LaunchedEffects:
1. observe LocalCache for
MeetingRoomPresenceEvent matching
this room's a-tag, pipe each
event into vm.onPresenceEvent
2. evict peers whose last heartbeat
is older than 6 min on a 60-s tick
(one missed 30-s heartbeat +
5-min "still here" tolerance)
The presence map is now populated from the wire when entering an
audio room. Listener counter + participant grid UI consume
vm.presences in the next commit.
Microsoft SwiftKey re-enters word-edit mode over a previously-committed
display token after autocorrect-on-space, then issues setComposingText
with a shortened version. Compose's auto-derived offset mapping for
OutputTransformation uses identity inside a wedge, so the IME's
replacement only overwrites the leading characters of the underlying
@npub1... bech32, leaving an orphan tail that no longer matches the
mention regex. The wedge collapses, the orphan bech32 becomes visible,
and the cursor lands in the middle of it. Gboard never enters word-edit
mode for previously-committed tokens, so it doesn't trigger this.
Add MentionPreservingInputTransformation that runs on every input
change and reverts any edit whose original-text range partially
intersects a complete mention without fully covering it. The mention
stays atomic; the IME re-reads the unchanged buffer and moves on.
Wire it into all OutputTransformation-using fields: chats, new note,
group DM, public channel, public message, classifieds, long-form.
https://claude.ai/code/session_01LVmmGa3Npuv2d1eeYm9BdZ
The custom OffsetMapping for the @-mention VisualTransformation used
percentage-based interpolation when the cursor offset fell inside a
substituted "@npub1..." range. An IME using extracted-text mode (e.g.
SwiftKey on Pixel 9a) could place the cursor in the middle of the
displayed "@DisplayName", which mapped to the middle of the underlying
bech32 npub. A subsequent backspace then deleted a char from inside
the bech32, the npub stopped matching the regex's 58-char length
check, and the collapsed mention "expanded" with the cursor stuck in
the middle of the now-visible raw npub.
Treat each substitution as an atomic wedge: any cursor strictly inside
a substituted range snaps to the wedge's trailing edge in both
directions. Tests are rewritten to verify the snap-to-boundary
semantics; the prior assertions pinned the buggy percentage behavior.
This fixes the cursor-jump-into-npub symptom in EditPostView and
ForwardZapTo (which use VisualTransformation directly). Chat input
fields use OutputTransformation with Compose's auto-derived mapping
and are not affected by this code path.
https://claude.ai/code/session_01LVmmGa3Npuv2d1eeYm9BdZ
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)
Adds two NIP-53 presence tags used by nostrnests' room UI:
["publishing", "0|1"] — peer is actively pushing audio packets
["onstage", "0|1"] — peer holds a speaker slot vs audience
These complement the existing "hand" and "muted" tags. They're
independent: a speaker can be onstage but paused (onstage=1,
publishing=0), or broadcasting silently (onstage=1, publishing=1,
muted=1). The participant grid (Tier 2) and listener counter (Tier 1
#8) consume them.
- PublishingTag / OnstageTag classes mirror HandRaisedTag's parse +
assemble shape exactly so the DSL (TagArrayBuilderExt) extends
uniformly.
- MeetingRoomPresenceEvent.publishing() / onstage() accessor parsers
return Boolean? (null when the tag isn't present) so callers can
distinguish "explicitly false" from "absent".
- The MeetingSpaceEvent overload of build() now accepts both fields
as nullable params; the MeetingRoomEvent overload deliberately
doesn't (those are tier-2 video meetings, not Clubhouse rooms).
- Round-trip tests pin the wire format and the "absent → null"
behaviour.
Speaker pushes 4 frames, then calls broadcast.close() + speaker.close().
Listener should receive the 4 pushed frames — and currently DOES NOT
auto-terminate its objects flow when the speaker disconnects, because
the relay's `subscribed complete` signal isn't propagated to a
Channel.close() on our listener side. UI code is expected to drive
its own teardown.
The test pins that current behaviour: it asserts the flow stays open
within a 5s window after speaker.close(). If a future change wires
the relay's done-signal through to a clean flow completion (the more
user-friendly behaviour), this test will fail loudly and ask you to
flip the assertion + rename the test —
`speaker_close_terminates_listener_flow_cleanly`.
Documents a known gap rather than asserting an aspirational contract,
so we don't paper over the missing path with a "happy when listener
unilaterally closes" test.
One speaker, two listeners A and B. Push 3 frames (both receive), A
unsubscribes, push 3 more frames, B's stream completes with all 6.
If A's unsubscribe accidentally tore down the speaker's broadcast or
B's subscribe (e.g. an over-eager session-wide cleanup in a future
refactor), B would time out waiting for the second batch — and the
test names that exact failure mode in the message.
Pin moq-lite-03's "from latest" subscribe semantics: a listener that
joins after the speaker has been broadcasting for a while sees only
new frames, not the pre-subscribe history.
Phase 1: speaker pushes 5 early frames (bytes 0..4) with no listener
attached. The relay does not buffer these in moq-lite-03.
Phase 2: late listener subscribes.
Phase 3: speaker pushes 5 more frames (bytes 100..104). The listener
takes the first 5 frames it sees and we assert ALL of them carry
bytes >= 100 — any contamination from phase 1 fails loudly with
the offending bytes named.
This pins the no-replay guarantee against a future regression to
"buffer-and-replay" behaviour, which would change recovery latency
characteristics for users joining mid-stream.
Verifies the broadcaster's mute path through a real moq-relay:
- push two unmuted frames -> listener receives them
- setMuted(true), push two muted frames -> listener never sees them
- setMuted(false), push two more unmuted frames -> listener resumes
Asserts the listener's flow contains exactly [0, 1, 2, 3] in order
(the muted 50, 51 frames must be absent, not silently filled with
zeros). This pins `if (muted) continue` in
AudioRoomMoqLiteBroadcaster against an accidental "send a silent
placeholder while muted" regression.
Also factors the previously-private DriverCapture / StubEncoder
helpers into a shared InteropFrameDriver.kt so the new test can
reuse them without copy-pasting per file.
The previous test
`listener_subscribed_before_announce_receives_late_frames` validated a
contract from older moq-rs versions: the relay would HOLD a SUBSCRIBE
issued before any publisher announced, then resolve it once a publisher
arrived. moq-lite-03 dropped that behaviour — the relay now rejects
immediately with `subscribed error err=not found` and FINs the bidi
without writing a SubscribeDrop body, which our session reader surfaces
as `MoqProtocolException: subscribe stream FIN before reply`.
Rename the test to
`subscribe_before_announce_fails_with_not_found` and flip the
assertion to pin the new contract. Acceptance message check is
permissive: matches the current "FIN before reply" form AND a
hypothetical future "not found" SubscribeDrop, so a relay change to
explicitly Drop wouldn't silently regress this test.
The class-level coverage doc loses the obsolete
"subscribe-before-announce holds" claim and gains a note on why
the test now flips.
Verified end-to-end against moq-relay 0.10.25 + moq-auth running
bare-metal (-DnestsInteropExternal=true): all five interop test
classes (Auth, AuthFailure, AuthEndpoints, RoundTrip, MultiPeer)
now pass — 13/13 cases green.
Lite-03's SubscribeResponse on the response side of a Subscribe bidi
is two pieces concatenated:
type varint (0 = Ok, 1 = Drop)
body size-prefixed bytes
The type discriminator sits OUTSIDE the body's size prefix. Earlier
drafts wrapped the whole thing in one outer size prefix; Lite-03 split
them. See `rs/moq-lite/src/lite/subscribe.rs::SubscribeResponse::encode`
(the `_` arm, which matches Lite-03+).
Our codec was producing — and reading — the older outer-wrapped form,
so against a Lite-03 relay we mis-parsed the type discriminator (`0`
for Ok) as a "size=0" outer prefix and surfaced a confusing
`MoqCodecException: truncated varint at offset=0 (remaining=0)` from
inside `decodeSubscribeResponse`. The first byte the relay sent was
the type, not a size, and our reader peeled it off as the outer length.
Fix touches three pieces:
* `encodeSubscribeOk` / `encodeSubscribeDrop` emit
`type + size_prefixed(body)` directly (no outer wrap), via a small
`prefixWithType` helper.
* `decodeSubscribeResponse` reads `type` then a length-prefixed body,
decodes the body in its own reader, and asserts the outer payload
is fully consumed.
* `readSubscribeResponseFromBidi` walks chunks into the buffer until
both the type varint and the size-prefixed body have arrived, then
re-emits the contiguous `[type][size][body]` slab so the decoder
parses it self-contained. Reuses the `EarlyExit` collector pattern
the publisher-inbound path already uses; avoids `Flow.iterator()`
which doesn't exist in kotlinx.coroutines.
Tests:
* `MoqLiteCodecTest::subscribe{Ok,Drop}_round_trips` no longer
`peelSizePrefix` the encoded bytes — the new wire form has no outer
prefix to strip; the bytes go straight through `decodeSubscribeResponse`.
* `MoqLiteSessionTest::publisher_acks_subscribe_and_pushes_group_data_on_uni_stream`
also drops `MoqLiteFrameBuffer().readSizePrefixed()` on the ack chunk
for the same reason.
Verified end-to-end against a bare-metal moq-relay 0.10.25 + moq-auth
(`-DnestsInteropExternal=true`):
* `NostrNestsRoundTripInteropTest::production_speaker_broadcasts_to_production_listener_via_real_relay`
passes — speaker → listener → 8 frames round-trip cleanly.
* `NostrNestsMultiPeerInteropTest::one_speaker_fans_out_to_two_listeners`
passes — same speaker reaches both listeners with the full frame
stream.
* The `listener_subscribed_before_announce_receives_late_frames` and
`two_speakers_in_same_room_deliver_independently_to_one_listener`
cases still fail with `subscribe stream FIN before reply` — those
are different relay-behavior issues (the v1 relay seems to FIN
subscribes against unannounced broadcasts and against a second
speaker on the same listener), unrelated to the codec.
Without `wt-available-protocols`, moq-relay (`web-transport-quinn`) falls
back to the legacy in-band SETUP exchange (moq-lite-02) instead of
selecting the moq-lite-03 sub-protocol from the ALPN-style negotiation
header. Then the relay tries to decode our first post-CONNECT bytes as a
SETUP_CLIENT message, hits an unknown control type, and closes the QUIC
connection with `connection closed err=invalid value` — surfaced
client-side as a stuck SUBSCRIBE that ends with
`subscribe stream FIN before reply for id=0` (the bidi gets FIN'd because
the whole connection is being torn down).
Pass `wt-available-protocols: "moq-lite-03"` on the Extended CONNECT
request, encoded as an RFC 8941 Structured Field List of strings (the
header format mandated by draft-ietf-webtrans-http3-14 §3.3). With this,
moq-relay logs `negotiated version=moq-lite-03 transport="quic"` and the
SUBSCRIBE makes it into the relay's actual moq-lite session pump.
Mechanism: web-transport-proto's `ConnectRequest::encode` reads
`self.protocols` and writes them as a comma-separated list of bare
strings under `wt-available-protocols`. The server side (web-transport-
quinn) reads the same header into `request.protocols`, and moq-native's
`QuinnRequest::ok()` picks the first match against its supported ALPN
list (`moq-lite-04`, `moq-lite-03`, `moq-00`, `moqt-15`, etc.). On
match, version selection happens via the WT sub-protocol response and
the in-band SETUP is skipped — which is what moq-lite-03 expects.
Default the factory list to `["moq-lite-03"]`. Callers that want a
different version (or to disable sub-protocol negotiation entirely
to talk to a SETUP-based server) override the constructor parameter.
Bare-metal harness: NostrNestsHarness.startExternal() now skips the
TCP probe of the moq-relay port. moq-relay binds UDP only; the Docker
forwarder happens to also open TCP, but a directly-launched binary
doesn't, so the previous `Socket(host, 4443)` probe failed with
ConnectException. The QUIC handshake from the test surfaces a real
transport problem if any.
Adds a "bring your own stack" path to NostrNestsHarness so the interop
tests can run without a Docker daemon. With `-DnestsInteropExternal=true`:
- Skip the `docker compose up` that builds + boots
moq-auth + moq-relay
- Port-probe + /health-check the same 8090 / 4443 endpoints the
Docker path uses
- close() is a no-op — the caller owns the lifecycle
Useful in two situations:
1. Sandboxes / restricted CI without a Docker daemon (just run
`cargo install moq-relay` + `node moq-auth/dist/index.js`
yourself, then run gradle with the flag)
2. Fast iteration — the Docker path takes ~30 s to compile
moq-relay on first run; with this, you keep both processes
alive across many test invocations
Forward `nestsInteropExternal` (and `nestsInteropDebug`,
`nestsInteropMoqRev`) through the Test task so the property reaches
test workers; without that, the gate stays off in the worker JVM.
Also: `assertSpeakerReached` / `assertListenerReached` now log a "✘"
checkpoint with the rich state description before calling fail().
JUnit captures the assertion message in a separate section, but the
"standard output" tab is what most people read first when scanning
for a cause — so the chained-cause string now lands in both places.
Replace inline `com.vitorpamplona.…` references in code + KDoc with
imports + the short class name across the audio-rooms surface. KDoc
references that would have introduced an `ui` → `model` import cycle
(e.g. NestsServerListState referring to CreateAudioRoomViewModel) are
demoted to plain text instead of clickable `[Class]` links.
No behavioural change.
When an interop test fails today the report shows a generic
AssertionError pointing at a `runBlocking {` line — useless for
narrowing down which sub-step (mintToken, WT connect, ANNOUNCE,
SUBSCRIBE_OK, frame round-trip) actually exploded.
This adds a small InteropDebug helper that:
- prints a labelled "▶ start" / "✔ ok" / "✘ fail — Class: msg ⟵ Cause: msg"
trail per step (output gated on `-DnestsInterop=true` so the
default test run stays silent)
- walks chained `cause` so the surface message reveals the real
network / protocol error instead of the wrapping string
- pretty-prints NestsSpeakerState / NestsListenerState (Failed in
particular unwraps `cause` for the report)
Each test body in NostrNestsRoundTripInteropTest +
NostrNestsMultiPeerInteropTest now wraps connect / startBroadcasting /
subscribeSpeaker / await-frames in `InteropDebug.stepSuspending(...)`
so a failure points at the exact sub-step rather than the test method.
Harness diagnostics: when `start()` fails, capture
`docker compose ps` + recent logs for moq-auth / moq-relay / strfry
into the IllegalStateException message before tearing the stack down.
Without this, the test report only shows "exited with code 1" —
operators have to re-run by hand to find out which container
crashed.
Output is hidden by default; only surfaces when
`-DnestsInterop=true` (or the explicit `-DnestsInteropDebug=true`)
is set.
The earlier integration audit identified the gaps; this is the
how-to-build-them. Split across four files plus an index so each
review / commit stays small:
- 2026-04-26-tier-plans-index.md — top-level pointer + sequence
dependencies + what's deliberately out of scope.
- 2026-04-26-tier1-coding-plan.md — listener counter, presence
aggregation, augmented kind-10312 tags (publishing/onstage),
live chat (kind 1311), reactions (kind 7 / 9735), edit + close
+ scheduled rooms, role parsing + promote/demote, hand-raise
queue, kick (kind 4312). Concrete file-level wiring for each
step + suggested commit order (six independent PRs).
- 2026-04-26-tier2-coding-plan.md — participant grid,
per-avatar context menu (follow / mute / zap / promote / kick),
zap entry points (room + speaker), share-via-naddr.
- 2026-04-26-tier3-coding-plan.md — room theming PARSER ONLY
(graceful fallback for themed rooms; full theming behind a
later phase), background-audio + wake-lock audit checklist.
- 2026-04-26-tier4-coding-plan.md — moq-auth token re-mint on
long sessions and moq-lite Connection.Reload-equivalent
reconnect with backoff. Step 1 is subsumed by Step 2 once the
reconnect path is in.
No code changes — pure docs. Each plan names exact file paths,
new types, reused helpers, strings, tests, and call-out risks so
an implementer (or follow-up agent) can pick up Step N without
re-deriving the surrounding context.
The remaining interop failures all root in the same window: a stale
keep-alive pool entry from one test class is reused on the FIRST POST
of the next test class, the connection RSTs as the request body
writes, and OkHttp's built-in retryOnConnectionFailure won't retry a
POST after any byte of the body has gone out. Same situation hits a
phone client whose Wi-Fi hands off mid-mint.
Two fixes, both production-shaped:
- OkHttpNestsClient.mintToken now wraps execute() in
executeWithTransportRetry(): one retry on SocketException /
EOFException / generic IOException. Request builders are
immutable, so the second pass opens a fresh connection cleanly.
HTTP error status codes (4xx / 5xx) and malformed responses are
NOT retried — they go to the caller as before.
- NostrNestsHarness now polls GET /health until it returns 200
after the port-probe succeeds. moq-auth's Node runtime opens its
listen socket before the request handlers are wired, so a POST
that arrives in that window can RST. Waiting for /health proves
the request pipeline is live, eliminating the SocketException
that hit the first test of every test class run after
AuthEndpoints.
Symptoms fixed:
- NostrNestsAuthFailureInteropTest.missing_authorization_header_is_rejected_401
-> SocketException
- NostrNestsRoundTripInteropTest.production_speaker_broadcasts_to_production_listener_via_real_relay
-> "Failed to reach http://127.0.0.1:8090/auth"
- NostrNestsMultiPeerInteropTest.* (3 tests, same root cause)
Each interop test class was running its own NostrNestsHarness.start()
in @BeforeClass and harnessOrNull?.close() in @AfterClass — meaning
the Docker stack tore down + spun back up between every class. That
sequence was both slow (~30 s Cargo build for moq-relay each time)
and unreliable: leftover network state from the prior `down -v` was
racing the next `up -d`, leaving moq-auth either unreachable
(SocketException) or producing truncated 401 responses (EOFException
on body.string()) for tests that ran after the first.
Symptoms before this fix (first class wins; everything else fails):
- NostrNestsAuthEndpointsInteropTest ✅
- NostrNestsAuthInteropTest → SocketException ❌
- NostrNestsAuthFailureInteropTest → EOFException ❌
- NostrNestsRoundTripInteropTest → "Failed to reach" ❌
- NostrNestsMultiPeerInteropTest → "Failed to reach" ❌
Fix: NostrNestsHarness.shared() returns a process-singleton. First
caller does the docker compose up + port-probe; every subsequent
caller reuses the same containers. Teardown is registered once via
Runtime.addShutdownHook so the stack lives for the JVM's lifetime
and dies cleanly at test process exit.
All five test classes now call shared() in @BeforeClass; the
@AfterClass blocks no longer call close() on the singleton (clearing
the local reference is enough — the shutdown hook handles the real
teardown). The original start() entry point is preserved for callers
that want a per-call harness.
Existing plan docs were written before the moq-lite swap, the
create-space + kind-10112 work, and the harness / submodule findings.
This refresh aligns them with what's actually live on the branch and
captures the work still ahead.
- 2026-04-26-audio-rooms-completion.md — flipped to a STATUS-FIRST
layout: implementation table for every protocol/transport/UI
surface, "pending" table for the remaining items (reconnect,
level meters, Desktop / iOS, Nests parity), pointers section
refreshed.
- 2026-04-26-moq-lite-gap.md — marked DONE with the commit range
that landed it (fb47a4c → 71cf99d → 015b0d7); "When picking up"
section now points at the shipped surface first, raw protocol
references second.
- 2026-04-22-nip-audio-rooms-draft.md — major surgery to match
today's nostrnests reality:
* status banner up top calling out the revision
* dependencies dropped IETF MoQ-transport, added moq-lite
Lite-03 + ALPN "moq-lite-03"
* HTTP control plane: GET <service>/<room-d-tag> → POST /auth
with {namespace, publish}, returning {token}; documented the
JWT claim shape (root, get, put), 600 s lifetime, regex
on `namespace`, JWKS endpoint, error matrix
* Audio transport: replaced IETF SETUP / TrackNamespace tuples
/ OBJECT_DATAGRAM with moq-lite Lite-03 (ControlType varint,
per-bidi message types, group uni streams, audio/data track,
no in-band SETUP, FIN-as-unsubscribe semantics)
* New event-kind sections: kind 4312 (admin command / kick),
kind 10112 (audio-room server list)
* Reconciliation section explaining what changed from the
original draft and why
- NEW: 2026-04-26-nostrnests-integration-audit.md — punchlist of
every nostrnests/NestsUI feature we don't yet ship, sourced from
a code-walk of the React app + moq-auth + API.md (which is
LiveKit-era and dead). Tier 1 (low-effort, visible): chat,
reactions, role parsing + promotion, hand-raise queue, kick
(kind 4312), edit/close room, scheduled rooms, listener counter.
Tier 2: participant grid, augmented presence tags
(publishing/onstage), per-avatar context menu + zap, share via
naddr. Tier 3: room theming. Tier 4: token-refresh +
Connection.Reload sanity checks.
Verified `:nestsClient:jvmTest` + `:amethyst:compilePlayDebugKotlin`
both still green after the doc changes (no code touched).
nostrnests's reference README under "Nostr Integration" already declares:
kind:10112 — User-published audio server lists
We initially picked 10062 (mirroring BlossomServersEvent's 10063) without
spotting that prior claim. Switching to 10112 so a single replaceable
event surfaces in both Amethyst and nostrnests's web UI without
collision.
No migration cost — no users have published kind 10062 yet.
Neither AccountInfoAndListsFromKeyKinds2 nor BasicAccountInfoKinds2 included
InterestSetEvent.KIND, so a fresh login on a new device wouldn't pull the
user's existing interest sets — they only showed up if the device already
had them in cache or the user re-created them locally. The spinner's
INTEREST_SETS group would silently be empty.
Also bump the AccountInfoAndListsFromKeyKinds2 limit from 20 to 80 so the
combined list of NIP-51 lists (10 kinds, now 11) actually fits.
The previous --recurse-submodules fix turned out to be moot:
nostrnests's docker-compose-moq.yml references `./moq` as a build
context, but the moq directory is NOT in the nostrnests repo and
is NOT a submodule — each developer is expected to clone
kixelated/moq into ./moq themselves before running compose.
Confirmed against the upstream README + repo listing
(nostrnests/nests has moq-auth/, nests-relay/, NestsUI-v2/ but no
moq/ directory).
Two new harness steps before `docker compose up -d`:
- ensureMoqSource — clone https://github.com/kixelated/moq.git
into <nests-cache>/moq on first run; fetch + checkout
DEFAULT_MOQ_REVISION on every run so the build is reproducible.
Override the pin via -DnestsInteropMoqRev=<sha-or-branch>.
- ensureDevCerts — run dev-config/generate-certs.sh to produce
the self-signed TLS chain moq-relay mounts read-only at
/certs. The script is idempotent (bails when fullchain.pem
exists) so we always invoke it; a chmod +x defensively
handles Windows / odd CI checkouts.
Reverted the spurious --recurse-submodules + submodule update path
since the repo doesn't have submodules at all — the original `git
clone` was correct, just incomplete.
The harness was running plain `git clone`, but
docker-compose-moq.yml references `./moq` (kixelated/moq-rs) and
`./moq-auth` as build contexts via git submodules. Without
--recurse-submodules the directories don't exist and `docker
compose up -d` fails with:
unable to prepare context: path '<cache>/nests/moq' not found
Fix: clone with --recurse-submodules on first run, and sync
submodules after every fetch+checkout so the working tree
matches whatever revision pin the checked-out commit declares.
Two adjacent additions so users can both create and host their own
audio rooms from inside Amethyst:
Create-space flow
- CreateAudioRoomSheet — modal bottom sheet on AudioRoomsScreen
surfaced by a new "Start space" FAB. Fields: room name, summary,
MoQ service URL, MoQ relay endpoint, optional cover image.
- CreateAudioRoomViewModel — builds + signs MeetingSpaceEvent
(kind 30312, status=OPEN, tagging the user as `host`),
broadcasts via account.signAndComputeBroadcast, then returns
launch info so the sheet can fire AudioRoomActivity straight
into the freshly-published room.
- Defaults pull the first saved Nests server (below) when present;
fall back to https://moq.nostrnests.com.
Nests servers settings (proposed kind 10062)
- NestsServersEvent — replaceable kind-10062 event listing the
user's preferred audio-room MoQ servers. Wire shape mirrors
BlossomServersEvent (one `server` tag per base URL); registered
in EventFactory; consumed by LocalCache via consumeBaseReplaceable.
- NestsServerListState — per-account observation state, mirror of
BlossomServerListState, exposed on Account.nestsServers.
- sendNestsServersList on Account; included in
accountSettingsEvents() so an outbox change republishes it.
- Filter additions: BasicAccountInfo + AccountInfoAndLists now
request kind 10062 from relays.
- NestsServersViewModel + NestsServersScreen — Settings UI to
add / remove / reset to recommended servers (currently just
nostrnests.com). Wired into AllSettingsScreen as "Audio-room
servers"; routed via Route.EditNestsServers.
- kind_nests_servers label for RelayInformationScreen.
Default suggestion list lives in DEFAULT_NESTS_SERVERS at the top
of NestsServersScreen — add new community-run moq-rs deployments
there as they come online.
The dialog used to hand the caller an integer index into the latest options
list, but the indexes were captured from a snapshot taken at remember time.
If options changed (a new community/list arrived) between dialog open and
tap, the user could pick "Community A" and have an unrelated entry selected.
Pass the resolved FeedDefinition directly so the picked item can never drift.
Other audit fixes folded into the same composable:
- Match the placeholder by both subclass and code string so TopFilter
variants that share an Address-derived code (PeopleList vs MuteList)
no longer collide.
- Drop the local mutableStateOf for `selected` and the derivedStateOf-in-
remember for `currentText` — both were redundant with the StateFlow
round-trip and caused an extra recomposition per pick.
- De-duplicate RenderOption with Name.name(context) (also fixes the
accessibility text disagreeing with the visible label for Geohash).
- Pre-compute the ordered (group, items) list once per options change.
- Drop IndexedFeedDefinition (no longer needed), use Spacer.width instead
of a Spacer with start padding.
Without this, `-DnestsInterop=true` on the Gradle command line was
only set on the Gradle JVM, not on the test executor JVM that
NostrNestsHarness.isEnabled() reads via System.getProperty. Result:
every interop test silently skipped no matter how the run was
invoked.
Also forwards `nestsInteropRev` (used by the harness to pin the
nostrnests revision in the cache).
Verified with `./gradlew :nestsClient:jvmTest --tests
NostrNestsAuthInteropTest -DnestsInterop=true` that the harness
now actually runs (the run only fails inside the test because the
sandbox blocks outbound traffic to GHCR / Docker Hub — both
registries return 503 at the auth-token endpoint, so we can't
pull strfry / build moq images here. On a network-enabled host
the harness will bring up the stack normally).