Commit Graph

11878 Commits

Author SHA1 Message Date
Claude b8a642b406 fix(cli/tests/dm): bind relay to 127.0.0.2 + dm-06 → --since filter
Two fixes needed to make the DM harness actually pass end-to-end:

1. Bind the loopback relay to 127.0.0.2 instead of 127.0.0.1.

   Quartz's `RelayTag.parse` (called from `ChatMessageRelayListEvent.
   relays()`) rejects any URL for which `isLocalHost()` returns true —
   the substring check matches `"127.0.0.1"` among others. That means a
   kind:10050 event whose `relay` tag reads `ws://127.0.0.1:8090/`
   silently parses to an empty relay list, and `RecipientRelayFetcher`
   reports `dmInbox == []`. Under strict mode `dm send` then correctly
   refuses with `no_dm_relays` — hiding an otherwise-valid test setup.

   `127.0.0.2` is the cleanest fix: still pure loopback (no network
   traffic, no config needed), not matched by `isLocalHost()`, and
   Linux binds any IP in 127.0.0.0/8 without setup. The Marmot harness
   is unaffected because its tests never depend on parsing their own
   kind:10051/10002 relay tags back out — they hit the bootstrap
   fallback path.

   marmot/setup.sh's `start_local_relay` now reads `${RELAY_HOST:-
   127.0.0.1}` so both harnesses can share it; the DM harness sets
   `RELAY_HOST=127.0.0.2` by default and exposes `--host` as an escape
   hatch.

2. Replace dm-06 cursor-advance check with `--since` filter check.

   The original assertion ("second no-flag `dm list` returns 0") was
   wrong for the actual design: `filterGiftWrapsToPubkey` always
   subtracts a 2-day lookback from `since` (required to catch NIP-59's
   randomised `created_at`), so a repeat no-flag query legitimately
   re-pulls everything in the last two days. The cursor advances the
   scan window for efficiency, not for idempotency. dm-06 now verifies
   `--since` actually filters: query with `--since <newest + 2 days +
   1 hour>` — after the filter's lookback subtraction, the effective
   floor lands past every message, and the list comes back empty.

Also: added `protoc` to the DM preflight checks so the missing-dep
error comes before 4 failed cargo retries.

Result on this machine: 6/6 pass, two clean runs in a row.
2026-04-23 21:32:39 +00:00
Claude b0698e0a66 test(cli): move tests/ out of tools/, separate marmot vs dm
`tools/` is for libraries (arti-build); shell-based interop harnesses
that drive the `amy` binary belong with the CLI module. New layout:

  cli/tests/
  ├── lib.sh                       # shared logging + result tracking
  ├── headless/helpers.sh          # shared amy_a / amy_json / assertions
  ├── marmot/                      # MLS group-messaging interop (vs whitenoise-rs)
  │   ├── marmot-interop.sh
  │   ├── marmot-interop-headless.sh
  │   ├── setup.sh
  │   ├── tests-create.sh
  │   ├── tests-manage.sh
  │   ├── tests-extras.sh
  │   └── patches/
  └── dm/                          # NIP-17 DM interop (amy ↔ amy)
      ├── dm-interop-headless.sh
      ├── setup.sh
      └── tests-dm.sh

Per-suite changes:
- Each suite owns its own setup.sh; previously the Marmot setup was at
  headless/setup.sh and the DM setup at headless/setup-dm.sh, which
  implied shared infra they don't actually share.
- `cli/tests/lib.sh` and `cli/tests/headless/helpers.sh` are the only
  shared bits across suites.
- The DM harness still reuses Marmot's `start_local_relay` /
  `stop_local_relay` (sourced via `../marmot/setup.sh`) — same relay
  binary, no duplication.
- All sourcing paths updated; REPO_ROOT now climbs three levels (was
  two when the harness lived under `tools/`); patches/ is now a sibling
  of marmot/setup.sh rather than under marmot/headless/.
- `.gitignore` updated to cover both suites' state dirs.

cli/ROADMAP.md and cli/tests/README.md updated to point at the new paths.
No behavioural change — every test runs the same code; only the file
layout and source paths moved.
2026-04-23 20:37:22 +00:00
Claude 112801ba82 test(cli): amy↔amy NIP-17 DM interop harness
Adds `tools/marmot-interop/dm-interop-headless.sh` — a zero-prompt
end-to-end harness that runs two independent `amy` processes against
a loopback nostr-rs-relay and verifies the NIP-17 DM surface.

Six scenarios:

  dm-01  text round-trip (kind:14) in both directions
  dm-02  `dm list` surfaces prior exchange with `type:text` discriminator
  dm-03  strict kind:10050 refuses sends to an inboxless recipient
         (surfaces the `no_dm_relays` error JSON)
  dm-04  `--allow-fallback` opts into NIP-65 read / bootstrap chain
         (asserts a `relay_source: "bootstrap"` or `"nip65_read"` row)
  dm-05  file message reference mode (kind:15) — send-file with
         --key / --nonce, verify recipient decodes url + key + nonce + mime
  dm-06  no-flag `dm list` advances `state.giftWrapSince`; second call
         returns an empty `messages` array

Shape matches the existing Marmot harness: reuses `lib.sh` for logging
+ result tracking, reuses `setup.sh` for the nostr-rs-relay lifecycle
(`start_local_relay` / `stop_local_relay`), adds a slim `setup-dm.sh`
preflight (amy + relay only, no whitenoise-rs / Marmot patches), and
defines tests in `tests-dm.sh`.

No new CI job yet — the relay build needs Rust + ~3 minutes on a
cold cache, same constraint as the Marmot harness.

Upload-mode (`dm send-file --file PATH --server URL`) is not scripted
here: it needs a local Blossom server, which is out of scope for this
pass. The shared upload classes are unit-tested on desktop at
`desktopApp/src/jvmTest/kotlin/.../service/upload/`.
2026-04-23 20:14:08 +00:00
Claude c614400b23 feat(cli): amy dm send-file --file PATH (encrypt + upload + publish)
`dm send-file` now has two modes:

- **Upload mode** — `dm send-file RECIPIENT --file PATH --server URL`:
  generate a fresh AES-GCM cipher, encrypt the file, upload the
  ciphertext to the Blossom server, then publish a kind:15 referencing
  the returned URL. Auto-detected metadata (encrypted hash + size,
  original sha256, mime type, image dimensions, blurhash) is folded
  into the kind:15 tags. The response surfaces the encryption key and
  nonce so the same encrypted blob can be re-shared without
  re-uploading.

- **Reference mode** — the previous shape still works:
  `dm send-file RECIPIENT URL --key HEX --nonce HEX [...]`. Useful
  when the upload happened elsewhere (Android client, third-party
  tool) or for replaying a previous upload.

Mode is selected by the presence of `--file`. Both paths share the
same wrap-and-publish pipeline (`publishWraps`) and respect the strict
NIP-17 §6 relay rule: kind:1059 only reaches the recipient's kind:10050
unless `--allow-fallback` is passed.

Implementation entirely reuses the upload helpers just promoted to
`commons/jvmMain/.../service/upload/`, so no new business logic lives
in `cli/`. The Blossom auth event (kind:24242) is built and signed via
`BlossomAuth.createUploadAuth(... ctx.signer ...)`.
2026-04-23 19:51:06 +00:00
Claude a7c1d45d93 refactor: promote desktop upload classes to commons/jvmMain
Moves five JVM-only upload helpers from desktopApp into commons so
the CLI can reuse the same Blossom upload + AES-GCM encryption path
without depending on :desktopApp:

  desktop/service/upload/DesktopBlossomAuth.kt        → commons/service/upload/BlossomAuth.kt
  desktop/service/upload/DesktopBlossomClient.kt      → commons/service/upload/BlossomClient.kt
  desktop/service/upload/DesktopMediaCompressor.kt    → commons/service/upload/MediaCompressor.kt
  desktop/service/upload/DesktopMediaMetadata.kt      → commons/service/upload/MediaMetadata.kt
  desktop/service/upload/DesktopUploadOrchestrator.kt → commons/service/upload/UploadOrchestrator.kt

API tweaks:
- Drop the `Desktop` prefix on every class.
- Rename the `DesktopMediaMetadata` object to `MediaMetadataReader`
  to avoid colliding with the `MediaMetadata` data class in the same
  package.
- BlossomClient no longer reaches into desktop's `DesktopHttpClient`.
  Callers pass an `OkHttpClient` (or use the default one-shot client);
  desktop continues to inject its Tor-aware client.
- Tighten BlossomClient's response handling — the OkHttp version on
  commons' classpath has a nullable `Response.body`, so explicitly
  null-check it.

Mechanical updates to keep desktop building:
- ComposeNoteDialog / ChatPane / DesktopUploadTracker switch to the
  new commons imports.
- DimensionTag-from-MediaMetadata: cross-module smart cast no longer
  works on the public `width`/`height` properties — replace with
  `?.let { … }` chains.
- commons/jvmMain now declares `commons-imaging` (used only by
  MediaCompressor's EXIF stripping).

Tests come along too: file names follow the renamed classes
(spotless ktlint enforces this).

Next commit will add `amy dm send-file --file PATH --server URL`,
which calls `UploadOrchestrator.uploadEncrypted(...)` directly.
2026-04-23 19:47:41 +00:00
Claude 305ce20b52 feat(cli): NIP-17 file DMs (kind:15) on receive + send, strict 10050
Receive:
- decryptDms now accepts both ChatMessageEvent (kind:14) and
  ChatMessageEncryptedFileHeaderEvent (kind:15). The result is a sealed
  DecryptedDm hierarchy (TextDm / FileDm) with a `type: "text"|"file"`
  discriminator in JSON. File messages emit url, mime_type,
  encryption_algorithm, decryption_key, decryption_nonce, hash,
  original_hash, size, dim, blurhash. `dm await --match X` searches the
  text body for kind:14 and the URL for kind:15.

Send:
- New `dm send-file RECIPIENT URL --key HEX --nonce HEX [...]` verb.
  Builds a kind:15 ChatMessageEncryptedFileHeaderEvent via the existing
  ChatMessageEncryptedFileHeaderEvent.build + AESGCM(key, nonce), then
  publishes through NIP17Factory.createEncryptedFileNIP17. The file is
  expected to already be uploaded; carrying the upload itself in `amy`
  is the next change.

Spec compliance (NIP-17 §6, "if no kind:10050, shouldn't try"):
- resolveDmRelays now returns kind:10050 only by default. If the
  recipient has none, send/send-file exit with `no_dm_relays` and a
  message recommending `--allow-fallback`. The fallback chain (kind:10002
  read marker → bootstrap) is preserved behind that opt-in flag for
  interop tests and brand-new accounts.

Each recipient row in the send response now includes `relay_source`
("kind_10050" / "nip65_read" / "bootstrap") so callers can tell where
the wraps actually went.

Note: Quartz already enforces the NIP-17 §7 step-3 impersonation guard
(seal pubkey is forced over the rumor's claimed pubkey inside
`Rumor.mergeWith`), so no extra check is needed in the CLI.
2026-04-23 19:34:52 +00:00
Claude 1e5b5d6276 refactor(commons): share NIP-59 gift-wrap+seal unwrap helper
Three call sites did the same two-layer peel —
  kind:1059 → (nip44 decrypt) → kind:13 sealed rumor → (nip44 decrypt) → rumor

Extracted as `GiftWrapEvent.unwrapAndUnsealOrNull(signer)` in
commons/relayClient/nip17Dm/, and collapsed:

- commons/marmot/MarmotIngest.kt — was an inline `when` switch; now a
  one-liner. Behaviour unchanged (still passes through if the inner
  isn't a seal, matching the previous defensive `else -> inner` branch).
- desktopApp/Main.kt — dropped the nested unwrap/unseal block in the
  kind:1059 case of the LocalCache dispatcher.
- cli/DmCommands.kt — replaced the manual chain in `decryptChatMessages`.

Android's DecryptAndIndexProcessor keeps its two separate handlers
(processNewGiftWrap / processNewSealedRumor) because the LocalCache
pipeline re-enters on each peel — that's a different shape and not
touched.

No behaviour change on any platform.
2026-04-23 18:40:35 +00:00
Claude 15205c24b5 feat(cli): amy dm send|list|await (NIP-17)
Adds three verbs wired into the top-level dispatch:

- `amy dm send RECIPIENT TEXT` — builds a kind:14 ChatMessageEvent,
  runs it through NIP17Factory to seal and gift-wrap, then publishes
  each wrap to its recipient's DM inbox (kind:10050, fallback kind:10002
  read, final fallback bootstrap). Emits one JSON line with the inner
  kind:14 id, per-recipient wrap ids, and per-relay ACK status.

- `amy dm list [--peer NPUB] [--since TS] [--limit N] [--timeout SECS]`
  — drains gift wraps addressed to us from our inbox relays (or outbox /
  bootstrap fallbacks), unwraps+unseals each one, dedupes by inner id,
  and returns them oldest-first. With no flags it advances the
  giftWrapSince cursor in state.json (matches the Marmot syncIncoming
  convention); with --peer/--since it's a stateless query.

- `amy dm await --peer NPUB --match TEXT [--timeout SECS]` — polls the
  same drain on a 2s tick until a DM from NPUB contains TEXT. Timeout
  exits 124 like the other await verbs.

All three reuse Quartz entirely (NIP17Factory, GiftWrapEvent,
SealedRumorEvent, RecipientRelayFetcher); the only shared piece we
needed in commons — filterGiftWrapsToPubkey — was extracted in the
previous commit. No business logic leaks into cli/.

Plan: cli/plans/2026-04-23-nip17-dm.md.
2026-04-23 18:23:12 +00:00
Claude c43a69b083 refactor: move filterGiftWrapsToPubkey from amethyst to commons
Pure function with no Android dependencies — relocating it to
commons/relayClient/nip17Dm/ so cli/ can reuse it for NIP-17 DM
subscriptions without pulling in :amethyst. Android caller updated
to import from the new package.

Prep for `amy dm send|list|await` — see cli/plans/2026-04-23-nip17-dm.md.
2026-04-23 18:16:25 +00:00
Claude 1edbe81b60 docs(cli): plan NIP-17 DM send/list/await verbs
Design doc for `amy dm send|list|await`, reusing the existing Quartz
gift-wrap (NIP-17/NIP-59/NIP-44) pipeline. Single file under
cli/plans/; extracts FilterGiftWrapsToPubkey from amethyst/ to commons/
as a prerequisite commit so the CLI can depend on it without pulling
in :amethyst.
2026-04-23 17:20:57 +00:00
Vitor Pamplona a84f2f1466 Merge pull request #2528 from davotoula/configurable-video-buttons
Configurable video buttons
2026-04-23 13:09:36 -04:00
davotoula f6a68eecab Video Player Buttons now sits between Reactions and Zaps in the Account Settings section 2026-04-23 19:02:59 +02:00
Vitor Pamplona dda935c1e9 Merge pull request #2527 from vitorpamplona/claude/remove-group-on-leave-JTVQb
Fix memory leak when leaving Marmot groups
2026-04-23 12:12:37 -04:00
Claude 62f4599407 fix: drop Marmot chatroom on leave so messages clear from caches
When the local user leaves a Marmot MLS group, MarmotManager already
wipes MLS state, the relay subscription and the persisted message log,
but the chatroom (and its decrypted inner messages) lingered in
marmotGroupList until the UI happened to call removeGroup. The CLI
leave path skipped that step entirely, and removeGroup itself only
dropped the chatroom from rooms — it left noteToGroupIndex entries and
the chatroom's own messages set in place, so notes stayed strongly
referenced and kept appearing in the Notification feed.

Move the in-memory cleanup into Account.leaveMarmotGroup, and have
MarmotGroupList.removeGroup also clear the chatroom's messages and the
note→group index. LocalCache holds notes weakly, so cutting the strong
refs is enough — GC reclaims them and the existing acceptableEvent
filter (which keys off marmotGroupList.rooms) hides anything still
in-flight.
2026-04-23 16:07:14 +00:00
Vitor Pamplona da559dc4e8 Merge pull request #2526 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-23 11:44:07 -04:00
Crowdin Bot f4dc793bda New Crowdin translations by GitHub Action 2026-04-23 15:42:39 +00:00
Vitor Pamplona 9b820b892c Merge pull request #2525 from vitorpamplona/claude/fix-interop-tests-12-13-8IXpX
Fix undecryptable outer layer events being marked as processed
2026-04-23 11:41:13 -04:00
Claude 0ec1544d66 fix: keep undecryptable Marmot kind:445 events retryable after epoch advance
`MarmotInboundProcessor.processGroupEvent` added every event id to its
dedup set regardless of result, including `UndecryptableOuterLayer`. The
handler buffers those events for a post-commit retry, but the replay hit
the Duplicate early-return and skipped MLS decryption — so future-epoch
messages and group-metadata commits that arrived before their epoch's
commit were silently dropped until an app restart re-fetched them.

This is interop test 12 (offline catch-up): messages 6-8 and the rename
commit only appeared after restarting Amethyst.

Skip the dedup write on `UndecryptableOuterLayer` so retries actually
re-attempt decrypt once the epoch advances. Memory is still bounded by
the handler's per-group pending buffer (64 events, FIFO-evicted).
2026-04-23 15:39:56 +00:00
davotoula 78e21a0274 Code review:
Dead videoGroup parameter in inner RenderTopButtons overload. Confirmed via git show origin/main — the branch introduced it, and it's only passed through but never read inside the body (the outer overload uses
  videoGroup separately for VideoQualityPopup). Removed from signature and both call sites (preview + outer wrapper).

RenderTopButtons runs for every visible video tile, so recomputing the
top-bar / overflow action lists on every recomposition added avoidable
per-frame filter+map allocations. Memoize on the inputs that actually
change availability.

Also drop the outer Box around AnimatedOverflowMenuButton — the inner
OverflowMenuButton already provides its own sized anchor Box, so the
wrapper added no layout value.
2026-04-23 17:24:54 +02:00
Vitor Pamplona eae9a4d1b3 Merge pull request #2524 from vitorpamplona/claude/fix-marmot-chat-filter-cDP3C
Fix Marmot group message filtering for multi-account scenarios
2026-04-23 10:44:28 -04:00
Claude d0b2af752e feat: add configurable video player buttons
Mirror the existing reaction row settings pattern. Each of the six
configurable video player buttons (Fullscreen, Mute, Quality, Share,
Download, PictureInPicture) can be placed either in the top bar or in
the overflow menu, with drag-to-reorder within its chosen location.

- Data model: VideoPlayerAction, VideoButtonLocation, VideoPlayerButtonItem
  added to AccountSyncedSettingsInternal so the config syncs via NIP-78.
- StateFlow wrapper AccountVideoPlayerPreferences plumbed through
  AccountSettings, Account, and AccountViewModel.
- New settings screen at Route.VideoPlayerSettings mirroring the reaction
  row screen (drag-to-reorder list with per-row FilterChip location).
- RenderTopButtons partitions configured buttons into top-bar vs
  overflow lists; overflow icon is hidden entirely when empty. Existing
  availability rules are preserved (Fullscreen needs zoom handler,
  Quality needs HLS with multiple renditions, Download hidden on live
  streams, PictureInPicture hidden without device support).
- Volume is now observed via Player.Listener.onVolumeChanged so the
  overflow Mute row stays in sync with the current state.
- VideoQualityPopup extracted so the quality chooser can open from the
  overflow menu too.
2026-04-23 16:43:19 +02:00
Claude 79188e3f7f fix: scope Marmot messages in notification filter to the current account
The notification filter accepted any note whose `inGatherers` contained
a MarmotGroupChatroom, regardless of which account's list that chatroom
belonged to. Since notes live in the global LocalCache and accumulate
gatherer references across account switches (and are not cleaned up on
leave), old messages stayed in Notifications after switching accounts
or leaving a group.

Mirror the NIP-17 DM guarantee: only accept a Marmot message when the
gatherer is the same instance currently held by the active account's
`marmotGroupList.rooms[nostrGroupId]`.
2026-04-23 14:06:41 +00:00
Vitor Pamplona f93382daf9 Merge pull request #2523 from davotoula/improve-search-toggles-pt2
Improve search part 2
2026-04-23 09:09:21 -04:00
davotoula 8c41d7ea90 pasting an npub1... / nprofile1... / nevent1... / naddr1... / note1... in the search bar will navigate to the target instead of just showing search results. 2026-04-23 15:02:36 +02:00
Vitor Pamplona b84d286ba9 Merge pull request #2522 from davotoula/improve-search-toggles
Improve search toggles
2026-04-23 08:48:12 -04:00
Vitor Pamplona 4be16ae226 Merge pull request #2516 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-23 08:43:47 -04:00
Vitor Pamplona 0e2acd8886 Merge pull request #2521 from davotoula/claude/fix-r8-room-workdatabase
fix for: release/benchmark crash on start-up
2026-04-23 08:43:39 -04:00
Vitor Pamplona 7cff2c8344 Merge pull request #2520 from davotoula/claude/fix-hls-uri-extension-dJ5rR
fix(playback): route HLS URIs to HlsMediaSource via MediaItem mimeType
2026-04-23 08:43:24 -04:00
Vitor Pamplona 6d8b75c7a2 Merge pull request #2519 from nrobi144/feat/relay-power-tools
feat(desktop): Relay Power Tools — Dashboard, Config Editors, Subscription Wiring
2026-04-23 08:41:22 -04:00
davotoula 458d9bcbab compact filter row with bottom sheet for source/follows/sort
Replaces the two-row, mixed-control filter UI under the search bar with a
single segmented row for scope (All/People/Notes) and a Tune icon button
that opens a ModalBottomSheet containing Source, Follows-only, and Sort
controls. A primary-color dot on the icon indicates non-default filter
state.

Also renames SearchSortOrder.DEFAULT_EVENT/DEFAULT_PEOPLE to
EVENT_DEFAULT/PEOPLE_DEFAULT for consistency with EVENT_OPTIONS/
PEOPLE_OPTIONS, and routes the sheet's reset button through these
canonical constants so the UI default cannot drift from the ViewModel.
2026-04-23 14:20:14 +02:00
davotoula 3b0bf07e58 add missing Column import and spotless formatting 2026-04-23 14:19:57 +02:00
Claude 376cbf8d10 feat(search): add scope, source, follows, sort toggles + bech32 auto-resolve (Android)
Android search previously dumped users, notes, hashtags, relays, and 3 channel
types into a single untoggled list and silently relied on NIP-50 relay search.
This adds a second-row control strip so users can pick what they're looking for
and how results are sourced/ranked.

Commons:
- New SearchScope { ALL, PEOPLE, NOTES } and SearchSource { LOCAL, RELAYS }
- SearchSortOrder: add POPULAR; EVENT_OPTIONS now [RELEVANCE, NEWEST, POPULAR]

Android SearchBarViewModel:
- scope / source / followsOnly / sortOrder StateFlows
- searchResultsUsers + searchResultsNotes respect scope, follows, and sort
- POPULAR sorts notes by Note.zapsAmount (descending)
- Follows-only filters authors (includes replies since filter is by author)
- directEventResolver: bech32 nevent/note/naddr -> Route.Note (auto-navigate)
- updateDataSource skips relay emit when source == LOCAL

Android SearchScreen:
- Second row: [All | People | Notes] SegmentedButton + Sort dropdown
- Third row: [Local | Relays] SegmentedButton + Follows-only chip
- bech32 hit triggers nav.nav(route) and clears the search

Desktop parity deferred - Desktop already ships an advanced panel that covers
most of this (kinds/authors/dates/hashtags/language/exclude terms + sort +
bech32 direct lookup). The new enums live in commons so Desktop can adopt.

Build note: gradle 9.3.1 can't be downloaded in this environment, so Android
and Desktop compilation were not verified here.
2026-04-23 13:17:06 +02:00
davotoula ac1b197336 fix(proguard): keep no-arg constructor of Room *_Impl classes
R8 in playBenchmark (minifyEnabled) stripped the no-arg <init> from
WorkDatabase_Impl because -keepnames preserves names but not members.
Room instantiates generated *_Impl subclasses reflectively via
Class.getDeclaredConstructor(), so startup crashed with
NoSuchMethodException in androidx.startup.InitializationProvider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:11:49 +02:00
Claude 81d65e96a8 fix(playback): route HLS URIs to HlsMediaSource via MediaItem mimeType
ExoPlayer's URI-based content-type inference only fires for http(s)
schemes + known extensions, so HLS playback fails in two cases Amethyst
ships regularly:

  1. NIP-71 video events with BUD-10 blossom URIs. Hash-based URIs carry
     no extension and the imeta's application/vnd.apple.mpegurl never
     reaches the MediaItem — MediaItemCache dropped it.
  2. Bare BUD-10 URIs pasted into kind 1 posts, which have no imeta at
     all. Even though the URI contains `.m3u8` in its path, ExoPlayer's
     inference skips custom schemes (`blossom:`).

Forward a normalized mimeType onto MediaItem.Builder.setMimeType so
DefaultMediaSourceFactory routes to HlsMediaSource regardless of scheme
or extension:

  - Normalize IANA Apple HLS variants (application/vnd.apple.mpegurl,
    application/x-mpegurl, audio/x-mpegurl, audio/mpegurl) to
    MimeTypes.APPLICATION_M3U8.
  - Fall back to `.m3u8` URI detection when no imeta mimeType is
    present. Matches the existing isLiveStreaming(url) heuristic.

Adds diagnostic logs at MediaItemCache.compute and
CustomMediaSourceFactory.createMediaSource so the routing decision is
observable in logcat, matching the diagnostic-logs pattern already used
elsewhere in playback.
2026-04-23 12:28:21 +02:00
nrobi144 835947d611 feat(desktop): relay config persistence, correct counts, per-screen picker
- Persist relay list events (kinds 10050/10007/10006) as JSON to
  java.util.prefs.Preferences with per-account key isolation
- Load persisted relay configs on startup before bootstrap subscription
- Validate loaded events (kind + pubkey check), 8KB guard on writes
- Fix SearchScreen relay count: "0 of 1" not "0 of 7" — uses searchRelays
- Fix FeedScreen relay count: shows feed relay count, not all connected
- Per-screen relay picker dialogs: Dns icon on Feed and Search screens
  opens AlertDialog wrapping existing editors (Nip65RelayEditor,
  SearchRelayEditor) — no new composable files
- Fix created_at dedup: use >= for replaceable event semantics
- Fix setters: use TimeUtils.now() not Long.MAX_VALUE
- Add consumePublishedEvent() for local immediate update after publish
- Remove stale "not loaded" warnings from Search/Blocked editors
- Fix FeedHeader type: Set<NormalizedRelayUrl> not Set<Any>
- Fix picker LaunchedEffect(Unit) to not overwrite user edits

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:00:03 +03:00
Vitor Pamplona 952d91a1fd Merge pull request #2517 from vitorpamplona/claude/fix-marmot-polling-R83Bs
Fix MLS UpdatePath filtering and improve interop harness diagnostics
2026-04-22 21:59:19 -04:00
Claude 3a2068a792 fix(marmot): buffer + retry UndecryptableOuterLayer events after epoch advance
Test 12 (offline catch-up) was almost passing: the retained-epoch
fallback (c88923a3) brought through the 5 epoch-1 application messages
the user sent before adding C, but 3 epoch-2 messages + the rename
commit still went missing. Only on app restart did they appear — the
relay re-delivered and the replayed arrival order happened to put the
epoch-2-advancing commit before the epoch-2 events.

Root cause: kind:445 events arrive from the relay in subscription
order, not epoch order. When B drives an offline-catch-up sequence
like

    msg-1..5 (epoch 1) → add C (epoch 1→2) → msg-6..8 (epoch 2) →
    rename (epoch 2→3)

the receiver can easily see {msg-6, msg-7, msg-8, rename, add-C,
msg-1..5}. Everything from the rename onward arrives before the
add-C commit, so the outer ChaCha20-Poly1305 layer fails with
UndecryptableOuterLayer — we don't have the epoch-2 exporter yet.
Quartz correctly reports the failure, but the receiver just logged
"likely from before our join" and dropped the event on the floor.
Nothing retried when the add-C commit eventually landed and the
epoch caught up.

Fix it at the GroupEventHandler level, where we can both see the
original GroupEvent and hook into every CommitProcessed:

  - Keep a bounded per-group ArrayDeque<GroupEvent> of events that
    failed with UndecryptableOuterLayer. FIFO-evict at 64 entries so
    a genuinely pre-join stream can't pin unbounded memory.

  - On every CommitProcessed for a group, drain that group's queue
    and re-run each event through the normal add() path. A successful
    retry may itself emit another CommitProcessed and trigger recursive
    draining — that's fine, epochs are strictly monotonic so recursion
    depth is bounded by the queue size.

  - Concurrent-safe: all queue mutation is guarded by a Mutex.

Ambient discriminator: we can't tell "epoch too old, from before our
join" from "epoch too new, commit hasn't arrived yet" just from
UndecryptableOuterLayer — both report it. Buffering both is fine:
truly-pre-join events stay buffered until FIFO eviction pushes them
out (no correctness problem, just a few dozen bytes of memory for a
bounded time).
2026-04-23 01:44:32 +00:00
Claude ab064d3cd3 Merge remote-tracking branch 'origin/main' into claude/fix-marmot-polling-R83Bs
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt
2026-04-23 01:11:10 +00:00
Crowdin Bot 745f3781c3 New Crowdin translations by GitHub Action 2026-04-23 01:07:37 +00:00
Vitor Pamplona 7666ed5cc8 Merge pull request #2507 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-22 21:06:39 -04:00
Vitor Pamplona e12c7ca595 Merge pull request #2515 from vitorpamplona/claude/fix-marmot-reactions-gtBUg
Fix MIP-03 inner event verification in group messages
2026-04-22 21:06:04 -04:00
Claude 23573c0a5b Merge remote-tracking branch 'origin/main' into claude/fix-marmot-polling-R83Bs 2026-04-23 00:52:09 +00:00
Crowdin Bot 19a92e9bf5 New Crowdin translations by GitHub Action 2026-04-23 00:50:42 +00:00
Vitor Pamplona f721142c73 Merge pull request #2514 from vitorpamplona/claude/grant-user-privileges-JxW5T
Add admin privilege management for Marmot group members
2026-04-22 20:49:15 -04:00
Claude 5106e572a5 fix: consume Marmot inner reactions/deletions as verified
MIP-03 inner events inside a kind:445 GroupEvent are unsigned (sig is
empty or placeholder), so LocalCache's `justVerify()` fails and
`consume(ReactionEvent)` skips the branch that attaches the reaction to
its target note via `addReaction()`. The same applies to kind:5
deletions. Side-channel inner events from other Marmot clients
(WhiteNoise in particular) were therefore silently dropped by
LocalCache, so an inbound emoji reaction never appeared on the target
chat bubble.

NIP-17 has the same "unsigned inner rumor" shape and already hands the
inner event to `justConsume(..., wasVerified = true)`. Align Marmot with
that contract at the two inbound call sites:
- `GroupEventHandler.add` (fresh kind:445 from relay)
- `Account` restore-from-disk loop (persisted plaintext at app start)

Authenticity is already guaranteed by the MLS layer:
`MarmotInboundProcessor.processPrivateMessage` rejects any inner event
whose `pubkey` doesn't match the MLS sender credential identity before
ever emitting an `ApplicationMessage`.

https://claude.ai/code/session_01KhVtLJAEkNCwpRWUUTqDFM
2026-04-23 00:34:51 +00:00
Claude c88923a3d1 fix(mls): decrypt retained-epoch messages correctly (Test 12 offline catchup)
The `tryDecryptWithRetainedEpoch` fallback in `MlsGroupManager.decrypt`
exists precisely to cover the interop harness's Test 12 scenario —
application messages encrypted under epoch N arriving at a receiver
that has already processed a commit advancing to N+1. The primary
path throws "Message epoch X doesn't match current epoch Y", then the
manager iterates the retained epoch window and tries each.

Two bugs kept the fallback from ever succeeding:

1. sender-data ciphertext sample length. The primary decrypt path uses
   `MlsCryptoProvider.HASH_OUTPUT_LENGTH` (32 bytes, = KDF.Nh for
   HKDF-SHA256, per RFC 9420 §6.3.2); the retained path was using
   `AEAD_KEY_LENGTH` (16). The comment on the primary path — "Using
   AEAD.Nk here made sender-data decryption fail against every
   spec-compliant sender" — was the same fix just never propagated
   to this branch. sender-data AEAD always failed, the try/catch
   swallowed it, and the fallback returned null.

2. content plaintext was returned raw. AEAD output is a
   PrivateMessageContent struct (§6.3.1):
     `applicationData<V> || signature<V> || padding`.
   The primary decrypt path parses this with `pmcReader.readOpaqueVarInt()`
   to extract applicationData; the retained path returned the whole
   struct. Callers saw a length-prefixed blob with signature + zero
   padding glued on — obvious in a raw diff, invisible in a pass/fail
   test that only checked "decrypted is not null".

Fix both in `tryDecryptWithRetainedEpoch`. Existing quartz↔quartz
tests still pass because they don't exercise out-of-order decrypt —
this branch was effectively dead code before.

Add testDecryptRetainedEpoch_ApplicationMessageAfterCommit as a
regression: Bob encrypts at epoch N, Alice advances to N+1 via
add-member, Bob's epoch-N message then arrives at Alice — must round-
trip through the retained-epoch fallback and match the original
plaintext exactly.
2026-04-23 00:30:57 +00:00
Claude e6e3120cd2 feat(marmot): let admins grant/revoke admin privileges from group info UI
Adds a star toggle next to each member in MarmotGroupInfoScreen that
admins can use to promote a non-admin to admin or demote a fellow
admin. Both paths show a confirmation dialog and publish a
GroupContextExtensions commit that rewrites admin_pubkeys. The toggle
is hidden for non-admin viewers and for the signer's own row (self
demotion must go through the leave flow). The demote button is also
hidden when the member is the only remaining admin so we never hit the
MIP-03 admin-depletion guard in MlsGroup at commit time.

Account.grantMarmotGroupAdmin / revokeMarmotGroupAdmin reuse
updateMarmotGroupMetadata so the new admin list ships alongside the
signer's outbox relays, keeping the canonical relay set consistent
with other metadata commits.
2026-04-23 00:28:54 +00:00
Claude d05742a8b6 debug(marmot): log publishMarmotKeyPackages path for Test 13 diagnosis
Test 13 (KeyPackage rotation) fails with "no new KP event_id observed"
— the interop log shows three `needsKeyPackageRotation=true` triggers
(once per group join), each of which invokes
`account.publishMarmotKeyPackages()` from `processMarmotWelcomeFlow`,
but no subsequent `kind:30443` publish ever appears in the logcat.

`publishMarmotKeyPackages` was completely silent: no-op guards, the
needsRotation check, the rotated-events count, and the per-event
publish all ran without a log line. We can't tell whether the rotation
produces events that never reach a relay, produces zero events,
short-circuits on needsRotation=false, or is never called at all.

Mirror the addMarmotGroupMember log pattern: entry guards (manager
null / not writeable), the needsRotation + relay count at decision
time, the rotateConsumedKeyPackages produce-count, and a per-event
publish line with id + target relay list. Next rerun of Test 13 will
tell us which branch is silently dropping the rotation.
2026-04-23 00:18:56 +00:00
Claude a4e031353a Merge remote-tracking branch 'origin/main' into claude/fix-marmot-polling-R83Bs 2026-04-22 23:41:40 +00:00
Vitor Pamplona 95f189338a Merge pull request #2513 from vitorpamplona/claude/fix-whitenoise-reactions-EWq2j
Filter non-displayable messages from Marmot group chat feed
2026-04-22 19:39:42 -04:00