Commit Graph

546 Commits

Author SHA1 Message Date
Claude b198385849 feat(desktop): tabs-first header for Home / Reads, compact Notifications header
Refactored the three feed screen headers so they all match the Messages
pattern — single compact row padded horizontal = 12, vertical = 8, no
oversized titles, no multi-row metadata.

FeedScreen (Home):
- Replaced the `headlineMedium` "Global Feed" / "Following Feed" title
  plus a redundant Global/Following chip row plus a separate relay-count
  meta-line plus a large "New Post" button with:
    [ Following | Global ]   [relays] [refresh] [+ compose]
  The selected tab IS the screen title. Relay count + followed-user
  count surface through a hover tooltip on the relays icon (desktop
  convention; info is preserved without taking a whole row).
- Relays icon click opens the picker when the account can edit, falls
  through to navigate-to-relays otherwise.

ReadsScreen:
- Same treatment: dropped the "Reads" label, lifted Following/Global
  chips to the left, single refresh icon on the right. "N relays
  connected" moved to the refresh button's content description.

commons/FeedHeader (used by NotificationsScreen):
- Dropped the RelayStatusIndicator (icon + count text + refresh button,
  took 3 slots). Now just titleMedium on the left + a single refresh
  IconButton on the right with relay count in its content description,
  matching the Messages "titleMedium + icon buttons" rhythm.
- Internal padding(horizontal = 12, vertical = 8) baked in so callers
  don't need a trailing Spacer.

https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
2026-04-24 14:23:32 +00:00
Claude 15e63c48a2 fix(icons): render Material Symbols with the correct tint
The painter measured the glyph with TextStyle(color = Unspecified) and
tried to override the color at draw time via drawText(layout, color = tint).
That override is unreliable across Compose versions: when the measured
style carries Color.Unspecified, drawText can fall through to Color.Black,
producing black-on-black icons (visible on the back arrow in dark mode).

Include the tint in the measured TextStyle directly. The (symbol, fontFamily,
density, tint, rtl) remember key on rememberMaterialSymbolPainter was already
keyed on tint, so the TextMeasurer cache still hits for every (symbol, color)
pair used by the app.
2026-04-24 13:04:30 +00:00
Claude 72bef059f2 perf(icons): share FontFamily + TextMeasurer across Material Symbol icons
Material Symbol icons were allocating a fresh Font wrapper on every
composition (Font(resource = ...) returns a new instance each call),
which invalidated the remember(font) cache in rememberMaterialSymbolsFontFamily
and forced a fresh TextMeasurer per call site. Tint was also baked into
the TextStyle passed to measure(), so any tint change (selected /
unselected reaction icons, hover states) produced a cache miss.

Hoist the FontFamily + TextMeasurer to CompositionLocals provided once
at the root (AmethystTheme on Android, MaterialTheme wrapper on desktop)
via ProvideMaterialSymbols. Every icon in the subtree now reuses:

- One FontFamily (Skia typeface cache hit for every subsequent glyph)
- One TextMeasurer with a 64-entry LRU, shared across all call sites
  so its measurement cache is hit across 300+ MaterialSymbols usages
- Tint applied via drawText(layout, color = tint) instead of TextStyle,
  so the measured TextLayoutResult is reused across tint variations

Icon(symbol = ...) now delegates to Material3's Icon(painter = ...),
restoring proper sizing/semantics without the custom Box+drawBehind
workaround. autoMirror handled inside the painter's onDraw. The
previously-unused MaterialSymbolPainter is now the single render path.

The weight parameter is dropped from the Icon/Painter APIs since the
app uses a single weight globally (MaterialSymbolsDefaults.WEIGHT).

A local fallback path keeps @Preview composables that don't wrap in
the theme renderable (per-composition allocation, same cost as before).

https://claude.ai/code/session_01V9esGUFH72ujCQNWCAnjKN
2026-04-24 03:01:23 +00:00
Claude a436df7a21 Merge remote-tracking branch 'origin/main' into claude/fix-arrowback-icon-0v6aT 2026-04-24 02:44:48 +00:00
Claude 12a5926f6a refactor(notifications): centralize age + self gates, predicate observer
Audit of every notify() path found self-exclusion + 15-min age checks
duplicated across nearly all of them, with two inconsistencies (DM kind 4
and zap kind 9735 were missing explicit self-checks). Reactions, zaps,
and chess also re-ran isTaggedUser even though consumeFromCache already
routes by the same `p` tag.

Collapse the duplication into two layers:

- Observer layer (NotificationDispatcher): 15-min rolling age cutoff is
  now enforced by a predicate on LocalCache.observeNewEvents, before any
  account routing happens. The Nostr Filter grammar's `since` is a fixed
  value from dispatcher start; the predicate re-evaluates TimeUtils.
  fifteenMinutesAgo() per event so the window rolls forward as wall-clock
  time advances.

- Per-account layer (dispatchForAccount): right after the call/wake-up
  branch and the MainActivity.isResumed gate, drop events authored by
  the current account. Kept per-account (not observer-wide) because in a
  multi-account session account A's outgoing event legitimately becomes
  account B's incoming notification on the same device, so a device-wide
  author-exclusion set would swallow A→B.

Mechanically, NewEventMatchingFilter now takes an optional predicate so
observers can reject on fields the Filter grammar can't express (like a
moving `createdAt` cutoff). LocalCache.observeNewEvents adds a matching
overload that forwards it.

With the gates centralized, every notify() method drops its own age and
self-author checks (and for reaction/zap, the redundant isTaggedUser).
notifyWelcome keeps its own guards because it's dispatched directly
from processMarmotWelcomeFlow, bypassing the observer and dispatchForAccount.
Calls and wake-ups also bypass the shared gates by their short-circuit
return before the shared block.

https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc
2026-04-24 02:28:41 +00:00
Vitor Pamplona 5b1be8ded5 Maybe 300 weight is better for these fonts 2026-04-23 22:10:18 -04:00
Claude 6a9e0a79a6 fix(icons): render MaterialSymbol Icons that rely on defaultMinSize
The Canvas-based MaterialSymbol Icon collapsed to 0x0 whenever callers
didn't pass an explicit size modifier (e.g. ArrowBackIcon, ClearTextIcon).

