Commit Graph

12244 Commits

Author SHA1 Message Date
Claude 5c40e27fde docs(nestsClient/specs): EGG-00..EGG-12 — Extensible Gossip Guidelines
Wire-protocol specs for nostrnests-style audio rooms, in the
style of Nostr NIPs and Blossom BUDs. Each spec documents one
self-contained capability that a client or relay can implement;
two compliant peers implementing the same set of EGGs round-trip
without further coordination.

Layout:

  README.md            cover, status table, conformance levels
  EGG-01.md  Room event (kind:30312)              required
  EGG-02.md  Auth + WebTransport handshake        required
  EGG-03.md  Audio plane (moq-lite)               required
  EGG-04.md  Presence (kind:10312)                required
  EGG-05.md  In-room chat (kind:1311)             optional
  EGG-06.md  Reactions (kind:7)                   optional
  EGG-07.md  Roles & moderation (kind:4312)       optional
  EGG-08.md  Scheduling (status=planned)          optional
  EGG-09.md  User server list (kind:10112)        optional
  EGG-10.md  Theming (c/f/bg tags)                decorative
  EGG-11.md  Recording                            decorative
  EGG-12.md  Catalog track (catalog.json)         optional

Conformance levels (Listener / Speaker / Host) defined in the
README so a deployment can declare "we implement EGG-01..EGG-04"
and other peers know exactly what to expect.

Each spec follows the same shape (Summary / Wire format /
Behavior numbered MUST/SHOULD/MAY rules / Example /
Compatibility) and fits on a single printed page. Wire formats
are documented exactly as nostrnests + amethyst implement them
on this branch — no hypothetical capabilities, no "future" tags
without an EGG number.
2026-04-27 03:54:45 +00:00
Claude 538525f003 feat(audio-rooms): home live-bubble surfaces follows broadcasting in a room
Companion path to the chat-driven inclusion landed in the
previous commit. The home filter now ALSO pulls a kind-30312
audio room into the live-bubble row when a follow is actively
broadcasting in it (kind-10312 presence with `publishing=1`).

Wire-up:

  * LocalCache gains a dedicated consume(MeetingRoomPresenceEvent)
    that, in addition to the addressable storage, attaches the
    version note to the room's LiveActivitiesChannel keyed by
    the kind-30312 address from the `["a", ...]` tag. The same
    fan-out kind-1311 chat already gets, applied to presence.
    Without this, channel.notes never sees presence events and
    the bubble can't pick them up via the chat-channel pump.

  * HomeLiveFilter.acceptableChatEvent extends to recognize
    MeetingRoomPresenceEvent. We only accept publishing=true
    presences from a follow targeting an OPEN/PRIVATE room —
    hand-raise / mute / pure-listener heartbeats are noise that
    would flood the bubble with everyone in the room every 30 s.
    Status check shares the new isMeetingSpaceLive() helper with
    the chat-driven path.

  * HomeLiveFilter.updateListWith resolves the room channel for
    incoming MeetingRoomPresenceEvents via the same
    `interactiveRoom().address` pointer.

End user effect: a follow opening their mic in an audio room is
now indistinguishable from them chatting in it — both pull the
room into the live-bubble row, both surface the room name + tap
straight into AudioRoomActivity. Bubble disappears within 15 min
of the last qualifying event (chat or presence) per the existing
window.
2026-04-27 03:37:19 +00:00
Claude 44533b2187 feat(audio-rooms): surface in home live-bubble row
When a follow chats in a kind-30312 audio room, the room now
appears in the live-bubble row at the top of home — same place
streaming kind-30311 + ephemeral chats already show up.

The plumbing already exists below the surface:
LocalCache.consume(LiveActivitiesChatMessageEvent) calls
getOrCreateLiveChannel(activityAddress) regardless of whether
the address is a kind-30311 or kind-30312, so liveChatChannels
already grows entries for audio rooms whenever a follow chats
in one. The home filter just rejected them at the
acceptableChatEvent guard because it required `info.status() ==
LIVE` — and `info` is hard-typed to LiveActivitiesEvent so it's
null for audio rooms.

