Commit Graph
546 Commits
Author SHA1 Message Date
nrobi144andClaude Opus 4.8 3f56d177d8 feat(desktop): note scheduling + NIP-37 opt-in draft sync
Adds note scheduling and NIP-37 opt-in encrypted draft sync to Amethyst
Desktop, and extracts the existing Android scheduled-post code into
`commons` so both platforms (and PowJobRestorer) share one implementation.

- Compose → clock icon → date/time picker (presets + exact-minute); the
  note is pre-signed and stored locally, then published at its time.
- Publishes while the app is open (45s in-app tick + launch catch-up) AND
  while fully closed: an OS job (launchd / schtasks / systemd, registered
  only while the queue is non-empty) relaunches the binary in a headless,
  key-free `--publish-scheduled` mode that opens a websocket and pushes the
  pre-signed bytes.
- A "Scheduled" deck destination (tabs Scheduled / Drafts / Articles):
  status, cancel, publish-now, edit (cancel + reopen prefilled).
- Drafts: save-as-draft with a default-OFF "Sync across devices
  (encrypted)" toggle publishing a NIP-37 DraftWrapEvent (kind 31234,
  NIP-44 to self); drafts sync down on a fresh device.

Extraction / de-dup: ScheduledPost → commons/commonMain; ScheduledPostStore
+ ScheduledPostPublisher → commons/jvmAndroid (Jackson/java.io.File are
gate-forbidden in commonMain). The commons store is a strict superset of
upstream's parallel Android store (account-scoped claim, CLAIM_TTL crash
recovery, PUBLISHING-only status guards, reload-before-claim); upstream's
new ScheduledPostWorkGate gating is adopted to drive it. Single-writer file
lock + reload-before-claim so the in-app timer and headless process never
double-publish. Store file 0600, dir 0700.

macOS verified on the packaged app-image (compose+schedule, in-app publish,
app-closed launchd firing, Scheduled screen, NIP-37 draft round-trip).
Windows/Linux OS-integration authored but untested; headless has no Tor
routing yet — both documented in the PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:26:12 +03:00
Claude e231b0cf5b Merge remote-tracking branch 'origin/main' into claude/concord-quartz-amethyst-plan-0oy779 2026-07-14 15:25:40 +00:00
Claude 170bc7c121 fix(ui): restore full-size quiet marks, tighten the dotted timestamp, drop Boosted
Quiet marks go back to the row's regular text size in bold with 16dp
icons; the hashtag/community soft links lose their 12sp override too
(the smaller tier read as too small). A new TimeAgoStyle.DottedTight
renders "• 5m" without the leading space for rows whose spacedBy
already provides the gap, removing the double space before the
timestamp. The OTS pending pill shrinks to the stamp icon plus an
ellipsis (the words move to the content description). The Boosted mark
is removed entirely — from the Android header, the commons component,
and the desktop feed — since the repost context is already visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5
2026-07-14 13:30:38 +00:00
Vitor PamplonaandClaude Opus 4.8 95ab9e6528 fix(desktop,cli): match the new interactive auth-callback signature
The relay-auth fix added an `interactive` flag to
RelayAuthenticator.signWithAllLoggedInUsers; update the desktop and cli
implementers (which don't prompt) to the 3-arg lambda.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:14:30 -04:00
Claude 38b2e65362 feat(quartz): expose pending outbox events per relay for auth context
Add INostrClient.activeOutboxEvents(url) (backed by
PoolEventOutbox.activeOutboxEventsFor) returning the full events still
pending delivery to a relay, not just their ids like activeOutboxCache.
This lets a host explain *why* a relay is being authenticated with —
e.g. a pending kind-1059 gift wrap means we're sending a DM to its
recipient — by inspecting kind/tags. Combined with the existing
activeRequests(url) filters, it is the generic challenge context the
NIP-42 decision hook needs. Updates the INostrClient test fakes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EZjmYpgHP4pf79Sav5QT8a
2026-07-10 22:40:09 +00:00
nrobi144andClaude Opus 4.8 5a8e8828b1 fix(desktop): actually wire the NIP-17 p-tag relay hint into the send path
The relay-hint plumbing existed end to end in quartz (NIP17Factory ->
GiftWrapEvent.create(recipientRelayHint), with GiftWrapRelayHintTest), but
DesktopIAccount called createMessageNIP17/createEncryptedFileNIP17 without
passing recipientRelayHints. The default {null} lambda meant every outgoing
gift wrap shipped a 2-element ["p", pubkey] tag — the hint feature was dead
in production. Manual testing (T8) caught this: wraps on the recipient's
inbox relay had no third element.

Pre-resolve each recipient's primary DM inbox relay (first entry of their
kind:10050, order-preserving) and pass it as the hint, yielding
["p", pubkey, "wss://primary-relay/"]. Recipients with no resolvable
kind:10050 map to null and keep the 2-element shape.

Adds resolveDmInboxRelaysStrictOrdered (order-preserving) as the basis for
both the target-relay set and the primary-relay hint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:39:21 +03:00
nrobi144andClaude Opus 4.8 b62a74f2ad fix(desktop): make bech32 npub selectable in new-DM picker without cached metadata
The new-conversation picker rendered a pasted npub as a non-clickable
Surface whenever getUserIfExists returned null — i.e. for any recipient
whose kind:0 metadata the local cache hadn't seen. The npub showed in the
results list but couldn't be selected, so you couldn't start a DM to
anyone new by npub.

