Commit Graph
512 Commits
Author SHA1 Message Date
Claude 9bb1d3aaf2 fix: stabilize flaky LocalRelayStoreHydrationTest against GC eviction
DesktopLocalCache stores Users in a WeakReference-backed LargeSoftCache. The
followee User in kind3IsHydratedBeforeKind0SoMetadataLoadsForFollowedAuthors is
created only during hydrate's kind:0 phase and has no Note referencing it, so it
is only weakly reachable once hydrate returns. A GC landing between hydrate()
and the assertions evicted it, flaking the test (reproduced deterministically by
forcing System.gc()).

Pin a strong reference to the followee's User for the duration of the test so
the cache cannot evict it, mirroring how followed users stay reachable via live
account/UI state in the running app. The ordering invariant the test asserts is
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHQ3g7wD9WbDvj7NspiAWW
2026-07-04 17:48:18 +00:00
davotoula 206c0979b1 Code review:
- align voice-file debug log with deleteOrWarn's is-gone contract
- Convert the delete-then-warn sites the sweep left hand-rolled in already
  touched files: ThumbnailDiskCache corrupt-file and temp-thumbnail cleanup,
  NappletBlobCache.put leftover temp, and SecureKeyStorage's bare delete of
  the fallback key file (the highest-stakes delete in that file).
- Drop the exists() guards left layered over deleteOrWarn — the helper
  already treats an absent file as silent success.
- Collapse AccountManager's legacy-file triple into a loop and drop the
  stale "silent" from its comment.
- Snapshot lastModified alongside length in NappletBlobCache.trimToSize so
  sortedBy compares in-memory values instead of stat-ing per comparison.
- Promote DesktopTorManager's private restrictToOwner into a shared
  File.restrictToOwner(tag) in commons (600 files / 700 dirs) — the repo's
  sixth private copy of this pattern was one too many; the remaining copies
  can migrate incrementally
2026-07-04 15:53:02 +02:00
davotoula 27650f2f77 fix: handle remaining unchecked File.delete() and Tor dir permission results 2026-07-04 15:52:33 +02:00
Claude 9bb75d9bbd fix(quartz/desktop): close socket on connect failure; NODELAY for desktop pre-init client
TcpNoDelaySocketFactory's connecting overloads used
`socket().apply { connect(...) }`, which leaks the file descriptor if
bind/connect throws (the JDK's connecting Socket constructors close on
failure; ours didn't). Wrapped in a helper that closes on throw. OkHttp
only calls the no-arg overload, so this guards any other direct caller.

DesktopHttpClient's pre-init `simpleClient` (direct relay sockets opened
before setInstance) now gets the same TcpNoDelaySocketFactory as
directClient. failClosedClient is left as-is: it's a SOCKS client and
OkHttp bypasses the socket factory for SOCKS proxies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 02:32:08 +00:00
Claude fd6662ca30 perf(quartz): TCP_NODELAY for every relay websocket client — kills CLOSE→REQ Nagle stalls
Found while attributing the small-REQ wire floor (backlog item 6,
latency half): geode's new WireReqFloorBenchmark measured a flat
43.7 ms per REQ round trip that survived every server-side change —
store configs, dispatchers, the pump — and then vanished when the
round's preceding CLOSE was dropped. Root cause is client-side: OkHttp
does not set TCP_NODELAY, relays never answer a CLOSE (NIP-01), so its
bytes sit unACKed for the peer's ~40 ms delayed-ACK window and Nagle
holds the next REQ behind them. CLOSE-then-REQ is a Nostr client's
hottest pattern — every feed/filter switch.

relayBench's harness client already shipped a no-delay socket factory
(which is why benchmark numbers never showed the stall) but the
production clients did not. New TcpNoDelaySocketFactory (quartz
jvmAndroid, next to BasicOkHttpWebSocket) is now used by the Android
relay pool factory, the Desktop relay client, amy's relay connections,
and geode's mirror worker. Direct connections only — SOCKS/Tor paths
are untouched.

With the factory, the benchmark puts geode's ~21-row REQ at ~1.25 ms
on the wire (matching relayBench): ~0.6 ms Ktor CIO+OkHttp loopback
floor, ~0.5 ms per-REQ server work (already investigated). Per-frame
burst cost measured negligible and the pump adds ~nothing, so the
send-path latency angle of backlog item 6 is closed as not-a-problem;
its ingest-CPU share remains a separate throughput question. Findings
recorded in quartz/plans/2026-07-04-small-req-floor.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 00:38:09 +00:00
Vitor PamplonaandGitHub 1460412989 Merge pull request #3458 from vitorpamplona/claude/nostrclient-receiver-perf-d8u27o
Add production benchmarks and negentropy sync optimizations
2026-07-03 10:39:12 -04:00
Claude f699fac16c perf: adopt CachingEventDecoder in Android, Desktop, and amy clients
Passes decoder = CachingEventDecoder() at all four NostrClient
construction sites: the Android app pool (AppModules), the Android
crawl client (buildCrawlClient — Event Sync / Cashu discovery, the
duplicate-heaviest path), the desktop RelayConnectionManager, and
amy's Context. Duplicate EVENT frames (14-57% of production traffic)
now skip the full JSON re-parse; dispatch semantics unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 14:37:25 +00:00
Vitor PamplonaandGitHub c90c1b4768 Merge pull request #3459 from vitorpamplona/claude/dispatchers-thread-caps-s8yp4c
Add lock-free concurrent collections and fix UDP socket threading
2026-07-03 10:37:00 -04:00
nrobi144andClaude ecedc4affe feat(desktop): notifications redesign — inbox UX, native OS toasts, shared filter
Rework the Amethyst Desktop notification experience end-to-end.

**In-app inbox** (`desktopApp/…/ui/NotificationsScreen.kt`)
- Dedicated Notifications entry in the sidebar and a new
  `DeckColumnType.NotificationSettings` overlay reachable from a ⚙ button
  in the column header — back button renders automatically via
  `navState.hasBackStack` in deck mode and via body Back in single-pane.
- Redesigned column: filter tabs (All / Mentions / Replies / Reactions /
  Zaps / Reposts / DMs) with per-kind counts, grouped cards (reactions
  and reposts collapse to "N reactions on your post" per day), unread
  dots driven by a persisted `lastReadAt` per pubkey, freshest-first
  ordering via `compareByDescending { timestamp }`.
- User metadata: avatars + display names on every row (including reactor
  strip inside grouped cards) resolved from `LocalCache`, with
  `metadataVersion` observation. Zap sender is the actual zapper (via
  `NotificationItem.effectiveAuthorPubKey` unwrapping
  `LnZapEvent.zapRequest.pubKey`), not the LNURL provider.
- Reaction/repost group cards are clickable → thread; DM cards click →
  Messages column; expandable to show note preview + reactor list.
- `NotificationSettingsScreen`: master toggle, 7 per-kind toggles,
  manual-DND dropdown, preview-privacy switch, per-platform status
  card, "Send a test toast". Permission-aware button adapts across
  NotRequested → Granted / Denied / BundleRequired with a macOS System
  Settings deep-link. State syncs with OS-level changes via
  `LocalWindowInfo.isWindowFocused` regain refresh.

**Native OS notifications** (`commons/…/moderation/notifications/`)
- `NotificationDispatcher` interface + `PermissionState` sealed
  hierarchy in `commonMain`. JVM impl `NucleusNotificationDispatcher`
  routes through Nucleus (three per-OS artifacts: macOS
  `UNUserNotificationCenter` via Swift/JNI, Windows WinRT toast via
  JNI, Linux libnotify via D-Bus). Falls back to `AwtTrayNotifier` when
  native lib fails to load. Async `requestPermission` +
  `refreshPermission` bridge Nucleus's callback API to `suspend`.