Canvas is a Spacer + drawBehind, and Spacer's measure policy only uses
maxWidth/maxHeight when the incoming constraints are fixed. Inside an
IconButton (state layer 40dp, content constraints 0..40), defaultMinSize
only lifts minWidth/minHeight, leaving the constraints unfixed - so
Spacer picked 0x0 and the glyph never drew. Icons that passed
Modifier.size(N.dp) worked only because size(...) produces fixed
constraints.

Swap Canvas for an empty Box with drawBehind. Box's EmptyBoxMeasurePolicy
uses minWidth/minHeight, so defaultMinSize(24, 24) now takes effect when
no size modifier is supplied - matching Material3's own Icon behavior.

https://claude.ai/code/session_01V9esGUFH72ujCQNWCAnjKN
2026-04-24 02:00:11 +00:00
Claude 655e56791b Merge branch 'main' into claude/review-marmot-implementation-BSrzC
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt
#	cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt
2026-04-23 22:36:29 +00:00
Vitor Pamplona 5707d4351a Merge pull request #2533 from vitorpamplona/claude/thin-material-icons-9SFWk
Replace Material Icons with Material Symbols
2026-04-23 18:23:42 -04:00
Vitor Pamplona 776160f6ef Merge pull request #2534 from vitorpamplona/claude/add-notification-filtering-RF0Td
Refactor notification pipeline to use cache observer pattern
2026-04-23 18:23:09 -04:00
Claude 5194190f5e test(marmot): cover MarmotManager leave + rejoin commons-layer round-trip
The quartz-layer companion (testLeaveAndRejoin_SameGroupIdEndToEnd in
MlsGroupManagerTest) only exercises the MLS engine. This one goes one
layer up and drives the full commons-layer pipeline end to end:

- NIP-59 gift wrap / unseal of the Welcome (kind:1059 -> kind:13 ->
  kind:444) via MarmotManager.ingest,
- ChaCha20-Poly1305 outer + MLS PrivateMessage decryption of the group
  event (kind:445) via the same ingest entrypoint,
- persisted inner-event log via MarmotMessageStore (asserts log is
  wiped on leave and does not resurrect pre-leave messages on rejoin),
- subscription bookkeeping on MarmotSubscriptionManager (asserts
  subscribe on join, unsubscribe on leave, re-subscribe on rejoin),
- KeyPackage bundle lifecycle via KeyPackageRotationManager +
  KeyPackageBundleStore (two distinct KPs generated for the two joins).

Same admin-kick workaround as the quartz-layer test: the standalone-
proposal ingestion path in MarmotInboundProcessor still returns
"Standalone proposals not yet supported", so the test has Alice
commit the Remove directly and documents that inline.

Self-contained in-memory stores live alongside the test so we don't
need to cross-depend on quartz's test utilities. Runs under commons
androidHostTest (same source set that already pulls in secp256k1
JVM bindings for real crypto).

https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
2026-04-23 22:20:01 +00:00
Claude c1d98f057d Merge remote-tracking branch 'origin/main' into claude/thin-material-icons-9SFWk
# Conflicts:
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
2026-04-23 22:12:15 +00:00
Claude 2d41118e80 feat(icons): swap drawer "Chess" menu icon to MaterialSymbols.ChessKnight
- Replace R.drawable.ic_chess (custom drawable) with MaterialSymbols.ChessKnight
  (U+F25E) in the navigation drawer's Chess menu entry. Knight is the most
  recognizable chess piece silhouette.
- Drop ic_chess.xml.

Chess board pieces (ChessPieceVectors.kt, CBurnett set) stay as ImageVector —
Material Symbols glyphs are monochrome and can't do the white-with-black-outline
style that keeps pieces visible on both light and dark squares.

https://claude.ai/code/session_016gV9gEaSud6kDMdcaqy8ti
2026-04-23 21:37:47 +00:00
Claude 0b06df3cad feat(icons): migrate remaining incognito drawable; delete dead Nip17Indicator
- GenericCommentPostScreen + ShortNotePostScreen: replace the "post anonymously"
  indicator (R.drawable.incognito) with MaterialSymbols.NoAccounts (U+F03E) —
  standard Material Symbols glyph, semantically matches "no identity attached".
- ToggleNip17Button.kt: the Nip17Indicator composable it defined had no callers.
  Delete the whole file.
- Drop incognito.xml (no remaining consumers).

https://claude.ai/code/session_016gV9gEaSud6kDMdcaqy8ti
2026-04-23 21:27:01 +00:00
Claude fa04107865 feat(notifications): dispatch from LocalCache for all delivery sources
The NotificationRelayService consumes events directly into LocalCache, which
made EventNotificationConsumer's hasConsumed dedup check short-circuit every
subsequent push-notification path. As a result, no notifications fired while
the service was active.

Rework the notification pipeline so every delivery source (FCM, UnifiedPush,
Pokey, active relay subscriptions, NotificationRelayService) feeds LocalCache
and a single dispatcher observes it for notification-relevant kinds. Foreground
suppression is scoped to MainActivity (PiP/Call activities keep notifying),
with carve-outs for CallOffer and WakeUp so time-sensitive events always fire.

- Add NewEventMatchingFilter observable primitive alongside
  EventListMatchingFilter for per-event emission (no list accumulation).
- Add LocalCache.observeNewEvents<T>(filter) backed by the new observable.
- Add MainActivity.isResumed flag flipped in onResume/onPause.
- Add NotificationDispatcher that observes LocalCache for GiftWrap,
  EphemeralGiftWrap, PrivateDm, LnZap, Reaction, Chess, and WakeUp.
- Replace EventNotificationConsumer.consume / findAccountAndConsume with
  consumeFromCache, which skips the hasConsumed check (the observer itself
  provides first-delivery semantics) and gates non-priority kinds on
  MainActivity.isResumed.
- Rewire FCM/UnifiedPush/Pokey receivers to feed LocalCache directly.
- Wire the dispatcher into AppModules lifecycle.
2026-04-23 21:05:56 +00:00
Claude 7235eaabb6 feat(icons): swap post + incognito_off drawables for Material Symbols
- BookmarkGroupItem: replace R.drawable.post with MaterialSymbols.News
  (codepoint U+E032). Drop now-unused post.xml.