A DM recipient is identified purely by pubkey; metadata is not required to
open a conversation. Use getOrCreateUser so a valid npub always resolves to
a selectable UserSearchCard, keeping the non-clickable fallback only for
keys that can't be resolved at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:22:23 +03:00
nrobi144andClaude Opus 4.8 9e707da2b1 fix(desktop): use strict kind:10050 in DM inbox resolver LocalCache fast-path
The indexer fan-out already used lists.dmInbox (strict), but the
LocalCache fast-path in DmInboxRelayResolver.resolve() — and the
no-resolver fallback in DesktopIAccount — went through the lenient
User.dmInboxRelays(), which falls back to NIP-65 read relays (kind:10002)
when the recipient has no kind:10050.

Because that fast-path returns first and short-circuits the strict
indexer lookup, a recipient with NIP-65 read relays but no published
DM-inbox would get gift wraps published to relays they never designated
for DMs — re-introducing the metadata leak (recipient pubkey + send
timing) the P0 fix was meant to close. LocalCache commonly holds
kind:10002 but not kind:10050, so this path fired often.

Switch both LocalCache lookups to dmInboxRelaysStrict().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 07:45:34 +03:00
nrobi144 8c844d76db fix(desktop): use strict kind:10050 for tier-1 AUTH classification
DesktopAuthCoordinator.selfApprovedRelaysFor was calling the lenient
User.dmInboxRelays() helper, whose NIP-65-read fallback silently
expanded the tier-1 auto-allow set to include every relay in the
user's read markers. That defeated the tier-2 prompt for any AUTH-
required relay the user happened to have in NIP-65 — such as
wss://pyramid.fiatjaf.com, which never surfaced a banner during
manual testing because the coordinator was auto-signing it as
tier-1.

Switch to dmInboxRelaysStrict() (added in the earlier pre-send
alignment fix) so tier-1 is truly kind:10050 only. The KDoc already
promised strictness ("write/read relays are NOT included") — this
just makes the code match.

Surfaced during manual T3 testing 2026-07-06 with an account whose
NIP-65 outbox included pyramid.fiatjaf.com.
2026-07-09 07:33:43 +03:00
nrobi144 2061ef9e37 fix(desktop): align pre-send DM validation with strict NIP-17 semantics + wire resolver fan-out
Two related fixes that surface the same class of bug: the pre-send
"Recipient has no DM relay list" warning could disagree with the
actual send path, causing sends that either fail after the user
clicks Send, or block sends the user would have expected to work.

(a) STRICT ALIGNMENT — User.dmInboxRelays() (commons) is the lenient
    "give me a delivery target for a DM" helper: it returns kind:10050
    if present else the NIP-65 read marker (kind:10002). The send path
    (DesktopIAccount.resolveDmInboxRelaysStrict → DmInboxRelayResolver)
    uses the strict variant that returns kind:10050 only, because NIP-17
    §Publishing mandates delivery to the recipient's kind:10050
    exclusively — routing a wrap through a NIP-65 read relay leaks the
    conversation metadata to a relay the recipient did not designate
    for DMs.

    Add User.dmInboxRelaysStrict() as the kind:10050-only accessor and
    switch ChatNewMessageState.updateRecipientRelayStatus() to it so the
    UI's "can we deliver" check matches what the send path actually
    enforces.

