# v1.15.0: Marmot Updates, a Advanced Search Box, and Books, DVM Updates

Highlights:

- Rebuilds **Marmot** onto the adopted spec — Amethyst groups now interoperate
  with White Noise, in both directions.
- Makes the **search box's tokens real filters**, locally and on relays, and puts
  Android and Desktop on one pipeline.
- Reads **publications, wikis, citations, library records and ratings**.
- Adds **NIP-A3 payment targets** to the zap picker, and stops NWC failing in
  silence.
- Shows which **DVMs are alive** before you pick one.
- Brings **NIP-53 live streaming** to Desktop — discover, watch and chat.
- Stops fresh installs **stranding on a Tor bootstrap**.

## New Features

### Marmot Groups Catch Up With the Spec

Marmot deprecated the MIP documents in July and MDK followed, which left our
implementation — shipped in v1.09.0 — no longer a valid Marmot client under
either profile the current spec defines. This release moves it onto the adopted
"current profile" and proves it against the reference.

- Implements the MLS extensions draft's `app_data_dictionary`, the account
  identity proof v2 that makes a group classifiable at all, and the six group
  components that replace the single packed `marmot_group_data` blob.
- Replaces the old same-epoch tie-break — lowest `created_at`, then event id,
  both of which a sender picks — with the spec's convergence: candidate branches
  derived by replaying MLS bytes, a bounded pass, and a six-step comparison over
  authenticated values only.
- Enforces publish-before-apply. A local commit is prepared on a clone and
  becomes canonical only once a relay in the group's own scope acknowledges it,
  so a failed publish can no longer fork this client away from the group.
- Fixes the defects that made us unaddressable: no account could mint a
  KeyPackage at all; KeyPackages were published as bare bytes instead of a framed
  MLSMessage; a Commit rebuilt our own leaf from defaults, silently demoting the
  group creator and making every invite we sent invisible; and a last-resort
  KeyPackage was discarded after one Welcome, so only the first invite anyone
  ever sent us worked.
- Fixes two MLS defects that meant nobody but a group's creator could invite into
  a group of three or more: a joiner was never given its Welcome path secret, and
  an UpdatePath was decrypted at our own leaf rather than at the node we hold a
  key for.
- Makes a departure actually take effect. A leaver's `SelfRemove` was committed
  and then dropped, so the group believed they were gone while they stayed in the
  tree holding its keys.
- Adds group disbanding, with the whole Commit shape the spec fixes, and an
  admin-only action behind its own confirmation.
- Sends attachments in the adopted encrypted-media v2 shape. Every image Amethyst
  sent into a group was invisible to every other implementation; an admin can now
  also switch a group over to encrypted attachments.
- Honours disappearing messages, pinned to the epoch that delivered each message
  rather than to the current setting, with a retention picker at group creation.
- Renders message edits and group system rows, and draws a system row only when
  this client derived it — a peer's assertion of "X removed Y" is no longer drawn
  as history.
- Adds several members in one commit, the way MDK does, instead of one commit per
  invitee.
- Adds a group avatar behind a plain https link, with the canonical URL rules the
  component requires and an SSRF guard at the renderer.
- Adds push token gossip in the shape the spec adopted, with the kind-451 owner
  proof, so relaying someone's record cannot repoint, re-sign or restamp it.
- Adds agent text stream previews over the repo's own QUIC stack, in both
  directions, with certificate pinning instead of blind trust.
- Runs the reference implementation's own fixtures, its portable scenario
  vectors, and a headless interop harness against MDK 0.9.21 — 29 of 29, both
  directions, including media in both.

### Search

- Ports the vespa-relay search field's token language: `from:`/`to:`,
  `since:`/`until:`, `#tag`, `label:`, `group:`, `kind:`, `lang:`, `domain:`,
  NIP-73 scopes, `-exclusions` and quoted phrases, drawn as chips over ordinary
  editable text.
- Runs them. Android handed the raw box — chips and all — to both the relay's
  NIP-50 `search` and the local scan, so `from:npub1… bitcoin` asked relays for
  the literal text of its own tokens and a `kind:` chip narrowed nothing.
- Puts both front ends on one `SearchPipeline`, so a caller cannot skip
  post-filters or ranking. Android had never applied `-term` exclusions or the
  reply/media pseudo-kinds, and Relevance sorted by date.
- Opens a picker under a half-written `kind:`, and gives the people picker the
  composer's own — NIP-05 resolution, relay lookups and follow ranking included.
- Names what a chip stands for: a group's name instead of its opaque id, a city
  instead of a geohash.
- Seeds the box with the filter of the screen it was opened from, across 17 more
  feeds, and remembers what you searched for on Android.
- Says what it is waiting on: the spinner now turns while a relay still owes an
  EOSE, and tapping it lists the relays outstanding.
