Discovery's RenderLiveActivityThumb cards already render a creator
+ name + like + zap row at the bottom — but only when the channel
holds a kind:30311 LiveActivitiesEvent. Nests (kind:30312) and
Meeting Rooms (kind:30313) populate channels with the same address
shape but `LiveActivitiesChannel.info` is null for them, so the
reactions row was hidden.
ShortLiveActivityChannelHeader:
- Add an `addressableNote: Note?` parameter to the inner overload.
The outer (LiveActivitiesChannel) overload fills it from
`LocalCache.getAddressableNoteIfExists(channel.address)`, which
works for any kind that registers an addressable note.
- Move the like/zap row OUT of the `liveActivitiesEvent?.let { }`
gate so it renders for any addressable note — kind:30311,
30312, 30313 all share it now.
- Keep the LIVE / OFFLINE flag inside the LiveActivitiesEvent gate
(it pings the streaming URL — stream-specific). Extracted into
a small `LiveChannelLiveFlag` helper so the reaction row can
render independently.
MeetingRoomsFeedLoaded:
- Swap from RenderMeetingRoomEvent to RenderLiveActivityThumb so
the Meeting Rooms list visually matches the Nests list and
Discovery — same cover-image card with status flag, participant
gallery, and bottom header (creator + name + reactions).
Other call sites (LiveActivityTopBar, LiveActivitiesChannelHeader)
use the outer `baseChannel` overload and pick up the new behavior
automatically.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
popUpTo(route) { inclusive = true } only pops if `route` is already in
the stack. From Home, tapping any other bottom-nav tab left Home in the
back stack, so canPop() returned true and the back arrow appeared on a
root tab.
Pop up to the graph's start destination instead (also inclusive). This
clears Home — and any drawer/deep-push entries above it — before
navigating to the new bottom-nav root, so each bottom-nav tap leaves
exactly one entry in the stack.
https://claude.ai/code/session_01PrirRcL7g8iX7vTqqLTkBS
Nests (kind:30312) and Meetings (kind:30313) were sharing one drawer
entry / one feed; the only thing they have in common is being NIP-53
events. They have different lifecycles (audio rooms vs scheduled
meetings) and different status enums (open/private/closed/planned vs
live/planned/ended). Mixing them under the Nests label was confusing.
Split:
- MeetingRoomsFeedFilter (new) — narrows to MeetingRoomEvent (30313),
with title-must-be-non-blank gate (mirrors the EGG-01-style minimum-
fields rule the Nests feed applies). Sort by status (LIVE > PLANNED
> ENDED), then participants among follows, then `starts` / created.
- NestsFeedFilter (existing) — narrowed to MeetingSpaceEvent (30312)
only. Drops the dual-kind branches in `innerApplyFilter` and the
parallel sub-room enum in `convertStatusToOrder`. Same EGG-01 gate
(room/status/service/endpoint).
UI:
- New MeetingRoomsScreen / MeetingRoomsFeedLoaded / MeetingRoomsTopBar.
Read-only — the app doesn't compose kind-30313 events locally; tap
routes a card to the standard thread view via `routeFor`.
- Reuses the existing in-feed renderer
(`note.types.RenderMeetingRoomEvent`) for visual consistency
with the Note thread surface.
- Mounts NestsFilterAssemblerSubscription so the wire side stays a
single REQ regardless of which screen the user opens first
(`makeLiveActivitiesFilter` already covers 30311/30312/30313/1311
in one filter).
Wiring:
- Route.MeetingRooms → MeetingRoomsScreen in AppNavigation.
- NavBarItem.MEETING_ROOMS in the catalog (Groups icon, label
"Meeting Rooms"). Same isDebug gate as NESTS while the surface is
still in development.
- meetingRoomsFeed in AccountFeedContentStates (mirroring nestsFeed
for updateFeedWith / deleteFromFeed propagation).
- ScrollStateKeys.MEETING_ROOMS_SCREEN.
- New string `meeting_rooms` ("Meeting Rooms").
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
The 280-line `NestActivityBody` orchestrated VM creation, four event
collectors, two eviction tickers, kick handling, two PIP bridges, the
foreground-service lifecycle, the kind-10312 heartbeat / debounce /
final-leave publisher, AND the screen routing. Each concern was clear
on its own but lost in the wall of LaunchedEffects.
Split into three sibling files, each named for its concern. The body
shrinks to ~70 lines of named composable wiring calls — the room's
lifecycle is now legible end-to-end at a glance.
NestRoomLifecycle.kt — VM + activity bridges:
rememberNestViewModel(room, signer)
AutoConnectAndTrackSpeakers(viewModel, onStageKeys)
LeaveOnKick(viewModel, onLeave)
PipBridge(ui, pipMuteSignal, viewModel, onMuteState, onConnectedChange)
NestForegroundServiceLifecycle(ui)
NestRoomEventCollectors.kt — read-side LocalCache → VM:
NestRoomEventCollectors(viewModel, event, roomATag, localPubkey)
internally:
PresenceCollector + PresenceEvictionTicker
ChatCollector
ReactionsCollector + ReactionsEvictionTicker
AdminCommandsCollector (with the host/moderator authority gate)
NestRoomPresencePublisher.kt — write-side kind-10312:
NestPresencePublisher(account, event, ui, handRaised)
internally: heartbeat (30 s) + mute-debounce (500 ms) + onDispose
final leave on a non-cancellable scope so it survives mid-network
scope cancellation.
publishPresence helper relocates here too.
NestActivityContent.kt — now 170 lines:
- NestActivityContent (entry, parses address)
- NestActivityBody (orchestration only, ~70 lines)
- publishPresence helper moved out
- dropped the half-dozen now-unused imports
Behavior identical: every LaunchedEffect / DisposableEffect ran
before the split runs after, with the same keys, same dispatchers,
same scopes. No new coroutines, no removed.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
The room screen used to fan out four parallel relay subscriptions
through separate ComposeSubscriptionManagers — RoomChat, RoomPresence,
RoomReactions, RoomAdminCommands — each opening its own REQ per outbox
relay. They all keyed on the same dimensions (roomATag, account) and
ran in the same place (NestActivityContent), so the multi-sub
fan-out was wire waste.
Replace with a single NestRoomFilterAssembler that issues two Filters
inside one REQ per relay:
- {kinds: [1311, 10312, 7], "#a": [roomATag]}
→ chat, presence, reactions on the shared a-tag gate
- {kinds: [4312], "#a": [roomATag], "#p": [localPubkey]}
→ admin commands; #p restriction stays on a separate Filter so
it doesn't over-restrict the chat / presence / reaction kinds
Net wire change: 4 REQs per relay → 1 REQ per relay with 2 Filters,
same event coverage. Same kinds, same #a/#p semantics, same outbox
relay set.
Files removed:
RoomChatFilterAssembler.kt
RoomPresenceFilterAssembler.kt
RoomPresenceFilterAssemblerSubscription.kt
RoomReactionsFilterAssembler.kt
RoomAdminCommandsFilterAssembler.kt
Files updated:
NestActivityContent.kt — one NestRoomFilterAssemblerSubscription
call replaces four. The per-kind LocalCache.observeEvents
LaunchedEffects stay; only the wire side got collapsed.
RelaySubscriptionsCoordinator.kt — register `nestRoom` instead of
the four old per-room assemblers.
NestsFilterAssembler (the rooms-list level subscription) is unchanged
— different keying (per-user, per-follow-list), different lifecycle.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
The room content was sliding under the status bar at the top and the
gesture / nav bar at the bottom because the outer Column only had
fillMaxSize + 16dp content padding. With Amethyst running edge-to-edge
the title cropped behind the status bar and the chat composer / Leave
button sat under the system nav bar.
Wrap the content Column in `windowInsetsPadding(WindowInsets.safeDrawing)`
INSIDE NestThemedScope but BEFORE the existing 16dp padding, so:
- The themed background color + optional `bg` image still paint
edge-to-edge (NestThemedScope's outer Box stays full-bleed).
- The room body — title, participants, chat, talk row, Leave —
is inset within the safe-drawing area.
PIP screen unchanged; the system manages PIP layout itself.
https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
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.