Commit Graph

625 Commits

Author SHA1 Message Date
Claude a690777ef6 feat: show external-id scope as reply context for NIP-22 comments
A Comment event (kind 1111) scoped to an external identifier (`I` tag)
previously rendered as a bare text post and landed in the Home "New
Threads" feed. Treat it as a reply to that external scope: it now shows
in the conversations feed and renders a typed chip (hashtag, geohash,
url, or generic) above the comment text.

https://claude.ai/code/session_01ArHkNXu1ANrVGZAyMWg4Xu
2026-05-14 15:05:04 +00:00
Claude f45479615e Merge remote-tracking branch 'origin/main' into claude/add-zap-button-nest-KoZG4
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt
2026-05-14 13:08:13 +00:00
Claude 524bc90d5a test(nests): cover RoomZapsAggregator; tidy zap-overlay comments
Adds RoomZapsStateTest mirroring RoomReactionsStateTest (grouping,
eviction, room-wide keying, idempotent snapshots, dedup). Also drops
the unused RoomZapsAggregator.isEmpty(), pins the zap chip's content
color to white since BitcoinOrange is theme-independent, and corrects
a few doc comments that overstated component/timer sharing.

https://claude.ai/code/session_01EW11kUdiEYPuPti7vtD2AR
2026-05-14 12:49:18 +00:00
Vitor Pamplona 0182439b55 fix NestViewModelTest for sender-grouped reaction aggregator
onReactionEventGroupsByTargetAndEvictsOnTick asserted the pre-change
targetPubkey grouping (recentReactions.value[bob]); the aggregator
now groups by sourcePubkey so the chip rises from the reactor's
avatar. Renamed to ...GroupsBySender and assert under alice's key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:45:21 -04:00
Vitor Pamplona 78be534df5 nests room: stable reactions overlay + repeatable kind-7 + speaker controls
Reaction-side rework after extended testing on 2026-05-13:

* Promoted users weren't seeing speaker controls in their own
  Amethyst — `StageControlsBar` gates on `isOnStage && ui.onStageNow`
  and `ui.onStageNow` was stuck at false (never reset back to true
  after some earlier path flipped it). Added a LaunchedEffect in
  NestActivityContent that mirrors `isOnStageMe → ui.onStageNow`
  whenever it becomes true, symmetric to the auto-stop effect.

* Reactions overlay sat inside AvatarAndBadges' wrap-content Box,
  so adding/removing the chip shifted the inner Box's centre and
  the role badges drifted. Lifted the overlay out to be a sibling
  of AvatarAndBadges inside the outer fixed-size Box; badges now
  stay anchored regardless of chip presence or animation state.

* Reaction grouping was by `targetPubkey` (NIP-25 p-tag) — emojis
  showed on the speaker being reacted to, not the reactor. Switched
  RoomReactionsAggregator to group by `sourcePubkey`. AudienceGrid
  threads `reactionsByPubkey` so an audience reactor sees their own
  emoji float from their audience-tab avatar too.

* Reaction chip animation: progress was an `Animatable.value` that
  Compose wasn't refreshing in the layout-consuming layer (visible
  bug: chip "blinked" with no movement). Replaced with a manual
  `withFrameNanos` loop writing to a `MutableFloatState` read inside
  a `graphicsLayer { … }` lambda — frame-clock animation that works.
  Each kind-7 is its own chip keyed by event-id (no more
  groupBy-content collapsing same-emoji bursts into one shared chip
  that restarts on every arrival). Chips stack at a fixed-size 30 dp
  Box with the emoji centred, so the X position is invariant under
  glyph width. Multiple concurrent chips overlap at the right-bottom
  corner in a `Box(BottomEnd)` (newest on top) rather than sliding
  leftward in a `Row`.

* Bottom-drawer `RoomReactionPickerSheet` (hard-coded 6 emojis, no
  NIP-30) replaced by a forked `RoomReactionPopup` that reuses
  `ReactionChoicePopupContent` (same NIP-30 custom-emoji support,
  same user-configured reaction set) but with two semantic deviations
  for live audio rooms: empty `toRemove` so all buttons stay
  "fresh", and the click handler signs+broadcasts a fresh kind-7
  template directly instead of going through `Account.reactTo` —
  which delegates to `ReactionAction.reactTo(note, …)` and
  short-circuits on `note.hasReacted(by, reaction)`. The bypass
  lets the user fire the same emoji repeatedly during a moment.

* Chip rendering matches NoteCompose: `RenderReactionContent`
  handles `:shortcode:url` via `InLineIconRenderer` + `CustomEmoji`,
  `"+"`→❤️, `"-"`→👎, anything else as Text.

Tests updated for sender-grouped aggregator semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:45:21 -04:00
Vitor Pamplona d21eb0e226 fix nests room host UX: visible toasts, promote propagation, edit-sheet keyboard, reaction animation
This commit bundles several issues surfaced while testing host actions
inside an audio room.

NestActivity didn't mount `DisplayErrorMessages`, so every toast emitted
by nest code (promote, demote, kick, force-mute, errors) queued into a
StateFlow whose only collector lives in MainActivity. Mounted it inside
NestActivity.setContent so toasts now render in front of the room UI —
same way the leave-confirmation AlertDialog already does.

Host actions used to fire a synchronous "Promoted X" toast regardless of
whether `signAndComputeBroadcast` actually completed. Silent signer
failures (TimedOut, ManuallyUnauthorized, CouldNotPerform, etc. — all
Log.w-only in launchSigner) were invisible from the host's POV. Replaced
with a coroutine-bound failure toast that surfaces the exception class
+ message; success is implicit via the UI update.