- Answers with one kind window derived from Quartz instead of three hand-kept
  lists — the audit found fifteen searchable, renderable kinds the search had
  never asked for, pictures and every video kind among them.
- Hides deletion (kind 5) and vanish (kind 62) events from results.

### Reading: Publications, Library, Wiki and Ratings

- Gives a kind-30040 publication a table of contents you can read from, listing
  sections by `a` **and** `e` tag and naming each row from the index alone.
- Renders kind-30041 sections: AsciiDoc converted to the same CommonMark renderer
  long-form uses, with wikilinks resolved to nevents and naddrs.
- Gives a chapter a crumb back to its book and a pager through the book.
- Parses and renders the library kinds — 31/32/33 citations, 30045 directories,
  30142 learning resources and 32176 Blossom piece indexes — and shows a learning
  resource's attached PDF inline instead of as a blue link.
- Parses and renders the NIP-54 wiki kinds (818 merge requests, 819 acceptances,
  30819 redirects) and kind-17 external reactions.
- Parses kind-34259 entity ratings and kind-31987 relay reviews, and rebuilds the
  rating card around cover art and stars that actually fill.
- Renders kind 0 as a profile card, in the feed and in thread view, instead of
  raw JSON.
- Links each Birdex species to its Wikidata entry.

### Payments and Zaps

- Offers a NIP-A3 pay-to hand-off in the zap picker, gated on an installed app
  actually resolving the URI and wearing that app's own icon.
- Hides on-chain zap amounts the wallet cannot fund, checked against the exact
  coin selection the send path uses.
- Names the payee on outgoing NWC transactions (NWC-06), and only claims a
  binding for a zap request the provider accepted.
- Never lets a NIP-47 refusal or timeout reach you as silence — a wallet
  answering `QUOTA_EXCEEDED` or `RESTRICTED` showed nothing at all.
- Stops sending optional NIP-47 params as `null`, which some wallets refuse, and
  stops downgrading to NIP-04 encryption on a cold info cache.
- Adds a Block Relay button to the relay NOTIFY payment prompt, so a paid relay
  asking on every reconnect can be answered once.

### DVM Liveness

- Adds DVM heartbeats (kind 11998), hides DVMs whose last beat is older than 15
  minutes from Discover, and marks the rest with an offline dot or banner.
- Fetches beats from each DVM's own outbox relays, not only the discovery
  selection, so a live DVM your relays do not carry stops looking dead.

### Relay Login

- Makes the prompt's "remember" switch mean the same thing for both answers. It
  was read only by the Log in button, so refusing with it on wrote nothing and
  the same dialog came back on the next reconnect.
- Relabels the buttons to the answer they give, and adds "Never, all relays"
  opposite "Always, all relays", both behind one confirmation that names the
  consequence.
- Stops an account-wide answer being swallowed when the confirmation outlives the
  prompt's 60-second window, and answers the prompts queued behind it.

### Everything Else

- Shares your screen in a call, on Android.
- Opens a real file picker for `<input type="file">` — it was a silent no-op in
  the in-app browser, in nSites and in napplets — and offers the camera where a
  mobile browser would.
- Exposes NIP-44 encrypt and decrypt on the injected `window.nostr`, so a web app
  logging in with Amethyst can seal a NIP-59 gift wrap.
- Emits the uppercase `D` day-index tags on kind 31923, so calendar events are
  discoverable by date.
- Notifies on the rest of NIP-34: PR replies, merges, closes, reopens and drafts.
  A merged PR used to arrive on the device and go nowhere.
