Commit Graph

11873 Commits

Author SHA1 Message Date
Claude 24fc44c77f Group drawer items into collapsible You/Feeds/Create/System sections
Separates personal lists (profile, bookmarks, my emoji packs, drafts,
wallet, etc.) from discovery feeds (pictures, videos, emoji packs feed,
communities, etc.) so the two uses of an emoji icon and the general mix
of ownership vs. browsing are no longer visually merged into one flat
list.

Each section is collapsible via a chevron header but expanded by default
for quick access.
2026-04-21 20:28:05 +00:00
Claude 3fa45a47c6 feat(cli): add amy CLI with Marmot subcommand surface
New :cli JVM module producing an `amy` binary that drives Amethyst
functionality headlessly. Identity and relay configuration sit at the
root (`amy init`, `amy whoami`, `amy relay …`); Marmot/MLS lives under
`amy marmot …` so future verbs (dm, feed, profile) can slot in cleanly.

Marmot surface covered:
- key-package publish / check
- group create / list / show / members / admins / add / rename /
  promote / demote / remove / leave
- message send / list (kind:9 inner events)
- await key-package / group / member / admin / message / rename / epoch
  (non-interactive polling with --timeout; exit 124 on timeout)

Wiring:
- NostrClient + BasicOkHttpWebSocket.Builder, reusing the existing
  publishAndConfirmDetailed and fetchFirst accessories
- MarmotManager from :commons with file-backed MlsGroupStateStore,
  KeyPackageBundleStore, and MarmotMessageStore (unencrypted — scratch
  harness use only)
- each invocation is one-shot: prepare → syncIncoming → run command →
  persist → disconnect

All commands emit one JSON object on stdout; diagnostics on stderr.
Designed so shell harnesses can pipe through jq without bespoke parsing.

https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
2026-04-21 20:09:11 +00:00
Vitor Pamplona 9147f1b08b Removes the topNav filter by chess for now.. we should use the left drawer/chess screen 2026-04-21 15:54:24 -04:00
Vitor Pamplona a9c9ae21ab Tries to improve the names on the left drawer 2026-04-21 15:45:36 -04:00
Vitor Pamplona fea9b001a0 Fixes lack of margin for custom emoji sets 2026-04-21 15:45:36 -04:00
davotoula 93755d5739 Add missing translations for badges, emoji packs, communities, and interest sets
Translates 112 new strings into cs-rCZ, pt-rBR, sv-rSE, and de-rDE covering
badges, emoji packs, communities, interest sets, autoplay videos, chat
raid/clip/zap suffixes, and marmot group empty state.
2026-04-21 20:24:27 +01:00
Vitor Pamplona 67cb3a619b Merge pull request #2480 from davotoula/claude/cache-hls-videos-evictor
Cache on-demand HLS videos + adaptive cache sizing
2026-04-21 15:13:27 -04:00
David Kaspar 15ea7930c7 Merge pull request #2479 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-21 21:08:29 +02:00
Crowdin Bot 6309309673 New Crowdin translations by GitHub Action 2026-04-21 18:56:42 +00:00
davotoula 2ed1b17f03 chore(playback): add diagnostic logs for cache + quality selection
Three debug-level log lines for verifying the HLS caching and
resolution-policy plumbing on a real device. Easy to revert as a single
commit before merging if the noise is unwanted.

- VideoCache: one line at app start with computed size, available, total disk
- CustomMediaSourceFactory: one line per MediaSource creation, CACHE or BYPASS
- InitialVideoQualitySelector: one line per applied policy, with selected
  resolution and the rendition list

Tail with: adb logcat -s VideoCache CustomMediaSourceFactory VideoQuality

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:42:49 +01: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
Vitor Pamplona a566c663ec Better way to control the optimization level 2026-04-21 14:39:41 -04:00
Vitor Pamplona 17a971b384 Moves resource loader to appleTest 2026-04-21 14:17:30 -04:00
Claude 0218653a9c perf(playback): size the video cache adaptively instead of fixed 1 GB
With multi-rendition HLS renditions in the tens-to-low-hundreds of MB
each, the previous 1 GB fixed budget held only ~10-20 videos and
evicted everything not currently on screen. A Nostr timeline is
append-only so LRU degenerates to FIFO here, which is actually fine —
the bottleneck was the budget, not the policy.