The real cause of "promoted user stays in audience tab" turned out to be
stale presence: a kind-10312 emitted before the role grant (with
onstage=0) was pinning the freshly-promoted speaker to the audience tab.
buildParticipantGrid now takes a `roleGrantSec` parameter (the
kind-30312 created_at) and treats presence as authoritative only when
strictly newer. Pinned with two new tests covering both the
stale-ignore and fresh-respect cases. The leave-stage-on-another-client
flow keeps working because that emits a fresh onstage=0.

RoomParticipantActions.rebuild now uses
`(original.createdAt + 1L).coerceAtLeast(now())` so a same-second
promote→demote can't tie-break the wrong way under NIP-01's lowest-id
rule.

EditNestSheet's bottom row got squashed when the keyboard appeared —
the form fields couldn't shrink, so the Save/Cancel/Close row took the
hit. Split into a scrollable form column + sticky action row, wrapped
the outer column in imePadding.

SpeakerReactionOverlay was driving drift on a 100 ms `delay` loop over
the 10 s eviction window — produced 0.16 dp per tick (visibly stepped)
and the chip barely moved before disappearing. Replaced with
`Animatable.animateTo` on Compose's frame clock and a 6 s duration so
the chip pops, lingers visibly, then drifts up and fades.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:45:20 -04:00
Claude 773d61692e feat: add audio playback support for .f4a files
F4A is AAC audio in an MP4 container (Adobe's variant of .m4a).
Register the extension in the media URL list so it routes to the
player, and map it to audio/mp4 so ExoPlayer picks the MP4 extractor.

https://claude.ai/code/session_01EuB46tfwBxtvRNDz9Ju99J
2026-05-14 10:56:45 +00:00
Claude 6425fcd651 feat(nests): add zap button to the full-screen action bar
Subscribes to kind-9735 zap receipts tagged with the room's a-pointer,
feeds them into the nest chat ledger (so RenderChatZap surfaces them
the same way live streams do) and into a sliding-window aggregator
that drives a floating  chip over the targeted participant's avatar
— mirrors the existing reaction overlay's animation cadence so both
streams visually feel like one system.

The button itself wraps NoteCompose's zapClick / ZapAmountChoicePopup
/ ZapCustomDialog defaults against the room's AddressableNote, so the
amount-choice popup, custom-amount dialog and multi-payable routing
all behave like the standard note  button.

https://claude.ai/code/session_01EW11kUdiEYPuPti7vtD2AR
2026-05-14 04:43:25 +00:00
Claude a12d5d147d chore: regenerate MaterialSymbols font subset for Remove glyph
The Stepper introduced in this branch references MaterialSymbols.Remove
(U+E15B). Regenerated via tools/material-symbols-subset/subset.sh so the
shipped TTF actually contains the glyph.
2026-05-13 23:36:29 +00:00
Claude 2816ceb479 feat: split Security Filters into a hub with per-category screens
Replaces the cramped single-screen layout (header settings + four tabs)
with a hub that uses the same grouped-card pattern as AllSettingsScreen,
plus a dedicated screen per blocked-content category. The tile control
mix is also modernized:

- "Show sensitive content" is now a 3-way SegmentedButton instead of a
  free-floating spinner sitting awkwardly next to switches.
- Numeric prefs (report threshold, max hashtags) use a stepper (- / + )
  instead of free OutlinedTextFields with no commit affordance.
- The report-threshold tile renders inline under its toggle and dims
  when the toggle is off, instead of appearing/disappearing and shifting
  layout.
- Each tile is a row with leading icon, title, description, trailing
  control - so the spinner/switch/stepper visual rhythm stays consistent.

The hub's "Blocked content" section shows a count badge per category and
routes to its own screen (Routes.BlockedUsers, SpammingUsers, HiddenWords,
MutedThreads). The hidden-words screen now docks the "Add word" field as
a bottom bar so it doesn't compete with the list for vertical space, and
selection mode is scoped per-screen instead of shared across tabs.
2026-05-13 20:48:53 +00:00
Vitor Pamplona 20a2270434 fix nests room author shown in audience with no audio
nostrnests publishes kind-30312 without a self-`p`-tag for the room
author — they're the implicit host. The lobby card already handles
this (NestJoinCard) but the in-room code did not: the author fell
into the pure-audience branch with role=null AND was missing from
the audio subscription set, so the host appeared as a regular
listener and no sound played.

buildParticipantGrid now takes hostPubkey and synthesizes a virtual
`["p", host, "", "host"]` when absent, so the existing presence-aware
onstage/audience rules apply uniformly — including "host leaves
stage" (onstage=0 → drops to audience, role stays HOST).

NestActivityContent owns the grid now and derives onStageKeys from
it, so the StageGrid render and the MoQ subscription set stay in
lockstep when anyone (host or speaker) steps off stage.
2026-05-13 15:41:51 -04:00
Claude 38463e5f2c feat: tap TimeAgo to toggle relative ↔ absolute timestamp
Every TimeAgo composable (Android NoteCompose timestamp, NormalTimeAgo,
ChatTimeAgo, ChatroomHeaderCompose last-message time) and every Desktop
timestamp Text (NoteCard, NotificationsScreen, ChatPane, ConversationListPane)
is now clickable and toggles to a scale-adjusted absolute date/time:

  - same day → time only (e.g. "14:32"), locale-aware
  - same year → "MMM dd, HH:mm"
  - older    → "MMM dd, yyyy"

State is hoisted per-call site via rememberSaveable so the toggle survives
scroll-induced disposal in lazy lists. Desktop call sites share a small
ToggleableTimeAgoText wrapper; Android keeps its existing composable shapes
and just gains a clickable modifier + state.

https://claude.ai/code/session_01AuPon9VQeRfKV1BTVQuKGC
2026-05-13 19:17:55 +00:00
Vitor Pamplona 16e87cea8d Merge pull request #2862 from davotoula/feat/161-mute-thread
Client-side thread mute (NIP-51 kind-10000 e tags)
2026-05-13 08:14:50 -04:00
Claude 3cc9ac8b7c fix(blossom-bridge): only the last path segment is the blob hash
BUD-01 defines a Blossom URL as `<server>/<sha256>[.<ext>]` — the blob hash is
always the last path segment. Walking the path right-to-left for any hex
match was too permissive: a non-Blossom URL like `https://example.com/<sha>/avatar.jpg`
(sha appears in an intermediate segment) would get incorrectly bridged.

Both parsers now look at the last path segment only and skip the URL entirely
when it isn't a sha256. The earlier hex-prefix case (share.yabu.me's
`<cache-prefix>/<blob>.ext`) still works because the blob is still the last
segment; the prefix flows into `xs` via the existing `buildServerBase` /
`extractServerBase` logic. Adds negative tests covering the
sha-in-non-last-segment case in both modules.
2026-05-13 10:27:26 +00:00
Claude 74c2bd2fa8 test(blossom-bridge): inline the share.yabu.me URL literally
Building the URL from extracted prefix/blob constants could mask a parser bug
that splits the path the same way the test constructs it. Use the full URL
from the bug report as a single literal so the test only agrees with the
parser if the parser actually parses the URL correctly.
2026-05-13 10:21:59 +00:00
Claude 1b287baaf2 fix(blossom-bridge): pick rightmost sha256 segment in URL path
CDNs like share.yabu.me serve blobs under `<cache-prefix-sha>/<blob-sha>.<ext>`
where both path segments are 64-char hex. The bridge previously locked onto the
first match (the cache prefix), dropping the blob segment from `xs` and asking
the local cache for a non-existent blob. Walking the path right-to-left makes
the rightmost sha — the one that carries the file extension — win, while the
prefix segments stay in `xs` so the cache can fetch upstream on miss.