- Remembers which side-menu sections you collapsed (#4010).
- Leaves the always-on notification card at the bare relay count, with the
  per-relay breakdown behind a "Show details" action.
- Lifts the 3-relay cap on Concord invite links, which blew up the invite button
  for any community with more.
- Saves videos to `Movies/` instead of `Pictures/` (#4009).

## Performance

- Draws Amethyst's own icons from a generated font rather than rasterising an
  ImageVector per card: frame duration P90 -10.7%, overrun P90 -17.4%.
- Defers feed animation transitions until there is something to animate: overrun
  P90 -19.4%.
- Stops copying whole GIFs into RAM before decoding them. One 69.8 MB GIF cost
  66 MB of heap plus 66 MB of native memory and ~700 ms of pure copying, on every
  scroll back into view.
- Restores the `ImageDecoder` path for still images, which the same identity
  check had cost every network image in the app.
- Makes the concurrent-fetch de-dupe actually work — it was rebuilt per request,
  so it never saw a second caller — rate-limits the cache reconciler to once a
  day, and reclaims cache files orphaned by a process death.
- Stops blocking an OkHttp thread to sign Blossom read-auth, which could occupy
  every per-host slot on a gated host, and collapses a cold burst onto one
  signature.
- Asks for many addresses in one relay filter instead of one filter each: opening
  a 240-section publication went from a black screen for ~2 minutes at 120% CPU
  to first paint at ~22s.
- Rewrites Curve25519 field arithmetic on 10 limbs of radix 2^25.5 and runs the
  ladder without allocating: x25519 541us → 121us, and against MDK
  `create_group/1` goes from 4.7x slower to 1.5x while `send_app_message` is
  6.5x faster.
- Stops `FilterMatcher` allocating once per event — three separate ways, on a
  path that runs tens of thousands of times per keystroke during a search.
- Adds an allocation-free read path for NIP-50 indexable text to all 133
  searchable kinds, pinned byte-for-byte against the write path.
- Shares one IME-inset holder per window instead of one per call site.
- Prices the NIP-19 bracket peel and makes it free.
- Defers log message construction to the lambda overload, including the loops the
  first sweep's line-anchored greps missed.
- Bounds the route-text scan by the budget rather than the input.
- Bounds in-flight on-device AI work and stops blocking IO threads on it.
- Single-flights the LNURL endpoint resolver: a zap-receipt burst was making ~20
  requests to one provider per user action.

## Improvements and Bug fixes

- Stops a fresh install stranding on a Tor bootstrap. The watchdog fired once per
  `Connecting` span and a timeout produces no status change, so after exactly two
  attempts it went silent — permanently. Arti now bootstraps on demand instead of
  blocking the whole directory download behind its lifecycle lock.
- Classifies the app's stand-in relays like the user's own until their lists
  arrive, so a new install stops routing everything over Tor by construction:
  first relay socket moved from login+5.9s to login+1.2s, and events ingested by
  the 20s census from 0–2,590 to 3,719–6,051.
- Only substitutes default relays when there is no event, not when the list is
  empty — publishing an empty search or inbox list is a choice, and the app was
  overriding it.
- Stops relay list updates wiping the public entries another client wrote
  (#4075).
- Recovers IME padding when Compose's insets listener freezes. A back gesture
  could leave a keyboard-sized gap app-wide, permanently, until the activity was
  killed; the correction is now 120ms rather than 400ms.
- Keeps `TextFieldState` writes on the UI thread, fixing a crash inside Compose's
  undo bookkeeping on the keyboard's own callback.
- Follows the phone's light/dark setting on the launch splash, and carries a
  pinned LIGHT/DARK choice into it through the per-application night mode.
- Uses the HLS master playlist and its full quality ladder, sizes the ladder to
  the player rather than to a fixed policy, caps it on metered connections, and
  fixes a picture-in-picture race.
- Caps MediaSession artwork so the platform never re-scales it — a crash on ROMs
  that recycle the source bitmap.
- Stops cancelling ML Kit GenAI inference futures, which killed the process from
  a thread the app does not own. The composer cancelled one on every keystroke.
- Makes the full-screen viewer follow the system bars instead of reserving the
  strip they would occupy, and brings the PDF viewer's chrome to parity.
- Caps navigation route text arguments. Sending a crash report to the developer
  crashed the app: ICU's regex stack overflows at ~100,000 characters and reports
  "no match" rather than an error.
- Narrows the `FileProvider` external root to the app-specific directory.
- Stops deleted lists showing their UUID in the feed filter picker (#3949).
- Restores GIF insertion on the comment reply screens, where a keyboard GIF did
  nothing and a shared one threw away the reply in progress, and wires it into
  four more composers.
- Keeps calls alive when the system destroys `MainActivity`, releases the
  screen-capture Surface instead of leaking one per session, and stops reopening
  the camera during teardown.
- Stops reporting coroutine cancellation as a signer failure, which surfaced as a
  "signer not found" toast on essentially every fast settings toggle.
- Stops a picker's scrolling from dragging the top and bottom bars with it.
- Tints the un-boosted repost icon like the rest of the action row, and fixes the
  liked and reposted colours.
- Stops labelling the mixed media feed "Shorts" — two different destinations in
  the bottom-bar picker carried the same label.
- Converts Android string escaping to Compose escaping inside the Crowdin sync,
  and gates it in CI, after three syncs in a row reintroduced ~2,900 escaped
  apostrophes that render with a visible backslash.

## Desktop

- Adds NIP-53 live streaming: a "Live now" section in Discover with ranking and
  search, a per-column live bar on the Following and Global feeds, and a watch
  overlay with player, live chat and live-mode controls, openable from anywhere.
- Routes kinds 30311 and 1311 into the desktop cache, which had no channel store
  at all, with a 500-message chat cap.
- Shares the unified search pipeline, and gives the spotlight the same people
  picker — typing `from:` there opened nothing.
- Surfaces macOS notification-permission errors and times the request out, rather
  than parking forever on a banner the user missed.

## Cli

- Redesigns `amy status` around who is signed in and what they saved, adding
  relay config, follows, Marmot messages, Concord communities and the operator
  key — and fixing a store report that read 0 events on the default backend.
- Drives the Marmot work end to end: current-profile group creation, retention,
  media policy and transfer, disband, and `amy marmot stream`.

## Quartz

- Adds the adopted Marmot profile: the app-data dictionary carrier, account
  identity proof v2, the six group components, the lifecycle state machine,
  branch selection, the bounded convergence pass, the candidate-graph builder,
  encrypted media v2, the kind-451 push owner proof and the agent-text-stream
  codecs.
- Replaces `org.json` with `kotlinx.serialization` across the remaining sources.
- Backs linuxX64's `LargeCache` with a striped-lock hash table instead of
  copy-on-write, which was O(n) per put and not thread-safe. Filling 100k entries
  goes from 70ms to 15ms with 1 GC instead of 24 — and the whole linuxX64 suite
  now passes (3,495 tests, from 78 failures), so the CI leg runs all of it.
- Matches `java.net.URLEncoder` on native and drops the `urlencoder` dependency.
  The two implemented different specifications, so Android and iOS emitted
  different bytes for the same magnet link or NWC deep link.
- Routes the roles the NIP-50 search extractor was stranding in the body tier: 19
  kinds gain a branch, including the marketplace and Podcasting 2.0 families
  whose titles could never reach a title band.
- Gives the Trusted List member score a 0–100 scale, reads the generic Trusted
  List entry in a NIP-85 Treasure Map, carries Map entries in both halves of the
  10040, and indexes Trusted List titles for NIP-50 search.
- Holds a transport failure as provisional for one retry when a relay drops the
  socket between our EVENT and its OK, and restores pool membership first so the
  retry dials something.
- Waits out the second instead of stamping `created_at` in the future when
  superseding a replaceable event.
- Adds `DvmHeartbeatEvent` (kind 11998) and the publication, library, wiki and
  rating parsers.

## Build & Documentation

- Bumps Kotlin to 2.4.20, Compose Multiplatform to 1.12.0 and AGP to 9.4.0, plus
  the rest of the version catalog.
- Executes the commons migration sweep: 38 relayClient files, the OkHttp client
  stack, 37 model and service singles, the top-nav feed datasource layer and the
  search history move to `commons`; the 12 typealias shims are deleted and their
  1,165 imports repointed; 25 files are promoted from `jvmAndroid` to
  `commonMain` after a per-file concurrency review.
- Migrates 2,565 string keys — about 105,800 locale entries — to the shared
  Compose resource catalog, and gates the orphaned-translation and escaping traps
  pre-push and in CI.
- Publishes desktop test reports when the desktop job fails, which was the only
  test-running job with none.
- Renames the `geode` Homebrew formula to `geode-relay`, since `geode` is
  permanently reserved for Apache Geode, and fixes a style violation in both
  formulae.
- Adds `marmotBench`, a head-to-head against MDK's own benchmark, reporting bytes
  allocated per operation alongside latency.

## Contributors

- @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z
- @npub1e2yuky03caw4ke3zy68lg0fz3r4gkt94hx4fjmlelacyljgyk79svn3eef
- @npub12cfje6nl2nuxplcqfvhg7ljt89fmpj0n0fd24zxsukja5qm9wmtqd7y76c
- @npub1nxa4tywfz9nqp7z9zp7nr7d4nchhclsf58lcqt5y782rmf2hefjquaa6q8
- @npub1w4uswmv6lu9yel005l3qgheysmr7tk9uvwluddznju3nuxalevvs2d0jr5
- @npub1y9tpxsfnccnnvg7kanqa8wsyxuu2z72wxr2hd3m29psac3dkucdqmm7p4e
- @npub1gvv9ahktvavf9qjtrgm62le7gplmmchd5usp5wpfhr85hf79kncqj8xchs

## Translations

- Dutch by @npub1w4la29u3zv09r6crx5u8yxax0ffxgekzdm2egzjkjckef7xc83fs0ftxcd
- Hindi by @npub1ww6huwu3xye6r05n3qkjeq62wds5pq0jswhl7uc59lchc0n0ns4sdtw5e6
- Hungarian by @npub1dnvslq0vvrs8d603suykc4harv94yglcxwna9sl2xu8grt2afm3qgfh0tp
- Polish by @npub16gjyljum0ksrrm28zzvejydgxwfm7xse98zwc4hlgq8epxeuggushqwyrm
- Slovenian by @npub1qqqqqqz7nhdqz3uuwmzlflxt46lyu7zkuqhcapddhgz66c4ddynswreecw