Mirror the image cache pattern: target 20% of currently-available disk,
clamped between a 256 MB floor (low-storage devices) and a 4 GB cap.
That typically lets the evictor keep an order of magnitude more videos
without notably affecting disk pressure on modern devices.

https://claude.ai/code/session_01W8yGieuEyMKeKY9vU9zjKH
2026-04-21 18:43:20 +01:00
Claude 6afacfb747 feat(playback): cache on-demand HLS videos in SimpleCache
Previously any URL containing ".m3u8" was routed through the non-caching
MediaSource factory under the assumption that HLS meant a live stream.
That was correct for kind-30311 live events but wrong for on-demand
NIP-71 videos with multi-rendition fMP4 segments: their .m4s files are
immutable and a perfect fit for SimpleCache's byte-range caching, yet
they were being re-downloaded on every scroll.

Plumb an explicit `isLiveStream` flag from the caller (LiveActivity sets
it to true; everything else keeps the default of false) down through
GetMediaItem / MediaItemData and stash it in MediaItem.mediaMetadata
extras. CustomMediaSourceFactory now reads that flag to decide whether
to use the caching or non-caching factory, falling back to the old URL
heuristic only if the flag is absent (e.g. a MediaItem built outside
the MediaItemCache path).

Also persist the flag through PiP's IntentExtras bundle and use the new
constants in MediaSessionPool for the callback URI key.

https://claude.ai/code/session_01W8yGieuEyMKeKY9vU9zjKH
2026-04-21 18:43:20 +01:00
Vitor Pamplona 2fed69107a Merge pull request #2478 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-21 13:35:47 -04:00
Crowdin Bot 57599eff4f New Crowdin translations by GitHub Action 2026-04-21 17:28:58 +00:00
Vitor Pamplona 8bd1a70867 Merge pull request #2477 from vitorpamplona/claude/fix-marmot-welcome-event-W66a8
Fix MLS spec compliance issues in commit path and message encryption
2026-04-21 13:27:35 -04:00
David Kaspar 862beb0548 Merge pull request #2476 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-21 19:25:57 +02:00
David Kaspar 74b7660af8 Merge branch 'main' into l10n_crowdin_translations 2026-04-21 19:25:48 +02:00
Claude 4feea50ed2 fix(marmot): compute parent_hash chain + align required_capabilities id
Answers "can MDK and marmot-ts see and talk to a user whose group was
created on Amethyst?" — yes, after four more spec-conformance fixes:

1. parent_hash chain (RFC 9420 §7.9.2): Amethyst's commit() and
   externalJoin() now compute the parent_hash for every parent node on
   the committer's direct path and seal the committer's LeafNode with
   the correct leaf parent_hash. Amethyst↔Amethyst used to work only
   because both sides stored empty parent_hash values; against a
   strict peer (ts-mls, openmls) every Amethyst-authored commit was
   rejected with "Unable to verify parent hash". processCommit()
   now also patches the computed parent_hashes back into its tree so
   treeHash() agrees with the sender — otherwise the epoch key
   schedule diverges and AEAD tags mismatch.

2. REQUIRED_CAPABILITIES extension type: 0x0002 → 0x0003 per
   RFC 9420 §13.3. The old value was ratchet_tree's slot, so Amethyst's
   GroupContext.extensions carried a required_capabilities blob
   labelled as ratchet_tree, and openmls rejected the GroupInfo
   as Malformed (ratchet_tree is not valid in GroupContext). This
   completes the extension-ID set from d7114fc (ratchet_tree,
   external_pub) now aligned with the IANA registry.

3. verifyParentHash on the receive side now computes the expected
   chain top-down from the post-update tree rather than reading
   parent_hash fields that applyUpdatePath leaves as empty
   placeholders.

4. An explicit parentHash parameter on buildLeafNode so COMMIT-source
   leaves include the computed value in their TBS signature.

Test harness — reverse interop (Amethyst → outside world):

  - quartz/tools/{mdk,tsmls}-vector-gen/emit-joiner-kp.{rs,mjs}
    generate an MDK/openmls and a marmot-ts/ts-mls KeyPackage with
    marmot_group_data (0xF2EE) and self_remove (0x000A) advertised in
    capabilities so Amethyst's required_capabilities is satisfiable.

  - AmethystAuthoredVectorGen.kt (env-var gated JUnit test) takes the
    foreign KP, adds it to a fresh Amethyst group, and emits the
    Welcome plus three application PrivateMessages as JSON.

  - verify-amethyst.{rs,mjs} replay the joiner's private state,
    call the foreign MLS library's join + process_message, and
    assert the plaintexts match.