Behaviour is unchanged when the path has a single sha; tests cover the new
two-hash layout in both the Coil-model path and the OkHttp interceptor path.
2026-05-13 10:11:32 +00:00
davotoula ff780bcb54 Code review:
- consolidate thread-root resolution
- consolidate mute-state writes
2026-05-13 09:06:53 +02:00
davotoula bac3710deb Commons:
- Note.isHiddenFor drops notes in muted threads
- LiveHiddenUsers carries mutedThreads + isThreadMuted predicate
2026-05-13 09:06:53 +02:00
Vitor Pamplona 925d9de71b Merge pull request #2857 from vitorpamplona/claude/fix-notecompose-scroll-jitter-8lw3R
Optimize Compose state management and flow subscriptions
2026-05-12 09:24:25 -04:00
Claude 4d9479fa1b Revert "refactor(RichTextViewer): move isMarkdown onto RichTextViewerState"
This reverts commit 7e2e3304e1.
2026-05-12 01:09:05 +00:00
Claude 5df0e2a37a Add LAN video casting (Chromecast on play, DLNA on both flavors) v1
fix(cast): handle every Chromecast session terminal state and HLS mime hint
fix(cast): repair DLNA Res ctor and add play-only protocol toggle
fix(cast): backfill Cast button for accounts with pre-existing video settings
fix(cast): include cast glyphs in symbol font subset and add diagnostic logs
fix(cast): wire jUPnP's required Jetty deps and survive dialog dismissal
fix(cast): add jetty-client so jUPnP can issue UPnP control requests
2026-05-11 16:35:47 +02:00
Claude 7e2e3304e1 refactor(RichTextViewer): move isMarkdown onto RichTextViewerState
Cleaner conceptual model: the parsed state object is the place where
all derived facts about the content live, so `isMarkdown` becomes a
field on `RichTextViewerState` (populated by `RichTextParser.parseText`
using the same cheap heuristic).

`RichTextViewer` now calls `CachedRichTextParser.parseText` once at the
top and dispatches on `state.isMarkdown` instead of running a separate
scan. `CachedRichTextParser.isMarkdown(content)` and its dedicated
`isMarkdownCache` go away — the single `richTextCache` carries the
decision alongside the parsed segments.

Inner callers (`DisplaySecretEmoji`, `MultiSetCompose` reaction
preview, `DisplayUncitedHashtags`) are unaffected: they continue to
receive a fully-parsed state with all segments populated.
2026-05-11 00:25:36 +00:00
Claude 18c1ab2ece perf(NoteCompose): cut per-item allocations during feed scroll
Three targeted fixes for the jitter that shows up as soon as a feed
contains nested NoteCompose (reposts, quotes, BechLink previews):

* `calculateBackgroundColor`: only schedule the 5s "new item" fade
  LaunchedEffect when the item is actually new and tracks read state.
  Inner notes pass `routeForLastRead = null` and were parking a
  coroutine for 5s per item, every scroll. Also drop a per-call
  `Color.copy(alpha = 0f)` allocation in favor of `Color.Transparent`.