(b) RESOLVER PROBE — pre-send validation was cache-only: if a peer's
    kind:10050 hadn't landed in LocalCache yet (e.g. the peer just
    published, or their event sits on an indexer relay we don't
    subscribe to), the UI reported them unreachable and blocked send
    even though the send path's DmInboxRelayResolver would have found
    them via indexer fan-out.

    Add an optional `dmInboxResolver: suspend (HexKey) -> List<...>?`
    callback to ChatNewMessageState. On a synchronous cache miss for any
    peer, the state optimistically blocks (preserving the "don't
    silent-fail" invariant) and launches a probe. If any peer's relays
    turn up, unblock immediately without requiring the user to reopen
    the conversation.

    Wired at both ChatNewMessageState construction sites in
    DesktopMessagesScreen to DesktopIAccount.dmInboxResolver (which
    Main.kt injected in the earlier P4 wire-up commit). Android's
    ChatNewMessageViewModel is a separate class and keeps
    cache-only behaviour — Android UI parity is deferred.

Surfaced during manual testing 2026-07-06 with two accounts where one
had a kind:10050 and one didn't: the UI correctly blocked send, but
the block persisted even after publishing kind:10050 for the missing
account until the conversation was reopened.
2026-07-09 07:33:43 +03:00
nrobi144 007217f407 fix(desktop): pad AuthApprovalBanner around macOS traffic lights
Amethyst Desktop uses apple.awt.fullWindowContent = true (see
applyNativeWindowChrome), which draws content edge-to-edge under
the title bar so the macOS traffic-light buttons overlap whatever
sits in the top-left of the App content column.

The AuthApprovalBanner mounts at (0, 0) of the content column,
which put its lock icon + "pyramid.fiatjaf.com" text directly
under the red/yellow/green window buttons. Screenshotted in
manual testing 2026-07-06.

Two-part fix:

1. Bump the row's own padding from horizontal 12dp / vertical 8dp
   to horizontal 16dp / vertical 10dp for better breathing room
   in general.

2. At the mount site in Main.kt, wrap the banner in a
   platform-aware Modifier: on macOS pad 80dp from start (clears
   3 traffic lights at 14pt each + spacing) plus 8dp top / bottom
   4dp; on other platforms just an 8dp horizontal / 4dp vertical
   margin. Non-mac users see the banner flush-ish since their
   window chrome doesn't overlap.

Padding lives at the mount site so the banner composable itself
remains reusable inside chat panes or other contexts where
traffic-light clearance isn't needed.
2026-07-09 07:33:42 +03:00
nrobi144 3bbeda4cef feat(desktop): wire DmInboxRelayResolver into NIP-17 send path
Completes Phase 4 end-to-end. DesktopIAccount.resolveDmInboxRelaysStrict
now uses the resolver injected from Main.kt instead of the
LocalCache-only fast path. Three-layer lookup at every call:

  1. LocalCache hit (kind:10050 already observed via feed pipeline)
  2. Resolver's 1h LRU cache
  3. Indexer fan-out via the dedicated unauthenticated NostrClient

The unauthenticated NostrClient is constructed in App() alongside
relayManager and connects on creation; DisposableEffect disconnects
on the App-level dispose. Critically NO RelayAuthenticator is
attached to this client — only the primary relayManager.client has
one (via DesktopAuthCoordinator). This closes security review F-01:
indexer queries no longer extract identity-key signatures during
kind:10050 probes against curated indexers.

resolveDmInboxRelaysStrict is converted from sync to suspend; the
three send paths (sendNip17PrivateMessage, sendNip17EncryptedFile,
sendGiftWraps) already run in suspend context inside DmSendTracker
batches, so the conversion is local. Resolver is plumbed through
MainContent as a new parameter rather than a CompositionLocal —
explicit threading matches the existing pattern for accountRelays
and relayManager.

The legacy LocalCache-only fallback inside resolveDmInboxRelaysStrict
is preserved for the constructor-default case (tests, CLI). When
dmInboxResolver is null, behaviour matches the pre-this-commit
strict-fix from 5293dae65.
2026-07-09 07:33:42 +03:00
nrobi144 2f3805bbfa feat(commons,desktop): inline AUTH approval banner with [Once] [Always] [Never]
Adds AuthApprovalBanner in commons.relayClient.auth — a Compose-
Multiplatform composable that renders one row per pending tier-2
NIP-42 AUTH challenge with three actions matching the AuthApprovalScope:

  [Once]    — sign this challenge, don't persist
  [Always]  — sign + persist ALWAYS via the store
  [Never]   — drop + persist BLOCKED via the store

Wired into desktop Main.kt as a global top-of-content banner reading
authCoordinator.pendingApprovals and calling authCoordinator.resolve.
Now tier-2 challenges actually have a UI to resolve — desktop AUTH is
end-to-end usable.

Up to 3 rows stack inline; the rest collapse into a "+N more pending"
row (click-to-expand can come later). Each row shows the relay's
display URL plus message-count when multiple challenges from the same
relay have coalesced.

The composable itself is in commons so Android picks it up free when
its AccountAuthApprovals VM wire-up lands — only the Main.kt-level
wiring (where to mount the banner in the layout) is platform-specific.

Lifecycle:
- Banner subscribes to pendingApprovals via collectAsState; recomposes
  only when the PersistentMap identity changes (per the substrate
  built in earlier commits).
- onResolve calls authCoordinator.resolve(url, scope), which completes
  the underlying CompletableDeferred + removes the entry from the
  pending map; the suspended signer wakes up and signs (or doesn't).
2026-07-09 07:33:21 +03:00
nrobi144 3ab3642757 feat(desktop): wire NIP-42 AUTH on desktop via DesktopAuthCoordinator
Until now desktop had no NIP-42 AUTH wiring at all — relays demanding
AUTH from desktop users got silently ignored. This commit closes the
gap, but does it the security-conscious way using the
AuthApprovalPolicy substrate from earlier commits.

DesktopAuthCoordinator binds to AccountState transitions in Main.kt
and per logged-in account:

- constructs a PreferencesAuthApprovalStore scoped by pubkey
- constructs an AuthApprovalPolicy with self-approved relays sourced
  from the active account's NIP-17 DM-inbox (kind:10050) cache
- constructs a RelayAuthenticator whose signWithAllLoggedInUsers
  lambda routes every AUTH challenge through the policy

Tier 1 (own DM-inbox + persisted ALWAYS) signs automatically. Tier 2
challenges hand back a CompletableDeferred surfaced on
authCoordinator.pendingApprovals. Until the inline banner UI lands
(P2.5 follow-up), tier-2 pending stays unresolved — which means
tier-2 relays don't get an AUTH response, same outcome as the
pre-this-commit world. The improvement here is tier-1: own DM
inbox relays now AUTH automatically without any prompt.

Lifecycle: onLogin attaches the authenticator; onLogout and account-
switch tear it down and complete any pending deferreds with BLOCKED
so suspended signers don't dangle.

Self-approved relays are deliberately scoped to kind:10050 (DM
inbox) only, NOT NIP-65 write/read relays. A user may follow read-
only relays they don't want to AUTH-identify themselves on — and the
common case where AUTH matters most is the user's own DM inbox.
2026-07-09 07:03:56 +03:00
nrobi144 d6c1b13136 fix(desktop): stop falling back to user's connected relays for NIP-17 DMs (P0 security)
Per NIP-17 §Publishing, gift wraps MUST only be published to the relays
advertised in the recipient's kind:10050. Today three send paths in
DesktopIAccount fall through to relayManager.connectedRelays.value
when the recipient has no kind:10050 cached:

  sendNip17PrivateMessage      (line 200)
  sendNip17EncryptedFile       (line 231)
  sendGiftWraps                (line 253)

This is the security-review F-04 metadata leak: at best the wrap never
reaches the recipient (their other clients don't read those relays);
at worst the recipient pubkey + send timestamp leak to general/feed
relays outside their chosen inbox. Same class of bug as the relay-
power-tools work explicitly closed for the relay picker on
2026-04-20 ("block DM fallback to all relays — metadata leak").

Replace the fallback with strict resolution: if the recipient has no
kind:10050 in the cache, return an empty target set. DmSendTracker
already handles total relay count == 0 with a "No relays available"
failure state, so the user gets a visible error instead of a silent
leak.

Indexer fan-out + a UI dialog for the missing-10050 case is the
permanent fix, scoped to Phase 4 (DmInboxRelayResolver). This commit
is the conservative pre-Phase-4 plug — better to fail visibly than
leak silently.

NIP-04 send is unchanged: that path is pre-NIP-17, the encrypted
content sits next to other public events on the sender's outbox by
design.
2026-07-09 07:03:55 +03:00
nrobi144 6abf0784da feat(desktop): add PreferencesAuthApprovalStore for persisted AUTH grants
Desktop persistence for the AuthApprovalPolicy in commons. Backs the
`auth_approvals` use case from the plan using java.util.prefs.Preferences
instead of the originally proposed sibling outbox.db SQLite table.

Trade-off rationale: the AUTH approval set per account is small
(typically < 50 relays for any user) and the read pattern is bounded
(one lookup per relay per session, easily cached in memory by the
policy layer). java.util.prefs is already in use elsewhere on desktop
(SearchHistoryStore, DesktopPreferences) and adds zero new
dependencies or schema migrations.

The retry_queue table from the same outbox.db proposal needs the
higher-throughput characteristics SQLite gives us; it remains scoped
to P3 (send visibility), which can introduce a proper sibling DB at
that point.

Per-account scoping by Preferences node — logout/account-delete calls
clear() which removeNode()s the subtree. ONCE scope is never written
to disk, enforced explicitly here in addition to the interface
contract.

Not yet wired into a DesktopAuthCoordinator (today desktop has NO
AUTH wiring at all). That wiring lands in P2.5 alongside the banner
UI.
2026-07-09 07:03:55 +03:00
nrobi144 2229986c5c fix(desktop): drop since on kind:1059 sub to honor NIP-17 randomized timestamps
Per NIP-17, seal (kind 13) and gift wrap (kind 1059) created_at are
randomized up to 2 days in the past for privacy. A subscription that
applies a `since` window — even with a 2-day adjustment — silently drops
wraps whose randomized timestamp predates the window, losing real DMs
and suppressing the unread badge.

Today only one caller (the desktop subscription coordinator) reaches
FilterDMs.giftWrapsToMe and it already passes no `since`, but the
parameter remained on the function signature as a footgun. Drop it so
the invariant is enforceable by the type, and document why in KDoc.
2026-07-09 07:03:54 +03:00
Claude d9dee8967b fix: resolve compiler warnings across modules
Clears real Kotlin compiler warnings surfaced across quartz, cli,
relayBench, amethyst, and desktopApp:

- quartz Sha256/EventHasher/ScratchLocal: ThreadLocal.get() is nullable
  in Kotlin; assert non-null (withInitial never yields null).
- quartz GitHttpClient: PriorityQueue.poll() under isNotEmpty() is
  non-null; assert it.
- relayBench CorpusDownloader: drop redundant !! on smart-cast Long;
  Jackson fields() -> properties().
- cli GrapeRankCommand: drop redundant ?. where latest is smart-cast.
- PodcastRemoteContent: OkHttp body is non-null; drop dead elvis.
- Dead/redundant expressions: remove no-op when-branch values and a
  redundant trailing Unit (HomeScreen, LocalCache, EmbeddedTabLayer,
  ParticipantHostActionsSheet, NestActionBar, ControlWhenPlayerIsActive,
  ShareNoteAsImageScreen exhaustive-when else).
- CalendarEventDetailScreen / SetPasswordDialog / ProfileClinkOfferResolver:
  drop always-true conditions (reorder to keep smart-casts).
- WalletColumnScreen: OkHttp body non-null; drop unreachable null-guards.
- PcmTapRegistry: the @OptIn used androidx.annotation.OptIn, which does
  not opt into Kotlin's ExperimentalCoroutinesApi; use kotlin.OptIn.
- GitRepositoryScreen: suppress the standard ViewModel-factory cast.
- PushNotificationReceiverService: suppress override-of-deprecated.
- Desktop GlobalScope call sites: @OptIn(DelicateCoroutinesApi::class).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GMqkg1ndvFihEwZcENiRs
2026-07-08 18:31:10 +00:00
Vitor PamplonaandGitHub 0ff48cfbe0 Merge pull request #3483 from nrobi144/feat/wot-shared-index-relays
feat(desktop): Web-of-Trust score badges + shared index relays + amy wot verbs
2026-07-08 12:53:08 -04:00
Vitor PamplonaandGitHub 50ce22e49e Merge pull request #3495 from nrobi144/feat/desktop-wallet-privacy-lock
feat(desktop): apply the privacy lock to the Wallet column
2026-07-08 12:52:54 -04:00
nrobi144 a7e91ac5f6 chore(desktop): log OutboxDispatcher summary per follow-set change
One-line summary log after loadKind3ViaOutbox so reviewers and manual
testers can confirm the outbox pipeline actually fired without wiring
in a full metrics collector. Shape:

  DEBUG: [WotOutbox] fetchKind3Only authors=N covered=M fallback=K
                     kind10002=X kind3=Y

Zero overhead when the log level is above DEBUG.
2026-07-07 16:47:44 +03:00
nrobi144 1e076c5cc2 feat(desktop): apply privacy lock to the Wallet column
Extends the messaging privacy lock to the Wallet deck column via the
same master `lockEnabled` flag (single toggle, single password) with
per-scope lock state so each route re-locks independently.

commons/ui/privacylock/
  LockScreen.kt        Shared internal composable (scope + copy)
  WalletLockGate.kt    Mirrors MessagesLockGate for scope=Wallet
  MessagesLockGate.kt  Shrunk to a 20-LOC wrapper delegating to LockScreen

desktopApp/security/
  DesktopLockScreen.kt         Shared password-input surface with optional
                               "No password set" deep-link (plan Q5).
  DesktopMessagesLockGate.kt   Now delegates to DesktopLockScreen
  DesktopWalletLockGate.kt     New; deep-links to Settings via
                               onNavigateToRelays when no password is set
  WalletFirstRunBanner.kt      Mirrors MessagesFirstRunBanner; both read
                               the single firstRunCardSeen flag (dismiss
                               once = dismissed everywhere)
  MessagesFirstRunBanner.kt    Copy updated: "Lock Messages and Wallet?"
  PrivacyLockBlurModifier.kt   Modifier.privacyLockBlurWhenUnfocused()
                               reads LocalWindowInfo.isWindowFocused;
                               applied to text nodes only (balance,
                               generated-invoice amount, QR code) — cards
                               and layout stay crisp (plan Q4).

desktopApp/ui/
  wallet/WalletColumnScreen.kt Inserts WalletFirstRunBanner at top;
                               wraps sensitive text with blur modifier.
  deck/DeckColumnContainer.kt  Wraps Wallet branch with
                               DesktopWalletLockGate; passes
                               onNavigateToRelays so the "No password"
                               branch deep-links to Settings.
  settings/PrivacyLockSettingsScreen.kt
                               Master-lock copy: "Enable privacy lock"
                               header; body mentions Messages AND Wallet
                               columns; auto-lock + caveat cards updated
                               to reference both routes.

Testing sheet: docs/plans/2026-07-07-wallet-lock-manual-testing.md
  12 manual scenarios covering cross-scope lockout, blur-on-unfocus,
  password-clear cascade, deep-link to Settings, and first-run banner
  parity across the two routes.

All existing PrivacyLockStateTest cases green + the 3 Wallet-reuse
tests from the previous commit. amethyst + desktopApp compile clean.
2026-07-07 13:33:32 +03:00
nrobi144 bb2a83c1fe feat(desktop,cli): route WoT kind-3 fetch through OutboxDispatcher (NIP-65)
Phase 3 of the outbox refactor (PR #3483, per Vitor's directive). The
WoT service's kind-3 seeding on Desktop and the `amy wot sync` verb now
go through OutboxDispatcher — index relays discover each author's
kind-10002 write relays, then per-outbox-relay REQs fetch kind-3.

Changes:

  Desktop:
    - DesktopRelaySubscriptionsCoordinator gains an inner
      OutboxCacheGateway that bridges DesktopLocalCache
      (cachedAdvertisedRelayList / consume) to OutboxDispatcher.
    - New suspend loadKind3ViaOutbox(pubkeys) method returns the
      dispatcher's Result for observability.
    - Main.kt WoT-seed effect now:
        1. gates on wotService.isDisabled to preserve MAX_FOLLOWS
           guardrail (fix 2 from Phase 1)
        2. calls loadKind3ViaOutbox instead of the direct
           loadKind3Batched on index relays
        3. keeps the 2s markReady safety net for cold-start UX
    - clear() now also clears outboxDispatcher's dedup markers.

  amy:
    - WotCommand.sync rewritten to construct an OutboxDispatcher, buffer
      events in the gateway, and persist to ctx.store after fetch
      returns (store.insert is suspending; can't call from non-suspend
      gateway callbacks).
    - --json output additively gains kind10002_received,
      outbox_covered_authors, fallback_authors, persisted keys.
    - --timeout N still supported; now maps to overallTimeoutMs.

Not in this commit (deferred to a follow-up on same PR if reviewers
want it):
  - Routing stranger-avatar kind-0 fetch through the outbox path
    (MetadataPreloader wiring is more invasive; keeps this diff focused
    on the primary WoT concern).

Plan: commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md
2026-07-07 13:31:41 +03:00
nrobi144 dddeae74b6 feat(wot): OutboxDispatcher — fetch kind 0/3 via each author's outbox relays
Reviewer Vitor (PR #3483): stop blasting kind 0/3 REQs at a static index
relay list. Use NIP-65: index relays discover each author's kind-10002,
then per-author kind 0/3 REQs go to that author's declared write relays.

New in commons/commonMain:
  - OutboxCacheGateway — platform-agnostic bridge to the local event
    cache. Three ops: cachedOutbox(pubkey), onOutboxDiscovered(event,
    relay), onDiscoveredEvent(event, relay).
  - OutboxDispatcher — three-phase pipeline reusing Quartz's existing
    RelayListRecommendationProcessor.reliableRelaySetFor(...) for the
    author→relay inversion + minimal-cover algorithm.
      Phase 1: REQ kind-10002 for authors not already cached, from
               index relays. Per-relay 4s timeout.
      Phase 2: reliable-relay-set → per-outbox-relay REQ for kind 0
               and/or kind 3 filtered to that relay's authors.
      Phase 3: index-relay fallback for authors that never returned
               a 10002. Preserves current behaviour on cold accounts.
    Retries the "not in kind*Succeeded and not in kind*InFlight" set so
    a zero-EOSE run is retryable on the next call.

New in DesktopLocalCache:
  - route() branch for AdvertisedRelayListEvent (kind 10002) storing in
    addressableNotes so cachedAdvertisedRelayList(pubkey) can serve
    future lookups without a REQ.
  - cachedAdvertisedRelayList(pubkey): AdvertisedRelayListEvent? — the
    gateway's peek into the cache for Phase-1 skipping.

Tests (7): cached-outbox-skips-Phase-1, Phase-1-discovers-then-Phase-2,
Phase-3-fallback-for-no-10002, cached-author-covered-when-Phase-1-hangs,
clear-releases-dedup, concurrent-EOSE-safety.

Plan: commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md
2026-07-07 13:26:38 +03:00
nrobi144 d0daf786b1 feat(commons): scope-parameterise PrivacyLockState for multi-route lock reuse
Genericises the messaging privacy-lock state holder so a single master
`lockEnabled` flag can drive multiple gated routes independently:

- `LockScope { Messages, Wallet }` enum added.
- `MessagesLockState` → `PrivacyLockState(scope, settings, coroutineScope)`.
  Each scope keeps its own StateFlow<LockState> + idle-timer Job; both
  scopes share the same `PrivacyLockSettings` so failed-attempt counters
  and lockout schedule stay device-global (brute-force protection).
- `LocalMessagesLockState` (single instance) → `LocalPrivacyLockState`
  (Map<LockScope, PrivacyLockState>) + `lockStateFor(scope)` accessor.
- `redactionLevel` → `dmRedactionLevel` (Kotlin-side rename; persisted
  prefs key `redaction_level_ordinal` unchanged).
- `setPasswordHashed(null)` cascades to `setLockEnabled(false)` so a
  master lock cannot stay armed without a credential to verify against.

MessagesLockGate, DesktopMessagesLockGate, MessagesFirstRunBanner,
SetPasswordDialog, and RedactionCard now read `lockStateFor(Messages)`
— behaviour-preserving. Ships 3 new PrivacyLockStateTest cases:
independent per-scope state, shared failed-attempt counter, and the
password-clear cascade.

Plan: docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md
2026-07-07 13:18:56 +03:00
nrobi144 5166216e2e fix(wot): hold MAX_FOLLOWS guardrail, add close(), correct SnapshotStateMap docs
Reviewer davotoula (PR #3483) flagged three commons/wot issues that would
bite the Android app on adoption:

  2. Guardrail bypass. handleFollowSet assigned myFollows before the
     MAX_FOLLOWS check, so subsequent applyKind3 calls whose follower
     landed in the huge set fully repopulated reverseIndex/_scores —
     defeating the "skip WoT for mega-follow accounts" promise. Fix:
     check size FIRST, clear myFollows, expose a disabled StateFlow, and
     early-return handleKind3 while disabled. Guardrail also releases
     itself when the follow set later shrinks back under the cap.

  3. No teardown API. WoTService owned a writer coroutine + ops Channel
     but had no close(). On account switch a new instance was created
     while the old one leaked its writer. Fix: implement AutoCloseable;
     close() shuts the channel so writerLoop exits and post-close
     trySend calls are dropped silently. Main.kt wires it via
     DisposableEffect(iAccount) so account switch is a clean teardown.

  4. Misleading docs. KDoc claimed Snapshot.withMutableSnapshot conferred
     per-key isolation. That's a SnapshotStateMap property, not a
     withMutableSnapshot property; the wrap only coalesces an op's
     writes into a single Compose commit. Rewritten to be accurate so
     future integrators don't trust the wrong invariant.

Tests: existing guardrail test extended with isDisabled assertion, plus
new tests for guardrail-holds-under-applyKind3, guardrail-releases-when-
follow-set-shrinks, close-stops-accepting-ops, and close-is-idempotent.

Plan: commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md
2026-07-07 13:13:20 +03:00
nrobi144 d8961c0d75 fix(desktop-cache): eliminate accountPubkey race that could wipe follow list
Reviewer davotoula (PR #3483) flagged a P0 race in
DesktopLocalCache.consumeContactList: lastContactListByAuthor was stamped
before the self-check. During login, hydration launched on Dispatchers.IO
before Main.kt's LaunchedEffect bound accountPubkey. If the user's own
cached kind-3 hydrated first, the map got poisoned; the same event later
arriving from a relay was rejected by the createdAt gate, _followedUsers
stayed empty, and FollowAction.follow would call createFromScratch and
wipe the real follow list.

Two-part fix:

1. Reorder Main.kt so localCache.accountPubkey is set before hydration
   launches. Also clear the pubkey on logout and on account switch.
2. Belt-and-braces: consumeContactList now only stamps
   lastContactListByAuthor inside branches where we know self identity.
   When accountPubkey is null (login/hydration window), skip the stamp so
   the relay retry that arrives after bind can populate _followedUsers.

Regression tests reproduce the "hydrate before bind, replay after bind"
scenario and confirm the follow set populates on retry.

Plan: commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md
2026-07-07 13:09:49 +03:00
nrobi144andClaude Opus 4.7 6361c54a4e fix(desktop): sidebar nav replaces detail overlay instead of hiding behind it
On Desktop, tapping a sidebar nav item while a detail screen (profile,
thread, article, editor) was open only mutated the sidebar destination.
The opaque `AnimatedContent` overlay driven by `ColumnNavigationState`
kept covering the (already-swapped) root content until the user hit
Back, creating the impression that the click did nothing.

Fix: emit a `clearOverlaySignal` from `SinglePaneState.navigate` and
`DeckState.focusExistingColumn`. Each layout collects the signal in a
`LaunchedEffect` and calls `navState.clear()`, draining any pending
detail stack so the tapped destination is what the user actually sees.

- SINGLE_PANE: one signal (Unit), one layout-local `navState`.
- DECK: signal payload is the column id; each `DeckColumnContainer`
  filters on `column.id`, so only the focused column's detail clears —
  other columns' navigation stacks are preserved.
- Same-item taps also clear (signal fires unconditionally, unlike a
  StateFlow value comparison).
- `onOpenSettings` uses the same navigate / focusExistingColumn paths
  and inherits the fix automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-06 16:08:06 +03:00
nrobi144 6ff2e54212 refactor(desktop): move Index Relays UI to Relays dashboard, match sibling-editor UX
Relocates the shared index-relays editor off the Configure/Settings
screen and into the Relays column's Configure tab as a 6th collapsible
section next to Connected / NIP-65 / DM / Search / Blocked relays —
where users already look for relay-list editing.

Rewrites the section to match the SearchRelayEditor pattern: local
SnapshotStateList buffer seeded from the persisted set, OutlinedTextField
with a compact IconButton(Add), per-row Close remove, Enter-key add,
plus a Save button that commits the buffer to PreferencesIndexRelays and
a Reset-to-defaults button that reseeds the buffer with the 4 built-in
defaults. Adds a savedMessage toast noting the 'restart to apply' caveat.

File moved: desktop/ui/settings/IndexRelaysSection.kt →
desktop/ui/relay/IndexRelaysEditor.kt (matches the *Editor.kt sibling
naming convention).
2026-07-06 09:53:28 +03:00
nrobi144 fe22de0817 feat: shared index relays across Desktop and amy + settings UI
Unifies the "index relays" set (used for kind 0 profile metadata and
kind 3 follow list REQs) across the Desktop app and the `amy` CLI so
they always compute WoT scores against the same data source, and adds
a user-configurable settings section for the list.

Before this change:
- Desktop hard-coded `DefaultRelays.RELAYS` at coordinator
  construction; users could not override.
- `amy wot sync` used `outboxRelays().ifEmpty { inboxRelays() }` —
  NIP-65 write / DM inbox relays, which are semantically different
  from index relays. `amy wot get` after `amy wot sync` could return a
  different score than the Desktop UI would compute.

New `PreferencesIndexRelays` (commons/jvmMain) is a tiny class backed
by `java.util.prefs.Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")` —
the same JVM-user-scoped shared-node trick `PreferencesHashtagSpamSettings`
already relies on. Both Desktop and amy running as the same OS user
observe the same value with zero extra plumbing. App-global (not
per-account); users typically have one preferred index-relay set
regardless of which account is logged in.

Behaviour changes for users who never open the settings UI: none.
`DEFAULT_INDEX_RELAYS` is byte-for-byte identical to the four URLs in
`DefaultRelays.RELAYS`.

Wiring:
- `DesktopRelayCategories` gains a straight-through `indexRelays`
  StateFlow (no combine — index relays are a curated user choice, not
  a NIP-65-derived set) plus `setIndexRelays(new)`.
- `Main.kt` instantiates `PreferencesIndexRelays` at App() root and
  passes it into both the subscriptions-coordinator constructor and
  `DesktopRelayCategories`. Coordinator snapshots the effective set
  at construction — changes take effect on next relaunch (documented
  in the settings section explainer).
- `Context.indexRelays()` reads the same preferences node so
  `WotCommand.sync` produces identical relay batches to Desktop.
- New `IndexRelaysSection` composable in
  `desktopApp/.../ui/settings/` — list + per-row remove + add-row
  with URL normalisation. Deletion of all entries falls back to
  defaults (delete-all is the reset — no separate "Reset" button).
  Placed between the Local Relay and Content Filters sections of the
  Relays settings screen.

Tests:
- `PreferencesIndexRelaysTest` — defaults fallback, round-trip
  persistence, blank-token skipping, non-empty defaults guardrail.
- Full existing test suites remain green.

Companion PR (search-result badges) landed on `feat/wot-search-badges`
and is this branch's parent. Both remain stacked on the WoT feature
branch pending upstream review.

Plan: docs/plans/2026-07-01-feat-wot-followups-search-badges-and-index-relays-plan.md
2026-07-06 09:31:02 +03:00
nrobi144 afa1a3b652 feat(desktop): WoT badges on search-result person cards
Extends the WoT trust indicator to the Search screen's person-picker
results, matching the badges already shown on note-card avatars.

- `UserSearchCard` (commons) gains an optional
  `badge: @Composable (BoxScope.() -> Unit)? = null` param, forwarded
  to its embedded `UserAvatar` (which has the slot from the WoT PR).
  Default null → no visual change for callers that don't opt in;
  Android search screens continue to render as before.
- `SearchResultsList` (desktopApp) inlines the score-lookup gates in
  a small `wotBadgeFor(pubkey)` helper and passes the badge lambda at
  both person-result call sites (main list + expandable overflow).

Same visibility rules as the note-card avatar badges:
score > 0, past the 2 s startup readiness gate, and pubkey not in
`LocalSpamExemptKeys` (self / already-followed).
2026-07-06 09:22:51 +03:00
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 ce3536ddf7 test(desktop-cache): bind accountPubkey before consuming self kind-3
The consumeContactList scoping fix means kind-3 events only update
`_followedUsers` when `event.pubKey == accountPubkey`. Tests were
constructing a fresh DesktopLocalCache() (accountPubkey = null) and
publishing kind-3s authored by `userPubKey` / `ownerPubKey`, so the
guard silently rejected them and `followedUsers` stayed empty.

Bind `accountPubkey` in the test setup so the guard passes.
2026-07-02 18:11:16 +03:00
nrobi144 9284e12e86 feat(desktop): Web-of-Trust score badges + amy wot verbs
Adds a friends-of-friends trust score on every user avatar in Desktop
feeds, threads, profile headers, and repost overlays. For pubkey X the
score is the count of accounts in the active user's follow set who also
follow X — Gossip / Snort convention. v1 is display-only; no threshold
filtering.

Data flow
- `commons/wot/WoTService` — sparse `SnapshotStateMap<HexKey, Int>` +
  reverse index + per-follower snapshot for diff-based updates.
  Single-writer coroutine (Channel<Op> → `Snapshot.withMutableSnapshot`)
  serializes all mutations. Cap at 5000 follows/event blocks DoS via
  hostile kind-3s. Guardrail at 2000 follows/account skips WoT for
  mega-accounts.
- `DesktopIAccount.wotService` — per-account instance, matches
  `Kind3FollowListState` / `BookmarkListState` conventions.
- `Main.kt` binds `localCache.accountPubkey`, collects
  `localCache.contactListEvents` → `applyKind3`, collects
  `localCache.followedUsers` → `onFollowSetChange` +
  `subscriptionsCoordinator.loadKind3Batched(...)` with
  `onEose = markReadyOnce`. 2 s fallback timeout guarantees badge
  visibility even if index relays never EOSE.
- `FeedMetadataCoordinator.loadKind3Batched(pubkeys, onEose)` — chunks
  authors into ≤100 per Filter within one subscription. Matches
  nostr-rs-relay defaults.

UI
- `commons/ui/components/UserAvatar` gets an optional
  `badge: @Composable BoxScope.() -> Unit`. Android call sites pass
  null (no compile-time coupling to Desktop-only tooltip APIs).
- `desktopApp/.../ui/note/WoTBadge` — Material3 `TooltipBox` +
  `PlainTooltip` (multiplatform-ready, keyboard/screen-reader a11y).
  `rememberTooltipState(isPersistent = true)` fixes the
  vanish-too-fast desktop default.
- `desktopApp/.../ui/note/WoTBadgedAvatar` — drop-in replacement for
  `UserAvatar` that overlays the badge when
  `LocalWoTService != null && LocalWoTReady && pubkey !in LocalSpamExemptKeys`.
  Score read is a plain `service.scores[userHex] ?: 0` — snapshot
  system tracks per-key, so avatars only recompose when their own
  score changes.
- Call-site migration at 4 v1 surfaces: NoteCard header (covers feed /
  thread / bookmarks / search / QuotedNoteEmbed via NoteCard),
  FeedNoteCard repost header (2 avatars), UserProfileScreen header
  (2 sizes).

Amy verbs
- `amy wot get <pubkey|npub> [--json]` — hydrates a WoTService from the
  local FsEventStore, prints score for target pubkey.
- `amy wot list [--threshold N] [--limit K] [--json]` — sorted score
  list.
- `amy wot sync [--timeout N]` — batch-fetches kind-3 for the active
  follow set from outbox/inbox relays, persists to the event store.

Tests + docs
- 14 unit tests: `WoTServiceTest` covers happy path, sparse map,
  self/follower exclusion, kind-3 churn diff, guardrail, event cap,
  ready gate, clear.
- Manual testing sheet with 17 scenarios at
  `desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md`.
- Plan at `docs/plans/2026-07-01-feat-desktop-wot-score-plan.md`.

Prerequisite `DesktopLocalCache.consumeContactList` scoping fix landed
as a separate commit.
2026-07-02 18:03:47 +03:00
nrobi144 741c377f80 fix(desktop-cache): scope consumeContactList to active user + fan out to SharedFlow
The old implementation tracked kind-3 replaceability with a single global
`lastContactListCreatedAt` scalar and unconditionally overwrote
`_followedUsers` on every accepted event. Once *any* subsystem starts
fetching other users' kind-3 events (WoT scoring, mutual-follow lookups,
etc.), a newer-createdAt kind-3 from a follower silently hijacks the
active user's follow-set state, cascading into feed filters, mute logic,
and sidebar counts.

Fix by tracking newest-per-author (`ConcurrentHashMap<HexKey, Long>`) and
guarding writes to `_followedUsers` / `lastContactListEvent` on
`event.pubKey == accountPubkey`. `accountPubkey` is bound from `Main.kt`
on login.

Also expose `contactListEvents: SharedFlow<ContactListEvent>` (buffer 64,
DROP_OLDEST) so downstream consumers (the incoming WoT service) can
observe every accepted kind-3 without adding a bespoke listener API.
2026-07-02 18:03:12 +03: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