Two-part fix for kind-10312 [MeetingRoomPresenceEvent] showing up as
empty cards in the channel chat panel.
(1) Filter at the chat-feed level. ChannelFeedFilter now rejects
MeetingRoomPresenceEvent — these only land in `channel.notes` so the
home live-bubble (HomeLiveFilter) can detect a follow broadcasting
in a Nest by walking channel.notes. They aren't chat content; the
chat panel rendering them as empty rows was a leak from that
home-bubble plumbing.
The filter is in ChannelFeedFilter rather than upstream in
LocalCache.consume because removing presence from channel.notes
would silently break the home-bubble's broadcasting-now detection.
This way the channel keeps the events for non-chat consumers and
the chat just refuses to surface them.
(2) Renderer fallback. NoteCompose now dispatches kind-10312 to
RenderMeetingRoomPresence — a one-line italic status row ("@user ·
raised their hand" / "stepped on stage" / "is speaking" / "joined
as audience" / "left the nest"). After (1) this only shows up on
non-chat surfaces (search results, thread view of a quoted
presence, profile timeline, …), but renders informatively instead
of as the empty card it was before.
Five new strings under nest_presence_*.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
NestsFeedFilter rendered any kind:30312 the relay handed us, including
shells with only a `d` and `a` tag (no room name, no status, no
service / endpoint). The lobby card then drew an empty title row with
a Join button that would 410 unknown_room — confusing UX.
Add a feed-filter gate: a kind:30312 must carry all four of EGG-01
rule 2's required tags before it shows up:
- room (display name)
- status (any recognized value, including the "live" / "ended"
legacy aliases nostrnests-web emits)
- service (auth sidecar URL)
- endpoint (MoQ relay URL)
Closed rooms that DO have all four still render — they may carry a
`recording` tag (EGG-11) and the listen-back affordance is the only
path to that audio post-close.
Sub-rooms (kind:30313 MeetingRoomEvent) get a parallel gate — must
carry a non-blank title.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
NestThemedScope was overriding MaterialTheme.colorScheme but the Box
holding the room body never explicitly painted the themed background
color — only consumers that read MaterialTheme.colorScheme.background
themselves picked up the change. A room that shipped
`["c", hex, "background"]` (EGG-10) but no `bg` image therefore
fell back to the platform surface and never looked themed.
Add `.background(themed.background)` to the Box. The optional `bg`
image still paints on top (per EGG-10 rule 3 — image overlays the
color); when no image is present, the themed color tints the whole
screen, matching the lobby card's containerColor pattern from the
last commit.
PIP screen left as-is: it's a small system-managed floating window
where the platform surface color is the right call.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
Replaces the title-and-summary stub with a proper lobby card.
Render order top-to-bottom:
1. Cover image (16:9, ContentScale.Crop) when the kind:30312 ships
an `image` tag. Skipped silently when absent.
2. Title + status flag row — reuses the existing
MeetingSpaceOpenFlag / MeetingSpacePrivateFlag /
MeetingSpaceClosedFlag / MeetingSpacePlannedFlag composables, so
LIVE/PRIVATE/CLOSED/PLANNED badges visually match the in-feed
RenderMeetingSpaceEvent surface.
3. Summary, capped at 3 lines with ellipsis.
4. Host row: 36dp avatar + UsernameDisplay + "Host" label. Avatar is
tappable → profile route via the `nav` parameter.
5. Speaker row: up to 5 24dp avatars from the kind:30312 `p`-tags
(role=speaker | admin), with a "+N" overflow label. Each avatar
navigates to its profile.
6. "Join nest" button bottom-right.
Theming applies the room's color tags (EGG-10):
- `["c", hex, "background"]` → Card containerColor
- `["c", hex, "text"]` → all body text colors
- `["c", hex, "primary"]` → JoinNestButton containerColor (passed
via new optional `primaryColorOverride` parameter so the in-feed
JoinNestButton call from RenderMeetingSpaceEvent stays unchanged)
Background image (`bg` tag) is intentionally NOT applied at the lobby
card level — it's reserved for the in-room screen via NestThemedScope
(which does need the full-bleed shader-based tile mode). Painting it
on a card would compete with the cover.
API change: NestJoinCard now takes `nav: INav`. The single call site
in ChannelView already has nav in scope.
New string: nest_lobby_host_label = "Host".
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
Bottom-nav taps clear the stack with popUpTo(route) { inclusive = true },
so a back arrow on those screens has nothing to pop. Switch the back-arrow
visibility to a runtime check on INav.canPop() — true when the destination
has a previous back-stack entry (drawer or any deep push), false when it's
the root (bottom-nav entry).
Applied across:
- TopBarWithBackButton: now takes nav: INav and renders the icon only
when nav.canPop()
- UserDrawerSearchTopBar: shows back arrow when canPop, drawer opener
otherwise (covers Home, Messages, Video, Discover, Notifications,
Communities, Articles, Pictures, Shorts, PublicChats, FollowPacks,
LiveStreams, AudioRooms, Longs, Polls, Badges, Products,
BrowseEmojiSets)
- WebBookmarks, Drafts, Wallet: wrap custom navigationIcon in canPop
- ProfileHeader: floating back arrow on the banner when canPop
Brings ParticipantsGrid and TalkRow closer to the nostrnests web UI
without restructuring the screen.
ParticipantsGrid:
- HandRaiseBadge: yellow circle with PanTool glyph at the top-right
of every member with handRaised=true. Subtle vertical bounce loop
via rememberInfiniteTransition (mirrors nostrnests'
animate-bounce). Shows in BOTH on-stage and audience sections —
the audience hand-up is the queue.
- MicStateBadge: bottom-center pill on on-stage speakers who are
publishing. Color encodes:
green — speaking now (in speakingNow)
red — publishing but muted (presence.muted=1)
primary — publishing, mic open, currently silent
Skipped for audience (showMicBadge=false on the audience section)
and for on-stage members who aren't publishing.
TalkRow:
- "Leave stage" OutlinedButton added in both Idle and Broadcasting
states. Drops the user off-stage via NestViewModel.setOnStage(false)
(presence emits onstage="0" → ParticipantGrid drops them to the
audience section). The Broadcasting variant also calls
stopBroadcast() so the audio session ends with the role change.
- "Stop talking" stays for users who want to pause audio without
leaving the stage.
New string: nest_leave_stage = "Leave stage".
Colors are hardcoded (yellow-500 / green-500) to match nostrnests
exactly — they read consistently in dark and light modes against the
participant tile background. Theming hook is a follow-up.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
The previous fix sorted + joined the user pubkeys into a comma-separated
String to keep the key Bundle-storable. That brought back the
StringBuilder + char[] + String + sorted-List allocation per visible
chatroom that the typed-key rework had eliminated.
ChatroomKey.users is often a kotlinx PersistentOrderedSet, which isn't
java.io.Serializable, so we can't just hold the Set reference as-is.
Copying into a HashSet costs one HashMap allocation per key, much less
than the joined-String path, and Set equality stays order-independent so
two equivalent chatrooms still hash to the same bucket.
LazyColumn keys are persisted in a SaveableStateHolder, which on Android
must be Bundle-storable (primitives, Parcelable, or Serializable). The
recent perf rework wrapped keys in plain data classes, which Kotlin does
not auto-mark Serializable, so opening a public chat crashed with:
java.lang.IllegalArgumentException: Type of the key
PublicChannelLazyKey(channelId=...) is not supported.
Mark the sealed interface Serializable, and decompose RoomId/ChatroomKey
fields (which live in quartz commonMain and can't depend on the JVM-only
Serializable interface) into String primitives. Each variant still wraps
a stable per-chatroom identity, so reorders move rows instead of
recreating them.
Replaces the v2 ChannelFeedViewModel/ChannelNewMessageViewModel detour
(commit bdb82f0) with a chat panel that:
- Reads messages from `NestViewModel.chat` directly (single source of
truth for the room — same VM that holds presence, reactions, and
hand-raise queue),
- Renders each message via `ChatroomMessageCompose` — the exact same
per-row composable that LiveStream chat uses in
`ChatFeedLoaded` — so visual rendering (avatars, names,
timestamps, NIP-21 mentions, embedded media, link previews,
reactions count) matches the rest of Amethyst's chat surfaces,
- Uses a slim composer reusing `ThinPaddingTextField` +
`ThinSendButton` (the same components inside `EditFieldRow`),
- LazyColumn with `reverseLayout=true` and the message list reversed,
so newest is at the bottom and auto-scroll works naturally.
Sends route through `accountViewModel.account.signAndComputeBroadcast`
of `LiveActivitiesChatMessageEvent.message(text, roomATag)` — same wire
path the room subscription is already listening on.
Composer is intentionally slim for v1: text-only. Drafts, @-mention
picker, file attachments, and reply preview are pinned to
ChannelNewMessageViewModel and out of scope here. Adding any of them is
a follow-up that can either (a) port the relevant primitives onto
NestViewModel or (b) widen NestViewModel to expose those fields.
New: `BouncingIntentNav` — Activity-context INav that translates the
small set of nav requests a chat row generates (Profile / Note /
Hashtag / EventRedirect / LiveActivityChannel / Community /
PublicChatChannel) into `nostr:` URIs and bounces them as ACTION_VIEW
Intents at MainActivity. AppNavigation already routes intent.data
through `uriToRoute`, so destinations land in the main app's NavHost
without receiver-side changes. NestActivity stays running in its own
task while the user explores; back from MainActivity returns to the
room with audio uninterrupted (foreground service).
Routes that don't have a `nostr:` mapping (settings, drafts, private
chatrooms, etc.) are no-ops in the bouncing nav — those aren't
reachable from a chat-row tap anyway.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
The hand-rolled NestChatPanel — bare LazyColumn of avatar + name +
plain-text rows + an OutlinedTextField composer — is replaced by the
same `RefreshingChatroomFeedView` + `EditFieldRow` stack that
`LiveActivityChannelView` uses for kind:30311 streaming chats.
The kind-30312 address already registers a `LiveActivitiesChannel`
inside `LocalCache.liveChatChannels` (via
`LocalCache.consume(LiveActivitiesChatMessageEvent)`), so handing the
channel to `ChannelFeedViewModel.Factory` is enough — no kind-30311-
specific path needed. The kind-1311 relay subscription stays where it
is in `NestActivityContent.RoomChatFilterAssemblerSubscription`, which
populates `channel.notes` exactly the same way.
Net effect for the user: chat now renders with the rest of Amethyst's
chat features — NIP-21 mention rendering, embedded images / videos,
reply previews, draft handling, mention/file pickers, replies — instead
of plain bodies in a 220dp box.
Layout intentionally unchanged elsewhere: the chat panel keeps its
embedded place at the bottom of NestFullScreen's verticalScroll Column
(top section is good per user). The chat list gets a fixed 420dp height
because a vertically-scrollable LazyColumn child cannot share its
parent's verticalScroll.
Nav: the activity has no Compose nav graph, so an `EmptyNav()` is
passed. Visual rendering is correct; in-message tap navigation (profile
tap, embedded note open, …) is a no-op until we plumb a deep-link-
bouncing INav. Outside scope for this commit.
`NestViewModel.chat` StateFlow is now unused by the panel (kept for now
to avoid touching the VM in the same change; cleanup is a follow-up).
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
The AI-suggested alt-text chip was using androidx.compose.material.icons.*,
which the project no longer pulls in (migrated to MaterialSymbols a while
back). The file failed to compile until the dep was either re-added or
the icons migrated. Switching to MaterialSymbols.AutoAwesome / .Close.
When the thread-screen master note is a kind:30312 MeetingSpaceEvent
(Nest) or kind:30313 MeetingRoomEvent, route through
RenderMeetingSpaceEvent / RenderMeetingRoomEvent — same path
NoteCompose uses for non-master entries (NoteCompose.kt:1034-1040).
Without this, the master note in the ThreadFeedView's FullBleedNoteCompose
fell through to the generic note body and showed neither the Nest card
nor the Join CTA. Users who landed on a Nest via search, quote, or naddr
deep-link saw a blank-looking thread; tapping the in-feed card now (since
the routing fix in 98202b6) opens NestActivity directly, but the thread
view itself was the remaining gap.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
The Pause action called stopSelf(), but onDestroy() then triggered the
auto-restart broadcast (because alwaysOnNotificationService was still
enabled), so the notification reappeared seconds later. There was no way
to actually pause without toggling the setting, so the button was just
confusing.
Drops the ACTION_STOP intent, the notification action, and the
always_on_notif_stop string from all locales. Also includes incidental
spotless fixes the pre-commit hook required.
When the host taps "Leave" we now show a 3-button confirmation:
- Close room → re-publish kind:30312 with status="closed", then leave
- Just leave → leave; room stays open until the 8h staleness window
- Cancel → dismiss
Audience-side leave flow unchanged. Without this prompt, hosts who tap
Leave silently abandoned the room — listeners saw it as "live" with no
audio until the EGG-01 rule 7 staleness timeout, eight hours later.
Implementation:
- `closeMeetingSpace(accountViewModel, event)` builds a verbatim
FormState from the event and reuses
EditAudioRoomViewModel.buildEditTemplate(... STATUS.CLOSED). Bypasses
the VM's mutable state so unsaved edits in an open Edit sheet can't
leak into the close payload.
- On publish failure we still leave (the user asked to) and surface a
toast — the room will auto-close at the 8h timeout.
Process death (host backgrounded + Android-killed) is still handled by
the same 8h fallback; no extra wire changes.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
The first-party nostrnests web client (NestsUI-v2) emits kind-30312
events using NIP-53 *streaming-event* tag names rather than the
kind-30312 names defined by NIP-53 itself. Confirmed against
`nostrnests/nests/NestsUI-v2/src/components/EditRoomDialog.tsx` and
`useRoomList.ts`:
- room name: `title` (NIP-53 spec for 30312: `room`)
- MoQ relay: `streaming` (NIP-53 spec for 30312: `endpoint`)
- moq-auth: `auth` (NIP-53 spec for 30312: `service`)
- status: `live` (NIP-53 spec for 30312: `open` / `private`
/ `closed` / `planned`)
Our parsers followed the NIP-53 spec, so events from the production
nostrnests web app fell through to status=null, which `checkStatus`
then surfaced as "Ended" in the live-rooms UI. Add legacy-alias
acceptance to all four parsers — read-side only; we still emit the
canonical NIP-53 forms, matching EGG-01.
Per-tag changes:
StatusTag: "live" → OPEN, "ended" → CLOSED
RoomNameTag: "title" alias → room
EndpointUrlTag: "streaming" alias → endpoint
ServiceUrlTag: "auth" alias → service
No emit-side changes; no spec change.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
When the + FAB is tapped on Audio Rooms and the user's kind:10112
nests-server list is empty (or has no http(s) entries), show an
AlertDialog offering to add the built-in default
(CreateAudioRoomViewModel.DEFAULT_SERVICE_URL — moq.nostrnests.com)
to their list, then continue into the create-room sheet.
Wired through the existing Account.sendNestsServersList path used by
the Settings screen's NestsServersViewModel.save(), so the resulting
kind:10112 propagates to the user's outbox identically. On signer/
network failure surface a toast and keep the dialog dismissed (the
user can retry from Settings).
If the user already has a usable server, behavior is unchanged — the
FAB opens the create-room sheet directly.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
ChannelCardCompose wraps content in ClickableNote, whose default onClick
calls routeFor(note, account). For MeetingSpaceEvent (kind:30312) that
falls through routeForInner's `is AddressableEvent` branch
(RouteMaker.kt:159-161) and returns Route.Note(addressTag) — the thread
view, which is the bug.
Bypass ChannelCardCompose for the rooms feed: AudioRoomFeedCard
renders RenderLiveActivityThumb directly inside a Column.clickable that
launches AudioRoomActivity, mirroring the home live-bubble pattern in
RenderLiveActivityBubble.kt:70-95. Falls back to the thread route when
service/endpoint/d are missing, same as the bubble.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
- Cache the AICore FeatureStatus check per service instance — was
re-running an RPC on every image attach.
- Two-pass decode with inSampleSize so 12 MP camera shots become
~1024 px before we hand them to the describer (avoids 40+ MB
ARGB_8888 allocations and the GC churn that follows).
- Switch ML Kit clients to var + lazy-on-first-use so close() no
longer triggers init for clients we never invoked.
- Wrap the composable's suggestAltText call in try/finally so a
cancellation mid-inference resets the spinner state.
Adds com.google.mlkit:genai-image-description as the primary alt-text
source — Gemini Nano via AICore produces full descriptive sentences on
supported devices. When checkFeatureStatus reports anything other than
AVAILABLE (or AICore is missing), the service falls back to the legacy
play-services-mlkit-image-labeling keyword join. Both paths sit behind
the same MLKitImageLabelService.suggestAltText API; the F-Droid stub is
unchanged.
Wire ML Kit image labeling into the media-attach dialog so the alt-text
field is prefilled with a confidence-filtered, comma-separated label
list when the user picks an image and the field is still empty. A
spinner shows during labeling and a dismissible "AI-suggested, edit me"
chip lets the user revert. Play flavor uses
play-services-mlkit-image-labeling; F-Droid ships a no-op stub.
Define the host-side flow that was previously implicit. A new-room
implementer can now go from "I want to start broadcasting" to first
audio frame without reading our source.
README — new "Hosting a new room" section:
- 8-step ASCII walkthrough symmetrical to "Joining sequence".
- Covers service/endpoint selection, kind:30312 compose + publish, JWT
mint with publish:true, WT/Setup, Announce, presence, Opus stream.
- Lists ongoing host duties (add speaker, kick, edit, close, recording).
EGG-01 — two new behavior rules:
- Rule 12 "Publish-before-mint ordering": peers MUST publish the
kind:30312 to relays BEFORE requesting a JWT for it, since the auth
sidecar reads the most-recent event by (pubkey, kind, d) to validate
existence / status. 410 unknown_room → retry 1s/2s/4s. Closes the
silent footgun where a host could mint a token before their event
has propagated.
- Rule 13 "Service/endpoint selection (host-side guidance)": pre-fill
from kind:10112 first entry, fall back to client default with user
override, both MUST be https://.
EGG-07 — new "Audio publish authorisation" section:
- Spells out who gets a publish:true JWT: host (by authorship,
implicitly — no need for ["p", _, _, "speaker"] on the host's own
p-tag), explicit "speaker" role, or "admin" role. All others 403
publish_forbidden per EGG-02.
- Relay does NOT re-read kind:30312; demoting a speaker mid-session
does NOT terminate their stream — host MUST kick (kind:4312) to
end an active broadcast.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
Edits across all 13 EGGs to remove implementer guesswork. After this an
implementer can build a listener and speaker without reading our source.
README:
- Conventions section: hex casing rule (lowercase, 64 chars, no 0x),
NIP-01 foundations, `a`-tag form, created_at tie-break, JSON / time.
- Joining sequence: numbered end-to-end walkthrough from `kind:30312`
to first audio frame, referencing each EGG.
EGG-01 (room event):
- `relays` tag is one tag with multiple values (not multi-tag).
- `service` URL trailing-slash normalization.
- `private` status: gate is implementation-defined, not "render as open".
- `d`-tag charset locked to [A-Za-z0-9._-] so it interpolates safely
into the moq-auth namespace.
- Relay-discovery rule: publish to `relays` tag ∪ NIP-65 outbox.
- Tie-break on identical created_at via smallest event id.
EGG-02 (auth):
- JWT signing pinned: ES256 over P-256, JWKS at /.well-known/jwks.json,
5-minute relay cache.
- NIP-98 tags pinned: u / method / payload, base64 RFC 4648 standard
(not base64url).
- Error taxonomy: full HTTP status + `error` slug table for /auth, plus
WebTransport CONNECT 200/401/403/404 table.
EGG-03 (audio):
- Pin moq-lite Lite-03 to kixelated/moq-rs `rs/moq-lite/src/lite/`.
- One Opus packet per moq Frame (no container, no timestamp).
- Pubkey hex casing reaffirmed at the suffix / broadcast slots.
- AnnouncePlease prefix="" for speaker discovery.
- Mute = stop publishing (not silence frames, not Announce Ended).
- Mid-stream join: discard pre-skip per RFC 7845.
EGG-04 (presence):
- Heartbeat jitter ±5 s required (anti-thundering-herd).
- "0"/"1" are strings, not booleans.
- Single-room rule via replaceable-event semantics.
EGG-05 (chat): 8 KB suggested, 64 KB hard cap, 3 msg/s render rate.
EGG-06 (reactions): 30s window measured against created_at, drop-on-arrival
if already stale.
EGG-07 (moderation): replay protection — dedupe kicks by id within 120 s.
EGG-08 (scheduling): planned rooms MUST 403 at the auth sidecar.
EGG-10 (theming): bg image caps (1 MB / 4096 px soft, 8 MB / 8192 px hard).
Deferred (per review): EGG-00 (Conventions as a standalone spec), EGG-13
(capability advertisement), EGG-14 (discovery), test vectors corpus.
The "Conventions" section in README covers EGG-00's most urgent content
inline.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
11 cases driving the predicate matrix against a real TextFieldState on
device:
- mention-free text passes through
- pure delete fully covering a mention is allowed
- partial overlap (at start, at end, inside) collapses atomically
- scope-exact replace with non-empty text collapses (SwiftKey case)
- scope-broader replace passes through
- append after mention preserves it
- trailing space and trailing newline are consumed during atomic collapse
- multiple mentions: only the touched one collapses
- cheap-gate path (mention-free original) is verified
All 11 pass on Pixel 9a; gives the predicate a regression net so future
predicate-tuning doesn't reintroduce the @Vitor Pamplona bug.
Run via:
./gradlew :amethyst:connectedPlayDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=\
com.vitorpamplona.amethyst.MentionPreservingInputTransformationTest
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fix(compose): tighten @OptIn scope from @file to the object
fix(compose): allow full-cover changes through; collapse only on partial overlap
refactor(compose): hoist MENTION_REGEX, fast-path mention-free text, drop redundant scaffolding
fix(compose): also collapse mention atomically on full-range non-empty replaces
fix(compose): atomically delete the whole mention on partial-overlap edits
fix(compose): opt-in ExperimentalFoundationApi in MentionPreservingInputTransformation
Address review findings:
- Add trap for temp dir cleanup on error
- Use -Zxz for max distro compatibility (older dpkg lacks zstd)
- Use --root-owner-group for correct file ownership
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.
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.
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.
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.
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.
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.
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.
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.
* 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.