- `DesktopNotificationAutoDispatcher` subscribes to
  `DesktopLocalCache.eventStream.newEventBundles` and fires OS toasts,
  applying a 9-check suppression pipeline: kind allow-list, master
  toggle, per-kind toggle, DND, window-focused, cold-boot
  (event.createdAt < sessionStart or >30s stale), macOS permission,
  semantic accept, 30s per-(kind,event-id) dedupe. Wired in Main.kt
  with DisposableEffect(loggedIn.pubKeyHex); window focus tracked via
  LocalWindowInfo → StateFlow.
- Adds `windows { menu = true; shortcut = true }` to
  `desktopApp/build.gradle.kts` so AUMID persists and Windows toasts
  survive reboot.

**Shared notification filter** (`commons/…/moderation/notifications/NotificationKinds.kt`)
- Extracted from Android's `NotificationFeedFilter`. Exposes
  `SUBSCRIPTION_KINDS` (13 kinds: text, DMs kind 4 + 14 + 1059
  gift-wrap, encrypted-file-header, comments 1111, reactions, reposts,
  generic reposts, channel messages 42, nutzaps 9321, zap receipts
  9735, onchain zaps 8333), `subscriptionFilter(pubKey, since, limit)`
  builder that `FilterBuilders.notificationsForUser` delegates to, and
  `tagsAnEventForUser(event, myPubKey, isTargetAuthoredByMe)` semantic
  gate. Reactions/reposts require target-author-match; other kinds
  require `p=me`. Fixes a bug where the helper defaulted to accept and
  let cache-seed leak "mentioned you" notifications from unrelated
  text notes.
- Android's `NotificationFeedFilter.NOTIFICATION_KINDS` now spreads
  `SUBSCRIPTION_KINDS` + Android-only extras (badges, git, highlights,
  polls, videos, voice, live-activities), so a change on either side
  propagates. Downstream push consumers (`NotificationDispatcher.kt`,
  `EventNotificationConsumer.kt`) read the resulting set transparently.
- Content sanitizer strips control chars, RTL overrides, zero-width
  chars, and URLs from toast titles. DM cards never render ciphertext
  body (decryption pipeline deferred).

**Tests** — `NotificationKindsTest` covers 17 scenarios: reactions
target-author-mismatch rejection, own-event rejection except zap kinds,
p-tag routing for text/DMs/zaps/nutzaps/gift-wraps/channel messages,
`SUBSCRIPTION_KINDS` sanity + `subscriptionFilter` shape.

**Testing constraint**: macOS OS notifications require a bundled
process. `gradle run` will always show BundleRequired — use
`gradle :desktopApp:runDistributable` and open the resulting
`Amethyst.app`. First permission grant surfaces the app in
System Settings → Notifications.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 15:19:18 +03:00
Claude ca5ae978fb perf: add lock-free-read ConcurrentLruCache, use on two hot read paths
Part C of the dispatchers/thread-caps audit. Both LnurlEndpointCache and
DesktopCachedRichTextParser were bounded caches backed by a LinkedHashMap
behind a single monitor (@Synchronized / Collections.synchronizedMap with
accessOrder). An access-order map structurally mutates on get, so every
read took the lock — serializing all readers on paths that are hot
(kind-9735 zap-receipt validation; feed rich-text rendering).

Add ConcurrentLruCache<K, V> in quartz utils: storage is a
ConcurrentHashMap so get is lock-free; writes + eviction run under a small
write lock that is off the read path. Eviction is least-recently-put order
(get does not refresh recency) — exactly what LnurlEndpointCache already
did, and fine for the deterministic rich-text parse cache.

Point both caches at the shared helper. Covered by a new
ConcurrentLruCacheTest (round-trip, eviction order, re-put recency
refresh, get-does-not-refresh, clear, and a concurrent size-bound smoke
test); the existing LnurlEndpointCacheTest still passes unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANuUziXKRafSTBxbh4SMoq
2026-07-02 16:21:27 +00:00
nrobi144 33bb81dddb Merge remote-tracking branch 'upstream/main' into feat/desktop-privacy-lock
# Conflicts:
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
2026-07-02 07:20:44 +03:00
Claude 461aa57b57 fix: use AmethystDefaults search relays and drop dead relay.nostr.band
relay.nostr.band has been decommissioned. Remove it from every runtime
relay list and route search-relay defaults through the shared
AmethystDefaults.DefaultSearchRelayList in commons:

- amy NipCommand: SEARCH_RELAYS now = DefaultSearchRelayList (drops the
  hardcoded relay.nostr.band/nostr.wine pair; RelayUrlNormalizer import
  no longer needed).
- desktop DesktopRelayCategories: DEFAULT_SEARCH_RELAYS now =
  DefaultSearchRelayList instead of a single relay.nostr.band entry
  (which would otherwise be empty after removal).
- desktop DefaultRelays and FollowPacks DISCOVERY_RELAYS: drop
  relay.nostr.band.
- Update NIP-50 example hostnames in desktop comments, the search-relay
  editor help text, and the localized search_relays_not_found_examples
  string across all locales to nostr.wine.