Both verifiers now print ALL PASS end-to-end:
  * openmls ← Amethyst: joinGroup + 3× process_message ✓
  * ts-mls  ← Amethyst: joinGroup + 3× processPrivateMessage ✓

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 16:13:42 +00:00
Crowdin Bot a6532f69f5 New Crowdin translations by GitHub Action 2026-04-21 15:20:36 +00:00
David Kaspar 4596316817 Merge pull request #2474 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-21 17:20:06 +02:00
Vitor Pamplona 4af6d9ef05 Merge pull request #2475 from davotoula/fix/short-note-post-screen-threading
fix:  the consumer read activity.intent instead of the intent parameter passed into the callback
2026-04-21 11:18:56 -04:00
David Kaspar e3d2a16767 Merge branch 'main' into fix/short-note-post-screen-threading 2026-04-21 17:16:36 +02:00
Claude cd12018e08 test(marmot): add ts-mls interop vector + decryptor
marmot-ts (the TypeScript Marmot client — browsers, Node, Bun, Deno)
wraps a completely different MLS implementation: ts-mls. That backend
shares no code with OpenMLS, which is what MDK/whitenoise use, so
Amethyst passing on both is a strong signal that the on-wire MLS layer
is spec-correct rather than coincidentally self-consistent.

Add a Node-based generator under quartz/tools/tsmls-vector-gen/ that
uses ts-mls 2.0.0-rc.10 directly to emit:
  - Alice's KeyPackage + Bob's joiner KeyPackage
  - Alice's Welcome after add_member + commit
  - Bob's init/encryption/signature private keys (PKCS#8 Ed25519 unwrapped
    to the raw 32-byte seed for parity with the MDK vector shape)
  - Post-join MLS-Exporter("marmot","group-event",32) KAT
  - Three application PrivateMessages from Alice → Bob with the
    expected plaintexts

TsMlsWelcomeInteropTest mirrors MdkWelcomeInteropTest but consumes the
new vector. It proves:
  - KeyPackage self-signature verifies under our Ed25519 + SignContent.
  - Amethyst.processWelcome unwraps the group secrets, derives the
    welcome_key/nonce, AEAD-decrypts GroupInfo, verifies GroupInfoTBS
    with signer_pub, decodes the ratchet tree, and binds the joiner's
    leaf — end-to-end.
  - Post-join exporter secret matches ts-mls byte-for-byte.
  - Amethyst decrypt() parses the RFC 9420 §6.3.1 PrivateMessageContent
    framing (application_data<V> + FramedContentAuthData.signature<V> +
    zero padding) and verifies the sender's FramedContentTBS signature
    against Alice's leaf, for all three ciphertexts.

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 13:34:32 +00:00
Crowdin Bot 806f645341 New Crowdin translations by GitHub Action 2026-04-21 13:30:58 +00:00
Vitor Pamplona 4d00e835a8 Merge pull request #2473 from vitorpamplona/claude/emoji-list-set-management-2ib4I
Add comprehensive emoji pack management UI and functionality
2026-04-21 09:28:44 -04: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 ed7b406ae7 fix(marmot): populate UpdatePath on empty commits + carry pending signing key
Two bugs that surfaced together once FramedContentTBS signing started
being checked at decrypt time:

1. Empty-proposal commits (pure forward-secrecy / self-update) were
   skipping the UpdatePath entirely, so the sender's commit_secret
   fell back to an all-zero vector while a spec-compliant receiver
   would derive a real commit_secret from the path — diverging the
   epoch key schedule on the very next message. Per RFC 9420 §12.4.1
   the path value MUST be populated when the proposal list is empty.

2. When an Update proposal rotates our own signing key, commit()
   rebuilds the committer leaf for the UpdatePath and signs it with
   signingPrivateKey (the PRE-rotation key) before the post-commit
   promotion step swaps signingPrivateKey for pendingSigningKey. The
   receiver then stores our leaf with the pre-rotation signature_key,
   while we start minting FramedContentTBS signatures with the new
   key — every subsequent decrypt fails signature verification. Use
   pendingSigningKey when present to seal the UpdatePath leaf.