Three changes:

  * HomeLiveFilter.acceptableChatEvent — branch on the chat's
    activityAddress.kind. For kind-30311 keep the existing
    info.status() == LIVE path; for kind-30312 read the
    addressable's MeetingSpaceEvent and accept OPEN/PRIVATE.
    "Closed" rooms drop out of the bubble even if a follow is
    chatting in the (now-archived) chat.

  * RenderLiveActivityBubble — when channel.address.kind ==
    30312, pull room title from the addressable so the bubble
    label reads "Lounge" instead of "naddr1abc…", and tap-launch
    AudioRoomActivity directly (one-tap into the room) instead
    of routing to ChannelView. Falls back to the channel route
    if the address is malformed.

  * LiveStatusIndicator.checkChannelIsOnline — the red live-dot
    surfaces for kind-30312 channels whenever their addressable
    is OPEN/PRIVATE, mirroring what status==LIVE means for
    streaming.

Audio rooms with no chat yet still don't surface (would require
populating channel.info, which would mean widening the channel
model — deferred to a later cycle per the architectural
discussion). The chat-driven case covers nostrnests' typical UX:
a host opens a room, audience members start chatting, the bubble
pulls in their followers.
2026-04-27 03:28:01 +00:00
Claude e3bf46377b fix(audio-rooms): unwrap hiddenUsers State for the feed key (audit walk #9)
Pre-existing bug surfaced during the user-walkthrough audit:
WatchAccountForAudioRoomsScreen used `val hiddenUsers =
hiddenUsers.flow.collectAsStateWithLifecycle()` (no `by`), so the
LaunchedEffect captured a State<T> object as a key rather than
the unwrapped value. State equality is reference-based and the
remembered State instance is stable across recompositions, so the
effect never re-fired when the user muted someone — the rooms
feed showed stale results until a manual refresh.

Switching to `val hiddenUsers by ...` unwraps the value so
LaunchedEffect re-keys when the hidden-users set changes.
2026-04-27 02:53:53 +00:00
Claude e041c77832 fix(audio-rooms): UX rough edges from the screen walk (audit walk #5-8)
Four user-visible quirks the walkthrough surfaced:

  5. EditAudioRoomSheet's "Close room" button was one tap with no
     confirm. A misclick destroyed the room. Added an AlertDialog
     gate ("All attendees will be disconnected. The room will
     show as CLOSED in the feed.") with a destructive primary
     action and a Cancel.

  6. Silent ActivityNotFoundException in ParticipantHostActionsSheet
     (View Profile) and MeetingSpace.kt (Listen to Recording).
     Both were `runCatching { startActivity(...) }` with no toast
     on failure — user taps, nothing happens, no feedback. Now
     they toast "No app installed to open this link." through
     the standard toastManager.

  7. ScheduleStartPicker dismiss didn't revert in-dialog state.
     User picks Dec 13, taps Cancel, reopens the dialog → still
     pre-selected to Dec 13 (the cancelled choice) instead of the
     committed value. Added `resetPickersToCommitted()` that
     rewinds both picker states to the committed unixSeconds on
     every dismiss / cancel path.

  8. CreateAudioRoomViewModel.publishAndBuildLaunchInfo accepted
     past start times. Added a `scheduledStartUnix < now` guard so
     the host gets "Pick a future start time." instead of publishing
     a kind-30312 with `status=planned` + a backdated `starts` tag.
2026-04-27 02:52:55 +00:00
Claude 9af95103e8 fix(audio-rooms): chat send / scroll / display name (audit walk #3, #4)
Three rough edges in the in-room chat panel:

  3. Send swallowed broadcast failures and cleared the draft
     unconditionally. An offline user typed, tapped Send, watched
     the text vanish, and never saw a toast — the message landed
     nowhere. Now the draft clears ONLY on success; failure toasts
     `audio_room_chat_send_failed_title` with the exception
     message and keeps the text in the field for retry. Composer
     also disables itself for the brief in-flight window so a
     double-tap can't fire two sends.

  4. Auto-scroll forced to bottom on every new message, even
     while the user was reading older messages. Switched to the
     standard "only scroll if pinned near the bottom" pattern via
     a derivedStateOf gate over `listState.firstVisibleItemIndex`.

  + Replaced the v1 placeholder (truncated 8-hex of the pubkey)
    with the canonical Amethyst chat author-name pattern:
    LoadUser kicks the metadata fetch, UsernameDisplay observes
    the kind-0 flow, and the result falls back to a truncated
    npub while the metadata is in flight or missing. Same code
    path RenderChatClip / RenderChatRaid use.
2026-04-27 02:49:36 +00:00
Claude e79f78fc28 fix(audio-rooms): keep state alive across Reconnecting blips (audit walk #1, #2)
Two reliability bugs the user-walkthrough audit caught:

  1. Foreground service flickered on every transport blip. The
     LaunchedEffect keyed on `isConnected = ui.connection is
     Connected`, so a Reconnecting state stopped the service
     mid-blip and restarted it on recovery. Beyond the audible
     dropout from losing the wake-lock, Android 14+ doesn't
     reliably let us re-promote to FOREGROUND_SERVICE_TYPE_MICROPHONE
     after losing it — risking a permanent broadcast-side regression
     after a single relay hiccup. Treat Reconnecting as "still
     live" for the service decision.

  2. TalkRow disappeared mid-broadcast on transient listener
     disconnect. The speaker session is INDEPENDENT of the
     listener — the user could be broadcasting cleanly while the
     listener is Reconnecting, but our `if (ui.connection !is
     Connected) return` early-exited the entire row, so the user
     could neither mute their mic nor stop their broadcast until
     the listener recovered. Switch the gate to "user can act on
     broadcast state" — Connected listener OR an in-flight
     broadcast handle (Connecting/Broadcasting).

Same logic applied to the PIP-entry gate so onUserLeaveHint
during a transient blip doesn't lock the user out of PIP.
2026-04-27 02:47:02 +00:00
Claude 54584c213f perf(audio-rooms): hoist ParticipantsGrid per-cell allocations (audit #9)
Each cell of the participant grid was allocating fresh Modifier
chains and lambdas on every recompose. With a 50-speaker room
and the grid recomposing on every connectingSpeakers /
speakingNow / reactions flip, the per-frame allocation count
adds up.

Hoist the constants:
  * gridModifier (fillMaxWidth + height + padding)
  * cellWidthModifier (avatarSize + 16.dp)
  * absentAlphaModifier (alpha 0.5f)
  * speakingBorderModifier (border + CircleShape)
  * spinnerModifier (size avatarSize - 8.dp)

Replace the `.let { ... }.let { ... }` Modifier chain on the
avatar with a `when` over the four (isSpeaking × absent) cases —
each case picks a pre-built Modifier rather than synthesising a
fresh chain.

Cache the per-pubkey long-click adapter via remember(pubkey,
onLongPressParticipant) so the `{ hex -> cb(hex) }` wrapper
isn't re-allocated on every recompose.
2026-04-27 02:30:13 +00:00
Claude 43c5313674 chore(audio-rooms): align catalog/announces error timing + comment (audit #5-7)
* announces() now throws UnsupportedOperationException at CALL
    time (not the first collect) on the IETF default — matches
    subscribeCatalog's timing so both can be guarded with one
    `runCatching { listener.announces() }` per session rather than
    a runCatching around every collect site (audit #6).

  * KDoc on announces() documents the deliberate hot-vs-cold
    asymmetry with subscribeSpeaker/subscribeCatalog: announce
    data is room-state with a single VM consumer (cold flow is
    sufficient), audio is per-frame playout with multi-collect
    resilience needs (hot SharedFlow with DROP_OLDEST). The
    different shapes are intentional, not accidental (audit #5).

  * MoqLiteNestsListener.wrapSubscription's "IETF SubscribeHandle
    path conventionally surfaces" comment was scoped to the audio
    track when first written; now the same body serves both audio
    and catalog. Re-frame so the comment applies to either track
    (audit #7).

Test: NestsListenerCatalogTest's announces-throws case now
asserts the call itself throws, not the collect.
2026-04-27 02:28:24 +00:00
Claude e4fe23a1e5 fix(audio-rooms): DROP_OLDEST on the SubscribeHandle pump (audit #8)
The re-issuing pump's MutableSharedFlow used the default
onBufferOverflow = SUSPEND. A stalled consumer (decoder, player,
or anything downstream) back-pressured the pump → the inner
handle.objects.collect → the underlying transport → the relay.
Effect: the room caches up to 64 frames (~1.3s) on the consumer
side, then the relay slows down sending us audio.

For Opus audio the desired behavior is the inverse: drop OLD
frames so playback stays live after a UI hiccup or a foreground
transition. Switch to BufferOverflow.DROP_OLDEST so the relay
keeps streaming at full rate and the consumer's audio stays
synchronized with the speaker, at the cost of dropped frames
during stalls (which would have been late anyway).
2026-04-27 02:26:30 +00:00
Claude c75c7c5599 fix(audio-rooms): VM catalog/buffering lifecycle leaks (audit #1-4)
Four related state-leak bugs surfaced by the new-interface audit:

  1. fetchSpeakerCatalog launched a fire-and-forget coroutine that
     wasn't tracked per-pubkey. With the wrapper's re-issuing
     handle the catalog subscription survives session swaps —
     and a removed speaker's collector kept re-adding the
     catalog map entry on every emission. Fix: per-pubkey job map
     (catalogJobs); cancel on closeSubscription before dropping
     the map entry.

  2. teardown() cleared activeSpeakers / speakingNow / announces
     but not _speakerCatalogs. Stale catalog data accreted
     across reconnects + room swaps. Fix: cancel every catalog
     job and reset the map alongside _announcedSpeakers.

  3. teardown()'s _uiState.copy(...) reset activeSpeakers and
     speakingNow but not connectingSpeakers. The pre-roll spinner
     could persist on stale pubkeys after a disconnect. Fix:
     include connectingSpeakers in the same copy.

  4. fetchSpeakerCatalog ran BEFORE the abandoned-subscription
     re-check in openSubscription, so a removed speaker's catalog
     subscription opened anyway. Fix: move the catalog kick-off
     to after the re-check + after slot.attach (the catalog needs
     a confirmed-attached audio slot to be cancellable via
     closeSubscription).
2026-04-27 02:24:22 +00:00
Claude 062944de83 feat(audio-rooms): announce-driven speaker discovery (T4 #17b)
Surface moq-lite's ANNOUNCE flow on NestsListener so the audio
room can render an authoritative "actively broadcasting"
indicator independent of kind-10312 presence's `publishing`
flag. Same channel nostrnests' web client uses for its live
badges (`useRoomAnnouncements` in the JS reference).

Wire-up:
  * RoomAnnouncement(pubkey, active) data class — one update
    per publisher transition (Active → broadcast came up,
    inactive → broadcast went down).
  * NestsListener.announces(): Flow<RoomAnnouncement> with a
    default body that throws UnsupportedOperationException on
    the IETF reference path.
  * MoqLiteNestsListener implements via session.announce("")
    against the room's namespace; the suffix carries the
    speaker pubkey hex straight through.
  * ReconnectingNestsListener routes via collectLatest so the
    consumer-facing flow restarts against each new session
    after a refresh / reconnect (no SubscribeHandle re-issuance
    pump needed — announces is a cold per-collect stream).
  * AudioRoomViewModel.announcedSpeakers: StateFlow<Set<String>>
    populated by observeAnnounces. Active emissions add the
    pubkey, inactive emissions remove it. Cleared on teardown.
    IETF listeners leave the set empty; UI falls back to
    presence's publishingNow flag.

Tests:
  * NestsListenerCatalogTest — adds the announces() default-
    throws-on-collect case.

Closes the audit's Tier-4 #17b gap. UI integration (e.g. a green
"live" dot driven by announcedSpeakers) is a follow-up — the data
flow is in place and downstream consumers can opt in.
2026-04-27 02:03:40 +00:00
Claude efcd32f987 feat(audio-rooms): proactive JWT refresh in the reconnecting wrapper (T4 #16)
moq-auth issues bearer tokens with a 600 s lifetime. When a token
expires the relay tears down the WebTransport session and the
wrapper recovers via the regular Failed → Reconnecting → Connected
path with a brief audible dropout (smoothed by the SubscribeHandle
buffer pump but still user-visible as a Reconnecting chip).

Add `tokenRefreshAfterMs` to connectReconnectingNestsListener
(default 540 s — 1 min before the relay's 600 s expiry). The
orchestrator now waits for either a terminal listener state OR
the refresh deadline; whichever fires first wins. On refresh:

  * close the still-healthy listener,
  * skip the reconnect-schedule path (refresh is a planned
    cutover, not a backoff event),
  * loop straight to openOnce() which mints a fresh JWT and
    establishes a new session.

The SubscribeHandle re-issuance pump cuts subs over to the new
session, and the wrapper's outward state never enters Reconnecting
during the cutover — the user-facing UI stays Connected end-to-end.
Setting tokenRefreshAfterMs <= 0 disables the behavior.

Tests:
  * ReconnectingNestsListenerTest gains
    `proactive_token_refresh_recycles_listener_without_failure_state`
    — drives the refresh path with a 50 ms window, captures every
    state emission, asserts neither Reconnecting nor Failed appear
    during a clean recycle.
2026-04-27 01:57:19 +00:00
Claude c3ff82913b feat(audio-rooms): pre-roll buffering overlay on speaker avatars
Tracks the window between SUBSCRIBE_OK and the first decoded audio
frame (typically 0.5-2s on a fresh subscription) so the user can
see audio is on its way rather than wonder why a "live" speaker
is silent.

Wire-up:
  * AudioRoomUiState gains `connectingSpeakers: ImmutableSet<String>`,
    a set-once-per-subscription field.
  * VM enters the set on `slot.attach(...)` (right after SUBSCRIBE_OK
    succeeds), exits on the first `onSpeakerActivity` callback (first
    decoded packet), and clears on speaker removal. Set membership
    survives subsequent silence — once a speaker has delivered a
    frame, we know the pipeline works.
  * ParticipantsGrid renders a small CircularProgressIndicator
    overlaid on the avatar (sized smaller than the picture so the
    user stays recognisable underneath).

Audience-side avatars don't get the overlay — they have no MoQ
subscription. Only on-stage speakers in the connectingSpeakers set
show it.
2026-04-27 01:39:30 +00:00
Claude d9fe3b5f83 feat(audio-rooms): consume moq-lite catalog metadata in VM
The catalog subscription API shipped two commits ago but nothing
read it. This wires it up:

  * RoomSpeakerCatalog data class (commons) — kotlinx.serialization
    parser for moq-lite's `catalog.json` payload (version, audio
    track list with codec / sample_rate / channel_count / bitrate).
    Forward-compat: ignoreUnknownKeys + nullable fields tolerate
    new keys and partial publishers.
  * AudioRoomViewModel.speakerCatalogs: StateFlow<Map<String,
    RoomSpeakerCatalog>> populated lazily as per-speaker
    subscriptions land. Catalog fetch piggybacks on openSubscription
    via subscribeCatalog — best-effort, doesn't gate audio.
  * Participant context sheet renders a single-line summary
    ("OPUS · 48kHz · 2ch") below the pubkey when the catalog is
    available.
  * Enabled kotlinx.serialization plugin on :commons (was already
    a transitive dep, just not wired for @Serializable codegen).

Tests:
  * RoomSpeakerCatalogTest — 7 cases covering the canonical shape,
    describe() formatting (codec uppercased, kHz / channel
    short-formed), all-null fallback, unknown-key tolerance,
    garbage-bytes fallback, and empty-audio-list.
2026-04-27 01:35:18 +00:00
Claude 3fb9f02b6d feat(audio-rooms): "Listen to recording" CTA for closed rooms
Wire MeetingSpaceEvent's existing RecordingTag into the
note-view renderer. Closed rooms (status=CLOSED) that ship a
`["recording", url]` tag get an OutlinedButton that hands the
URL to the system media player via ACTION_VIEW — most users have
a podcast / audio app registered for HTTPS audio URLs.

Live and scheduled rooms keep the Join button. Closed rooms
without a recording render no CTA — there's nothing to enter.

Adds:
  * MeetingSpaceEvent.recording() accessor
  * TagArrayBuilder<MeetingSpaceEvent>.recording(url) DSL builder
  * ListenToRecordingButton composable in the note renderer

Note: nostrnests' moq-lite refactor dropped the LiveKit-era
recording HTTP surface, but the kind-30312 `recording` tag is
still spec-allowed and individual hosts can populate it via
out-of-band capture. This commit makes Amethyst the receiving
end for any host who does.
2026-04-27 01:29:16 +00:00
Claude 2ec19fe961 docs(audio-rooms): catalog KDoc reflects the now-shipped subscribe path
The MoqLiteNestsListener KDoc still pointed to "we'll add a
parallel subscribe in a follow-up". The follow-up shipped in
the previous commit; refresh the doc to describe the actual
behavior so anyone reading the listener doesn't think catalog
support is still missing.
2026-04-27 01:21:29 +00:00
Claude e484e3b210 feat(audio-rooms): subscribeCatalog on NestsListener (moq-lite only)
Surface moq-lite's `catalog.json` track on the NestsListener API.
The catalog publishes one JSON object per group describing the
broadcast (codec, sample rate, optional speaker-side hints) and
is the canonical channel a watcher reads first to discover
available tracks.

Wire-up:
  * NestsListener.subscribeCatalog(pubkey) — interface method with
    a default body that throws UnsupportedOperationException, so
    the IETF DefaultNestsListener fails loud rather than returning
    a flow that never delivers data.
  * MoqLiteNestsListener overrides it to wrap
    `session.subscribe(broadcast = pubkey, track = "catalog.json")`
    through the same MoqObject mapping the audio path uses.
  * ReconnectingNestsListener routes both subscribeSpeaker and
    subscribeCatalog through a shared `reissuingSubscribe` helper —
    catalog handles also survive session swaps via the
    MutableSharedFlow re-issuance pump.

Tests:
  * NestsListenerCatalogTest — verifies the interface default
    rejects with UnsupportedOperationException so a caller wired
    against the IETF reference path fails fast.

VM/UI consumers (e.g. "speaker codec" tooltip) are intentionally
out of scope for this commit; this exposes the capability so a
follow-up can plug in a JSON parser + per-pubkey catalog cache.
2026-04-27 01:20:22 +00:00
Claude 308b0bf86b feat(audio-rooms): names below avatars in participant grid
Add a per-cell UsernameDisplay below each ClickableUserPicture in
the participant grid. Picks up the existing display-name + nip-05
crossfade behaviour and caches one User reference per pubkey.
Cell width is bumped to avatarSize + 16dp so longer names
ellipsize cleanly rather than spilling into the neighbour.

Closes the visible follow-up flagged in the LazyHorizontalGrid
participant-renderer commit.
2026-04-27 01:17:10 +00:00
Claude a7766d6369 feat(audio-rooms): true repeating-tile background mode
Replace the FillBounds fallback for RoomTheme.BackgroundMode.TILE
with a real ImageShader+ShaderBrush pipeline. Compose's AsyncImage
has no tile-mode option; the canonical path is to fetch the bitmap
through Coil into an ImageBitmap and wrap it as a ShaderBrush with
TileMode.Repeated on both axes, then paint a Box-sized background.

While the load is in flight (or on failure) the scope paints
nothing so the underlying material surface shows through — same
fail-open behavior as the rest of the theming layer.
2026-04-27 01:15:44 +00:00
Claude caab21dde6 feat(audio-rooms): in-feed Join button closes deep-link loop
A `nostr:naddr1...` for a kind-30312 routes through MainActivity's
URI handler to Route.Note(addressTag) and lands on the
RenderMeetingSpaceEvent renderer in the note view. Until now that
renderer only showed the room name + summary + participant list —
there was no way to actually enter the room without going through
the live-activity ChannelView lobby (which is for kind-30311).

Extract the Join button from AudioRoomJoinCard into a standalone
JoinAudioRoomButton composable, then drop it into the note-view
renderer. Hidden when status=CLOSED (you can't join a finished
room) or when the event lacks service/endpoint/d-tag. Single
helper means the lobby card and the in-feed button share the
same launch logic; future surfaces (room list, search results)
can add the same button trivially.
2026-04-27 01:13:13 +00:00
Claude 05e7e57c4b refactor(audio-rooms): VM uses connectReconnectingNestsListener (T4 #2)
Switch the production NestsListenerConnector default to wrap each
session in connectReconnectingNestsListener, then retire the VM's
own scheduleAutoRetry / autoRetryAttempts / connectInternal /
retryPending state machine.

A single retry policy now lives in the transport layer (the
wrapper's NestsReconnectPolicy) rather than racing two of them —
the VM's previous retry would tear down + recreate subscriptions
on every attempt, dropping audio. With the wrapper:

  * transport drops auto-retry with exponential backoff,
  * existing SubscribeHandle objects keep emitting via the
    MutableSharedFlow re-issuance pump (already shipped in T4 #2's
    earlier commit),
  * the existing speaker set survives through Reconnecting →
    Connected without per-attempt re-subscription.

UI side: new ConnectionUiState.Reconnecting(attempt, delayMs)
surfaces the wait. AudioRoomFullScreen shows a "Reconnecting…"
chip during the transient window — the user typically doesn't
need to act, the wrapper recovers on its own.

Existing AudioRoomViewModelTest covers connect/disconnect/teardown
unchanged; ReconnectingNestsListenerTest in :nestsClient owns the
retry-policy contract.
2026-04-27 01:09:01 +00:00
Claude 635d6f7eb3 feat(audio-rooms): SCHEDULED badge + start time in room list
Replace the placeholder OPEN-flag fallback with a dedicated
MeetingSpacePlannedFlag for kind-30312 rooms in `status=planned`.
The badge:

  * renders "SCHEDULED" on a primary-blue chip (distinct from the
    green OPEN, orange PRIVATE, and black CLOSED variants)
  * adds a localized "Starts <date+time>" subline when the event
    ships a `["starts", "<unix-seconds>"]` tag and the moment is
    still in the future
  * falls back to just "SCHEDULED" once the start time has passed
    (nostrnests pivots to "Live now" in that case; we'll match
    that once the kind-30312 typically flips to status=open at
    start-time)

Closes the visible loop on the scheduled-rooms feature for
audience members — host-side picker + emit was already in place,
this is the receiving-side render.
2026-04-27 01:02:32 +00:00
Claude 21c6bf766e test(nests-interop): reconnecting-listener round-trip + session swap
Two new opt-in interop cases drive the production
connectReconnectingNestsListener against the real nostrnests
relay (set -DnestsInterop=true to run):

  * reconnecting_wrapper_round_trips_frames_via_real_relay —
    happy-path validation that the new MutableSharedFlow-backed
    pump doesn't drop frames against real moq-lite framing.

  * reconnecting_wrapper_keeps_handle_alive_across_session_swap —
    custom connector opens two real sessions; we close the first
    mid-stream, observe the orchestrator's automatic reconnect,
    and confirm that frames published after the swap arrive on
    the SAME consumer-facing SubscribeHandle. Real-wire version
    of the in-process test added in the previous commit.
2026-04-27 00:55:58 +00:00
Claude ee09fc4014 feat(audio-rooms): URL-based font loading
Wire the deferred font-URL path of the kind-30312 `["f", family,
url]` tag. New RoomFontLoader downloads the font asset via the
account's image-channel OkHttpClient (so it picks up the same
Tor / privacy posture as image fetches), caches under
`cacheDir/audio-room-fonts/<sha256-of-url>`, and wraps the
local file as a Compose FontFamily via `Font(file = ..., ...)`.

Same URL → same cache key → no repeat downloads across launches.
Failures (404, malformed font, Tor circuit timeout) fall back to
the system-font-name mapping or platform default — themes never
fail loud.

AudioRoomThemedScope subscribes via `produceState`; while the
download is in flight the typography stays on the system-font
fallback so the room renders immediately. Once the file lands,
the URL-loaded family takes over.
2026-04-27 00:53:09 +00:00
Claude 04ac8d3157 fix(nests-reconnect): break the StateFlow collect + survive session swaps (T4 #2)
Two related bugs in connectReconnectingNestsListener:

1. Orchestrator never advanced past the first Failed. The reconnect
   loop used `listener.state.collect { ... return@collect }` to
   "exit" on a terminal state, but `return@collect` only returns
   from the lambda — a StateFlow's collect keeps running. As a
   result, after the first transport failure the orchestrator
   re-entered the lambda for every subsequent emission and never
   reached the outer `delay(delayMs)` / openOnce() that opens the
   next session. Switch to `onEach { mirror } + first { terminal }`
   so the upstream completes deterministically.

2. SubscribeHandle stopped emitting after a reconnect (the
   long-deferred Tier-4 follow-up). Reissue the subscription on
   every activeListener swap by feeding a wrapper-side
   MutableSharedFlow from a `collectLatest` pump that re-calls
   `listener.subscribeSpeaker(...)` against each fresh session.
   The buffer (64 frames ≈ 1.3 s of Opus) covers a typical
   re-handshake without dropping speech. Unsubscribing the
   logical handle cancels the pump and forwards a best-effort
   unsubscribe to the live underlying handle.

Tests: ReconnectingNestsListenerTest covers both happy-path
session swap (frames keep arriving) and the unsubscribe-before-swap
path. Uses real coroutines + Dispatchers.Default rather than
runTest because the test scheduler doesn't auto-advance the
StateFlow + delay chain reliably under UnconfinedTestDispatcher.
2026-04-27 00:47:36 +00:00
Claude f5d8548a35 feat(audio-rooms): TimePicker stitching for scheduled rooms
Chain Material3 DatePicker → TimePickerDialog in
ScheduleStartPicker, mirroring the ExpirationDatePicker pattern
used in ShortNotePostScreen. Hour + minute are picked in the
local zone and stitched into UTC unix seconds via the system
zone offset (the DatePicker hands back UTC midnight, so we add
the local hh:mm and subtract the offset).

Closes the time-of-day follow-up from the scheduled-rooms commit.
2026-04-27 00:05:07 +00:00
Claude 14863415d5 feat(audio-rooms): font-tag parser + system-font typography (T3 #1)
Closes the deferred font tag from the Tier-3 plan. Wire-up:

  * quartz: FontTag(family, optionalUrl) parser/assembler with
    blank-rejection on family + blank-URL-becomes-null normalisation.
  * MeetingSpaceEvent.font(): FontTag? accessor.
  * TagArrayBuilder.font(family, url) DSL.
  * RoomTheme gains fontFamily / fontUrl fields.
  * AudioRoomThemedScope maps `family` to a Compose FontFamily for
    the four CSS-style generic-family names ("sans-serif", "serif",
    "monospace", "cursive") — each maps to the corresponding system
    fallback. The whole Material3 typography is rebuilt with that
    family so headlines / body / labels all swap together.

URL-based font loading (RoomTheme.fontUrl) is the natural follow-up:
fetch + cache via OkHttp, then build a FontFamily from a local
file. Until then, an unknown family is silently a no-op — the room
renders in the platform default rather than crashing or fetching
on the UI thread.

Tests:
  * FontTagTest — 9 cases covering family-only, family+URL,
    blank rejection, missing family, wrong tag name, blank-URL
    normalisation, assembler shapes (with + without URL), and
    the assembler's blank-family rejection.
  * RoomThemeTest gains 3 cases for font projection (family-only /
    family+URL / empty event).
2026-04-26 23:55:49 +00:00
Claude 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.
2026-04-26 23:51:25 +00:00
Claude 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.
2026-04-26 23:47:32 +00:00
Claude 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.
2026-04-26 23:45:41 +00:00
Claude 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
2026-04-26 23:40:34 +00:00
Claude 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.
2026-04-26 23:38:09 +00:00
Claude 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.
2026-04-26 23:35:51 +00:00
Claude 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.
2026-04-26 23:29:09 +00:00
Claude 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.
2026-04-26 23:05:19 +00:00
Claude 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.
2026-04-26 23:01:51 +00:00
Claude 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.
2026-04-26 23:00:30 +00:00
Claude 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.
2026-04-26 22:57:40 +00:00
Claude 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.
2026-04-26 22:55:33 +00:00
Claude 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.
2026-04-26 22:52:38 +00:00
Claude 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
2026-04-26 22:49:47 +00:00
Claude 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)
2026-04-26 22:45:01 +00:00
Claude 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.
2026-04-26 22:41:54 +00:00
Claude 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.
2026-04-26 22:40:37 +00:00
Claude 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.
2026-04-26 22:38:39 +00:00
Claude 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.
2026-04-26 22:26:23 +00:00
Claude 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`.
2026-04-26 22:19:40 +00:00
Claude 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.
2026-04-26 22:14:37 +00:00
Claude 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.
2026-04-26 22:13:24 +00:00