* `EventObservers` + `WatchBlockAndReport`: wrap the `StateFlow`
  lookups in `remember(note)` so each recomposition of the same note
  doesn't re-resolve `note.flow().…stateFlow` (and, in
  `WatchBlockAndReport`, hit the synchronized LRU lookup) on every
  pass. Affects observeNote / Replies / Reactions / Zaps / Reposts /
  Ots / Edits and the per-item hidden-flow check.

* `produceCachedState` / `produceCachedStateAsync`: short-circuit on
  cache hits. The previous `produceState` body always launched a
  coroutine even when the LRU already had the value; this is the
  common path for BechLink previews and draft notes during scroll.
  Now we read the cache synchronously inside `remember`, and only
  launch a `LaunchedEffect` for the actual miss.

No behavior change.
2026-05-10 20:47:03 +00:00
davotoula 7a77e3f5ee fix(richtext): recognize HLS MIME types as video in createMediaContent + propagate imeta image field to MediaUrlVideo.artworkUri
Code review:
- simplify result types and shared helpers
- propagate CancellationException from poster path + cover sibling
2026-05-10 09:40:00 +02:00
m 9d84d01569 style: spotlessApply formatting + fix icon imports for upstream MaterialSymbols 2026-05-08 22:38:39 +10:00
M dca55ebcc4 fix: update renamed APIs (trustAllCerts→usePinnedTrustStore, IRequestListener→SubscriptionListener)
Adapt cherry-picked PR commits to current main where:
- ElectrumxServer.trustAllCerts was renamed to usePinnedTrustStore
- IRequestListener was renamed to SubscriptionListener
2026-05-08 22:31:33 +10:00
M 5369694b4d refactor: move NamecoinSettings and NamecoinResolveState to commons module
Eliminates code duplication between Android and Desktop:

- Move NamecoinSettings to commons/model/nip05DnsIdentifiers/namecoin/
  (with @Serializable and @Stable annotations)
- Extract NamecoinResolveState sealed class to its own file in commons
- Move NamecoinSettingsTest to commons (shared by both platforms)
- Replace Android NamecoinSettings.kt with a typealias to commons
- Update all imports in Desktop and Android modules
- Remove debug println statements from ImportFollowListDialog and Main
2026-05-08 22:31:33 +10:00
Vitor Pamplona 06d6425369 Merge branch 'main' into claude/add-blossom-cache-support-ts8mK 2026-05-08 08:15:44 -04:00
Vitor Pamplona 317b205858 Merge pull request #2781 from mstrofnone/fix/selectable-error-messages
fix(desktop): make error messages selectable so users can copy them
2026-05-08 08:12:49 -04:00
Vitor Pamplona 0127ed4412 Merge pull request #2778 from nrobi144/fix/bunker-timeout-and-decrypt
feat(desktop): custom feeds system with creation, discovery, and author search
2026-05-08 08:11:55 -04:00
Claude bb871f6c71 fix(blossom): preserve upstream path prefix in xs= hint
The bridge was emitting `xs=https://cdn.nostr.build` for URLs like
`https://cdn.nostr.build/i/<sha>.jpg`, dropping the `/i/` prefix. The
local cache appends `/<sha>` to the xs server hint per spec, so it would
try to fetch `https://cdn.nostr.build/<sha>` — a 404 on nostr.build's
non-Blossom CDN scheme.

Anchor the server-base extraction on the slash immediately preceding the
sha-bearing path segment so we emit `xs=https://cdn.nostr.build/i`.
The cache then appends `/<sha>` and reaches the original blob.

Affects all three bridge entry points:
- MediaUrlContent.toCoilModel (note media)
- bridgeProfilePictureUrl (profile pictures)
- LocalBlossomCacheRedirectInterceptor (everything else via OkHttp)

Flat Blossom paths (`<host>/<sha>`) keep emitting `xs=<host>` unchanged.
2026-05-08 11:35:53 +00:00
Claude aa598b9949 fix(blossom): profile pictures now route through local cache
bridgeProfilePictureUrl previously returned a `blossom:` URI, but
profile pictures go through ProfilePictureFetcher (Coil routes by type
on `ProfilePictureUrl`), which feeds the URL straight to NetworkFetcher
and never sees BlossomFetcher. NetworkFetcher then tries to issue an
HTTP request against the `blossom:` scheme and fails — silently — so
profile pictures from Blossom servers stopped loading.

Return a direct `http://127.0.0.1:24242/<sha>.<ext>?xs=<host>&as=<pub>`
URL instead. The OkHttp request goes to the local cache normally; the
new redirect interceptor sees it's already on localhost and passes
through unchanged.
2026-05-08 10:25:30 +00:00
Claude 119ddf7cad feat(blossom): add as= author hint and profile-pictures-only mode
- `as=<authorPubKey>`: when converting plain http(s) imeta URLs into
  `blossom:` URIs we now include the note author's pubkey, letting the
  local cache consult their kind:10063 BUD-03 server list on miss.
  Plumbed via RichTextParser/CachedRichTextParser/RichTextViewer/
  ExpandableRichTextViewer/TranslatableRichTextViewer.