- IncognitoBadge: drop the NIP-17 "encrypted" indicator (encryption is the
  expected default; no need to flag) and replace the legacy NIP-04
  unencrypted indicator with MaterialSymbols.NoEncryption (U+F03F).
  Drop now-unused incognito_off.xml.

incognito.xml is kept — still used by the "post anonymously" toggle in
ToggleNip17Button, GenericCommentPostScreen, and ShortNotePostScreen.

https://claude.ai/code/session_016gV9gEaSud6kDMdcaqy8ti
2026-04-23 21:01:59 +00:00
Claude 310cae0a64 feat(cli): add amy marmot message react / delete verbs
Adds two inner-event verbs on top of the existing kind:9 text send path:
  amy marmot message react  GID TARGET_EVENT_ID EMOJI
  amy marmot message delete GID TARGET_EVENT_ID...

Both build an unsigned rumor per MIP-03 (RumorAssembler) and wrap it in
the usual kind:445 outer via MarmotOutboundProcessor — the same shape as
text messages, just a different inner kind. The target event is looked
up in the account's decrypted inner-message log (persisted by the
inbound pipeline) so the NIP-25 / NIP-09 templates can populate the
target's pubkey and kind tags without making the caller re-derive them.

Test 09 previously noted that react round-trip verification was blocked
on a missing CLI verb. Expanded it to react via amy and confirm B sees
the kind:7 surface in its messages stream.

https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
2026-04-23 20:50:47 +00:00
Claude 61c01981cc feat(marmot): add Reset Marmot State safety valve in settings
Gives users a last-resort "nuclear" option when Marmot local state is
corrupted or otherwise unrecoverable — wipes every MLS group, retained
epoch secret, persisted KeyPackage bundle, subscription and in-memory
chatroom for the current account. Does not broadcast leave/SelfRemove
commits, since a graceful teardown may be impossible in exactly the
scenarios where users reach for this option. The KeyPackage is
republished lazily on the next sync so the account stays reachable.

Adds clearAllState() helpers on MlsGroupManager and
KeyPackageRotationManager, a resetAllState() orchestrator on
MarmotManager, an Account.resetMarmotState() entry point, and a
destructive row + confirm dialog in the AllSettingsScreen Danger Zone
that mirrors the existing Request-to-Vanish pattern.

https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
2026-04-23 20:08:26 +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 8ff091ca0e feat(icons): migrate Material Icons → Material Symbols (thin weight)
Replace the `material-icons-extended` library with Google's Material Symbols
variable font, rendered via a new `commons/icons/symbols/` package. Weight is
set to 100 (Thin) via a single tunable default (MaterialSymbolsDefaults.WEIGHT),
so we can re-tune line thickness globally in one line without regenerating
assets.

Key changes:
- commons/composeResources/font/material_symbols_outlined.ttf: Material Symbols
  Outlined variable font (all 3600+ glyphs, 4 design axes: wght/FILL/GRAD/opsz).
- commons/icons/symbols/: MaterialSymbol data class, 219 codepoint constants,
  variable-font FontFamily helper, Canvas-backed Icon composable (overload that
  accepts MaterialSymbol and supports auto-mirror for RTL), and a Painter
  wrapper for use with Image / AsyncImage.
- Bulk-rewrote 250+ call sites across amethyst/, commons/, desktopApp/:
  Icons.{Default,Outlined,Filled,Rounded,AutoMirrored.*}.X → MaterialSymbols.X
  or MaterialSymbols.AutoMirrored.X. `imageVector =` renamed to `symbol =`
  inside Icon() calls; Image(imageVector = MS.X) converted to
  Image(painter = rememberMaterialSymbolPainter(MS.X)).
- Dropped material-icons-extended dependency from commons, desktopApp,
  amethyst build files.

Hand-drawn custom icons (Bookmark, Like, Reply, Zap, chess pieces, robohash)
remain as ImageVector and are untouched.

https://claude.ai/code/session_016gV9gEaSud6kDMdcaqy8ti
2026-04-23 19:44:17 +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 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 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
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
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
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
Claude c4e7e0d9b4 fix: don't render Marmot reactions/deletions as chat bubbles
WhiteNoise threads its kind:445 payloads across three inner kinds:
kind:9 for chat, kind:7 for emoji reactions, kind:5 for unreacts. The
Amethyst ingest pipeline was routing every inner event into the group
chatroom feed, so a kind:7 reaction rendered as a chat bubble whose
only content was the emoji, quoting the liked message via the target
`e` tag. That looked identical to a threaded reply. The actual kind:9
reply from `wn messages send --reply-to` never arrived, which made the
two look swapped in the UI.

Three changes to untangle this:

- MarmotGroupList.addMessage/restoreMessage now skip inner events with
  kind:5 and kind:7 before they enter `MarmotGroupChatroom.messages`.
  The reaction is still consumed by LocalCache so it attaches to the
  target note's reaction row, and the deletion still revokes that
  reaction — they just don't appear as standalone bubbles.
- LocalCache.computeReplyTo learned to derive thread parents for
  `ChatEvent` (kind:9) from plain NIP-10 `e` tags in addition to the
  existing NIP-18 `q` tag path. WhiteNoise emits `e`-tagged replies;
  without this the reply bubble had no quote context in the feed.
- tools/marmot-interop/marmot-interop.sh: `wn messages send` exposes
  its `reply_to` field through clap v4, which renames snake_case to
  kebab-case by default. The script was passing `--reply_to`, which
  clap rejected; the `|| true` + redirected stderr hid the error and
  no reply was ever published. Use `--reply-to`.

https://claude.ai/code/session_01K3g1uWLhByoEdBS77zdF32
2026-04-22 22:07:15 +00:00
Claude 11ba03334c fix: classify Marmot groups in Messages Known/New tabs by admin follow
Before, every Marmot group landed unconditionally under the Known tab of the
Messages screen while the New Requests tab ignored groups entirely, so an
invite from a stranger appeared next to real conversations. The dedicated
Marmot group list screen split by ownerSentMessage only, so unreplied groups
where an admin was followed still showed as New Requests.

Replace both splits with a shared MarmotGroupChatroom.isKnown(followingKeySet):
- user has replied -> Known
- no known members and no messages -> New Requests
- otherwise Known iff any admin is in the follow set

