Replace the hard-coded Material3 dark theme with a per-OS theme that
follows the host system's appearance, accent color, fonts, and rounding
language. Adds a `desktop/platform/` module with:
- PlatformInfo: detects macOS, Windows, GNOME, KDE, other Linux via
XDG_CURRENT_DESKTOP / DESKTOP_SESSION.
- PlatformAppearance: reads OS dark/light preference (defaults on macOS,
gsettings on GNOME, kreadconfig on KDE, registry on Windows) and
refreshes on window focus.
- PlatformAccent: pulls the user's accent color from each OS (named
AppleAccentColor, gsettings accent-color, kdeglobals AccentColor,
DWM AccentColor) and falls back to Amethyst purple.
- PlatformFonts: resolves the OS's preferred UI font via Skia's
FontMgr (SF Pro Text on macOS, Segoe UI Variable on Win 11,
Cantarell/Adwaita Sans on GNOME, Noto Sans on KDE).
- PlatformShapes / PlatformTypography / PlatformColorScheme: per-OS
rounding (macOS 8/10/14, libadwaita 9/12/16, Breeze 6/8/12, WinUI
4/8/8), letter-spacing tightening for SF Pro / Adwaita, and surface
tones from each OS's reference palette.
macOS gets native chrome treatment: `apple.laf.useScreenMenuBar`
routes the MenuBar to the system menu bar; `apple.awt.transparentTitleBar`
+ `apple.awt.fullWindowContent` lets the deck/sidebar extend under the
traffic lights; a 28dp top strip in MainContent reserves space so
content doesn't underlap them.
DeckSidebar bumps from 48dp to 56dp (desktop density) and switches its
background to the Material3 `surfaceContainer` token, which our new
per-OS schemes drive to the right tone for each platform.
https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
Delete() returning false on a still-existing file meant we silently lost
scratch state for MLS group / key-package / message stores. Route the
four call sites through a `deleteOrWarn` helper that logs via the
KMP-safe quartz Log when the file refuses to disappear.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace FilledTonalIconButton with FilledTonalIconToggleButton so the
container color reflects raised/lowered state, and drop the tautological
`if (handRaised) PanTool else PanTool` conditional flagged by Sonar.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
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
A previous commit misread the spec: p-tags on a WakeUp identify the
AUTHORS of the referenced events (the people whose events are the
subject of the wake-up), not the recipients. The consumer's existing
npub-in-p-tag match is therefore correct — "is this logged-in account
the author of a referenced event?".
- Revert WakeUpEvent.build() back to notify(about.toPTag()) and replace
the comment with a spec-accurate one.
- In wakeUpFor, source author pubkeys from event.authorKeys() (p-tags,
canonical) first, merge in the e-tag author hints, and fall back to
the WakeUp signer only when both are empty. Bound by MAX_WAKEUP_REFS.
computeReplyTo and the referenced-event fetch path remain untouched —
those fixes are orthogonal to the p-tag semantic.
The WakeUp notification path had three latent bugs that caused pushed
WakeUps to silently not deliver anything to the user:
- WakeUpEvent.build() tagged the about event's AUTHOR as the recipient
(via EventHintBundle.toPTag()), so a WakeUp about a zap from Alice to
Bob would p-tag Alice. The consumer matches recipients by p-tag, so
Bob — the intended recipient — was filtered out. Forward the about
event's audience (its own p-tags) instead.
- wakeUpFor() passed the WakeUp's own note to EventFinderQueryState. The
finder's filterMissingEvents only queries for the note itself (already
in cache) or its replyTo (empty because computeReplyTo had no WakeUp
case). The referenced events were never REQ'd on relays. Link the
referenced events via computeReplyTo and subscribe the finder on each
referenced note directly so filterMissingEvents picks them up.
- UserFinder was subscribed against the WakeUp's own pubKey (typically a
push bot), not the author of the event the notification is about.
Pull author pubkeys from the e-tag author hint, falling back to the
WakeUp signer only when the hint is absent.
Also: bound e-tag count per WakeUp to 16 to prevent subscription
flooding, log the 30s timeout, extract WAKEUP_WINDOW_MS constant, drop
the now-unused Note parameter from wakeUpFor.
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
WelcomeEvent (kind 444) has no `p` tag, so the cache-observer path's
tag-based account matching in consumeFromCache silently dropped them.
Route Welcomes instead via a dedicated notifyWelcome entry point invoked
from processMarmotWelcomeFlow — the one place we reliably know which
account the invite was for (the one whose signer just unsealed it and
joined the MLS group).
Drop the dead processWelcome workaround from the old notify(WelcomeEvent)
body; with the cache-first architecture, MLS processing always completes
before the notification fires.
- NotificationDispatcher: remove WelcomeEvent from the observer filter,
expose a public `notifyWelcome(event, account)` entry.
- EventNotificationConsumer: rename notify(WelcomeEvent) to public
notifyWelcome, add notification-enabled + foreground-suppression checks,
drop the manager.processWelcome fallback.
- DecryptAndIndexProcessor: on WelcomeResult.Joined, invoke
Amethyst.instance.notificationDispatcher.notifyWelcome to fire the
"You've been added to <group>" notification.
The broadcast banner was rendered full-width with a 50dp bottom
offset that assumed a bottom navigation bar underneath. On screens
without the bottom bar the flat rectangle hovered above empty space,
which looked like a misaligned attachment rather than an overlay.
Restyle the banner as a floating card: 94% width (capped at 560dp for
tablets), rounded corners, shadow elevation. Keep it above a bottom
bar when present (50dp bar + 8dp float gap), but since it now reads
as an intentional floating toast, the same offset looks correct on
screens without a bottom bar as well.
The existing testReaddAfterRemove_RejoinerCanEncryptAndDecrypt in
MlsGroupLifecycleTest exercises the MLS layer (two raw MlsGroup
instances, admin kicks peer, fresh KP, rejoin). Nothing covered the
same flow one level up at the MlsGroupManager layer, where the
persistent state store, retained-epoch wipe, and Welcome routing for
the same nostrGroupId actually live.
Adds testLeaveAndRejoin_SameGroupIdEndToEnd: two MlsGroupManager
instances (Alice + Bob) with separate InMemoryGroupStateStores go
through the full cycle — Alice creates with a MarmotGroupData
extension, Bob joins via Welcome, bidirectional message round-trip,
Bob calls manager.leaveGroup (asserts store + retained epochs both
wiped), Alice removes Bob's leaf, Bob's second KeyPackage is admin-
added, Bob processes the second Welcome under the same nostrGroupId,
and bidirectional round-trip works again at the new epoch.
Closes the "MarmotManager.leaveGroup + rejoin" and "persistent-store
leave + fresh Welcome for same nostrGroupId" gaps from the review.
Documents inline why Alice drives the eviction commit directly: the
standalone-proposal ingestion pipeline (MarmotInboundProcessor) is a
separate, still-unimplemented concern.
https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
Rely on the existing Account → DecryptAndIndexProcessor flow to unwrap
GiftWrap → Seal → inner payload, and observe the final inner kinds instead
of duplicating the unwrap logic in EventNotificationConsumer. The only wrap
left to handle in the notification path is the outermost server-added
GiftWrap from FCM / UnifiedPush, whose recipient tag the push server strips.
- Let LocalCache store kind:444 WelcomeEvent (consumeRegularEvent). The Seal
handler used to early-return on Welcomes because kind:444 had no cache
handler; now it does, so Welcome lands in cache and still routes to MLS
processing.
- DecryptAndIndexProcessor: cache the inner event first, then branch into
processMarmotWelcomeFlow vs eventProcessor.consumeEvent — both paths now
emit to the observer.
- NotificationDispatcher observes the final payload kinds (ChatMessage 14,
ChatMessageEncryptedFileHeader 15, CallOffer 25050, Welcome 444) in
addition to the direct-arrival kinds. Wrappers (1059/21059/13) are no
longer in the filter.
- EventNotificationConsumer drops unwrapFully entirely; consumeFromCache
now just matches the account by `p` tag on an already-unwrapped event.
- New PushWrapDecryptor helper probes each saved account signer to decrypt
the outer server wrap and feeds the inner GiftWrap to LocalCache — where
the per-account EventProcessor takes over.
- FCM (PushNotificationReceiverService) and UnifiedPush (PushMessageReceiver)
call PushWrapDecryptor instead of LocalCache.justConsume directly.
Marmot groups without a configured display name now fall back to the
NIP-17 strategy: a stacked avatar of the members and a comma-separated
list of their first names. Applied to both the group list row and the
chat top app bar.
NonClickableUserPictures and DisplayUserSetAsSubject get List<HexKey>
overloads so the renderers can be reused without dragging Marmot through
ChatroomKey (which is also a NIP-17 dedup key — different Marmot groups
can share the same member set).
Tests 14 (wn removes A) and 15 (wn_c leaves) were skipped with a
rationale referring to the pre-filter applyUpdatePath that rejected
wn/mdk-core commits whose UpdatePath omitted nodes with empty-copath
resolution per RFC 9420 §7.7. That code path was fixed in 3279c246
("use filtered direct path in UpdatePath per RFC 9420 §7.9") which
made both outbound generation and inbound validation operate on the
filtered path. The inverted-role interop tests can now actually run.
Test 14 creates a dedicated Interop-14 group, has wn_b admin-remove A,
and asserts amy flips to "not_member" on her next sync.
Test 15 creates Interop-15, has wn_c self-remove, nudges wn_b to
commit the SelfRemove via a rename, and asserts amy sees (C gone,
A present) AND can still publish a kind:9 that B receives — proves
the filtered commit applied cleanly and encryption survived.
https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
- 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
After the harness move from tools/marmot-interop/ to cli/tests/,
three doc locations were still describing the old layout:
- cli/DEVELOPMENT.md § Testing — the "round-trip" row pointed at a
non-existent `cli/src/test/resources/scripts/`, and the "interop
with other clients" row claimed it was out of scope. Both now name
`cli/tests/marmot/` and `cli/tests/dm/` with what each covers, and
the interop-script template points at the concrete test files
rather than re-inventing a smaller example.
- cli/README.md — the "For an interop-test script template" pointer
now links to `cli/tests/README.md` alongside the DEVELOPMENT.md
section.
- .claude/skills/amy-expert/SKILL.md — the "Where things live" tree
omitted the new `tests/` subtree entirely. Added it with a
one-line description per suite. Also extended the wire-up checklist
with a step 7: add a harness case when a new verb changes observable
wire behaviour.
No content changes elsewhere; just path corrections.
The compact GroupRelayStrip at the top of the screen uses a coloured dot
to indicate whether each relay has carried any kind:445 traffic in the
last 7 days, but nothing tells the user what the dot means or how stale
a given relay actually is. Adds a detailed "Relays" section to the
scrollable body that lists each group relay with its URL, status dot,
and a "last event 3m ago" line derived from the existing relayActivity
StateFlow on MarmotGroupChatroom.
Rows click through to the existing RelayInfo route so users can inspect
NIP-11 metadata, just like the strip. Uses the shared timeAgo helper
for consistent formatting with the rest of the app.
https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW
Two fixes needed to make the DM harness actually pass end-to-end:
1. Bind the loopback relay to 127.0.0.2 instead of 127.0.0.1.
Quartz's `RelayTag.parse` (called from `ChatMessageRelayListEvent.
relays()`) rejects any URL for which `isLocalHost()` returns true —
the substring check matches `"127.0.0.1"` among others. That means a
kind:10050 event whose `relay` tag reads `ws://127.0.0.1:8090/`
silently parses to an empty relay list, and `RecipientRelayFetcher`
reports `dmInbox == []`. Under strict mode `dm send` then correctly
refuses with `no_dm_relays` — hiding an otherwise-valid test setup.
`127.0.0.2` is the cleanest fix: still pure loopback (no network
traffic, no config needed), not matched by `isLocalHost()`, and
Linux binds any IP in 127.0.0.0/8 without setup. The Marmot harness
is unaffected because its tests never depend on parsing their own
kind:10051/10002 relay tags back out — they hit the bootstrap
fallback path.
marmot/setup.sh's `start_local_relay` now reads `${RELAY_HOST:-
127.0.0.1}` so both harnesses can share it; the DM harness sets
`RELAY_HOST=127.0.0.2` by default and exposes `--host` as an escape
hatch.
2. Replace dm-06 cursor-advance check with `--since` filter check.
The original assertion ("second no-flag `dm list` returns 0") was
wrong for the actual design: `filterGiftWrapsToPubkey` always
subtracts a 2-day lookback from `since` (required to catch NIP-59's
randomised `created_at`), so a repeat no-flag query legitimately
re-pulls everything in the last two days. The cursor advances the
scan window for efficiency, not for idempotency. dm-06 now verifies
`--since` actually filters: query with `--since <newest + 2 days +
1 hour>` — after the filter's lookback subtraction, the effective
floor lands past every message, and the list comes back empty.
Also: added `protoc` to the DM preflight checks so the missing-dep
error comes before 4 failed cargo retries.
Result on this machine: 6/6 pass, two clean runs in a row.
- 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
KeyPackageFetcher.fetchKeyPackage used client.fetchFirst, which closes
the subscription on the first event a relay sends. For kind:443
(KeyPackage) that's wrong after a rotation: the publisher does not
replace the prior event (kind:443 is not addressable), so relays may
still hold the old KP and whichever one wins the race gets returned.
Switch to fetchAll — drain every matching event until EOSE, then pick
the one with the highest created_at. Keeps freshly-rotated bundles
reachable and matches whitenoise/mdk semantics.
Unskips interop test 16 (wn rotates, amy discovers). The inverse path
(test 13) already worked because `wn keys check` looks up via the
addressable kind:10051 index instead of kind:443 directly.
https://claude.ai/code/session_01Unm6uLHGLj9UcBY7hWfJVW