Commit Graph

507 Commits

Author SHA1 Message Date
Vitor Pamplona c4cf92429a Merge pull request #2549 from vitorpamplona/claude/improve-desktop-design-iOokB
Add native OS theming and improve desktop UI layout
2026-04-25 19:28:51 -04:00
Claude 9d912ad82a fix(marmot): receive standalone SelfRemove proposals (test 15)
Two bugs that conspired to break interop test 15 once the test 14 OOM
was fixed:

1. **No receive path for standalone PublicMessage proposals.**
   wn/openmls publishes a non-admin's `SelfRemove` as a kind:445 carrying
   a `PublicMessage(content_type=PROPOSAL)` envelope and waits for an
   admin to fold it into the next commit. Quartz's `MarmotInboundProcessor`
   answered every such event with `Error("Standalone proposals not yet
   supported")` and dropped it. The admin's subsequent commit then
   failed with `Commit references unknown proposal (ref not found in
   pending proposals)` because nobody had staged the SelfRemove.

   Add `MlsGroup.receivePublicMessageProposal(pubMsg)` that:
   - rejects mismatched epoch / group_id / sender,
   - reconstructs the FramedContentTBS exactly as the proposer did and
     verifies the leaf signature,
   - verifies the membership_tag against the current epoch's
     membership_key (same threat model as inbound PublicMessage commits),
   - decodes the inner Proposal (only `SelfRemove` is accepted today —
     other types come bundled in commits' `proposals` lists),
   - stages the proposal in `pendingProposals` so a later commit can
     resolve its `ProposalRef`.

   `processPublicMessage` now routes `ContentType.PROPOSAL` through
   that helper and returns a new `GroupEventResult.ProposalStaged`
   variant (also surfaced as `MarmotIngestResult.ProposalStaged`),
   replacing the old hard-error path.

2. **Wrong `ProposalRef` hash input.** RFC 9420 §5.2 specifies that a
   `ProposalRef` hashes the **encoded `AuthenticatedContent`** that
   delivered the proposal, not the bare `Proposal` struct. Quartz was
   hashing `proposal.toTlsBytes()` only — fine for our local-only
   flows where commits inline rather than reference our own pending
   proposals, but fatal once we needed to match wn's reference to an
   inbound proposal.

   Extend `PendingProposal` with an optional `authenticatedContentBytes`
   field. The standalone-proposal receive path captures the full
   `wire_format || FramedContent || FramedContentAuthData` envelope at
   stage time. The reference-resolution code in `processCommitInner`
   prefers those bytes when present and falls back to the bare-proposal
   hash for locally-proposed entries (which never get referenced
   today).

Marmot interop score: 14/16 → 15/16 (test 9 — amy's kind:7 reaction
triggers a `SecretReuseError` on B's wn — is unrelated to this path
and remains for follow-up).

https://claude.ai/code/session_013VYkpz8P1mPh9Ejxy9anhJ
2026-04-25 02:22:04 +00:00
Claude 4b99761960 Merge remote-tracking branch 'origin/main' into claude/improve-desktop-design-iOokB 2026-04-24 18:23:51 +00:00
Vitor Pamplona 141cbf93c3 Merge pull request #2538 from vitorpamplona/claude/add-reply-notifications-GvMOn
Add reply and mention notifications for public notes
2026-04-24 13:27:14 -04:00
Claude b69224433c Merge remote-tracking branch 'origin/main' into claude/improve-desktop-design-iOokB 2026-04-24 16:20:29 +00:00
davotoula 999184cced optimise imports 2026-04-24 18:15:58 +02:00
Claude c5712af2de refactor(desktop): move commons/ui/chat into desktopApp
Every file under commons/src/commonMain/kotlin/.../commons/ui/chat/
(ChatBubbleLayout, ChatMessageCompose, ChatroomHeader,
DmBroadcastBanner, DmBroadcastStatus, UserDisplayNameLayout) was
imported only by desktopApp. Android has its own parallel
implementation at amethyst/.../chats/feed/, so nothing shared.

Moved the six files into desktopApp/.../ui/chats/ alongside
ChatPane.kt, DesktopMessagesScreen.kt, etc. — same package as every
existing caller, which lets us drop six `import` lines from
ChatPane.kt + DmSendTracker.kt + DesktopMessagesScreen.kt. Empty
commons/ui/chat/ directory removed.

https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
2026-04-24 16:09:25 +00:00
Claude 7da69fbaee fix(desktop): hover-state polish + Search width cap + reserve reaction-icon space
NoteCard hover (header+text inner clickable + avatar/name chip):
- Clip(RoundedCornerShape(8.dp)) before .clickable on the header+text
  Column so the ripple rounds instead of cutting a hard rectangle.
- Avatar+name chip gets a stadium clip (RoundedCornerShape(100.dp))
  before its clickable — matches how Slack / Notion / Linear render
  user-pill hover states.

Chat bubble hover (ChatBubbleLayout):
- combinedClickable was applied outside the Surface's shape clipping,
  so the ripple ignored ChatBubbleShapeMe/Them. Moved the shape into a
  named val and added Modifier.clip(bubbleShape) ahead of
  combinedClickable. Hover now rounds with the bubble.

Chat reaction icon (ChatPane detailRow):
- On hover the "add reaction" icon used to conditionally appear and
  shift every other element in the detail row, causing the whole list
  to reflow up or down. Wrapped in a Box with Modifier.alpha that
  fades in/out — the icon is always laid out so the row stays fixed.
  Button is disabled when not hovered so it's click-through when
  invisible.

Search text field:
- Now padded by LocalReadingSidePadding so it stays inside the 720dp
  reading column on wide displays (previously the search Row's
  fillMaxWidth spanned the whole window after the ReadingColumn
  refactor).
- Bumped height from 40dp → 44dp. M3 OutlinedTextField has ~16dp of
  internal vertical content padding that was clipping the bodyMedium
  placeholder at 40dp. Same change for the chat input.

https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
2026-04-24 15:55:57 +00:00
Claude c4ae0a30eb fix(desktop): width cap ordering, platform icon weight, Settings hierarchy
Three related fixes:

ReadingColumn modifier ordering:
- fillMaxSize().widthIn(max = 720) was locking width to parent before
  widthIn could cap it, so the cap had no effect in practice. Switched
  to widthIn(max = 720).fillMaxWidth().fillMaxHeight() which now caps
  correctly on wide displays. Same fix applied to the three screens
  that use Box+widthIn inline (UserProfile, Search, Settings).

Platform-aware Material Symbols weight:
- ProvideMaterialSymbols now takes an optional weight parameter that
  defaults to MaterialSymbolsDefaults.WEIGHT (300) so Android keeps
  current behavior. Desktop passes PlatformIconWeight.current which
  maps:
    macOS   → 200 (thin, matches SF Symbols stroke)
    GNOME   → 300 (matches libadwaita symbolic icons)
    KDE     → 300 (matches Breeze)
    Windows → 400 (matches Fluent Icons)
    other   → 300

Settings section hierarchy:
- "Wallet Connect (NWC)" and "Relay Settings" section headers dropped
  from titleLarge (22sp) to titleSmall (14sp) so they're smaller than
  the "Settings" screen title (titleMedium 16sp). Restores the
  visual hierarchy the user reported inverted.

https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
2026-04-24 15:27:53 +00:00
Claude 86a86e1426 refactor(notifications): observer takes a single predicate
Filter.match is itself just a boolean predicate, so carrying a Filter and
a separate composition predicate on NewEventMatchingFilter duplicated the
abstraction. Collapse to one predicate:

- NewEventMatchingFilter(predicate, onNew): drops the Filter parameter.
- LocalCache.observeNewEvents(predicate): primary API.
- LocalCache.observeNewEvents(filter): kept as a one-liner that forwards
  filter::match, so existing feed/DAL callers are unchanged.

In the dispatcher the freshness check, kind check, p-tag match, and
since cutoff now live in a single lambda, short-circuiting on cheap
kind comparison first. NOTIFICATION_KINDS is now a Set<Int> for O(1)
membership instead of O(n) List.contains — cheap, but on a hot path
that runs for every new cache insertion it pays for itself.

https://claude.ai/code/session_01GQDJxiHPogdzCNhUBN7Pjc
2026-04-24 14:39:42 +00:00
Claude ff12b6abbe feat(desktop): Messages-style headers across every remaining screen
Rolled the compact header pattern out to the screens the earlier pass
missed. Each one now lives on the same padding/typography rhythm as
Messages: Row(fillMaxWidth, padding horizontal = 12, vertical = 8),
titleMedium for labels, IconButton size = 32dp with 20dp icons tinted
with colorScheme.primary.

- ThreadScreen: back button + "Thread" title. Was headlineMedium with
  bottom-only padding; now compact row.
- UserProfileScreen: back + "Profile" title on the left, Edit /
  Follow buttons kept on the right (they stay as text buttons — they
  represent destructive intent, not icon affordances).
- ArticleReaderScreen: back + "Article" title; zoom % label still
  surfaces next to the title when != 100%.
- ArticleEditorScreen: back IconButton + "Article" title (was a text
  "Back" OutlinedButton with no title). Save / Publish buttons kept
  on the right — same reasoning as UserProfileScreen.
- ChessScreen: back (conditional) + "Chess" / "Live Game" title;
  refresh + New Game converted from Button → IconButton so the
  header reads at the same scale as the rest.
- RelayDashboardScreen: had no header at all, just a PrimaryTabRow.
  Converted Monitor / Configure to FilterChip tabs-first (matching
  Feed / Reads) so the selected tab is the title.

FeedHeader relocation:
- Moved commons/ui/feed/FeedHeader.kt → desktopApp/ui/FeedHeader.kt.
  Only NotificationsScreen.kt referenced it, and dropping the import
  from that file was enough because it's now in the same package.
- Removed the empty commons/ui/feed/ directory.

https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
2026-04-24 14:38:43 +00:00
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