Wire the combined Messages feeds (ChatroomListKnownFeedFilter and
ChatroomListNewFeedFilter) through the helper, invalidate dmNew on group list
changes so empty groups flow into New Requests, and have
MarmotGroupListScreen observe kind3FollowList so newly followed admins move
groups between tabs immediately.
2026-04-22 21:40:23 +00:00
Claude 5a18dc41c8 fix(marmot): inner events are unsigned rumors per MIP-03
MIP-03 ("Application Messages" → Security Requirements) is explicit:

  > "Inner events MUST remain unsigned (no `sig` field)
  >  This ensures leaked events cannot be published to public relays."

Authentication comes from (a) MLS framing — the sender's LeafNode
credential signs the outer MLS Application message — and (b) the
mandatory check in MarmotInboundProcessor.processPrivateMessage that
the inner event's `pubkey` equals the MLS sender's credential identity.
The Nostr Schnorr signature is redundant, and leaving it populated
means a leaked plaintext can be replayed as a valid public kind:9.

whitenoise-rs is spec-compliant (builds via `UnsignedEvent::new()` +
`ensure_id()`, never calls sign). Amethyst was the non-conforming side
on both directions:

1. Send path (`MarmotManager.buildTextMessage`,
   `AccountViewModel.sendMarmotGroupMediaMessage`) called
   `signer.sign(template)`, producing a signed rumor that shipped a
   valid Schnorr signature through the encrypted channel. Switch both
   to `RumorAssembler.assembleRumor` — same id derivation (SHA-256
   over canonicalized [0, pubkey, createdAt, kind, tags, content]),
   but `sig = ""`.

2. Restore path (`Account.kt` on startup reload of persisted inner
   events) called `cache.justConsume(innerEvent, null, false)`, which
   routed through `consumeRegularEvent` → `justVerify` → `event.verify()`
   → FAIL on empty sig → Note registered with `event = null`, message
   never rendered. Pass `wasVerified = true`, matching what the live
   receive path already does after the previous commit (573c5c2b).

Existing on-disk persisted messages from older signed-rumor builds
still load — `wasVerified=true` skips sig verify entirely, so both
legacy signed and spec-correct unsigned rumors deserialize cleanly.
2026-04-22 20:14:07 +00:00
Claude 8416afd965 debug(marmot): log removeMarmotGroupMember + syncMetadataTo transitions
The bug report: after tapping Remove on the Marmot Group Info screen,
the target npub stays in the member list, and the only log line is
the round-tripped kind:445 hitting the "Duplicate" branch in
GroupEventHandler.add. The author-side path (Account.removeMarmotGroupMember
→ MarmotManager.removeMember → syncMetadataTo → publish) has no logging,
so there's no way to tell which step broke — the MLS tree mutation,
the local member-list push to chatroom.members, or the UI recomposition.