- Profile-pictures-only mode: a new per-account toggle that restricts
  the bridge to profile pictures (RobohashFallbackAsyncImage). When on,
  feed images and videos skip the bridge entirely. Profile pictures
  recover their sha256 from the URL path when present (covers
  Blossom-hosted avatars like https://nostr.build/i/<sha>.jpg).
2026-05-08 08:45:02 +00:00
m 3e246e9e0b fix(desktop): make error messages selectable so users can copy them
Several user-visible error messages on Desktop are rendered with plain
`Text` composables, which means they can't be selected or copied. That
makes it awkward to share an error in a bug report or paste a hex error
string into a search.

Wrap the error text in `SelectionContainer` at the canonical sites:

- `commons.ui.components.LoadingState`: wrap the description in
  `EmptyState` and the message in `ErrorState`. `EmptyState` is reused
  as the in-feed error renderer (e.g. 'Error loading feed' with the
  underlying error in `description`), so this covers feed/loading
  errors across screens that use these helpers.
- `ComposeNoteDialog`: wrap the validation error and the upload error
  in the compose-note dialog.
- `auth/LoginCard` (Nostr Connect): wrap the connection error.
- `auth/KeyInputField`: wrap the supporting-text error so the inline
  message under the nsec input field can be copied.

No visual changes \u2014 `SelectionContainer` does not affect layout or
styling. Selection works on Compose Desktop (mouse drag) and on Android
(long-press) without further changes.
2026-05-08 16:32:48 +10:00
Claude 9c4e87b937 feat(blossom): route image fetches through local Blossom cache
Adds support for the local-blossom-cache spec
(https://github.com/hzrd149/blossom/blob/master/implementations/local-blossom-cache.md):
when http://127.0.0.1:24242 responds 2xx to HEAD /, image and video
fetches are routed through it with xs= upstream hints so it can proxy
on miss. Toggle defaults ON per account; disable from Media Servers
settings.

Covers both blossom:// URIs and plain http(s) URLs that carry an imeta
sha256, by rewriting the latter to a synthetic blossom:<sha>?xs=<host>
URI before handing it to Coil/ExoPlayer.
2026-05-07 12:10:06 +00:00
Claude 23b8bfd34a refactor(nests): per-stream channel count + AudioBroadcastConfig (I4 prep)
Split the previously global `AudioFormat.CHANNELS = 1` into a
`DEFAULT_CHANNELS` constant + per-call-site `channelCount` parameters
so a single broadcast can advertise stereo Opus without forcing every
mono call site to grow a new argument. Generalises the catalog factory
to `MoqLiteHangCatalog.opus48k(name, channels)` with memoised JSON
bytes per shape, threads a new `AudioBroadcastConfig(channelCount)`
through `connectNestsSpeaker` / `connectReconnectingNestsSpeaker` /
`MoqLiteNestsSpeaker`, and adds a `channelCount` parameter to
`MediaCodecOpusEncoder`. Production behaviour is unchanged for
mono callers (the new config defaults to mono); the listener side
already discovers the channel count from the catalog via
`NestViewModel.awaitAudioPipelineConfig`. No test or wire changes.

Phase 1 of `nestsClient/plans/2026-05-06-i4-stereo-cross-stack-scenario.md`.
The hang-interop test scaffolding (`HangInteropTest`, `runSpeakerToHangListen`,
Rust `hang-listen` / `hang-publish`, `JvmOpusEncoder`) doesn't exist on
this branch yet, so the I4 forward + reverse scenarios are deferred
until the parent T16 plan lands.

https://claude.ai/code/session_01EqJEADzH9yjSuoP5L9js8i
2026-05-06 22:57:21 +00:00
Vitor Pamplona 32e578dcc8 Merge pull request #2754 from vitorpamplona/claude/fix-jvm-test-timeout-oBEdj
Fix NestViewModelTest hanging by properly tearing down VMs
2026-05-06 18:37:24 -04:00
Claude 4c13b2be5c fix(commons): dispose NestViewModel between tests so :commons:jvmTest stops hanging
NestViewModel.connect() launches an infinite cliff-detector loop in
viewModelScope (`while(true) { delay(...) }`). The existing tests in
NestViewModelTest call connect() but never disconnect/onCleared, so
that loop stays alive. Under runTest's virtual scheduler each delay
returns instantly, the loop spins millions of iterations per second of
real time, and runTest never reaches the idle state — every test
wedges until the per-test deadline (60 s × 17 tests => :commons:jvmTest
hangs for ~17 minutes).

Wrap each test body with runVmTest, which runs the body inside a
try/finally that calls disconnect() on every VM created by
newViewModel before runTest tries to drain. teardown() cancels
cliffDetectorJob, the scheduler becomes idle, and the test returns.
A 10 s runTest timeout is the safety net — healthy tests now finish
in milliseconds, and tripping the timeout signals a new viewModelScope
coroutine that needs its own teardown call.

Verified: :commons:jvmTest --rerun-tasks now completes in 52 s
(409 tests, 0 failures); NestViewModelTest's 17 tests run in 0.083 s
total versus the previous indefinite hang.

https://claude.ai/code/session_01R9wUxnRJrr299W8TzRyFs7
2026-05-06 22:34:35 +00:00
Claude 30ea809a7a Merge remote-tracking branch 'origin/main' into claude/fix-green-circle-ui-H7zsK
# Conflicts:
#	commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt
2026-05-06 22:23:33 +00:00
Claude 832ad78a6e fix(nests): green speaking ring no longer cropped + gates on actual speech
The full-screen Nest stage's green speaking indicator had two issues:

1. **Cropping at the top.** The glow halo (drawBehind, up to MAX_GLOW_RADIUS
   past the avatar) and detached outer ring rendered onto the avatar's own
   draw layer, which only had the avatar's bounds (75dp circle). The
   surrounding stage Surface (RoundedCornerShape(20dp)) clipped the topmost
   pixels of the first row's glow. Wrap the avatar+badges in an outer Box
   that reserves [ringPadding] (max of MAX_GLOW_RADIUS / OUTER_RING_GAP +
   OUTER_RING_MAX_WIDTH) and move the drawBehind there, drawing relative to
   the inner avatar circle. Badge corner-alignment stays on the inner Box
   so role/hand/mic badges still anchor to the avatar.

2. **Lit up on mic-on, not on actual speech.** `onSpeakerActivity` was
   called once per MoQ object received — i.e. every ~20 ms while the mic
   was open, regardless of whether the frame contained voice, silence, or
   room noise. That meant the green ring was effectively a "mic is on"
   indicator. Split the heartbeat (kept in `onSpeakerActivity`, which
   still drives the cliff detector and clears the connecting overlay)
   from the speaking detector (now in `onAudioLevel`, gated on the
   decoded peak amplitude clearing SPEAKING_LEVEL_THRESHOLD = 0.06,
   ~-24 dBFS). The existing 250 ms expiry job gives natural hysteresis
   between syllables.
2026-05-06 21:32:44 +00:00
Claude 580aaf0201 fix(nests): replace flat 30s cliff cooldown with consecutive-failed-recycle backoff
Production trace showed a single failed recycle (relay accepted SUBSCRIBE
but never opened uni-streams) leaving the user with ~22s of dead air —
the 30s cooldown blocked any retry while audio never recovered, then
they'd give up. The cooldown couldn't tell "recovered then re-stalled"
(safe to wait) from "recycle did nothing" (need to retry sooner).

Replace with a per-attempt backoff: 0 → 5s → 12s → 24s → 30s cap. Reset
to attempt 0 on any real frame after the recycle, so a recover-then-
restall always re-fires immediately. Cumulative wall-clock to 4 recycles
goes from "4 in 30s" (the moq-rs wedge case) to "4 in 41s", protecting
the relay while letting the typical single-failure case retry inside 5s.

Also stop overwriting `lastFrameAt[pubkey]` on recycle: keeping the real
last-frame timestamp is what lets the predicate distinguish the two
cases via `lastFrameAt > lastRecycleAt`.

Hardening on the leave path:
- mark `closed` @Volatile so the cliff-detector finally block reliably
  observes the value set by `leave()` / `onCleared()` (visible in the
  trace as `cliff-detector EXITED ... closed=false` after a Leave press).
- replace `runCatching { recycleSession() }` with explicit
  `catch (CancellationException)` re-throw so a Leave during recycle
  exits promptly rather than running one extra loop iteration.

https://claude.ai/code/session_015euGg6AERTRGj7HYysKnLV
2026-05-06 21:26:07 +00:00
Vitor Pamplona e144226eee Merge pull request #2750 from vitorpamplona/claude/debug-audio-dropout-n0g6Z
Add audio format negotiation and cliff detection for Nests
2026-05-06 16:07:59 -04:00
Claude 28358b4141 refactor(nests): extract ActiveSubscription + plan deferred manager refactor (Audit-9, Audit-14)
Audit-9: NestViewModel.kt is 2112 lines and growing, ~1000 of which
are subscription-lifecycle state machine concerns intertwined with
the room-level public API. Pulling out the full
`NestSubscriptionManager` is a multi-week refactor with subtle
coupling (catalog readiness affects spinner state, mute has effective
+ per-speaker flavours, expiry jobs need the parent scope) and
warrants its own focused review pass.

For now, take the small tractable subset:

  - Extract `ActiveSubscription` from a `private inner class` in
    NestViewModel to its own file as `internal class
    ActiveSubscription` in the same package. The class is purely
    state-holding (handle, roomPlayer, player, isPlaying); zero VM
    coupling beyond the slot map's value type. Same visibility for
    NestViewModel callers; one less private helper class buried
    1500 lines into NestViewModel.kt.

  - File `commons/plans/2026-05-06-nest-subscription-manager-extraction.md`
    documents the deferred full extraction: target shape, state
    that moves, methods that move, what stays in VM, why deferred,
    when to land. Picks up the next person who opens NestViewModel.kt
    rather than leaving them to re-derive the rationale.

Audit-14: T11.3 (stream priority for moq-lite group uni streams) was
deferred from the T11 commit (drop bestEffort=true) because the
:quic-side change touches the writer's hot path and warrants its own
review pass. New file `nestsClient/plans/2026-05-06-stream-priority-followup.md`
spells out:

  - Why: bestEffort=true was incidentally biasing drain order toward
    newer groups; without it, the writer's round-robin order can
    serve a stale group when a fresh one is more useful.
  - Target shape: `QuicStream.priority` field, sortedByDescending
    in the writer's send-frame loop, `WebTransportWriteStream.setPriority`
    pass-through, `MoqLiteSession.openGroupStream` calls
    setPriority(sequence). With code sketches.
  - Test: pin iteration order via the writer's emitted-frames tape.
  - Risk profile: starvation, perf cost of per-pass sort, compat.
  - When to land: after interop verification stabilises.

No code changes in this commit beyond the ActiveSubscription move.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 20:04:56 +00:00
Claude 8a49486b37 fix(nests): G4+G5 plumb catalog sampleRate through decoder + AudioTrack
G4: Audit-9 (catalog-driven decoder reconfig) plumbed numberOfChannels
end-to-end but left sampleRate hardcoded at AudioFormat.SAMPLE_RATE_HZ
in MediaCodecOpusDecoder.buildOpusIdHeader / buildFormat and in
AudioTrackPlayer's MinBufferSize / 250 ms target / setSampleRate
calls. For Opus this is benign in practice — Codec2 always emits
48 kHz PCM regardless of OpusHead inputSampleRate — but hardcoding
the constant means a future codec or container variant whose
decoder DOES respect input sample rate would mis-clock playback.
And the OpusHead identification header should match what the
catalog declares either way.

Thread sampleRate alongside channelCount through every layer:

  - MediaCodecOpusDecoder constructor takes
    `sampleRate: Int = AudioFormat.SAMPLE_RATE_HZ`. buildFormat
    and buildOpusIdHeader both take it as a parameter.

  - AudioTrackPlayer constructor takes the same. Used in
    AudioTrack.getMinBufferSize, the 250 ms-equivalent target
    bytes calculation (`(sampleRate / 4) * BYTES_PER_SAMPLE *
    channelCount`), AndroidAudioFormat.Builder.setSampleRate, and
    the diagnostic log.

  - decoderFactory and playerFactory in NestViewModel become
    `(channelCount: Int, sampleRate: Int) -> ...`.

  - awaitDecoderChannelCount → awaitAudioPipelineConfig, returning
    a private `AudioPipelineConfig(channelCount, sampleRate)`
    struct so openSubscription handles both fields uniformly.
    `sampleRate` falls back to AudioFormat.SAMPLE_RATE_HZ on
    timeout / non-positive declaration with a warning log.

  - NestViewModelFactory + NestViewModelTest updated to the
    two-arg factory shape.

G5: documented the SUBSCRIBE_BUFFER safety budget on
CATALOG_AWAIT_TIMEOUT_MS's kdoc. With the current 500 ms timeout +
SUBSCRIBE_BUFFER = 64-frame DROP_OLDEST flow + 50 fps Opus, at
most 25 frames buffer during the wait — leaving ≥ 39 frames of
margin (≈ 780 ms) before the oldest frame would be evicted. Even
at the production framesPerGroup = 50 (1 group/sec) cadence this
never trips during normal startup. No code change; just pinning
the rationale so a future timeout bump is checked against the
buffer size.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 19:36:05 +00:00
Claude 75f572ba3d test+fix(nests): pin stripLegacyTimestampPrefix; primaryAudio filters to legacy
Two audit-pass gaps:

G1 — `MoqLiteNestsListener.Companion.stripLegacyTimestampPrefix`
is the entire receive-side wire-format converter for audio frames
(every web speaker's Opus packet flows through it) and had zero
unit tests. A regression here is invisible to users — silent decode
failure of every web speaker — and to interop tests, because both
sides agree on the same bug. New StripLegacyTimestampPrefixTest pins:

  - all four QUIC varint-length tiers (1 / 2 / 4 / 8 bytes), one
    test per tier with a representative timestamp value plus a
    tag-byte assertion so the test fails loudly if Varint.encode's
    tier-selection changes.
  - empty-payload and malformed-payload fast paths (returns
    payload unchanged, same instance — no allocation).
  - round-trip against `NestMoqLiteBroadcaster`'s exact wire
    shape (`varint(timestamp_us) + raw_opus_packet`) across five
    timestamp values spanning all four tiers.

G6 — `RoomSpeakerCatalog.primaryAudio()` previously returned
`audio.renditions.values.firstOrNull()` with no container filter.
A future publisher that emits CMAF before legacy in iteration
order would surface the CMAF rendition, and our decoder pipeline
would then feed CMAF MOOF/MDAT bytes to its legacy
`varint(ts)+opus` parser and decode garbage. Filter to
`container.kind == Container.LEGACY_KIND` so:

  - mixed CMAF+legacy publishers always surface the legacy entry
    regardless of map iteration order
  - CMAF-only publishers correctly return null (caller falls
    back to "unknown codec / no audio")
  - publishers that omit `container` entirely also return null —
    treating "no container declared" as "legacy by default" would
    silently mis-decode a CMAF-only publisher that just forgot
    to spell out `container.kind`

LEGACY_KIND const lives on `Container.Companion` so call sites
share one canonical spelling. Three new test cases pin the new
filter behaviour (mixed iteration order, CMAF-only, missing
container). The pre-existing `toleratesUnknownKeys` test is
updated to declare a legacy container — the unknown-key
tolerance we're checking is about unrecognised siblings, not
container-presence.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 19:09:24 +00:00
Claude 4714e3c723 fix(nests): T13 reset Opus decoder on publisher boundary in NestPlayer
The reissuing-subscribe wrapper splices fresh-publisher frames into the
same `SharedFlow` that NestPlayer consumes, so across a JWT-refresh
hot-swap (speaker side) or a cliff-detector recycle (listener side)
the decoder receives a discontinuous frame stream while keeping its
Opus predictor state. Result: an audible warble at every publisher
cycle. Single-stream tests don't catch it because they never present
two distinct publishers.

Detect the boundary via `MoqObject.trackAlias` — the underlying
`MoqLiteSession.subscribe` assigns a fresh subscribeId per SUBSCRIBE,
which the listener wrapper surfaces verbatim as `trackAlias` on every
emitted object. A change between consecutive objects = new publisher.

Add an optional `decoderFactory: (() -> OpusDecoder)?` to NestPlayer.
When non-null, NestPlayer tracks `lastTrackAlias` and on a change
releases the current decoder and rebuilds via the factory. The
default `null` preserves the legacy single-decoder behaviour so
existing tests / callers that don't care about boundaries stand
unchanged.

NestViewModel.openSubscription wires the factory: a closure capturing
the catalog-derived `channelCount` so a rebuild reuses the SAME
channel layout — without that capture, a rebuild after a stereo-
publisher cycle would default to mono and silently downmix.

Two new NestPlayerTest cases pin the behaviour:
  - `publisher_boundary_rebuilds_decoder_when_factory_provided`:
    factory invoked twice (initial + boundary), first decoder
    released on boundary, second released on stop.
  - `publisher_boundary_no_op_when_factory_is_null`: legacy path
    holds onto the same decoder across trackAlias changes (unchanged
    semantics).

NestPlayer's first constructor parameter is now `initialDecoder`
(was `decoder`); positional-arg call sites are unchanged but the
named-arg call sites in the test suite are updated accordingly.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 18:00:52 +00:00
Claude 1c47fd2403 feat(nests): drive Opus decoder + AudioTrack channel count from catalog
Web speakers (kixelated/hang reference) emit stereo Opus when the
user's AudioContext picks a stereo input — and our catalog now lets
us know that ahead of decoder construction. Previously the decoder
+ AudioTrack were both hardcoded to mono via AudioFormat.CHANNELS, so
a stereo web publisher's frames either threw on the Opus TOC byte
mismatch, decoded with downmix artifacts, or output left-channel-only
depending on the device's MediaCodec implementation.

Wire the catalog-discovered numberOfChannels through:

- MediaCodecOpusDecoder accepts `channelCount: Int = 1`. Drives
  MediaFormat.createAudioFormat's channel count, the OpusHead CSD-0
  channel byte, and the per-frame output buffer size (stereo frame =
  2× shorts vs mono). Validates 1..2 — RFC 7845 mapping family 0
  covers both mono and stereo without an explicit channel mapping
  table; multichannel needs family 1 which we don't support.

- AudioTrackPlayer accepts `channelCount: Int = 1`. Drives the
  AudioTrack channel mask (CHANNEL_OUT_MONO vs CHANNEL_OUT_STEREO)
  and the 250 ms wall-clock buffer-size target (stereo doubles bytes
  per sample).

- NestViewModel.openSubscription now starts the catalog fetch
  BEFORE waiting for it, then awaits `_speakerCatalogs[pubkey]` for
  CATALOG_AWAIT_TIMEOUT_MS (500 ms) before constructing the decoder
  + player. With the speaker-side emit-on-subscribe hook in place
  (3417e44), the catalog frame is on the wire within one round-trip
  of the SUBSCRIBE_OK, so the wait typically resolves in tens of ms.
  Falls back to mono if the catalog never arrives — covers legacy
  publishers that don't emit a catalog and the failure-mode where
  the relay never forwards the catalog group.

- decoderFactory + playerFactory signatures change from
  `() -> X` to `(channelCount: Int) -> X`. Two call sites updated:
  NestViewModelFactory (production) and NestViewModelTest's
  newViewModel helper.

Speaker side stays mono-only — our encoder is fixed at
AudioFormat.CHANNELS=1 and the catalog we publish declares
numberOfChannels=1. This change is exclusively about handling
incoming audio from publishers that don't share that constraint.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 17:26:47 +00:00
Claude 87b43d4d78 fix(nests): bump preroll to 200 ms + surface persistent decode failures
Two audit follow-ups for receive-path interop with kixelated/moq web
publishers (NestsUI v2's reference encoder):

1. ROOM_PLAYER_PREROLL_FRAMES 5 → 10 (≈100 ms → ≈200 ms). The web
   reference encoder uses `groupDuration: 100ms` (5 Opus frames per
   group) and stacks another 100–200 ms of jitter on top via the
   relay's outbound forward queue under residential network
   conditions. 100 ms of preroll reliably underran on web speakers,
   producing audible clicks between adjacent groups; 200 ms covers
   the observed envelope while keeping perceived join latency well
   below the wire latency a listener already sees.

2. Per-subscription consecutive Opus decode-error counter in
   NestViewModel.openSubscription. Single-frame decode errors are
   normal noise — Opus PLC papers over a single bad frame and the
   prior code silently swallowed them — but a structural mismatch
   (wrong wire format, missing legacy-container varint strip,
   codec/channel-count mismatch, corrupted Opus) fails *every* frame
   in a row. The previous behaviour made exactly that class of bug
   invisible: no audio, no log, no UI signal.

   New logic increments a per-subscription AtomicLong on each
   DecoderError / EncoderError, resets on every successful onLevel
   tick. When the streak hits DECODE_ERROR_STREAK_LOG_THRESHOLD
   (50 frames ≈ 1 s of solid failures) we log ONE conspicuous warning
   tagged `NestRx` with the underlying throwable's stacktrace.
   Logged once per crossing (exact-equality, not modulus) so a stuck
   stream produces a single attention-grabbing line instead of a
   50 Hz flood.

   Picks the non-lambda Log.w overload deliberately so the throwable
   stacktrace survives — fires exactly once per stuck-stream streak,
   so the unconditional message-string build is fine.

Both changes are additive — no callers changed, no public API
touched. The stale `ROOM_PLAYER_PREROLL_FRAMES = 5` reference in
NestMoqLiteBroadcaster's framesPerGroup kdoc is updated to match.

https://claude.ai/code/session_014JfZJHSTvyYYWJbC9VbB47
2026-05-06 16:43:43 +00:00
davotoula d2e04a6726 Code review:
exhaustive when on Nip05State in Nip05OrPubkeyLine
memoize pubkey display in Nip05OrPubkeyLine
2026-05-06 18:32:11 +02:00
davotoula 54c48bc5b7 extract Nip05OrPubkeyLine into shared composable
Extract nip05 value calculation into helper
2026-05-06 18:32:11 +02:00