Un-Ignore the two empty-commit / multi-epoch tests that documented
this divergence (one in MlsGroupLifecycleTest, one in
MlsGroupEdgeCaseTest). Both pass now, as does the existing signing-
key rotation cross-member round-trip test.

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 13:19:59 +00:00
Claude 236ebfe4d6 fix(marmot): apply RFC 9420 PrivateMessageContent framing to app messages
MlsGroup.encrypt / decrypt used to pass raw application bytes as the
AEAD plaintext — no PrivateMessageContent framing, no FramedContentTBS
signature, no padding. Amethyst ↔ Amethyst round-trips worked (both
sides skipped it the same way) but every cross-implementation message
was unreadable.

Per RFC 9420 §6.3.1 the AEAD plaintext for an application PrivateMessage
is the serialized PrivateMessageContent:

    opaque application_data<V>;     // varint-prefixed payload
    opaque signature<V>;            // FramedContentAuthData.signature
    opaque padding[N];              // zero padding (N may be 0)

where the signature is `SignWithLabel(., "FramedContentTBS",
FramedContentTBS)` computed over (version || wire_format ||
FramedContent || GroupContext). This change wires both sides up
end-to-end:

* encrypt now builds FramedContentTBS via the new
  buildApplicationFramedContentTbs helper, signs it with the caller's
  signature private key, and writes application_data<V> || signature<V>
  (zero-length padding) as the AEAD plaintext.
* decrypt parses application_data<V> and signature<V> from the AEAD
  plaintext, asserts all remaining bytes are zero padding, rebuilds
  FramedContentTBS for the sender's leaf, and verifies the Ed25519
  signature against that leaf's signatureKey before returning the
  application payload. Non-application PrivateMessages now error
  (commit/proposal-inside-PrivateMessage was never correctly handled
  by the old raw-bytes path either; leaving that for a follow-up).

Un-Ignore MdkWelcomeInteropTest.testBobDecryptsMdkApplicationMessagesFromAlice
— Amethyst now decrypts and verifies each of the three openmls-authored
application messages against Alice's committer leaf.

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 12:49:01 +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 91f5d21cc3 test(marmot): MDK-authored Welcome interop vector + decryptor
Add a Rust generator under quartz/tools/mdk-vector-gen that emits a
Welcome, joiner KeyPackage, committer signer_pub, joiner's three
private keys, and a post-join MLS-Exporter KAT, using the same
openmls 0.8 backend MDK / whitenoise use. Commit its output at
quartz/src/commonTest/resources/mls/mdk-welcome.json.

MdkWelcomeInteropTest consumes the vector and proves Amethyst can:
  - parse a KeyPackage OpenMLS authored and verify its self-signature;
  - run the joiner's processWelcome end-to-end (HPKE unwrap of group
    secrets, welcome-key derivation, AEAD GroupInfo decrypt, ratchet
    tree decode, signer leaf lookup, GroupInfoTBS Ed25519 verify);
  - derive MLS-Exporter("marmot", "group-event", 32) byte-for-byte
    identically to openmls — the exact exporter Marmot uses for the
    outer ChaCha20 wrap on kind:445 events.

testBobDecryptsMdkApplicationMessagesFromAlice is @Ignored with a
detailed TODO because Amethyst's MlsGroup.encrypt/decrypt skip the
RFC 9420 §6.3.1 PrivateMessageContent framing (application_data<V> +
FramedContentAuthData + padding). Amethyst ↔ Amethyst works (both
sides omit it) but every cross-implementation PrivateMessage fails.
Tracked as a follow-up.

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 05:04:41 +00:00
Claude d7114fc6e2 fix(marmot): align MLS extension IDs and sender-data sample size with RFC 9420
Three latent spec divergences that only surfaced once we fed the Marmot
stack with a Welcome authored by openmls 0.8 (the MLS backend under MDK
/ whitenoise):

* ratchet_tree extension type was 0x0001 (RFC 9420 §13.3 assigns 0x0001
  to application_id; ratchet_tree is 0x0002). Welcomes and GroupInfos
  Amethyst emitted were unreadable to any spec-compliant peer, and
  Welcomes from peers couldn't be joined because the tree lookup failed.
