bed9a2ee2141156832b71f0026e031ca0587f4a4
12216 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bed9a2ee21 |
feat(audio-rooms): zap participant via ZapCustomDialog (T2 #3)
Add a "Send zap" row to the participant context sheet. Reuses the standard ZapCustomDialog targeting the user's kind-0 metadata addressable note (the canonical "zap a user" base note used throughout amethyst, e.g. profile screen). Single-payable invoices hand off to a wallet via payViaIntent — the same path ReactionsRow uses for in-feed zaps. Split-payee zaps (uncommon for a personal LN address but theoretically possible if the metadata has a zap-split tag) toast a "use the profile screen" message rather than wire a Compose nav host into ModalBottomSheet — the audio-room activity has no nav host of its own and routing back to MainActivity for the rare case isn't worth the plumbing. |
||
|
|
f896bfa526 |
feat(audio-rooms): View Profile via nostr: deep-link (T2 #2)
The participant context sheet's "View profile" row was previously a
TODO comment — AudioRoomActivity is a separate Android activity
without its own nav stack. Wire it through MainActivity's existing
nostr: URI handler:
* encode the target hex as npub via NPub.create(...)
* fire ACTION_VIEW on `nostr:npub1...` with explicit MainActivity
component (FLAG_ACTIVITY_REORDER_TO_FRONT brings the running
instance forward instead of spawning a duplicate)
* the audio-room foreground service keeps audio alive while the
user is on the profile screen
Reuses the existing audio_room_participant_view_profile string.
|
||
|
|
9541c99758 |
feat(audio-rooms): LazyHorizontalGrid participant renderer (T2 #1)
Replace the two LazyRow stage/audience sections with a single
ParticipantsGrid composable backed by buildParticipantGrid in
commons. The grid:
* derives on-stage from kind-30312 role + kind-10312 onstage flag
* renders absent members (promoted but never emitted presence) at
50 % alpha — matches nostrnests' grey-out for "promoted but
never joined"
* uses LazyHorizontalGrid with a 1-row Fixed cell so adding a
second row (e.g. names below avatars) is a one-line change
Audience derivation moves from AudioRoomActivityContent into the
projection function — the activity-side filter for "neither host
nor speaker" was only used by the now-removed StagePeopleRow call,
so deleting it shrinks the activity composable and keeps both
audience and absence logic in commons.
|
||
|
|
cc829f6b0b |
fix(audio-rooms): request audio focus + Tier-3 audit notes
Tier-3 background-audio audit (item #4 found the only gap): AudioTrackPlayer.start() never called requestAudioFocus, so an inbound phone call mixed on top of the room audio instead of ducking it. Fixed by acquiring focus at the SERVICE level (one owner per room session, not per player) in AudioRoomForegroundService.requestAudioFocus(): * AUDIOFOCUS_GAIN with USAGE_VOICE_COMMUNICATION + CONTENT_TYPE_SPEECH so the OS treats the room like a phone call. * setAcceptsDelayedFocusGain(false) — immediate focus or nothing. * On-change listener is a no-op; the OS handles duck-on-loss. * abandonAudioFocusRequest in onDestroy. * Best-effort acquire — runCatching swallows denials so a refused request doesn't fail service start (the OS will duck/pause us by its own policy, which is degraded but acceptable). Audit notes file under nestsClient/plans/ documents items 1-5 with commit-time citations: 1. PARTIAL_WAKE_LOCK — already correct 2. FG type microphone (14+) — already correct 3. FG type media-playback for listen-only — already correct 4. Audio focus — fixed in THIS commit 5. PIP keep-alive — already correct |
||
|
|
cdf930938d |
feat(audio-rooms): connectReconnectingNestsListener (T4 #2)
Wraps `connectNestsListener` with a transport-loss reconnect loop
that consumes [NestsReconnectPolicy] for exponential backoff.
Mirrors the JS reference's `Connection.Reload` behaviour.
connectReconnectingNestsListener(httpClient, transport, scope,
room, signer, policy = default,
connector = real)
Behaviour:
* Returned [NestsListener] forwards the underlying listener's
state while a session is alive.
* On Failed (transport / handshake error), the orchestrator
increments the attempt counter, surfaces
[NestsListenerState.Reconnecting(attempt, delayMs)] for that
delay, then opens a fresh session via the injected connector.
* On Closed (user-driven disconnect), the loop exits — Closed
is terminal.
* `policy.isExhausted(attempt+1)` halts the loop when
[NestsReconnectPolicy.maxAttempts] hits. Default policy is
Int.MAX_VALUE so a long-running room keeps trying.
Subscribe-handle preservation across a reconnect is intentionally
NOT in this commit. Caller-owned [SubscribeHandle]s bind to the
SESSION; once the session is replaced, the handle's flow stops
emitting. The KDoc spells this out so callers can either:
1. Re-subscribe inside their own
`state.collectLatest { if (Connected) sub() }` loop
2. Wait for the future MutableSharedFlow-buffered upgrade flagged
in the Tier-4 plan ("MutableSharedFlow per handle that the
per-session pump emits into").
Test seam: the `connector: suspend () -> NestsListener` parameter
defaults to the real connect call. Tests can pass a scripted fake
that returns a sequence of (Failed, Connected, Failed, Connected)
listeners and verify the orchestrator walks the policy correctly
— production path is unchanged.
|
||
|
|
f89ab8f1a2 |
feat(audio-rooms): scheduled rooms (T1 4b)
Shipped the deferred slice of Tier 1 Step 4: hosts can now
schedule a room for a future time instead of going live
immediately.
Quartz:
StatusTag.STATUS.PLANNED — new enum value alongside OPEN /
PRIVATE / CLOSED. Pre-Lite-03
clients that don't know the value
get null from the parser; the room
renderer's existing fallback handles
that. The amethyst feed renders
PLANNED with the OPEN badge in v1
(a "Scheduled — starts at HH:MM"
chip is a visual follow-up).
StartsTag — `["starts", "<unix-seconds>"]` parser + assembler.
Strict numeric — non-numeric values return null so
a malformed event can't crash the room-list
renderer. Negative values are rejected at assemble.
MeetingSpaceEvent.starts() — accessor.
TagArrayBuilderExt.starts(unixSeconds) — DSL helper.
Amethyst:
CreateAudioRoomViewModel —
onScheduledToggle(scheduled) — flips PLANNED vs OPEN.
onScheduledStartChange(unixSeconds) — picker callback.
FormState.scheduled / .scheduledStartUnix — additive fields.
canSubmit gates on a picked time when scheduled = true.
publishAndBuildLaunchInfo() — emits status=PLANNED +
`["starts", <unix>]` when
scheduled; otherwise the
existing OPEN path runs.
CreateAudioRoomSheet —
Schedule toggle (Material3 Switch) above the picker.
ScheduleStartPicker — OutlinedButton that opens a Material3
DatePickerDialog. The selected date is
saved as 00:00 of that day in the
local time zone (TimePicker stitching
is a follow-up; nostrnests' web UI
does the same date-only flow).
MeetingSpace.kt feed item — added the PLANNED branch to the
exhaustive when so the build passes.
Tests:
StartsTagTest — numeric parse, malformed-rejected, missing /
wrong-name rejection, assemble shape, negative rejected,
STATUS.PLANNED parse round-trip.
|
||
|
|
e28ff87e8e |
feat(audio-rooms): AudioRoomThemedScope renderer (T3 #1 finale)
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.
|
||
|
|
2e38aa6c77 |
feat(audio-rooms): NestsReconnectPolicy + Reconnecting state (T4 #2 foundation)
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.
|
||
|
|
440fc61104 |
feat(audio-rooms): RoomTheme projection from kind-30312 (T3 #1)
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.
|
||
|
|
885b77f1f3 |
feat(quartz): theme parser tags for kind-30312 (T3 #1)
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.
|
||
|
|
cc33bdbb92 |
feat(audio-rooms): share-room as naddr1 (T2 #4)
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.
|
||
|
|
336ca45e31 |
feat(audio-rooms): per-participant context sheet (T2 #2)
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. |
||
|
|
5cb2351cdb |
feat(audio-rooms): RoomMember + ParticipantGrid pure projection (T2 #1)
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. |
||
|
|
d1be2ae19e |
feat(audio-rooms): kick + per-participant host actions (T1 #6)
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
|
||
|
|
7f5133a08f |
feat(quartz): AdminCommandEvent for audio-room kick (kind 4312)
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) |
||
|
|
be227fc966 |
feat(audio-rooms): host hand-raise queue UI (T1 #5)
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.
|
||
|
|
6d1299c72a |
feat(audio-rooms): pure builders for promote / demote (T1 #5)
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.
|
||
|
|
9fa6f756ca |
feat(quartz): role-check helpers on ParticipantTag
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. |
||
|
|
e9aefed496 |
feat(audio-rooms): host edit + close room (T1 #6)
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.
|
||
|
|
3e3ce4ea3c |
feat(audio-rooms): kind-7 reactions wire + overlay + picker
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`. |
||
|
|
aa4c24b974 |
feat(audio-rooms): recentReactions StateFlow on AudioRoomViewModel
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. |
||
|
|
87a54bf479 |
feat(audio-rooms): RoomReaction + sliding-window aggregator
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. |
||
|
|
a5cc594416 |
feat(audio-rooms): live chat panel on the room screen
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). |
||
|
|
3a3d53a437 |
feat(audio-rooms): per-room kind-1311 chat subscription
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. |
||
|
|
25a8c460aa |
feat(audio-rooms): chat ledger on AudioRoomViewModel
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. |
||
|
|
2c41d989ac |
feat(audio-rooms): listener counter badge on the full-screen room view
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. |
||
|
|
9b4f2e394d |
feat(audio-rooms): per-room kind-10312 presence subscription
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.
|
||
|
|
052fdce655 |
feat(audio-rooms): expose presences StateFlow on AudioRoomViewModel
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 |
||
|
|
87dd8ee2ec |
feat(audio-rooms): RoomPresence + aggregator for listener-side fan-in
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. |
||
|
|
9c3cbdaee6 |
feat(audio-rooms): emit publishing + onstage tags on presence heartbeat
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)
|
||
|
|
5a5eaa3b50 |
feat(quartz): augment kind-10312 presence with publishing + onstage
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. |
||
|
|
a2f4adda0c |
test(audio-rooms): pin current speaker-close listener-flow 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. |
||
|
|
2b86d71c1d |
test(audio-rooms): one listener's unsubscribe doesn't tear down others
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. |
||
|
|
dcbe31d4c8 |
test(audio-rooms): late-joining listener doesn't replay history
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. |
||
|
|
d1b00dd346 |
test(audio-rooms): pin mute/unmute end-to-end
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. |
||
|
|
8b7ad2ebe1 |
test(audio-rooms): pin moq-lite-03 not-found contract for early subscribe
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. |
||
|
|
5a86cdd4e4 |
fix(audio-rooms): SubscribeResponse framing matches moq-lite-03
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.
|
||
|
|
da1c4d3968 |
fix(audio-rooms): advertise moq-lite-03 in WT CONNECT sub-protocols
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. |
||
|
|
d00e406587 |
test(audio-rooms): -DnestsInteropExternal bypasses Docker
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.
|
||
|
|
1a26b23448 |
style(audio-rooms): import-and-use instead of inline FQNs
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. |
||
|
|
5f90588fe4 |
test(audio-rooms): InteropDebug step logger + harness diagnostics
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.
|
||
|
|
e71a2b26ef |
docs(audio-rooms): coding plans for Tier 1-4, one file per tier
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.
|
||
|
|
74d5e77a83 |
fix(audio-rooms): retry mintToken on transport hiccup + harness /health warmup
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)
|
||
|
|
7e67c4655f |
fix(audio-rooms): share Docker harness across all interop test classes
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. |
||
|
|
8b5af5d496 |
docs(audio-rooms): refresh against shipped state + nostrnests gap audit
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 (
|
||
|
|
015b0d7dac |
fix(audio-rooms): switch NestsServersEvent to kind 10112 (nostrnests claim)
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. |
||
|
|
13e84f276d |
fix(audio-rooms): clone kixelated/moq + run generate-certs in harness
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. |
||
|
|
64859546b3 |
fix(audio-rooms): clone nostrnests with submodules so docker compose finds moq/
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. |
||
|
|
364b2cd926 |
feat(audio-rooms): start space FAB + Nests servers settings (kind 10062)
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.
|
||
|
|
76b772ab41 |
test(audio-rooms): forward -DnestsInterop to test workers
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). |