Preview sample data, captured sample-event JSON, and quartz test
fixtures that mention relay.nostr.band are left untouched (no runtime
effect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ttcqa3V78bugGraGhtehj
2026-07-01 23:59:15 +00:00
Claude b1fda59cd6 fix: drop relay.damus.io from default relay lists ahead of shutdown
relay.damus.io is being decommissioned, so remove it from every runtime
default/fallback relay set to stop the app and amy from wasting connection
slots on a dead host:

- commons Constants: remove `damus`; it dropped out of `bootstrapInbox`
  (default NIP-65 inbox) and `eventFinderRelays` (default outbox/fallback),
  both still carrying 6 healthy relays.
- ChessConfig: remove damus from CHESS_RELAYS / CHESS_RELAY_NAMES, leaving
  the 3 relays the FETCH_TIMEOUT comment already assumes.
- desktop DefaultRelays: remove damus and the also-dead relay.snort.social.
- desktop FollowPacks DISCOVERY_RELAYS: remove damus.
- amy NipCommand SEARCH_RELAYS: swap damus for the NIP-50-capable nostr.wine.

Comments, @Preview sample data, and test fixtures that mention damus.io are
left untouched — they have no runtime effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ttcqa3V78bugGraGhtehj
2026-07-01 23:35:24 +00:00
nrobi144 9d8856efea fix(privacylock): provide CompositionLocals inside App() for tests
CI failure: AppStateMachineTest called App() directly, bypassing
the outer Window { CompositionLocalProvider } shell in Main.kt.
DesktopMessagesLockGate read LocalMessagesLockState and hit the
compositionLocalOf { error(...) } trap.

Fix: construct the state holder + provide both LocalMessagesLockState
and LocalPrivacyLockSettings INSIDE App() itself. Extracted the body
of App() into a private AppInner() so the provider can wrap it
cleanly. The outer providers are removed from Main.kt — no longer
needed.

Trade-off: MessagesLockState is now scoped to App() (via
rememberCoroutineScope) instead of windowScope. That means it
rebuilds on appRestartKey change, which is intentional — an app
restart should reset the coroutines too. The seeded initial value
is still read synchronously from prefs so the first composition
sees the correct LockState (deep-link race fix preserved).

Also fixes an unrelated `!!` warning on existingHash in
SetPasswordDialog by using a safe smart-cast check.
2026-07-01 12:53:32 +03:00
nrobi144 72c3dff870 feat(privacylock): P0 security hardening — 600k iterations + backoff
Addresses the P0 items in the security review at
docs/plans/2026-07-01-privacy-lock-security-review.md.

## PBKDF2 iterations 100k → 600k (M1) via versioned hash format (M2)

- New PasswordHasher storage format: `v1$saltB64$hashB64` (600k
  iterations, matches OWASP 2023 Password Storage Cheat Sheet for
  PBKDF2-HMAC-SHA256).
- Legacy `saltB64$hashB64` (100k iterations) format still verifies
  correctly — no user gets locked out by the bump.
- `hash()` always produces `v1$…`; users migrate to v1 opportunistically
  when they Change or Set a new password.
- New `PasswordHasher.isLegacyFormat()` helper for callers that want
  to force-migrate on next successful unlock.
- Verify cost goes from ~50ms → ~250ms on a modern laptop — well
  within tolerable UX for a lock users open a handful of times per
  session.

## Exponential backoff on failed unlock (M3)

- `PrivacyLockSettings` gains `failedUnlockAttempts: StateFlow<Int>`
  and `lockedUntilEpochMs: StateFlow<Long?>`, both persisted via
  java.util.prefs so a reboot cannot reset the backoff.
- `MessagesLockState.onFailedUnlockAttempt(nowMs)` implements the
  schedule: no lockout for first 4 fails, then 30s / 60s / 120s /
  300s (capped at 5 min).
- `MessagesLockState.onUnlockSuccess()` transparently clears the
  attempt counter and any active lockout (also called from the
  banner-enable path).
- DesktopLockScreen shows a countdown ("Try again in 27s") in the
  supportingText, disables the password field and Unlock button
  during lockout, ticks every 500ms via a LaunchedEffect.
- RemovePasswordDialog inherits the same protection — Settings can't
  bypass the throttle by disabling the lock.
- 4 new unit tests cover threshold behavior, base trip, doubling +
  cap, reset on success. All 13 tests green.

## Not in this commit

- L1/L2 (String/CharArray memory retention) — out-of-tree fix in
  Compose; accepted per threat model.
- L3 (post-uninstall prefs) — release-notes item.
- M4 (Limitations copy update) — deferred; existing "does not
  protect against filesystem access" line already covers.
2026-07-01 12:06:45 +03:00
nrobi144 f34c79c848 feat(privacylock): require password to disable + clear on remove
Toggling the Messages lock OFF now prompts for the current password
via a new RemovePasswordDialog. On successful verification, the
password hash is cleared AND the lock is disabled — re-enabling
later requires setting a fresh password.

Rationale: a user should not be able to disable the lock without
proving they know the password. Clearing the hash on remove prevents
a "silent re-enable" attack where someone toggles OFF then ON again
and inherits the old password. Matches Signal PIN, WhatsApp Chat
Lock, macOS FileVault disable posture.

- New RemovePasswordDialog in SetPasswordDialog.kt: single Current
  password field, reveal toggle, red "Remove" button (colorScheme.error),
  Cancel + Escape dismiss. Auto-focus + Enter submits. Uses the same
  Dialog+Surface+DialogHeader shell as SetPasswordDialog.
- DialogHeader refactored to take a title String (was isChange Bool).
- PrivacyLockSettingsScreen toggle-off path routes through the new
  dialog when a hash exists. Corner case (lockEnabled=true but no
  hash — user manually cleared prefs) still disables directly.
- On successful remove: setLockEnabled(false) + setPasswordHashed(null)
  + "Privacy lock removed" snackbar.
2026-07-01 11:35:52 +03:00
nrobi144 292f8a0c78 feat(privacylock): redesign Set/Change password dialog + snackbar
Rewrites SetPasswordDialog.kt with modern 2024-2026 UX. Adds
"Privacy lock enabled" / "Password updated" confirmation snackbars.

Dialog changes:
- Dialog + Surface(shape=shapes.large, tonal=6.dp) shell instead of
  default AlertDialog. Fixed width 440dp. Matches NewDmDialog.kt.
- Header row with Lock icon + title (titleLarge). Softer body copy
  under the header for the first-time-set path.
- Set-a-password flow uses ONE password field with a reveal toggle
  (Visibility / VisibilityOff, per-field independent). The reveal
  toggle IS the confirmation — no more "confirm password" field.
  Matches WhatsApp Chat Lock + macOS Users & Groups.
- Change-password flow uses two fields (current + new), each with
  its own reveal toggle. Current is verification, not redundancy.
- Real-time checklist row under the New field: green CheckCircle +
  "Min 6 characters" when satisfied, outlined Circle + muted text
  otherwise. Copy-pattern from EditProfileScreen NIP-05 status.
- Save button disabled until the checklist passes.
- Bumps PRIVACY_LOCK_MIN_PASSWORD_LENGTH from 4 to 6.
- Auto-focus first field on open (LaunchedEffect + FocusRequester).
- Enter submits (via onPreviewKeyEvent + KeyboardActions.onDone).
- Escape / click-outside-dismiss are disabled to prevent accidental
  loss of typed password (dismissOnClickOutside = false).
- Wrong-current error shown inline under the Current field.
- All reveal toggles reuse the KeyInputField.kt idiom verbatim.

Snackbar plumbing (scope-local — no CompositionLocal):
- PrivacyLockSettingsScreen owns a SnackbarHostState overlaid at
  Alignment.BottomCenter. LockToggleCard receives an onSaved
  callback, fires "Privacy lock enabled" on first-time set or
  "Password updated" on change.
- DesktopMessagesScreen (banner path) owns its own SnackbarHostState
  overlaid at BottomCenter. MessagesFirstRunBanner takes an
  optional onSaved callback (default {}), fires "Privacy lock
  enabled" after the dialog saves.
2026-07-01 11:35:52 +03:00
nrobi144 d216d22c3e feat(privacylock): Messages first-run discovery banner (Desktop)
Adds an inline banner at the top of the Desktop Messages deck column
that nudges users to enable the privacy lock. Fires only when
!lockEnabled && !firstRunCardSeen; dismissal is sticky across
restarts + lock enable/disable cycles.

- MessagesFirstRunBanner: AnimatedVisibility(expandVertically + fadeIn)
  wrapper around a Surface + Row with a padlock icon, title, body,
  and Enable / Not now buttons. Modeled on OfflineBanner.kt.
- SetPasswordDialog extracted from PrivacyLockSettingsScreen.kt into
  a shared desktop/security/ file so the banner and the settings pane
  both point at the same composable.
- MessagesLockState.onUnlockSuccess() relaxed to accept Disabled as a
  valid previous state, so enabling from the banner keeps the user
  Unlocked and doesn't flash the lock screen. New unit test covers
  this path; all 9 tests green.
- DesktopMessagesScreen wraps its two-pane / compact layout in a
  Column with the banner on top and a Box(weight(1f)) around the
  panes so fillMaxSize propagates correctly.
2026-07-01 11:35:52 +03:00
nrobi144 1c0141aba1 feat(privacylock): Desktop wiring — gate, password unlock, settings
Phase 5 (Desktop-only). Wraps the Messages deck column behind a
PBKDF2-hashed password gate; drops the Android-app slice.

- PrivacyLockSettings gains passwordHashed field + setter (salt$hash,
  base64). Backed by java.util.prefs on desktop.
- PasswordHasher: PBKDF2-HmacSHA256, 100k iterations, 16-byte salt,
  256-bit key, constant-time compare. Same primitive family as
  SecureKeyStorage.
- DesktopMessagesLockGate: synchronous branch select in composition
  (no LaunchedEffect guard) — closes the deep-link race per plan
  §Security Hardening H1. Renders content when Disabled/Unlocked;
  renders inline password TextField when Locked. Fires
  MessagesLockState.onLeaveRoute() in DisposableEffect onDispose so
  navigating away from the Messages column re-locks immediately.
- DesktopMessagesLockGate handles the "no password set" edge case
  with a Disable-lock affordance.
- LocalPrivacyLockSettings CompositionLocal + LocalMessagesLockState
  (from commons) both provided once at the App composition root in
  Main.kt. Constructed with the existing windowScope so the state
  holder's idle timer coroutines are lifecycle-scoped to the Window.
- DeckColumnContainer: DesktopMessagesScreen wrapped in
  DesktopMessagesLockGate for the Messages column.
- Desktop PrivacyLockSettingsScreen: Column + Card layout matching
  LocalRelaySettingsScreen (no Scaffold). Toggle, "Change password"
  affordance with a full set/change dialog (old + new + confirm),
  inactivity timer dropdown (1m / 5m / 15m / 1h / Never), redaction
  level dropdown (Hidden / Full), honest limitations copy. Auto-opens
  the set-password dialog if user toggles ON with no password set.
- Slotted into the existing Settings pane in Main.kt right after
  LocalRelaySettings.
2026-07-01 11:35:52 +03:00
nrobi144 d0646acf3c Merge upstream/main into feat/desktop-hashtag-spam-filter 2026-07-01 09:53:09 +03:00
nrobi144 7a18e30fc4 feat(desktop): hashtag-spam filter with collapse-with-reveal
Damus-inspired content filter that collapses notes abusing `t` hashtag
tags into a compact reveal-on-click placeholder. Ships default ON with a
threshold of 5 (adjustable 1–20 in Settings → Content Filters, or off).

Scope
- Pure check (`HashtagSpamCheck`) + settings interface
  (`HashtagSpamSettings`) live in `commons/moderation/`, callable by
  Desktop, `amy` CLI, and (future) Android.
- JVM-backed `PreferencesHashtagSpamSettings` writes to the shared
  `java.util.prefs` node `com/vitorpamplona/amethyst/filters`, so `amy`
  and Desktop observe the same value automatically.
- `CollapsedSpamNote` placeholder in `commons/ui/note/` takes only
  primitive scalars so Android can adopt it without touching commons.
- Desktop wraps every `NoteCard` call site (FeedNoteCard, QuotedNoteEmbed,
  BookmarksScreen, 5 SearchResultsList sites) with a shared
  `SpamCheckedNoteRender` helper. Thread root notes auto-expand via
  `forceReveal=true`; replies still respect the filter.

Exemptions
- Long-form articles (kind 30023)
- Authors in the follow list plus self
- Repost wrappers check the inner event's tags via precomputed
  `note.replyTo`, falling back to `containedPost()`

Search UX fixes bundled in
- Removed the `#hashtag` → "Direct lookup" card. `QueryParser` already
  extracts `#xxx` into the query's hashtag filter, so typing `#bitcoin`
  now goes straight to filtered results.
- Search-result rows now trigger metadata loading via
  `subscriptionsCoordinator.loadMetadataBatched(authors)` and observe
  each user's metadata flow via a new `rememberDisplayData` helper, so
  display names + avatars refresh when kind-0 arrives from index
  relays. Same helper reused in Bookmarks.

Tests + docs
- 19 unit tests (check × 10, displayed-event unwrap × 4, prefs × 5),
  all green.
- Manual testing sheet with 16 scenarios at
  `desktopApp/plans/2026-06-29-hashtag-spam-filter-manual-testing-sheet.md`.
- Plan at `docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md`.
- Cross-client desktop feature backlog reference at
  `desktopApp/plans/_desktop-feature-backlog.md`.
2026-07-01 09:46:43 +03:00
nrobi144 de2c970e3e feat(desktop): Follow Packs (NIP-51 kind 39089) discovery and follow flow
Adds a Follow Packs experience to Amethyst Desktop:

- New "Discover" sidebar destination with featured-pack hero, hashtag chips
  driven by NIP-12 `t` tags, a 3-up "From the pack" notes feed, and a
  right-rail of mini pack thumbnails.
- New "Follow Packs" launchable column (App Drawer + Discover "Browse all")
  with multi-field search across title, description, creator name/npub,
  and `t` tags.
- Pack detail overlay (read-only) with per-member Follow/Unfollow buttons
  that reflect the live kind-3 state, plus pack-level Follow all /
  Unfollow all with a dedupe-aware confirm dialog ("Follow N new (M
  already followed)").
- Bulk follow / unfollow batched into a single kind-3 publish via new
  `FollowActions.buildUnfollowBatch` and `Kind3FollowListState.follow/
  unfollow(users: List<User>)`. The mutating call sites are Mutex-
  protected against concurrent races.
- naddr → 39089 references in notes render as a rich inline card with
  avatar stack + Follow all CTA. Cache miss triggers a one-shot
  subscription; empty / deleted packs render minimal states.
- Shuffle button rotates both the featured pack and the gallery,
  excluding the last 5 shown.
- Pack image fields render via Coil `AsyncImage` with a deterministic
  gradient fallback.

Protocol additions:
- Quartz: `FollowListEvent.hashtags()` convenience accessor.

Bug fixes wrapped into the feature:
- `DesktopLocalCache.consumeContactList` now also loads the event into
  `addressableNotes` so `Kind3FollowListState.getFollowListEvent()`
  returns the user's actual kind-3. Without this, every bulk follow
  silently replaced (rather than appended to) the contact list.
- Added Material Symbols `Shuffle` codepoint and regenerated the
  bundled subset font (still 432 KB).
2026-06-30 12:05:16 +03:00
Vitor PamplonaandClaude Opus 4.8 0d0aba90c9 fix(account): don't let adding a read-only npub clobber an existing signing account
Accounts dedup by npub (= pubkey), so adding/scanning your own read-only npub for
a pubkey you already hold the nsec for would overwrite the signing account:
- Android: setDefaultAccount rewrote the per-npub file from fresh read-only
  settings — wiping the account's cached follow/relay/mute lists and flipping
  hasPrivKey off, which silently disables its push notifications (every
  notification path early-returns on !hasPrivKey). The account looked lost ("can't
  post anymore") even though the nsec survived on disk.
- Desktop: saveCurrentAccount overwrote signerType to ViewOnly, orphaning the
  stored key and routing every later switch through loadReadOnlyAccount.

Guard the downgrade at the single persistence point on each platform: when the
account being made current is read-only and a SIGNING account already exists for
the same pubkey, keep the signing account and switch to it instead. A signing
account already subsumes a read-only one, so this loses nothing.

- Android LocalPreferences.setDefaultAccount now returns the settings that
  actually became current; AccountSessionManager.loginAndStartUI shows that.
- Desktop AccountManager.saveCurrentAccount reuses switchAccount() to reload the
  signing account. Covered by a regression test (verified failing without the
  guard).

This replaces the closed proposal e05208d9, which solved the same underlying bug
with a much heavier accountId rework (separate npub/nsec switcher entries — a
niche feature) that itself shipped a logout(deleteKey=true) data-loss bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:00:51 -04:00
mstrofnone d4cc4c67de fix(desktop): resolve Namecoin names in the home tab search bar
The home tab inline search bar in FeedScreen.kt previously ignored
Namecoin identifiers (e.g. mstrofnone.bit) — only the full Search screen
resolved them via LocalNamecoinService. Users typing a .bit name into
the home search pill saw 'no results found' instead of the resolved
profile.

This wires the same Namecoin resolution pattern used by SearchScreen.kt
into FeedTabsHeader:

- Detect Namecoin identifiers with NamecoinNameResolver.isNamecoinIdentifier
- Resolve via LocalNamecoinService.resolveDetailed (cancelling stale lookups
  when the user keeps typing)
- Render a compact InlineNamecoinResultRow above the regular search results
  showing Loading / Resolved / NotFound / Error states
- Clicking the resolved row navigates to the user profile, matching the
  full Search screen behaviour
2026-06-26 10:58:44 +10:00
Claude 55bc75512a feat(nip46): support optional client metadata in connect request
Implements nostr-protocol/nips#2381: a client MAY attach an optional
4th positional parameter to the NIP-46 `connect` request carrying a
JSON-stringified `{name, url, image}` object, mirroring the fields
already present in `nostrconnect://` URIs. This lets a bunker:// paired
signer show who is asking to connect.

- quartz: add BunkerClientMetadata and a clientMetadata field on
  BunkerRequestConnect; serialize it as the 4th param (omitted when
  empty) and parse it back, degrading malformed/empty JSON to null.
- quartz: NostrSignerRemote carries and sends clientMetadata on
  connect() and threads it through fromBunkerUri().
- commons: BunkerLoginUseCase.execute() accepts optional clientMetadata.
- desktopApp: advertise Amethyst's metadata on bunker login.
- cli: the receiving bunker logs the connecting client's identity
  (display-only; never gates the ACK on it, since the client pubkey is
  unauthenticated in bunker:// pairing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYpupiVAq4VyHDdjrYyPdi
2026-06-23 16:17:19 +00:00
Claude 18f736abf7 refactor: stop writing deprecated NIP-31 alt tags on events we create
The NIP-31 event-level "alt" tag is deprecated, so Amethyst no longer
emits it on any event it builds. Removed all `alt(...)` builder calls and
`AltTag.assemble(...)` insertions across every event kind in quartz (and
the few app-side builders), along with the now-unused `ALT`/`ALT_DESCRIPTION`
companion constants and the `TagArrayBuilder.alt()` / `AltTag.assemble()`
write helpers.

Reading alt tags from incoming events is kept (AltTag.parse/match,
TagArray.alt(), Event.alt()) for interop with clients that still send them,
and the imeta media accessibility `alt` field (NIP-92/94) is untouched.

Updated/removed tests that asserted alt-tag presence and refreshed the
deterministic event-id/sig golden masters in UpdateMetadataTest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xAESAz1H1VNjmQpMVqBXj
2026-06-20 16:46:25 +00:00
Claude 96c5d9bcb6 feat: reply with kind 1111 to new Amethyst kind-1 thread roots
When replying to a note that is a kind 1 TextNoteEvent, is the root of a
new thread (no e-tags), and was itself posted from Amethyst (NIP-89
client tag), build a NIP-22 kind 1111 CommentEvent instead of a kind 1
reply. Forks keep using kind 1.

Applies across all kind-1 reply paths: the Android composer
(ShortNotePostViewModel), the notification quick-reply
(NotificationReplyReceiver), and the desktop composer (ComposeNoteDialog).

Adds Event.isClient / TagArray.isClient helpers (NIP-89, case-insensitive)
with unit coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7RyevA6jL1NuY7uev2agS
2026-06-20 15:00:04 +00:00
Vitor PamplonaandGitHub 8ef4e42c16 Merge pull request #3278 from nrobi144/feat/desktop-relay-latency-health
feat(desktopApp): relay latency health — tracking, classifier & dashboard UI
2026-06-19 10:22:07 -04:00
David KasparandGitHub 29d6bc308e Merge pull request #3260 from nrobi144/worktree-fix-desktop-macos-keychain-proguard
fix(desktop): macOS forced re-login on cold boot — ProGuard strips java-keyring backend
2026-06-19 10:24:49 +01:00
Róbert NagyandGitHub 5e3fea94a1 Merge branch 'main' into feat/desktop-relay-latency-health 2026-06-19 10:28:07 +03:00
David KasparandGitHub b73890ac96 Merge pull request #3261 from nrobi144/feat/desktop-launch-optimization
feat(desktop): launch optimization foundation + icon-decode/relay-bootstrap fixes
2026-06-18 11:38:59 +02:00
nrobi144andClaude Opus 4.7 19efb6a28e feat(desktopApp): surface relay latency in dashboard + popup + banner
Phase 3 of relay-latency-health: wire the Phase 1 tracker and Phase 2 store
into the desktop UI across three surfaces. After this commit the feature is
end-to-end usable in the running app.

Wiring (Main.kt):
  - Construct a RelayLatencyTracker per account (same lifetime as
    RelayHealthStore).
  - Install a RelayLatencyListener alongside the existing RelayHealthListener
    on relayManager.client; uninstall both on account switch / app exit.
  - Pass the tracker to the store via the new latencyTracker constructor
    param so sweep + snapshot happen on the existing 60 s reclassify tick.
  - nip11Provider: read live from Nip11Fetcher's session cache (new
    `allCached()` accessor). The classifier reads it every tick.
  - authProvider: hardcoded `{ false }` for desktop — NIP-42 isn't wired in
    desktop yet, so any auth-required or payment-required relay is treated
    as "auth not complete" and excluded from the slow cohort. Avoids
    perpetually flagging paid relays that CLOSED our anonymous queries.

RelayMetricsTab + RelayMetricCard (dashboard):
  - Tab collects latencySnapshots + slowRelays ONCE; per-row passes the
    per-relay value snapshots (not the whole map). Strong-skipping then
    handles the rest — unchanged rows skip on 60 s ticks.
  - Each row gains three compact columns: OK / EOSE / FR p50s (in ms).
    Missing metrics omit their cell — common in the first ~60 s before the
    tracker's first snapshot lands.
  - A red "Slow: <metric> 2.4×" AssistChip appears next to the columns
    when the classifier flags the relay.

RelayDetailPanel (the per-row NIP-11 popup):
  - New "Latency (rolling last 50 samples)" section below the existing
    NIP-11 fields, listing each metric's p50, sample count, and cohort
    multiplier when the relay is currently flagged on that metric.
  - First-result row carries a tooltip explaining filter-dependence so
    users don't misread "slow first-result" as pure network slowness.

UnhealthyRelaysPopup:
  - Now also collects store.slowRelays and renders a "Slow relays" section
    below the existing "Unresponsive relays" list (when slowRelays is
    non-empty). Each slow row: relay URL, metric + p50 vs cohort, slow
    chip, Dashboard + Snooze actions. Snooze reuses the existing 7-day
    snooze field on RelayHealthRecord.

UnhealthyRelayBannerHost:
  - Banner now visible when either unhealthy OR slowRelays is non-empty.
  - Count text reads "$dead relays unresponsive — Review" /
    "$slow slow relays — Review" / "${dead+slow} relays need attention —
    Review" depending on which buckets have entries.

Compose stability:
  - All public StateFlow types from RelayHealthStore expose ImmutableMap,
    and RelayLatencySnapshot is @Immutable with ImmutableMap fields, so
    strong-skipping engages.
  - Per-row composables (RelayMetricCard, SlowRelayPopupRow) only take
    @Immutable value parameters — no maps passed in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-18 12:11:26 +03:00
nrobi144andClaude Opus 4.7 a07c9ac7cc feat(commons,desktop): wire latency tracker into RelayHealthStore + persistence
Phase 2 of relay-latency-health: hook the Phase 1 tracker into the existing
RelayHealthStore lifecycle and persist its rings via the existing
PreferencesRelayHealthPersistence so samples survive restarts.

commonMain:
  - RelayLatencyProvider: small interface so the store can drive a tracker
    that lives in jvmAndroidMain (the impl needs ConcurrentHashMap).
  - RelayHealthSnapshot: optional `latencySamples` field. Default empty;
    older saved snapshots load cleanly without it.
  - RelayHealthStore now takes optional `latencyTracker` / `nip11Provider`
    / `authProvider` constructor params:
      * exposes `latencySnapshots: StateFlow<ImmutableMap<Url, RelayLatencySnapshot>>`
        — MutableStateFlow updated inside the existing 60 s reclassify tick
        (one timer, not two — the tracker is scope-less and gets
        `sweep(now)` called from reclassify).
      * exposes `slowRelays: StateFlow<ImmutableMap<Url, SlowReason>>` —
        derived via `_latencySnapshots.map(classifySlowRelays).stateIn(
        scope, SharingStarted.Eagerly, persistentMapOf())`. The classifier
        reads `nip11Provider()` / `authProvider` live, so paid/auth-only
        relays only join the cohort once their auth completes.
      * `init {}` restores persisted samples into the tracker; the
        existing `schedulePersist()` now bundles `tracker.samplesForPersistence()`
        into the saved snapshot via a new private `snapshotForPersist()`
        helper. The same helper feeds the final flush in `close()`.
    No new dispatcher / scope / timer — everything piggybacks on the
    existing infra (single SupervisorJob, 5 s persist debounce, 60 s tick).

jvmAndroidMain:
  - RelayLatencyTracker now implements RelayLatencyProvider. Overrides drop
    the inline `System.currentTimeMillis()` default; callers from commonMain
    pass `TimeUtils.nowMillis()` explicitly.

desktopApp (jvmMain):
  - PreferencesRelayHealthPersistence persists per-relay latency rings in
    separate keys (`lat_<account-prefix>_<sha256(url)[..16]>`) so the 8 KB
    Preferences ceiling on the main `health_<account>` key isn't blown by a
    user with many relays. Each key holds one relay's four metric rings as
    `wss://relay.url\tok:csv|eose:csv|fr:csv|ping:csv`. On save, keys for
    relays no longer in the snapshot get removed so the prefs node doesn't
    grow unboundedly across account churn.

Notes:
  - Persistence still uses the existing 5 s debounce path. The deepened plan
    called for 30 s for `lat_*` keys; deferring that micro-optimization
    until we observe write thrash in practice. The cap on writes is
    one-rewrite-per-5s-of-activity which matches what the existing snooze
    persistence already does, so latency adds zero new flush events.
  - Tracker is wired only when a `RelayLatencyProvider` is passed to the
    store. Existing tests / Android continue to compile and run with
    latency unconfigured — `latencySnapshots` stays empty and `slowRelays`
    derives to empty. Desktop wiring lands in Phase 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-18 12:11:25 +03:00
nrobi144 48a8178c98 feat(desktop): App() Compose UI smoke tests + Phase 5.2 regression tests
Adds a `LaunchTestOverrides` bundle (default null in production) so
`App()` can be driven from `createComposeRule()` against the in-process
fixture relay instead of the OkHttp + kmp-tor + DesktopHttpClient stack
it normally constructs via `remember { … }`. `DesktopRelayConnectionManager`
gains a secondary constructor taking a `WebsocketBuilder` so the
`LocalRelayManager` composition local (typed as
`DesktopRelayConnectionManager?` and consumed by ~20 screens) does not
have to be relaxed.

`AppStateMachineTest` exercises four scenarios:

1. `appShowsLoginScreenWhenNoSavedAccountExists` — App() with no
   `accounts.json.enc` reaches LoggedOut and renders LoginScreen.
2. `appWithViewOnlyAccountReachesLoggedInWithoutCrashing` — App() with
   a pre-seeded ViewOnly account reaches LoggedIn end-to-end through
   `MainContent`, the deck columns, NWC wiring, etc.
3. `bootstrapSubscriptionFiresEagerlyEvenWhenRelayNeverConnects` —
   wires a `NeverConnectsWebsocketBuilder` so no connection ever opens,
   yet App() still reaches LoggedIn within 5s instead of the previous
   30s gate timeout. Direct regression test for the Phase 5.2
   bootstrap-gate removal.
4. `bootstrapSubscriptionFiresAtMostOncePerAccountLoad` — wraps the
   fixture builder with a `RecordingWebsocketBuilder` and asserts the
   bootstrap REQ does not loop or double-fire.

The `LaunchScenario` benchmark drops its private
`BenchmarkRelayConnectionManager` subclass in favor of the new
secondary `DesktopRelayConnectionManager(WebsocketBuilder)` constructor.

278/278 desktopApp tests pass.
2026-06-18 12:09:41 +03:00
nrobi144andClaude Opus 4.7 123c055828 fix(desktop): macOS forced re-login on cold boot — repair ProGuard keep rules for java-keyring
ProGuard in the release DMG (compose-rules.pro) was keeping
pt.davidafsilva.apple.** — a library no longer in the dependency graph.
The actual macOS-keychain dependency is com.github.javakeyring:java-keyring,
which reflection-loads its OS-specific backend (OSXKeychainBackend /
SecretServiceBackend / WinCredentialStoreBackend) at Keyring.create()
time. The shrinker stripped the backend classes, Keyring.create() threw
BackendNotSupportedException on every cold boot, SecureKeyStorage's
fallback silently returned null (no password prompt in a GUI cold-boot),
and every account whose key lived in the OS keychain (nsec, NIP-46
bunker ephemeral, NWC secret) was forced back to the login screen on
each launch of the release DMG. Dev/Gradle runs skip ProGuard, which is
why this never surfaced in development.

Primary fix:
- Replace dead pt.davidafsilva.apple.** keep rules with
  com.github.javakeyring.** and keep native methods + constructors on
  internal.** backends.

Defense in depth (so a future regression is visible, not silent):
- AccountManager._keychainUnavailable: StateFlow<Boolean> mirrors the
  existing _storageCorruption / _forceLogoutReason channels.
- loadInternalAccount / loadBunkerAccount raise the signal when
  accounts.json.enc points at a key the keychain cannot return.
- LoginScreen shows a one-line error banner when the signal is set;
  cleared on any successful login.

Tests:
- AccountManagerLoadAccountTest gains four cases: Internal-no-privkey
  signals, Bunker-no-ephemeral signals, clearKeychainUnavailable
  resets, happy path does NOT signal.

See docs/plans/2026-06-18-fix-desktop-macos-bunker-relogin-plan.md for
brainstorm + plan + deferred follow-ups (Linux/Windows DMG verification,
signed-DMG smoke test, ProGuard mapping regression guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-18 11:31:29 +03:00
nrobi144 d2d044a634 refactor(desktop): relax App() torManager param to ITorManager interface
App() only consumes torManager.status, which is on ITorManager.
Loosening the parameter type lets future tests substitute a fake without
having to construct the concrete DesktopTorManager (which eagerly builds
a kmp-tor TorRuntime on first status access). No production behavior
change: DesktopTorManager already implements ITorManager and the existing
call site at Main.kt:633 upcasts naturally.

This is a small intermediate step on the road to the still-pending Phase
1.4 App() Compose smoke test, which is the last item blocked on broader
App() dependency injection (relayManager / localCache / localRelayStore
are still remember'd internally).
2026-06-18 11:13:54 +03:00
nrobi144 b14ee5ec5c feat(desktop): in-process relay seam, launch benchmark, bootstrap-gate removal
Phases 2.1/2.2/2.3, 3.1/3.2, 4, 5.2, and 6 of the launch-optimization plan
land together because they share a single set of seams and a single
benchmark report.

* InProcessWebsocketBuilder + LaunchFixtureRelay wrap quartz's existing
  InProcessWebSocket + NostrServer (with EmptyPolicy) so any test can
  drive a NostrClient against an in-memory relay seeded with arbitrary
  events. Roundtrip verified by LaunchFixtureRelayTest.

* LaunchFixture builds a deterministic 50-note synthetic home-feed
  snapshot from a fixed RNG seed (kind:1 + author kind:0 + kind:3 +
  kind:10002). A real-world JSONL artifact is a drop-in replacement.

* NoteCard gets a stable testTag + a CompositionLocal-backed
  onPlaced hook. Production overhead is one composition-local read
  plus one null check per placement (default
  LocalNoteCardInstrumentation = null).

* LaunchMarkers records named markers against TimeSource.Monotonic.
  LaunchScenario.coldBoot drives the AccountManager (ViewOnly path)
  + DesktopLocalCache + RelayConnectionManager + LocalRelayStore
  stack against the fixture relay and reports t_account_logged_in,
  t_first_event, t_n_events.

* LaunchBenchmark runs 2 warmup + 5 measured iterations, computes
  min/q1/median/q3/max, atomically writes the report file, and is
  skipped by default — opt in via AMETHYST_BENCH=true. Baseline +
  post-fix snapshots committed under desktopApp/benchmarks/.

* SubscribeBeforeConnectTest proves NostrClient / RelayPool queue REQs
  issued before connect() and flush them when the connection comes up.
  The bootstrap-config subscription in Main.kt drops its
  `connectedRelays.first { isNotEmpty() }` + 30s withTimeoutOrNull gate
  on the strength of that invariant — the subscription now fires
  eagerly and recovers when no relay ever connects instead of silently
  giving up after 30s.

All 274 desktopApp tests pass. No flaky tests introduced.
2026-06-18 11:10:14 +03:00
nrobi144 b338d7db4b feat(desktop): collapse icon decoding to a single memoized lazy
Phase 5.1 of the launch-optimization plan: the cold-boot critical path
loaded /icon.png up to four separate times (taskbar setup, Window icon,
Tor splash, account-loading splash). Two of those sites also paid an
ImageIO.read to obtain a BufferedImage, and the Window-icon site
additionally round-tripped the image back through ImageIO.write so Skia
could re-decode it.

IconResources holds one lazy each for the bytes, the decoded
BufferedImage, the platform-adapted BufferedImage (squircle on macOS),
and the two BitmapPainters (raw + adapted). All four call sites in
Main.kt now consume the cached values directly — no remember, no
re-decode.

IconResourcesTest pins the memoization invariants (same instance on
repeated access). All 271 desktopApp tests pass.

End-to-end delta vs the baseline will be measured once Phase 3
benchmarks land; the worst-case savings on cold boot are two
ImageIO.read calls plus three resource reads plus one ImageIO.write,
all on the main thread.
2026-06-17 11:40:22 +03:00
nrobi144 ff55898ab9 feat(desktop): launch-optimization plan + test pyramid foundation
Phase 1 of the desktop launch-optimization plan: pin the behavior of
the cold-boot critical path before any launch refactor lands.

* Plan document committed to desktopApp/plans/.
* AccountManager: ViewOnly load + decode-failure state transitions are
  pinned by AccountManagerLoadStateTransitionsTest (2 tests).
* LocalRelayStore: gains a homeDir constructor parameter so tests can
  point the SQLite event store at a temp directory; production callers
  unchanged via default argument.
* LocalRelayStoreHydrationTest pins hydrate's contract:
  - empty DB is a no-op,
  - kind:3 contact list is consumed before kind:0 metadata,
  - kind:1 within the 7-day window is hydrated,
  - kind:1 older than 7 days is excluded.

All 266 desktopApp tests pass. No production behavior change.
2026-06-17 11:36:02 +03:00
Vitor PamplonaandClaude Opus 4.8 230c247909 fix(desktop): consistent macOS .icns + multi-size Windows .ico
The macOS icon.icns shipped inconsistent artwork across its embedded
sizes — a transparent full-bleed glyph at 256/512 (shown on the DMG mount
window and Spotlight) but a white-carded glyph at 128 (shown in the Dock).
It was also missing every @2x Retina tier and its 16/32/48 entries decoded
to corrupt noise, a signature of a generic PNG->ICNS converter rather than
iconutil. Regenerate from a single transparent glyph master into a proper
iconset (all standard sizes + @2x) compositing one consistent rounded-card
look at every size, then assemble with iconutil.

The Windows icon.ico held a single 32x32 BMP, so Windows upscaled a blurry
32px everywhere it needed a larger icon. Rebuild as a multi-size .ico
(16/32/48/64/128/256, PNG-encoded) from the same glyph, full-bleed and
transparent per Windows convention (matches the Linux icon.png).

Linux icon.png is unchanged — a single transparent PNG never had the
inconsistency and Linux desktops expect transparent icons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:29:03 -04:00
nrobi144 552540e77d fix(desktopApp): move sleep/resume detection out of Quartz
Per Vitor's review of #3221: wake-detection is platform-specific UX, not
NostrClient's job. Quartz already exposes `reconnect(onlyIfChanged = false,
ignoreRetryDelays = true)` which does the full disconnect + connect — the
app layer just needs to call it when it detects a wake.

- Revert the keep-alive heuristic in NostrClient.kt; the loop is back to the
  conservative `reconnectIfNeedsTo` path it had before.
- Add `runSleepResumeMonitor` (desktopApp/network/SleepResumeMonitor.kt): a
  60s tick that watches for wall-clock overshoot and calls the supplied
  `onWake` lambda. No native deps.
- Wire it in `Main.kt` next to the metrics LaunchedEffect: on >5x overshoot
  call `relayManager.client.reconnect(onlyIfChanged = false,
  ignoreRetryDelays = true)`.

Real OS sleep events (NSWorkspace on macOS, D-Bus PrepareForSleep on Linux,
WM_POWERBROADCAST on Windows) can be layered in later as platform improvements
without touching Quartz again.
2026-06-16 09:59:31 +03:00
nrobi144 b91519157e fix(commons,quartz): RelayHealthStore threading + sleep-resume socket recovery
Follow-up to #3186, addressing the unresolved review feedback:

- RelayHealthStore.schedulePersist() wrapped the blocking save() in withContext(ioDispatcher)
  so prefs.flush() no longer sits on the Compose composition thread on Desktop.
- close() now fires the final save on a detached IO-bound scope instead of blocking
  the composition thread for ~50ms during account switch / app exit.
- @Volatile on persistJob/tickJob and a closed-flag guard so the relay-network thread
  and composition thread no longer race on plain vars (and post-close work is dropped).
- desktopApp/Main.kt passes Dispatchers.IO to RelayHealthStore so persistence flushes
  land on the IO dispatcher instead of Dispatchers.Default.

Plus a separate-but-related fix to the offline-banner-stuck-after-Mac-sleep issue:
NostrClient.keepAliveJob now tracks wall-clock overshoot of its scheduled tick.
If the OS suspended us (laptop lid closed, system sleep), delay() returns far
past its deadline and the OkHttp websockets we held are dead even though
BasicRelayClient.isConnected() still reads true until the next ping fails.
On a >5x interval overshoot, force relayPool.disconnect() + connect() instead
of trusting needsToReconnect(), so feeds resume without an app restart.
2026-06-15 13:21:32 +03:00
mstrofnone 96943fb0d1 desktop(namecoin): port DiagnosticCard from Android settings
Adds the Namecoin diagnostics card that Android renders below the
per-server test results to the desktop settings panel, so support
requests carry the same information on both platforms.

- last test timestamp + pass/fail tally
- host OS (name/version/arch) — desktop equivalent of Android's
  Build.MANUFACTURER/MODEL row
- JVM name + version — desktop equivalent of Android's API level row
- distinct TLS versions observed during the test run

Also adds a 'Testing next server...' inline progress row matching the
Android section's behaviour when the test loop is mid-run.

Pure UI addition; no model, preference, or callback changes.
2026-06-14 08:01:59 +10:00
Claude 74d394a7f7 Merge remote-tracking branch 'origin/main' into claude/beautiful-ride-n6y3s2
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
2026-06-12 22:52:57 +00:00
Vitor PamplonaandGitHub 1927ce1ef0 Merge pull request #3191 from vitorpamplona/claude/elegant-allen-9mt4he
Unify payment card UI with PaymentCard component
2026-06-12 10:59:49 -04:00
Vitor PamplonaandGitHub b37e62cd62 Merge pull request #3186 from nrobi144/feat/unhealthy-relay-review
feat(desktop): unhealthy-relay review banner + popup
2026-06-12 09:31:34 -04:00
nrobi144 a46a72a89f feat(desktop): unhealthy-relay review banner + popup
Surfaces relays unresponsive for 7+ days across the user's NIP-65 (10002),
DM (10050), and Search (10007) relay lists. A non-modal banner appears
above feed columns (and above the single-pane content) whenever the
classifier finds anything; tapping it opens an anchored Popup with one
row per unhealthy relay and per-row Remove / Open Dashboard / Snooze 7d
actions plus a banner-level "Snooze all 7d".

Quartz
- RelayStat gains best-effort lastConnectAt + lastIncomingAt timestamps
  (epoch seconds, 0 = never observed). RelayStats listener pushes them
  on onConnected / onIncomingMessage. Durable per-relay history lives
  outside quartz in the commons RelayHealthStore.

Commons (new commons/relays/health/ package)
- classifyRelayHealth() pure function with the v1 gates:
  * first-run grace (don't flag for 7d after firstScanAt)
  * offline grace (don't flag if no relay anywhere has responded)
  * Tor-mode skip (relay timing is intentionally lossy through Tor)
  * per-relay snooze (snoozedUntil > now)
  * 10006 (blocked) excluded from detection but still part of the
    multi-list Remove action
- RelayHealthStore (account-scoped, supervised scope, 5s debounced
  persist, 60s ticker for snooze expiry).
- RelayHealthListener wires the quartz lifecycle into the store.
- RelayHealthPersistence interface (no expect/actual — single impl per
  platform via injection).
- RelayListMutator interface + RelayRemovalResult sealed type.
- Shared UnhealthyRelayBanner (errorContainer @ 50% alpha) and
  UnhealthyRelayRow (static outlined tag chips, no ripple) composables.
- 8 classifier unit tests covering each gate + multi-list membership.

Desktop wiring
- PreferencesRelayHealthPersistence (java.util.prefs.Preferences, per
  account via 8-char pubkey prefix).
- DesktopRelayListMutator runs the 4 sign-and-broadcast jobs in
  parallel via async/awaitAll so a slow NIP-46 bunker doesn't multiply
  latency by 4.
- Banner placed in DeckColumnContainer + SinglePaneLayout, store +
  listener + per-account scan trigger wired in Main.kt's MainContent.

Scope: Desktop only for v1. Android wiring is intentionally not in
this PR — the commons module is platform-neutral and ready for Android
to follow whenever someone wants to pick it up.
2026-06-12 06:40:46 +03:00
Claude 275c53ad7b feat: modernize inline payment cards and surface their descriptions
Redesign the three payment cards rendered in the middle of a post
(Lightning invoice, CLINK Offer, Cashu token) around a shared PaymentCard
scaffold that follows the wallet screens' Material3 idiom: tonal card,
icon + label header with a copy action, centered headline amount, and a
full-width themed Pay/Redeem button (no more hardcoded white text or
7sp mint lines).

Descriptions were not being rendered at all:
- BOLT-11: LnInvoiceUtil only decoded the amount from the HRP. Add
  tagged-field parsing (description 'd', expiry 'x', timestamp) with
  BOLT-11 spec-vector tests; the invoice card now shows the memo and
  flags expired invoices (Pay disabled). Desktop card shows it too.
- Cashu: V3 'memo'/'unit' and V4 'd'/'u' were parsed then dropped.
  CashuToken now carries them; the card shows the memo and no longer
  mislabels non-sat units (usd/eur cents formatted as decimals).
- CLINK Offers: the card now shows who gets paid (avatar + name from
  the pointer's pubkey, tappable to the profile).

https://claude.ai/code/session_019VuZ4y3ij6Ly4VVExE1W1W
2026-06-12 00:05:30 +00:00
Claude 96eaefda9b fix: tie rumor host lifetime to the Note — replace the RumorHosts index
A memory audit found the global strong-reference RumorHosts index could
never be kept in sync with LocalCache.notes, which holds WeakReferences:
seven prune paths (pruneExpiredEvents — rumors inherit the seal's
expiration tag — hidden/old-message/replaceable/reaction/hidden-event
prunes, and cleanMemory) plus silent GC eviction dropped rumor notes
without clearing their entries, clear() had no callers (logout, account
removal, memory trim), and orphaned stubs accumulated unbounded.

The stub now lives on the Note (Note.rumorHost): whatever removes or
garbage-collects the note frees the stub, closing every leak path by
construction. Cost is one nullable reference per Note (~200-400 KB at a
50k-note steady state) versus the index's per-entry map overhead plus
unbounded orphan growth. All consumers already held the Note: toNEvent,
Account.broadcast, deleteEnvelopes, removeIfWrap, chat pruning, and the
ingestion pipeline. RumorHosts is deleted.

Also fixes the desktop regression the audit surfaced: the desktop
gift-wrap handler now records the wrap on the rumor note, so desktop
nevent citations of chat messages point at the wrap id again instead of
exposing the private rumor id.

https://claude.ai/code/session_01B39MQmrT3dz137nfpXABvo
2026-06-11 23:35:25 +00:00
nrobi144 eae1ed88ec fix(desktop,media): address PR #3175 review findings
Correctness fixes in GlobalMediaPlayer.kt
- snapshotFlow { hasMedia } collector for initial seek used `return@collect`
  which only exits the lambda; the collector kept running and each
  subsequent playVideo() call accumulated a live collector that would
  re-fire a stale seekTo() on the wrong media. Replaced with `Flow.first`
  which terminates the collection cleanly.
- playVideo()/playAudio() reset the public MediaPlaybackState to
  volume=100/isMuted=false on a new URL, but the kdroidFilter player
  retains its `volume` across openUri(); muting one track and starting a
  new one left the engine silent while the UI showed unmuted. Reset
  `player.volume = 1f` to match the public state.
- ensureVideoPlayer()/ensureAudioPlayer() called createVideoPlayerState()
  synchronously from the Compose getter; if native init throws (missing
  GStreamer on Linux, broken NativeLibraryLoader extraction) the whole
  window would crash. Wrapped in runCatching and changed
  activeVideoPlayerState to nullable. Consumers in DesktopVideoPlayer
  and GlobalFullscreenOverlay handle the null path by rendering the
  thumbnail / blank backdrop respectively; playVideo()/playAudio()
  surface "Video playback unavailable" through the existing
  errorReason -> PlaybackErrorMessage path.

Crash mitigation (kdroidFilter 0.10.0 UAF in MacVideoPlayerSurface)
- NowPlayingBar previously mounted a SECOND VideoPlayerSurface against
  the same VideoPlayerState while the feed card was already mounting
  one, doubling the draw rate against the shared frame bitmap and
  widening the UAF window in MacVideoPlayerSurface's RasterFromBitmap
  path. Mini-preview now renders the cached thumbnail (or the music
  icon fallback). 0.10.1 contains an upstream fix
  ("recover video playback after composition removal") but is not yet
  on Maven Central — single-surface mounting is the only mitigation
  we can ship today.

VideoThumbnailCache.kt
- Truncated-download cache poisoning: when an origin ignored the
  Range: header and returned HTTP 200 with the full body, we capped
  the copy at MAX_THUMB_BYTES and persisted the truncated file
  forever. Subsequent thumbnail attempts hit the broken cache file
  and re-failed JCodec/ffmpeg every time. Tag download results with
  whether the server actually returned 206; on 200, extract from the
  temp file and delete it (no persistent cache hit).
- Tor bypass: replaced the bare OkHttpClient with
  DesktopHttpClient.currentClient() so thumbnail fetches respect the
  user's Tor preference (fail-closed when Tor is expected but
  bootstrapping).
- ffmpeg version probe leaked the process on hang: now drains stdout
  to DISCARD and calls destroyForcibly() on timeout.
- Frame-extract ffmpeg subprocess could deadlock on a chatty stderr
  pipe: redirectError(DISCARD) so we never wait on stderr; a finally
  block destroys the process if anything leaked through the timeout.

CI workflow cleanup
- Removed vlc-setup download cache + pre-fetch steps from
  build.yml and smoke-test-desktop.yml. They were targeting an
  ir.mahozad.vlc-setup plugin we no longer apply, so they wasted
  ~minutes of CI time per leg and tied the build to videolan.org
  reachability for no reason.
- Trimmed create-release.yml's stale VLC-plugins justification on
  the linuxdeploy-vs-appimagetool comment.

.gitignore + missing per-OS ffmpeg READMEs
- The pre-PR rules blanket-ignored desktopApp/src/jvmMain/appResources/{linux,macos,windows}/
  so the LGPL FFmpeg drop-in slot READMEs created in 704f4f44e never
  reached the commit. Refined the ignore rules to keep stale vlc/ workspace
  trees out of git (still ignored) while explicitly tracking the
  ffmpeg/README.md drop-in slot under each OS. The READMEs document the
  recommended LGPL build source per OS for the bundled-FFmpeg packaging
  path.

Verified on macOS arm64:
  ./gradlew :desktopApp:compileKotlin   BUILD SUCCESSFUL
  ./gradlew :desktopApp:test            BUILD SUCCESSFUL
  ./gradlew :desktopApp:spotlessApply   clean

Refs PR #3175 review by @davotoula.
2026-06-11 17:46:12 +03:00