Mirror the addMarmotGroupMember logging style on removeMarmotGroupMember
(entry, no-op guards, built commit id, publish) so we can see whether
the author path even runs end-to-end, and have syncMetadataTo log the
group id and the previous→new member count so the StateFlow update
becomes observable. No behavior change.
2026-04-22 19:12:19 +00:00
Vitor Pamplona e5dd70b12c Merge pull request #1983 from vitorpamplona/claude/always-on-notification-service-NuW9I
Add always-on notification service for real-time Nostr relay connections
2026-04-22 09:54:57 -04:00
Vitor Pamplona 1d7f2e7e6d Merge pull request #2493 from vitorpamplona/claude/fix-marmot-interop-tests-3ZSSj
Fix MLS commit cryptography and add comprehensive validation
2026-04-22 09:42:41 -04:00
Claude 72d1793a25 fix(marmot): send SelfRemove as standalone PublicMessage proposal
Reverts the SelfRemove-as-commit approach. openmls rejects a
commit whose only proposal is a SelfRemove from the committer with
RequiredPathNotFound — the spec model is that the removing member
publishes a STANDALONE proposal, and an admin receiver
(wn's mdk auto-commit path) folds it into their next commit.

Add `MlsGroup.buildSelfRemoveProposalMessage()` which frames
Proposal.SelfRemove as `MlsMessage(PublicMessage{proposal})` with
the proper FramedContent / signature / membership_tag and returns
the ready-to-publish bytes + the pre-commit exporter key for outer
encryption. Rewire `MlsGroupManager.leaveGroup` and
`MarmotManager.leaveGroup` to use it.

Target: test 11 (amy leave → B auto-commits SelfRemove →
removes amy from member list).

https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
2026-04-22 08:21:23 +00:00
Claude b01ee5336a fix(marmot): leaveGroup sends a SelfRemove-only commit, not a standalone proposal
Quartz's leaveGroup was returning `proposal.toTlsBytes()` and
publishing that raw on kind:445. That's not a valid MLS message
— it's just the proposal struct with no FramedContent / MlsMessage
framing — and even if it parsed, mdk/openmls would only STAGE the
proposal. The target never actually leaves the tree until someone
commits with that proposal.

MIP-03 explicitly allows non-admins to commit a SelfRemove-only
commit, so amy can produce a proper committable kind:445 herself
after the self-demote. Replace `selfRemove()` (returned raw bytes)
with `selfRemoveCommit()` which adds the proposal to the pending
set and runs the standard `commit()` path. Plumb the resulting
CommitResult through MarmotManager.leaveGroup so the outer
encryption uses `preCommitExporterSecret` (the epoch we're
leaving, which is the one every existing member still has).

This finally propagates amy's leave to B in test 11.

https://claude.ai/code/session_1469d7f4-bb66-4ffa-a44d-1dfa4b526484
2026-04-22 07:46:05 +00:00
Claude f08b010f50 fix(marmot,cli,interop): interop-compatible Marmot flows and harness correctness
Five protocol-level fixes and a batch of harness correctness fixes to get
the headless Marmot/Whitenoise interop harness from 1/13 to 5/13 passing
cleanly, with the remaining failures all rooted in wn's per-account
serial event-processor retry backoff (which drops undecryptable
pre-membership commits after several minutes) rather than amy behaviour.

quartz + commons
----------------
* MarmotGroupData: hold CURRENT_VERSION at 2. mdk-core (the Rust MLS
  engine used by whitenoise-rs) strict-rejects v3 payloads with
  `ExtensionFormatError("Trailing bytes in NostrGroupDataExtension")`
  — our v3 welcomes and GCE commits never apply, so every cross-client
  group flow dies at welcome processing. We still parse v3 happily on
  the way in; we just don't emit it until mdk publishes the
  forward-compat fix MIP-01 mandates.
* MarmotManager.updateGroupMetadata: MERGE extensions instead of
  REPLACING. RFC 9420 §12.1.7 says GCE proposals blow away the old
  extension list; callers that pass only [marmot_group_data] dropped
  [required_capabilities], which peers then reject. Preserve every slot
  except the one we're updating.
* MarmotManager.createGroup: new optional `initialMetadata` parameter
  that bakes MarmotGroupData into epoch-0 GroupContext.extensions
  directly. Without it, creators had to publish a pre-membership
  "bootstrap" commit that no later joiner could decrypt — each such
  peer then burned their retry budget on an undecryptable kind:445
  before seeing the real state. Threaded through MlsGroup.create /
  MlsGroupManager.createGroup.
* MarmotManager.mlsGroupIdHex: new translation helper so any code
  juggling the MIP-01 nostr_group_id (what amy indexes on) and the
  MLS GroupContext groupId (what mdk indexes on) can cross-reference
  them without reaching into MlsGroupManager directly.

amethyst module
---------------
* Account.leaveMarmotGroup: self-demote before SelfRemove per MIP-01,
  and promote a surviving member to admin first if the caller is the
  sole admin (otherwise we'd throw "admin depletion"). Matches the
  cli/GroupMembershipCommands.leave flow.

cli (amy)
---------
* Context.syncIncoming: don't advance `giftWrapSince` on empty polls
  (so the first-ever sync doesn't bump the cursor past every
  past-timestamped wrap we've ever been sent), subtract 2 days lookback
  when filtering (NIP-59 randomWithTwoDays gift wraps can have any
  createdAt in the last 48h), and only advance `groupSince` for groups
  we actually received events for.
* Context.syncIncoming: after ingest, if any Welcome consumed a
  KeyPackage, rotate and publish a fresh one immediately. MIP-00
  requires this — a KP can only be welcomed once and leaving the
  consumed one on relays just means later senders invite us with a
  bundle we no longer have private keys for.
* Context.resolveGroupId: accept either nostr_group_id (amy's primary
  key) or the MLS GroupContext groupId (what wn emits) on every verb
  that takes a group id. Wired through GroupAdd/Remove/Leave/Metadata
  /Read, Message send/list, and all the await* verbs so harness
  scripts never have to juggle both forms for a single group.
* GroupCreateCommand: bake initial metadata into epoch 0 (see the
  quartz change above). Dropped the now-redundant bootstrap commit
  publish and tightened the JSON output to include `mls_group_id`.
* GroupMembershipCommands.leave: self-demote admin before SelfRemove;
  promote an heir if we're the only admin, otherwise the GCE would
  deplete admins and the leave aborts.
* MarmotIngest.ingestGiftWrap: unwrap the sealed-rumor layer. NIP-59
  wrap is gift-wrap(kind:1059) → seal(kind:13) → rumor; the old code
  only unwrapped once and then checked `inner.kind == 444`, which is
  always false because inner is actually the seal. Unseal once more
  before the Welcome check. This single fix is what unsticks every
  amy-side Welcome ingestion.

wn harness patches + scripts
----------------------------
* whitenoise-defaults-env.patch: honour $WHITENOISE_DISCOVERY_RELAYS in
  `Relay::defaults()` (release builds otherwise bake damus.io / primal
  / nos.lol into every new account's NIP-65 / Inbox / KeyPackage
  lists, which breaks publishing and prevents the inbox subscription
  plane from ever reaching an operational state in a sandbox).
* setup.sh: sleep 2s after amy's initial kind:30443 publish so
  nostr-rs-relay has a chance to fsync before wn's first targeted
  discovery query. Without it wn's `keys check` races the relay's
  WAL flush and intermittently returns NotFound.
* lib.sh: peel wn's `{"result": …}` wrapper in `jq_group_id`,
  `wait_for_invite`, `wait_for_message`, `wait_for_member`. Post-v0.2
  wn `--json` output nests everything under `.result` (and
  `groups invites[]` nests further under `.group.mls_group_id`) —
  these helpers were still pattern-matching on the flat shape, so
  they returned empty strings for a perfectly good response.
* tests-{create,manage,extras}.sh: track both group IDs per test
  (amy's nostr + wn's MLS), pass each CLI the id it understands, and
  bump the post-commit wait timeouts to 90–120s so wn's exponential
  retry backoff has time to work through the pre-membership commits
  it can't decrypt and get to the ones it can.

https://claude.ai/code/session_016kAxdp6ubB5CnF9URhCEzP
2026-04-22 00:28:45 +00:00
Claude 9b9084ffaf docs(cli): split amy docs by responsibility + add amy-expert skill
Separate the single DEVELOPMENT.md into focused docs per audience:

- cli/README.md — trim agent/interop sub-sections; user-facing contract,
  commands, data-dir, troubleshooting only.
- cli/DEVELOPMENT.md — pared down to architecture, how-to-add-a-command,
  output conventions, testing, housekeeping.
- cli/ROADMAP.md (new) — north-star, parity matrix, ordered milestones,
  non-goals. The live checklist for CLI feature parity.
- cli/plans/2026-04-21-cli-distribution.md (new) — packaging strategy
  (Homebrew / winget / Scoop / deb / rpm / AUR / AppImage).
- commons/plans/2026-04-21-event-renderer.md (new) — cross-cutting
  renderer design owned by commons, consumed by cli + desktop + android.

Agentic routing:

- .claude/skills/amy-expert/SKILL.md (new) — routing triggers + the
  five hard rules (thin-layer, JSON contract, non-interactive,
  data-dir-is-world, extract-before-adding).
- references/command-template.md, extraction-recipe.md,
  output-conventions.md — bundled copy-paste references.

Root .claude/CLAUDE.md:

- Adds cli/ to the module list with its sharing rule.
- Registers amy-expert in the skills table.
- Documents per-module plans/ convention; freezes docs/plans/.

https://claude.ai/code/session_01BQ5ZHwa8BAgEQ9zeM4CKhW
2026-04-22 00:23:00 +00:00
Claude fc6b7871c0 refactor: reuse AccountFilterAssembler, pause heavy feeds on background
The notification service no longer creates its own relay subscriptions.
Instead, it relies on the AccountFilterAssembler subscription from the
Compose tree, which covers notifications, gift wraps, metadata, follows,
relay lists, and drafts. This ensures follow/mute list changes are
reflected in notification filtering.

Heavy feed subscriptions (Home, Video, Discovery, ChatroomList) now use
LifecycleAwareKeyDataSourceSubscription which subscribes on ON_START and
unsubscribes on ON_STOP. When the app backgrounds, these feeds pause and
their outbox relays disconnect. Only inbox and DM relays stay connected
via AccountFilterAssembler.

This prevents bandwidth waste on feeds nobody is viewing while the
always-on service keeps relay connections alive.

https://claude.ai/code/session_01LEPfmgGnwjB9a5SDFw5U8t
2026-04-21 23:24:11 +00:00
Vitor Pamplona ae1549b884 Merge pull request #2488 from vitorpamplona/claude/debug-marmot-whitenoise-oO26C
Add CLI interface (amy) for Marmot/MLS group operations
2026-04-21 18:47:56 -04:00
Claude 559c69660f feat(cli,commons): amy login + amy create, share defaults with Amethyst
Move the "defaults a new Amethyst account gets seeded with" into commons
so the UI and amy agree byte-for-byte on what a fresh account looks like.

commons additions:
- commons/defaults/Constants.kt         — relay URL constants (moved from amethyst/)
- commons/defaults/AmethystDefaults.kt  — DefaultChannels, DefaultNIP65RelaySet,
                                          DefaultNIP65List, DefaultGlobalRelays,
                                          DefaultDMRelayList, DefaultSearchRelayList,
                                          DefaultIndexerRelayList (moved from
                                          amethyst/model/AccountSettings.kt)
- commons/account/AccountBootstrapEvents.kt — data class + bootstrapAccountEvents(signer, name)
  that builds the nine events a new Amethyst account publishes: kind:0, 3,
  10002, 10050, 10051, 10099, 50, 51, + channel list. One source of truth.

Retrofits:
- AccountSessionManager.createNewAccount now delegates event construction to
  the commons helper; it still packages them into AccountSettings for the
  Android storage path.
- DefaultSignerPermissions stays in AccountSettings.kt — it uses Android
  NIP-55 Permission/CommandType types.
- 19 import updates across amethyst/ to point at the new commons paths.

New CLI verbs:
- amy create [--name NAME]: mint a keypair, write identity.json, seed
  relays.json with the Amethyst defaults, publish all nine bootstrap events
  to DefaultNIP65RelaySet via NostrClient.publishAndConfirmDetailed.
- amy login KEY [--password X]: accept any identifier form Amethyst's login
  screen accepts — nsec1, ncryptsec (+ --password, NIP-49), BIP-39 mnemonic
  (NIP-06), npub1, nprofile1, 64-hex pubkey (default) or 64-hex privkey
  (with --private), NIP-05 (name@domain.tld, HTTP lookup). Read-only keys
  land with privKeyHex=null; Identity now carries that cleanly.

https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
2026-04-21 21:41:47 +00:00
Claude 89198ab24e refactor(marmot,cli): lift shared logic into quartz/commons
Five extractions so the Amethyst UI and the new amy CLI share one
implementation for each piece of Marmot/identity behaviour instead of
reimplementing it per platform.

quartz additions:
- MarmotGroupData.bootstrap(..) + withMergedRelays(..): factory for the
  metadata shape every inviter stamps on a fresh group and the outbox-
  merge rule every admin commit carries forward.
- KeyPackageFetcher: (a) pure fetchRelaysFor union of target kind:10051,
  target outbox, my outbox; (b) one-shot fetchKeyPackage; (c) publish-
  side resolveKeyPackagePublishRelays.
- resolveUserHexOrNull: single entry point for npub / nprofile / 64-hex
  / NIP-05 identifiers. Uses the existing Nip19Parser + Nip05Client
  infra so NIP-05 resolution is live over HTTP.

commons additions:
- MarmotManager.leafIndexOf(..): pubkey -> leaf index lookup used by
  every removeMember caller.
- MarmotManager.addMember(nostrGroupId, keyPackageEvent, relays):
  convenience overload that lifts the base64 decode into the manager.
- MarmotManager.buildTextMessage(..) returning a TextMessageBundle;
  optionally persists the outbound inner event (CLI needs persist,
  Amethyst relies on LocalCache loopback).
- MarmotIngest.ingest(event): routes kind:1059 / kind:445 through
  unwrap -> processWelcome / processGroupEvent -> persist the decrypted
  application message. Deliberately platform-agnostic.

Retrofits:
- Account.addMarmotGroupMember and fetchKeyPackageAndAddMember now take
  a KeyPackageEvent directly; Account.keyPackagePublishRelays delegates
  to KeyPackageFetcher.
- AccountViewModel.updateMarmotGroupMetadata uses bootstrap + merged.
- AccountViewModel.sendMarmotGroupMessage builds the kind:9 template via
  buildTextMessage(persistOwn = false) to avoid double-persisting.
- AccountSessionManager's NIP-05 login branch delegates to
  resolveUserHexOrNull; no more hand-rolled Nip05Id / nip05Client.get.
- CLI Context exposes nip05Client and requireUserHex; syncIncoming now
  calls MarmotManager.ingest; all command sites use KeyPackageFetcher
  + the shared identifier resolver. Npubs util deleted.

https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
2026-04-21 20:43:00 +00:00
davotoula 63454761ef fix(playback): bypass cache for live streams opened via ZoomableContentView
Live streams routed through the Live Streams tab go through ShowVideoStreaming
→ ZoomableContentView, not LiveActivity.kt. ZoomableContentView is generic
and was calling VideoView without isLiveStream=true, so live stream segments
ended up in SimpleCache. The cached HLS manifest then went stale (live
manifests roll constantly) and playback stopped after ~30 seconds.

Plumb the flag through the data model: add isLiveStream to MediaUrlVideo,
set it true when ShowVideoStreaming constructs the model, and pass it from
ZoomableContentView into VideoView. URL heuristic (.m3u8) wasn't viable —
that would also bypass cache for on-demand NIP-71 multi-rendition videos
and defeat the original feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:42:33 +01:00
Claude 0a42b96ee4 Merge latest main into emoji feature branch
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt
2026-04-21 13:24:03 +00:00
Claude 3488bbfd94 refactor(emoji): review cleanup — bugs, naming, and UX polish
Critical:
- EmojiPackScreen delete dialog: swap plus icon for Delete
- EmojiGrid: switch emoji AsyncImage from Crop to Fit so non-square
  artwork and transparent backgrounds render correctly
- AddEmojiDialog: wrap Address.parse in runCatching; a malformed
  user-entered address no longer crashes the app
- BrowseEmojiSetsSubAssembler: use the query scope, not the account
  scope, for the feed-freshness collector so the subscription lives
  only as long as the query

High:
- Drop descriptive kdoc from AddEmojiDialog and EmojiPackViewModel
- EmojiPackCard takes an optional author; Browse feed always shows
  it, MyEmojiList shows it only for foreign packs
- EmojiPackScreen: helper line 'Long-press to remove' above the grid
  so the interaction is discoverable
- AddEmojiDialog: private toggle is now a Switch (correct control
  for a boolean form value) instead of a FilterChip

Medium:
- EmojiPackState: distinct, accurate error messages on the add/
  remove-to-selection paths (was copy-paste)
- OwnedEmojiPacksState: import NameTag/TitleTag rather than using
  fully qualified names inline
2026-04-21 12:42:48 +00:00
Claude 8bd498a4d7 feat(emoji): surface decrypted private emojis end-to-end
Private emojis stored in the encrypted .content of kind 30030 EmojiPackEvents
were honest about being private but useless: they never appeared in the `:`
autocomplete or the reaction menu, even for the pack owner. This wires up
async decryption so pack owners actually see their private emojis everywhere
public ones already show.

- quartz: add EmojiPackEvent.publicEmojis() / privateEmojis(signer) /
  allEmojis(signer) helpers, mirroring BookmarkListEvent's public/private
  accessor split. privateEmojis() returns null for non-author signers, which
  preserves the rule that foreign packs cannot expose private entries.
- commons: EmojiPackState.mergePack becomes suspend (mergePackWithPrivate)
  and decrypts inside the existing combineTransform hot path; the StateFlow
  consumers (account.emoji.myEmojis → EmojiSuggestionState) get private
  entries automatically once decryption resolves. The combiner already runs
  on Dispatchers.IO so the suspending decrypt does not block the UI.
- amethyst: RenderEmojiPack uses produceState to seed with the public list
  immediately and replace with the merged list once allEmojis(signer)
  resolves — keeps the reaction-menu gallery responsive.
- AddEmojiDialog explainer + KDoc no longer claim private emojis are
  invisible; OwnedEmojiPacksState.toOwnedEmojiPack uses the new accessors.
- Test EmojiPackEventTest covers public/private/all access including the
  foreign-signer case.

https://claude.ai/code/session_01SNG3nj8ZZDChggTsg1qznn
2026-04-21 03:22:03 +00:00
Vitor Pamplona 4d3ebb51ba Merge pull request #2471 from vitorpamplona/claude/debug-group-event-handler-DEcD1
Fix MLS group state divergence and outer-layer decryption issues
2026-04-20 23:11:45 -04:00
Claude 96cedcbc9b chore(marmot): demote benign inbound failures from WARN to DEBUG
Two kinds of noisy warnings appear every time an invitee or a
restarted client catches up with events still sitting on the relays.
Both are actually expected, per-spec behavior — not errors.

(a) Outer ChaCha20-Poly1305 decrypt fails with "current and N
    retained epoch key(s)".

    A newly-joined member processes the three kind:445s that
    advanced the group to their current epoch (add-me-commit plus
    any earlier commits). The commits were outer-encrypted with
    keys that predate their Welcome, so they never held them — this
    is MLS forward secrecy working as intended. No state on the
    receiver's side actually needs to update: they're already at
    the post-Welcome epoch.

(b) "NO matching KeyPackageBundle for eventId=…" after app restart.

    The gift-wrapped Welcome (kind:1059) is still on the relay and
    gets redelivered on every subscription refresh. The client's
    `processedEventIds` dedup set is in-memory, so after a restart
    the Welcome flows all the way to `processWelcome`, which then
    fails to find the KeyPackage bundle because it was consumed and
    marked during the first processing.

Both of these fire every time a member opens the app, so the logs
currently look like something is broken even when everything is
behaving correctly.

Fix:

- Add `GroupEventResult.UndecryptableOuterLayer(groupId,
  retainedEpochCount)`. `MarmotInboundProcessor.processGroupEvent`
  and `applyCommit` now call the new `tryDecryptOuterLayer` (which
  returns `ByteArray?` instead of throwing) and surface this
  variant when every available key fails. `GroupEventHandler.add`
  logs it at DEBUG with a one-liner noting "likely from before our
  join".

- Add `WelcomeResult.AlreadyJoined(nostrGroupId)`. When
  `MarmotInboundProcessor.processWelcome` is called with a
  `hintNostrGroupId` we're already a member of, short-circuit
  before the KeyPackageBundle lookup and return `AlreadyJoined`.
  `processMarmotWelcomeFlow` logs at DEBUG.

- `MarmotManager.processGroupEvent` treats `UndecryptableOuterLayer`
  as a no-op for subscription-timestamp updates (same as
  Duplicate/Error/CommitPending), so a unreadable past-epoch event
  doesn't move the group's `since` forward.

No behavioral changes beyond logging level and the short-circuit of
a Welcome we already processed (which was throwing and returning
Error before). Existing callers that only care about successful
`Joined` / successful `CommitProcessed` / `ApplicationMessage`
branches are unaffected — the two new variants join the
`Duplicate` / `CommitPending` / `Error` quiet side of the sealed
class.

https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
2026-04-21 02:56:52 +00:00
Vitor Pamplona 32dcdde0cc Merge pull request #2469 from vitorpamplona/claude/add-zaps-to-chat-n1wsc
Add live stream top zappers leaderboard and NIP-53 event types
2026-04-20 21:11:00 -04:00
Claude b039543807 fix(marmot): revert snapshot-based stage/merge; capture pre-commit key in CommitResult
The snapshot/restore machinery added in the previous commit
(ca988b7) to implement MDK-style `stageCommit` + `mergeStagedCommit`
introduced a tree-rebuild path (RatchetTree.decodeTls followed by a
fresh SecretTree construction) that, in production, caused a
recursive StackOverflowError in `SecretTree.getNodeSecret` during
encryption after a second add-member cycle. The unit test flow did
not exercise it and couldn't reproduce.

Keep the underlying fix — outer-encrypt outbound kind:445 commits
with the PRE-commit (epoch-N) exporter secret — but deliver it with
far less machinery:

- `CommitResult.preCommitExporterSecret` now carries the
  `MLS-Exporter("marmot", "group-event", 32)` value captured inside
  `MlsGroup.commit()` BEFORE any state mutation. It's the key
  existing members still at epoch N hold, so the outer kind:445
  wrap with it is decryptable to them (RFC 9420 §12.4 + MDK parity).
- `MlsGroup.commit()` captures this exporter once at the top of the
  function, threads it into the returned `CommitResult`.
- `MarmotManager.addMember / removeMember / updateGroupMetadata`
  read `commitResult.preCommitExporterSecret` and pass it to
  `MarmotOutboundProcessor.buildCommitEvent(..., exporterKey=...)`.
  The eager `group.addMember` / etc. continue to advance the local
  epoch immediately — the same behavior as before the previous
  commit, which was proven safe under production load.

Removed:
- `MlsGroup.stageCommit` / `mergeStagedCommit` / `stageAddMember` /
  `stageRemoveMember` and their snapshot/restore helpers.
- `MlsGroupSnapshot` / `StagedCommit` types.
- `MlsGroupManager.stageAddMember` / `stageRemoveMember` /
  `stageRotateSigningKey` / `stageUpdateGroupExtensions` /
  `mergeStagedCommit`.
- `tree` back to `val` in `MlsGroup`.

Kept from the previous commit:
- `MarmotOutboundProcessor.buildCommitEvent(... exporterKey: ByteArray?)`
  with default falling back to `groupManager.exporterSecret(...)`.
- `MlsGroupManager.processCommit` retains epoch secrets AFTER
  successful apply (was a secondary bug polluting the retention
  window with duplicates of the current key when a commit failed).
- `pushRetainedEpoch` helper that accepts a pre-captured
  RetainedEpochSecrets, used throughout instead of the old
  `retainEpochSecrets(nostrGroupId, group)`.

Test updated:
- `testCreatorCanAddTwoMembersAndAllDecryptFollowingMessage` now
  uses the eager `addMember` path and asserts that the member still
  at the old epoch can outer-decrypt with the pre-commit key shipped
  back via `CommitResult`. Also decrypts Alice's self-echo and
  sends multiple follow-up messages — this is the scenario that
  triggered the production stack overflow before the revert.
- `testAddMemberExposesPreCommitExporterSecret` (renamed) confirms
  `CommitResult.preCommitExporterSecret` equals the exporter the
  group had immediately before the call, and that the post-commit
  exporter differs from it (proves we actually rotated).

https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
2026-04-21 00:50:29 +00:00
Claude 5705a2b840 fix(marmot): outer-encrypt commits with pre-commit exporter secret
Per RFC 9420 §12.4 and the MDK reference implementation, a kind:445
Commit MUST be outer-encrypted with the epoch-N exporter secret — the
key existing members still hold — so that they can decrypt, process the
commit, and advance to N+1. Amethyst was instead encrypting with the
epoch-(N+1) key because MlsGroupManager.addMember / removeMember /
updateGroupExtensions applied the commit locally *before* handing the
bytes to MarmotOutboundProcessor.buildCommitEvent, which read the
current (post-commit) exporter via groupManager.exporterSecret.

Observed in a 3-party Amethyst-to-Amethyst test (David creates group,
adds Eden then Fred, sends "Hi"): Eden joined at epoch 1 via Welcome,
then "succeeded" in outer-decrypting the add-Eden kind:445 with her
own post-commit key but failed to apply ("Duplicate encryption key:
leaf 1") because she was already in the tree from the Welcome. Eden
never advanced past epoch 1, so Fred's subsequent join and David's
application message were undecryptable. Fred, joining directly at
epoch 2, saw the Hi message fine — matching the symptom where the
last-added member is the only one who can read new messages.

Fix splits commit creation from commit application on the outbound
path, mirroring OpenMLS / MDK's `create_commit` + `merge_pending_commit`
pair:

- MlsGroup gains stageCommit() / mergeStagedCommit(). stageCommit
  captures a full snapshot, runs the existing commit logic (which
  mutates in place), captures the post-commit snapshot, then restores
  the pre-commit snapshot so the caller observes epoch N until merge.
  The returned StagedCommit carries the pre-commit exporter secret
  and the framed commit bytes. stageAddMember / stageRemoveMember
  are thin wrappers that propose + stage.
- MlsGroupManager exposes stageAddMember, stageRemoveMember,
  stageRotateSigningKey, stageUpdateGroupExtensions, and
  mergeStagedCommit. The eager addMember/removeMember/commit/etc.
  entry points are retained (documented as test-only) so the MLS
  unit tests in MlsGroupTest, MlsGroupLifecycleTest, etc. keep
  working unchanged.
- MarmotOutboundProcessor.buildCommitEvent takes an optional
  exporterKey override; callers pass StagedCommit.preCommitExporterSecret.
- MarmotManager.addMember / removeMember / updateGroupMetadata now
  stage → build kind:445 → markEventProcessed → mergeStagedCommit.
- MlsGroupManager.processCommit was also retaining epoch secrets
  *before* calling group.processCommit, so a failed commit (such as
  the add-me relay echo that the new member can't apply) polluted
  the retained window with a duplicate of the current epoch key.
  Now retain only on success.

Regression tests:
- testStagedAddMemberUsesPreCommitExporter: asserts stage does not
  advance the epoch and that preCommitExporterSecret equals the
  pre-stage exporter output.
- testCreatorCanAddTwoMembersAndAllDecryptFollowingMessage: end-to-end
  David/Eden/Fred reproduction — creator adds two members in sequence
  and publishes an application message; both members must decrypt it.
  Fails without the fix, passes with it.

https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
2026-04-21 00:50:29 +00:00