* external_pub extension type was 0x0003 (that slot is
  required_capabilities; external_pub is 0x0004). External commits would
  have failed interop the same way.
* sender-data ciphertext_sample used AEAD.Nk (16 B) bytes of the
  ciphertext; RFC 9420 §6.3.2 mandates KDF.Nh (32 B). Every
  cross-implementation PrivateMessage would fail sender-data AEAD.

Amethyst ↔ Amethyst round-trip tests passed because both sides were
wrong the same way — the existing MlsConformanceTest hard-coded the
wrong extension IDs, so it even asserted the broken invariant.
Conformance test assertions updated to the spec-correct values.

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 05:04:25 +00:00
Claude fec6a7bba4 test(marmot): tighten MLS interop with IETF crypto vectors
Existing interop tests covered wire-format round-trips but never actually
ran ciphertext from IETF/OpenMLS/mls-rs test vectors through our crypto
code paths. That left two major spec bugs unverified by CI — the wrong
HPKE Welcome-context and the wrong "eae_prk" DHKEM label — because
both sides of every round-trip were wrong the same way.

Tighten coverage with cross-implementation KATs:

CryptoBasicsInteropTest
  + testEncryptWithLabelDecryptsIetfVector: decrypt the IETF
    encrypt_with_label vector directly (kem_output + ciphertext), not
    just our own round-trip output.

