The host's primary path to add an audience member to the stage was the
long-press → "Promote to Speaker" row, which is hard to discover and was
also being swallowed by ClickableUserPicture (which ignores onLongClick
when onClick is null). Wire a tap handler on audience cells so a single
tap opens the same per-participant sheet, surfacing Promote to Speaker
for hosts and View Profile / Follow / Mute for everyone else.
Two related bugs the user hit while testing against nostrnests:
1. Approving a hand-raise made the Hands tab disappear (the host's
local app saw the role grant) but the audience member did not
appear on stage anywhere else.
Cause: RoomParticipantActions.rebuild() rebuilt the kind-30312
without the original room's `relays` tag. The recently-added
broadcast fan-out path (Account.computeRelayListToBroadcast) keys
off `event.allRelayUrls()` for kind 30312, so a republished room
event with no relays tag only reaches the host's outbox — not
nostrnests's fixed five reads or the audience member subscribing
on those reads. The audience member never sees their SPEAKER tag
and stays absent from the participant list.
Fix: copy `original.relays()` into the rebuild so every republish
(approve, demote, kick → re-broadcast) keeps the relay routing
that lets the room reach its full audience.
2. Tapping "Leave Stage" as the host emptied the StageGrid
("Waiting for speakers…") but the action bar still showed the
Talk + Leave Stage cluster.
Cause: two different definitions of "on stage" were in play.
StageGrid uses ParticipantGrid (role + presence.onstage flag),
so flipping onStageNow=false drops the host out. The action bar
gated on the role-only `onStage: List<ParticipantTag>` (the
host's HOST tag never goes away), so the cluster stayed.
Fix: derive isOnStageMe from participantGrid.onStage so both
surfaces share the same "stepped off" semantics. The host who
taps Leave Stage now drops to the audience-style mute toggle
and can rejoin (kind-10312 onstage flips back to 1) without
the controls lying about their state.
https://claude.ai/code/session_016G7oP5BotPjUBgvMYrrrxh
Hand-raise toggles in Amethyst weren't appearing in nostrnests's UI.
nostrnests's NestsUI v2 routes reads against a fixed five-relay list
(NestsUI-v2/src/lib/const.ts: relay.snort.social, nos.lol, relay.damus.io,
relay.ditto.pub, relay.primal.net) — no outbox model. Its presence query
is just `kinds:[10312], "#a":[roomATag]` over those five relays.
Commit 637174ef already taught computeRelayListToBroadcast to fan a kind
30312 *room* event out to its `relays` tag. But kind 10312 presence (and
chat / reactions, etc.) is a different event type that *links* to the
room via an `a` tag. For those, broadcast was only reaching:
- the broadcaster's outbox relays
- the single firstOrNull() hint baked into the `a` tag
- the relay we happened to receive the room event from
If none of those overlap with nostrnests's fixed five reads, the
hand-raise update is silently dropped.
Extend the `linkedAddressIds` block in computeRelayListToBroadcast: when
the linked address resolves to a MeetingSpaceEvent / MeetingRoomEvent /
LiveActivitiesEvent, also add that linked event's allRelayUrls(). This
covers presence updates and any other room-scoped event that points at
a Nest via `#a`.
https://claude.ai/code/session_016G7oP5BotPjUBgvMYrrrxh
PrimaryTabRow's BadgedBox places the count badge in the top-end corner
of its anchor, which on tight tab labels lands directly on top of the
text. Lay them out as a Row instead so "Audience 3" reads cleanly,
matching the tabbar comment's "Hands · 3" intent.
Two interrelated bugs from the deployed-schema flip:
1. Rooms created in Amethyst didn't appear in nostrnests's "Live Now"
lobby. The lobby filter in NestsUI's skeleton.js requires a `title`
tag on every kind:30312 — events without one are dropped from
`live`/`planned`/`ended`. RoomNameTag still emitted the legacy
`room` name, so our rooms passed our own readers (which tolerate
both names) but were invisible to NestsUI. Flip canonical to
`title`, keep `room` as legacy on read.
2. The mute badge over the avatar disappeared shortly after toggling
mute, even though the broadcaster was still muted. Two causes:
- ParticipantsGrid gated both the muted ring and the MicStateBadge
on `member.publishing`. But per the deployed kind-10312 semantics
`publishing=0` whenever `muted=1` (NestRoomPresencePublisher
mirrors this — `publishingNow = broadcasting && !isMuted`), so
the badge that was supposed to *indicate* mute was hidden
exactly when it became relevant. Show the badge when on stage
and either publishing OR muted; gate the muted ring on `muted`
alone.
- The presence heartbeat captured `micMutedTag` / `publishingTag`
/ `onstageTag` / `handRaised` at LaunchedEffect launch and never
refreshed them. The 500 ms debounced LaunchedEffect did publish
the new mute state, but ~30 s later the heartbeat overwrote it
with the captured pre-toggle values, flipping `publishing`
back to 1 and `muted` back to 0 server-side. Wrap the dynamic
fields in `rememberUpdatedState` so the heartbeat reads the
latest snapshot on every refresh; keep the existing key set
(event/handRaised/onstage) for prompt re-emission on those
transitions.
Tests updated for the new canonical `title` emission; both jvmTest
suites pass.
The deployed nostrnests reference (NestsUI v2 + moq-auth + moq-rs)
emits a different on-the-wire schema than the previous EGG-01 / EGG-09
drafts and Quartz writers. Verified by reading the production
NestsUI bundle. The deployed schema is now canonical:
kind:30312 (room event)
- relay URL → ["streaming", url] (was ["endpoint", url])
- auth URL → ["auth", url] (was ["service", url])
- live status → ["status", "live"] (was "open")
- ended status → ["status", "ended"] (was "closed")
- room name → ["title", name] (was ["room", name])
kind:10112 (user MoQ-server list)
- 3-element entries: ["server", relay, auth]
- first-element name "relay" accepted as a synonym (legacy)
- 2-element entries: derive auth host by replacing leading "moq."
with "moq-auth.", or prepending "moq-auth." otherwise
Implementation
- ServiceUrlTag.TAG_NAME = "auth", LEGACY_TAG_NAME = "service"
- EndpointUrlTag.TAG_NAME = "streaming", LEGACY_TAG_NAME = "endpoint"
- StatusTag enum: PLANNED/LIVE/PRIVATE/ENDED with code "live"/"ended";
legacy "open"/"closed" still parsed on read
- NestsServersEvent: emit/read 3-element [server, relay, auth] tags
with the legacy-shape tolerances above; expose a NestsServer pair
- Account.nestsServers.flow now produces List<NestsServer> pairs
- NestsServersScreen: rewritten edit-field asks for both URLs,
recommended row shows the nostrnests pair, list rows show both URLs
- NestsScreen first-time setup writes the nostrnests pair, not a
single URL; gate the create FAB on both URLs being parseable
- CreateNestViewModel: drop the resolveServerPair hardcoded mapping —
the saved pair is now authoritative; defaults stay correct for an
empty kind-10112 list
Specs
- EGG-01 rewritten to canonical streaming/auth/live/ended with a
legacy-spelling table; example uses the real nostrnests pair
- EGG-02 references "auth" tag throughout; error taxonomy says "ended"
- EGG-09 rewritten to 3-element server tag with derivation fallback
- New nestsClient/specs/nip-53-proposed.md — a single self-contained
proposed update to upstream NIP-53 covering kind 30312 + kind 10112
with the deployed schema and the JWT-mint flow
All existing unit tests adjusted; quartz:jvmTest, amethyst:testPlayDebugUnitTest,
and nestsClient:jvmTest pass.
The previous defaults pointed both `service` and `endpoint` URLs at
`moq.nostrnests.com:4443`. That host only listens on UDP/QUIC for the
WebTransport relay — TCP :4443 is unreachable. OkHttp can't speak HTTP/3,
so the JWT-mint POST hangs and fails with `ConnectException`, leaving
the room screen stuck on "Reconnecting".
The real nostrnests deployment co-locates auth and relay on different
hosts (verified against the production NestsUI bundle):
relay (WebTransport, QUIC only): https://moq.nostrnests.com:4443
auth (JWT mint, regular HTTPS): https://moq-auth.nostrnests.com
Split the defaults accordingly and add a `resolveServerPair` helper that
maps a single saved kind-10112 URL onto the (service, endpoint) pair the
kind-30312 event needs. The helper recognises both the auth-host and
the legacy relay-host the prior defaults wrote, so users upgrading from
the broken version migrate transparently the next time they open the
create-room sheet. Community deployments that genuinely co-locate keep
working via the fallback.
Update the recommended-servers list and the kind-10112 documentation to
match: the stored `server` URL is the moq-auth base (the URL that ends
up in the kind-30312 `service` tag).
Add a navigation row in AllSettingsScreen to open the existing
PaymentTargetsScreen, so users can configure their NIP-A3 payment
targets (kind 10133) from the account settings menu.
The profile 'Last seen' line was rendered by feeding timeAgo() output
into the 'Last seen %1$s ago' template. timeAgo() returns a bare
absolute date (e.g. 'Jan 14' or 'Jul 27, 2024') for anything older
than a month, so the result was nonsensical:
- 'Last seen Jan 14 ago'
- 'Last seen Jul 27, 2024 ago'
Replace the call with a new lastSeenSentence() helper that always
returns a self-contained, grammatical sentence:
- 'Last seen 5 minutes ago'
- 'Last seen 2 hours ago'
- 'Last seen 3 days ago'
- 'Last seen 2 weeks ago'
- 'Last seen on Jul 27, 2024 (9 months ago)'
- 'Last seen on Jan 14, 2024 (1 year ago)'
Anything older than a week now also shows the absolute date alongside
a coarse relative duration, so users can see exactly when the activity
happened without giving up the human-readable 'X ago' framing.
Adds plural-aware duration_minutes/hours/days/weeks/months/years
resources plus last_seen_on_date / last_seen_just_now / last_seen_never
helper strings. The existing 'last_seen' string keeps its placeholder
shape so existing translations continue to work for the recent-duration
case.
Adds 124 missing translations across cs-rCZ, pt-rBR, sv-rSE, and de-rDE
covering the new Nests (NIP-53 audio-rooms) UI, AI-suggested alt-text
hints, tracked broadcasts setting, and meeting/live-stream tags.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Activity used the system default soft-input behavior (pan), so opening
the keyboard slid the whole screen up, partially hiding the composer
and putting the top of the screen out of reach. Set adjustResize on
NestActivity and add consumeWindowInsets + imePadding to the Scaffold
body so the column reflows above the IME and the textfield stays in
view.
Bring the audio-room lobby filter closer to the NostrNests reference:
- Reject service/endpoint URLs that aren't HTTPS, dropping legacy
`wss+livekit://` rooms a moq-lite client can't reach.
- Drop PLANNED rooms whose `starts` is more than 1 h in the past or
more than 30 d in the future.
- Add a single lobby-wide kind-10312 REQ (10 min, limit 500) per
active relay and use it to hide OPEN/PRIVATE rooms whose host
crashed without flipping status to CLOSED. Brand-new rooms keep
showing for the freshness window so they don't pop in late.
- Expand follow-style top filters to include rooms whose p-tagged
speakers are followed, not just the host.
The bottom action bar in NestFullScreen was rendering under Android's
gesture navigation area in edge-to-edge mode, leaving the leave / talk
/ react buttons partially obscured. Apply BottomAppBarDefaults
windowInsets to the surface so it pads above the system navigation.
The Android emulator (and a number of dual-stack networks where the
IPv6 path is broken) advertises working v6 connectivity but silently
drops outbound packets. With the JDK / RFC 6724 default of AAAA-first,
any host that publishes a stale or unreachable AAAA record — e.g.
moq.nostrnests.com, whose Linode v6 currently doesn't accept
connections — causes every HTTPS call and every QUIC handshake to
fast-fail with ConnectException, parking the Nests room screen on
"Reconnecting" indefinitely.
Plug a small Dns wrapper into both OkHttp factories that puts
Inet4Address entries ahead of v6 in the resolved list (v6 stays as
fallback for genuinely v6-only hosts), and switch UdpSocket.connect
from getByName to getAllByName + IPv4-first selection so the QUIC
audio path makes the same choice as the HTTPS auth POST.
The public nostrnests deployment serves moq-auth + moq-relay on
:4443; :443 is not the live endpoint. With the old defaults a
freshly-created room could never mint a JWT, so the room screen
parked on "Reconnecting" forever and the room never showed up in
the nostrnests UI.
Move the LIVE chip and listener count out of the header strip and into
the right side of the Stage card's title row (listeners first, then
chip). The remaining summary now hides by default and toggles when the
user taps the room title in the top bar, reclaiming vertical space for
the stage and chat.
Pulls three high-signal features from NestScreen into the PIP layout
without crowding the small floating window:
- Audio-level pulsing rings on active speakers, mirroring the
full-screen StageGrid contract. The ring width tracks live audio
level so the PIP visibly responds to who is talking, instead of
showing static borders.
- Adaptive speaker selection: when anyone is speaking, PIP shows only
the active speakers (up to 4); when exactly one person is talking
the avatar bumps to 56dp for a clear focus mode. With no one
speaking, falls back to the first 4 on-stage participants so the
PIP isn't blank during silence. Fixes the 'same 4 faces while
someone else talks off-screen' problem in big rooms.
- Self-status row (mic broadcasting / muted, hand-raised), so a
glance at the PIP answers 'am I muted?' without expanding back.
Hidden for pure audience listeners with no hand to keep clutter low.
- Reconnecting / Failed caption over a dimmed body so audio drops
are obvious — previously the avatars stayed lit even with no audio
arriving.
https://claude.ai/code/session_01BpwAxN9gs79CJuWKqf3BEB
The only way to keep audio playing while leaving the room screen was the
Home/Recents gesture (onUserLeaveHint). Back swiped the user out of the
session entirely, and there was no in-UI affordance signalling that PiP
existed. Users wanting to step away had no obvious path that preserved
audio.
- BackHandler in NestActivityContent: while connected and PIP is
supported, back minimizes to PIP. Disconnected/Connecting/Failed
fall through to the default finish() so cancelling still works.
- Minimize IconButton in the room TopAppBar (hidden when the device
doesn't advertise FEATURE_PICTURE_IN_PICTURE).
- Public NestActivity.enterPip() consolidates the connected + feature
guards so onUserLeaveHint, BackHandler, and the Minimize button all
share one entry point.
https://claude.ai/code/session_01BpwAxN9gs79CJuWKqf3BEB