WelcomeInteropTest
  + testX25519Rfc7748Vectors: RFC 7748 §6.1 X25519 KATs.
  + testX25519DhMatchesJavaXdh: compare against JDK 11+ XDH on the IETF
    encrypt_with_label priv/kem_output.
  + testWelcomeInitKeyMatchesPrivateKey: init_pub == publicFromPrivate
    (init_priv) for every IETF welcome vector.
  + testIetfKeyPackageSelfSignatureVerifies: exercises Ed25519 +
    SignContext + LeafNode TLS encoding against OpenMLS output.
  + testIetfWelcomeFullDecryptAndGroupInfoSignature: end-to-end path
    through HPKE GroupSecrets decrypt → welcome_key derivation → AEAD
    GroupInfo decrypt → GroupInfoTBS Ed25519 verify against signer_pub.
  + testPassiveClientWelcomeDecryptsGroupInfoAndFindsLeaf: same
    end-to-end path on passive-client-welcome.json, plus ratchet-tree
    leaf lookup and signer-leaf GroupInfo verify. Skips vectors with
    external_psks (we don't model PSK secret derivation here) and with
    no ratchet tree (delivery-service lookup).

The TreeKEM UpdatePath KAT was considered but needs the full direct
path / copath / resolution / LCA plumbing from MlsGroup.processCommit
to pick which HPKE ciphertext a given leaf should decrypt. Not worth
the duplication in a test — the "UpdatePathNode" HPKE path now has KAT
coverage indirectly via the IETF encrypt_with_label vector above
(same HPKE stack, different label).

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 04:22:08 +00:00
Claude fa24f1c9a2 fix(marmot): use "eae_prk" label in DHKEM ExtractAndExpand
RFC 9180 §4.1 specifies DHKEM derives the shared secret as:

    eae_prk       = LabeledExtract("", "eae_prk", dh)
    shared_secret = LabeledExpand(eae_prk, "shared_secret",
                                  kem_context, Nsecret)

Hpke.extractAndExpand was using "shared_secret" for both the Extract and
Expand labels, so every HPKE decapsulation produced a key schedule that
disagreed with every spec-compliant implementation (OpenMLS, MDK/
whitenoise, OpenSSL, BoringSSL, Java XDH). Round-trips within Amethyst
worked because both sides were wrong the same way; cross-implementation
Welcome HPKE decryption always failed with AEAD BAD_DECRYPT.

Add IETF-vector interop tests locking in the fix:
- decrypt the crypto-basics encrypt_with_label KAT end-to-end,
- decrypt GroupSecrets from a passive-client Welcome vector,
- X25519 DH matches RFC 7748 §6.1 and Java XDH on a KAT input,
- init_pub in each Welcome vector matches publicFromPrivate(init_priv).

Drop the stale comment in CryptoBasicsInteropTest that claimed the vector
decryption was impossible due to an "X25519 DH discrepancy" — the DH was
always fine; the eae_prk label was the bug.

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 03:56:03 +00:00
Claude 38b0c681d1 fix(marmot): pass encrypted_group_info as HPKE context for Welcome
RFC 9420 §12.4.3.1 specifies the HPKE context for encrypted_group_secrets
must be the encrypted_group_info field of the Welcome:

    encrypted_group_secrets = EncryptWithLabel(init_key, "Welcome",
                                encrypted_group_info, group_secrets)

Both our buildWelcome and processWelcome were passing an empty context,
which let Amethyst↔Amethyst round-trips work, but made Welcome messages
sent by RFC-compliant implementations (MDK/OpenMLS, used by whitenoise)
fail with BAD_DECRYPT inside the HPKE AEAD open.

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 03:28:12 +00:00
Claude 876ca197de Merge async decryption of private emojis into integration branch 2026-04-21 03:25:06 +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 84fbf44e8d Merge pull request #2472 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-20 23:15:32 -04:00
Crowdin Bot 8ec7da0725 New Crowdin translations by GitHub Action 2026-04-21 03:13:18 +00:00
Vitor Pamplona f859449aba Merge pull request #2470 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-04-20 23:11:53 -04: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
Claude 7ee5f64fd8 fix(marmot): reject past/future-epoch commits instead of partially applying
After a group creator restarts the app, their own relay-echoed
kind:445 commit events (add-member, updateGroupExtensions, etc.) stop
being filtered by `inboundProcessor.processedEventIds` — that
in-memory dedup set doesn't survive process death. The echo then:

1. Outer-decrypts via a retained epoch key (we still hold those).
2. Gets parsed as a `PublicMessage` with `contentType = COMMIT`.
3. Flows into `applyCommit` → `groupManager.processCommit` →
   `group.processCommit`.

`MlsGroup.processCommit` is not atomic: it runs proposal application,
transcript-hash recomputation, epoch advance, key-schedule derivation,
and SecretTree rebuild **in place** before reaching the final
confirmation-tag check. If that check fails — which it always does
for an already-applied commit, because the tag was computed against
the original epoch's confirmation key and we're now several epochs
past — the exception fires after the mutations, leaving the group
context at a bogus "epoch + 1" with a completely new exporter
secret.

Net result on the wire: the creator's next outbound kind:445 is
encrypted with this rogue exporter secret. Every other member is at
the real epoch, so they all log "Outer decryption failed with current
and N retained epoch key(s)" and no one receives the message. The
creator's own self-echo still decrypts because his sentKeys /
self-state are internally consistent; only external observers are
cut off.

Fix: in `MarmotInboundProcessor.applyCommit`, after parsing the
PublicMessage header but **before** calling `processCommit`, compare
`pubMsg.epoch` with the current local `group.epoch`.
- `pubMsg.epoch < current` → commit is from the past (either our own
  already-applied echo or a stale relay replay). Return
  `GroupEventResult.Duplicate` and don't touch local state.
- `pubMsg.epoch > current` → we're behind; the inbound pipeline
  upstream needs to feed us the intervening commits in order. Return
  an error describing the gap without touching local state.
- Otherwise → proceed to `processCommit` as before.

Making `processCommit` itself atomic would be nicer, but is a much
larger refactor touching tree mutations, key-schedule state, and
per-sender ratchets. The epoch check catches the production scenario
at the right boundary — we never call into `processCommit` with
bytes that we know won't apply cleanly.

Regression test:
- `testCreatorDoesNotDoubleApplyOwnCommitAfterRestart`: David creates
  a group, adds Eden and Fred, persists. Simulates David's app
  restart by rebuilding the `MlsGroupManager` from the store. Then
  feeds David's own add-Eden commit back through the inbound
  pipeline — exactly what a relay echo would deliver. Asserts the
  result is `Duplicate`, and that David's epoch and exporter secret
  are unchanged afterwards. Before the fix, his epoch advances by
  one and his exporter secret drifts, which is exactly what the
  production logs showed.

- `testCreatorCanSendMessageAfterRestart`: save/restore round-trip
  sanity check that the restored manager's exporter secret matches
  what the members still at the same epoch hold, and that the first
  post-restart message from the creator is decryptable by an
  un-restarted member.

https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
2026-04-21 02:31:12 +00:00
Claude 621fe9c8c5 fix(marmot): derive SecretTree leaf secrets by walking down from root
Fred (third member of a three-member group, `leafIndex = 2`) crashed
with `StackOverflowError` on ART / NPE on JVM the moment he tried to
send his first application message. The recursion / null is in
`SecretTree.getNodeSecret`, which assumes `nodeIndex` is always a
direct child of `BinaryTree.parent(nodeIndex)`:

    val parentIdx = BinaryTree.parent(nodeIndex, BinaryTree.nodeCount(leafCount))
    val parentSecret = getNodeSecret(parentIdx)
    val leftIdx = BinaryTree.left(parentIdx)
    val rightIdx = BinaryTree.right(parentIdx)
    treeSecrets[leftIdx] = leftSecret
    treeSecrets[rightIdx] = rightSecret
    return treeSecrets[nodeIndex]!!

That invariant holds only for power-of-two trees. For Fred (leafCount
= 3, nodeCount = 5), `BinaryTree.parent(4, 5)` walks via
`parentInRange(5, 5)` and returns **3** (the root) — skipping the
virtual intermediate node 5 that would normally sit between node 4
and the root in a 4-leaf tree. But `left(3) = 1` and `right(3) = 5`;
neither is 4. The cache fills entries 1 and 5 from the root's secret,
node 4's entry is never populated, and the final
`treeSecrets[nodeIndex]!!` throws NPE. On Android the error path
instead re-enters `getNodeSecret` until the stack is exhausted.

The fix walks **down** from the root to the target leaf, picking
left or right at each step based on `targetNode < currentNode`
(holds in left-balanced MLS trees) and using `BinaryTree.left` /
`BinaryTree.right` which correctly descend through virtual
intermediate nodes. The node-level cache is no longer needed —
`getOrInitSender` caches the per-sender ratchet state once, so
re-deriving the leaf secret each time a new sender is initialized
costs a handful of HKDF calls at worst.

Side effects (all in the right direction):

- Re-enabled 6 previously `@Ignore`d tests in
  `MlsGroupLifecycleTest` and `MlsGroupEdgeCaseTest` that exercised
  exactly the three-member / non-full-tree path this fix addresses:
  `testThreeMemberGroup_SequentialAdditions`,
  `testExternalJoin_ZaraJoinsViaGroupInfo`,
  `testExternalJoin_ExporterSecretsAgree`,
  `testSigningKeyRotation_EpochAdvances`,
  `testEncryptDecryptAfterSigningKeyRotation`,
  `testPskProposal_EpochAdvancesWithPsk`. They now pass.

- `testEmptyCommit_AdvancesEpoch` and
  `testMultipleEpochTransitions_EncryptDecryptStillWorks` remain
  `@Ignore`d — they hit a **different** pre-existing bug where
  Alice's and Bob's exporter secrets diverge after a no-proposal
  commit (commit with only an UpdatePath). Unrelated to the
  non-full-tree fix; tracked separately. I retagged them with an
  explanatory note instead of the old "see
  testThreeMemberGroup_SequentialAdditions" reference.

- Fixed an unrelated typo in
  `testMultipleEpochTransitions_EncryptDecryptStillWorks` that
  asserted `7L` and then `6L` for the same epoch value.

Regression test:
- `testFredEncryptsAfterJoiningThreeMemberGroup` creates a
  three-member group via the production path (Alice adds Bob, then
  adds Fred; Bob processes the add-Fred commit), then has Fred
  encrypt an application message and asserts both Alice and Bob can
  decrypt it. Without the fix this throws NPE / stack-overflows at
  `encrypt()`.

https://claude.ai/code/session_014zfdNeeKAfU1zGGyFw4bUL
2026-04-21 01:49:05 +00:00
Crowdin Bot 76da39cc2a New Crowdin translations by GitHub Action 2026-04-21 01:12: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 900d953fb1 fix(live-chat): keep Top Zappers chips the same height when anonymous
The "Anonymous" chip rendered without a 24dp UserPicture, so its pill
shrank to the text's height and broke horizontal alignment with the
avatar chips in the row. Give the chip row a minimum height equal to
the avatar size so text-only variants stay visually aligned.

https://claude.ai/code/session_01WJA5PvDABegBYUu6h42YZi
2026-04-21 01:05:52 +00: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