Commit Graph
1038 Commits
Author SHA1 Message Date
Claude 69204f16cb fix: use Mutex instead of JVM-only synchronized in AuthApprovalPolicy
The synchronized intrinsic is only available on JVM/Android, breaking the
iOS (compileKotlinIosSimulatorArm64) build in commonMain. Replace the Any()
lock with a kotlinx.coroutines Mutex + withLock, which is KMP-common and
safe here since all three store methods are already suspend functions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A918G2Ks9i9LXRuVoNgm7R
2026-07-09 15:19:01 +00:00
nrobi144 3f0ad05b58 fix(commons): guard RelayLatencyTracker.sweep against ConcurrentModificationException
The per-relay pending maps in RelayLatencyTracker are
Collections.synchronizedMap(LinkedHashMap): individual read/write
ops are thread-safe, but per the synchronizedMap javadoc iteration
is NOT — callers MUST hold the map's monitor while walking its
views. sweep() was iterating directly, so any network-dispatcher
mutation (adding a pending REQ, receiving an OK) during a sweep
would throw ConcurrentModificationException on AWT-EventQueue-0,
killing the Compose renderer while coroutine work kept running.

Pre-existing bug, documented in memory
desktop_relay_health_cme_crash. Ordinarily "not our problem", but
it's actively blocking manual T3 testing of this branch's AUTH
approval banner: adding any new relay triggers a
RelayHealthStore.reclassify sweep, so testers can't get a banner
render in without hitting the crash. Fix it here so the branch is
actually testable end-to-end.

Wrap both iteration loops in synchronized(pending) blocks. Sweep is
O(pending) with typically single-digit entries per relay, so the
hold time is negligible and the network dispatcher just briefly
waits.

Reproduced during manual T3 testing 2026-07-06 when adding
wss://pyramid.fiatjaf.com. Stack: RelayLatencyTracker.sweep:182 →
RelayHealthStore$reclassify$flagged$1.invokeSuspend:268.
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 49d31ccb44 feat(commons): SigningOpState.Progress for per-step in-flight UI
Adds a Progress(current, total, label?) variant to SigningOpState so
multi-step signing operations (NIP-17 group sends via remote signer,
batched zaps) can show "Encrypting via remote signer (3 of 5)" rather
than an opaque indeterminate spinner.

Backwards compatible:
- Pending stays a data object — existing callers' `is Pending` checks
  unaffected.
- New helper `isPending()` returns true for both Pending and Progress;
  SigningState.execute uses it so a second execute() during Progress
  returns null (matching the old single-flight semantics).
- SigningAwareButton renders both Pending and Progress as a spinner;
  callers wanting the counter must read the state directly.
- SigningStatusBar adds a Progress branch that shows "<label>
  (<current> of <total>)" — uses "Signing" as default label.

New `SigningState.updateProgress(current, total, label?)` lets the
in-flight block emit progress updates between Pending start and
finish. No-op when state is Idle/Error so callers don't have to gate.

Wire-up for NIP-17 bunker sends (publishing per-recipient progress
during NIP17Factory.createWraps' mapNotNullAsync) is a follow-up
that depends on threading the SigningState reference into the
factory's signing lambda; the substrate is here.
2026-07-09 07:33:42 +03:00
nrobi144 4ce21e7034 test(commons): AUTH end-to-end exercising policy → signer round-trip
Four tests covering the lambda shape that DesktopAuthCoordinator's
signWithAllLoggedInUsers calls into for every NIP-42 challenge:

  build RelayAuthEvent template → classify via policy → sign or
  block → return List<RelayAuthEvent> for RelayAuthenticator

- tier-1 own-inbox auto-signs a valid kind:22242 event with the
  right challenge + relay tags
- tier-2 unknown surfaces a PendingAuthApproval; ONCE resolution
  produces a signed event (no persistence)
- tier-2 BLOCKED returns null AND persists the rejection
- tier-2 ALWAYS persists and skips the prompt on subsequent calls

Concurrency: the policy.classify call inside the lambda suspends on
the CompletableDeferred when prompting; tests use coroutineScope +
async + yieldUntilNotNull to model the banner-resolving-from-outside
pattern, mirroring how DesktopAuthCoordinator.resolve() drives the
deferred from a UI click.

Together with the existing PoolEventOutboxStateTest (auth-required
carve-out), AuthApprovalPolicyTest (classifier), and
GiftWrapRelayHintTest (NIP-17 hint placement), this completes
unit-level coverage of the AUTH pipeline. The websocket-level
round-trip stays covered by geode/.../KtorRelayTest.kt against a
real Ktor mock relay; that infra is reusable for a future
desktopApp integration test that combines mock relay + this stack.
2026-07-09 07:33:42 +03:00
nrobi144 2240d64ae8 test(commons): cover DmInboxRelayResolver three-layer lookup + cache
Eight tests covering the resolver contract:

- localLookup hit short-circuits indexer fan-out
- empty indexer set returns empty
- empty local + empty indexer (no events arrive) yields empty
- cache hit within TTL skips indexer
- cache expiry triggers fresh indexer call
- clear() wipes all entries
- invalidate(pubkey) removes only the named entry
- localLookup returning an EMPTY list falls through to cache/indexer
  (the takeIf { isNotEmpty() } guard — emptyList from localLookup
  means "I don't know", not "I know they have nothing")

Uses EmptyNostrClient so RecipientRelayFetcher.fetchRelayLists returns
no events — covers the canonical "indexer found nothing" path without
needing a real mock relay. Tests for the populated-indexer path will
land with the Phase 4 wire-up commit when a Ktor-based mock relay is
plumbed through.
2026-07-09 07:33:22 +03:00
nrobi144 e091f6d3d3 feat(commons): DmInboxRelayResolver with strict kind:10050-only fan-out
Three-layer resolver for "where do I publish this NIP-17 gift wrap":

1. LocalCache hit — if the caller already saw the user's kind:10050
   via the regular feed pipeline, skip I/O entirely.
2. In-memory LRU cache — TTL 1h, 100 entries; avoids re-querying
   indexers when opening several conversations in sequence.
3. Indexer fan-out — RecipientRelayFetcher against a curated set
   (DefaultDmIndexerRelays: relay.nos.social, relay.damus.io,
   nos.lol, relay.nostr.band, purplerelay.com — purplepag.es
   deliberately excluded for poor kind:10050 coverage).

Strictness vs. the existing User.dmInboxRelays():
  - filters to kind:10050 ONLY; NEVER falls back to NIP-65 read
    marker (kind:10002). User.dmInboxRelays() silently substitutes
    that, which is the same metadata-leak class fixed by 5293dae65.
  - empty list = canonical "unreachable" signal; caller refuses to
    publish (DesktopIAccount.resolveDmInboxRelaysStrict already
    does this).

Security: the NostrClient passed in MUST be a dedicated
unauthenticated instance — no RelayAuthenticator attached. An
authenticated indexer fan-out (the current state with the primary
client) would extract identity-key signatures during the kind:10050
probe, escalating "indexer learns we want to DM pubkey X" into
"indexer learns user U wants to DM pubkey X". KDoc warning is
explicit; Phase 4 follow-up creates the unauth client in Main.kt
and injects it.

LocalLookup callback is plugged via lambda so CLI / headless
callers (amy) can use this without a Compose LocalCache.

Not yet wired into DesktopIAccount.resolveDmInboxRelaysStrict —
that wire-up is the next commit and converts the sync helper to
suspend, threading through sendNip17* batch construction.
2026-07-09 07:33:22 +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 2ba051a952 feat(commons): add AuthApprovalPolicy classifier for tiered NIP-42 AUTH
The current Android-only AuthCoordinator signs every NIP-42 AUTH
challenge from every relay unconditionally (and across every logged-in
account). For desktop there is no AUTH wiring at all — challenges are
ignored, so AUTH-walled relays silently drop DMs.

Both behaviours fail the security review: unconditional signing lets
any relay the user reads (or any malicious relay they touch) extract an
identity-key signature with timestamp, and signing across all accounts
links them under one relay observer.

This commit adds the substrate for a tiered classifier — wire-up will
follow with the desktop AuthCoordinator (P2.5) and SQLite-backed
persistence (P2.4). The policy itself is platform-agnostic and lives in
commons so Android can adopt the same design later.

Two tiers, no third silent-drop path:
- auto-allow when the relay is in the user's own outbox/DM-inbox set,
  or has a persisted ALWAYS grant (subject to BLOCKED override)
- prompt-and-suspend via CompletableDeferred for everything else, with
  the user's `[Once] [Always] [Never]` choice driving the deferred

Includes InMemoryAuthApprovalStore for tests + the ONCE session cache;
SqliteAuthApprovalStore lands in P2.4 with the sibling outbox.db.

Eight unit tests cover tier-1, persisted ALWAYS, persisted BLOCKED
(including BLOCKED overriding tier-1), unknown-prompt-then-cache,
re-eval of selfApprovedRelays on Account changes, and store.clear().
2026-07-09 07:03:55 +03: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 e45d8b18e6 fix(commons): import kotlin.concurrent.Volatile for iOS/Native compat
`@Volatile` without `import kotlin.concurrent.Volatile` resolves to the
JVM-only `kotlin.jvm.Volatile`, which breaks `commonMain` on iOS/Native
targets. CI catches this on `:commons:compileKotlinIosSimulatorArm64`.

Two call sites needed the import:
- OutboxDispatcher.kt:468 (`@Volatile private var lastCount`)
- FeedMetadataCoordinator.kt:433 (`@Volatile private var lastCount`)

Verified: `./gradlew :commons:compileKotlinIosSimulatorArm64` now green.

Closes CI break on PR #3483.
2026-07-08 11:24:15 +03:00
Claude 01d61b0851 fix: resolve remaining Kotlin compiler warnings in commons and cli
- Suppress DEPRECATION on REASONABLE_SIGN_KINDS, which intentionally lists the
  deprecated TorrentCommentEvent kind.
- Replace deprecated readLine() with readlnOrNull() in SecureKeyStorage.
- Drop unnecessary !! non-null assertions in KeyCommands and NostrConnect where
  the receiver is already smart-cast to non-null.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nqdy4VTLKidUWzGTJPja9
2026-07-07 21:39:04 +00:00
nrobi144 8bcebd886e perf(wot): parallelize Phase 2 outbox REQs + partial-result telemetry
Manual test showed the previous sequential loop over recommendations
timed out the overall budget when a well-connected account produced 23
outbox-relay recommendations (23 x 4s per-relay = 92s worst case).

Refactor: build one filterMap keyed by recommendation.relay and issue a
single client.subscribe. All relays fan out in parallel; the per-relay
EOSE gate bounds the wait regardless of set size. Phase 3 fallback
gets the same shape for consistency.

Also:
- Bump overallTimeoutMs default from 8s -> 20s (belt only; parallel
  Phase 2 makes it unlikely to trip).
- Log OVERALL TIMEOUT when withTimeoutOrNull returns null so
  reviewers can distinguish 'ran fine, no data' from 'timed out'.
- Add per-phase debug logs (start / phase1 done / phase2 recommendations
  and done / phase3 fallback and done) so the outbox pipeline is
  auditable without a debugger.

Existing 7 OutboxDispatcher tests still pass.
2026-07-07 16:56:08 +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 0a631d0730 docs(plans): mark PR #3483 fix-outbox plan completed 2026-07-07 13:32:32 +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 5217035f94 fix(coordinator): retryable batched-REQ dedup and race-free EOSE aggregator
Reviewer davotoula (PR #3483) flagged two commons/relayClient issues on
FeedMetadataCoordinator that both bite the Android app once WoT is wired
there:

  5. loadKind3Batched / loadMetadataBatched marked pubkeys as sent BEFORE
     any relay EOSE'd. On flaky-network cold-starts where every index
     relay timed out, the pubkeys stayed permanently marked and WoT was
     silently empty for the whole session — the next call short-circuited.
     Fix: pubkeys enter `queuedKind3Pubkeys` / `queuedPubkeys` only after
     ≥1 EOSE; on zero-EOSE timeout they roll out of the new
     `inFlightBatched*` sets so a subsequent call retries.

  6. `val eoseReceived = mutableSetOf<NormalizedRelayUrl>()` was mutated
     from per-relay `onEose` callbacks the client dispatches on
     `Dispatchers.IO`. Concurrent `add()`/`size` on an unsynchronised
     HashSet could drop entries or throw CME, forcing the batch to wait
     the full timeout instead of firing early. Fix: `BatchEoseGate`
     funnels EOSE notifications through a `Channel` so a single consumer
     coroutine is the sole reader/writer of the `seen` set — KMP-safe,
     no `synchronized {}` or JVM-only atomics.

Tests exercise:
  - zero-EOSE timeout → retry re-fires
  - ≥1 EOSE → next call short-circuits
  - full-EOSE from 20 relays hammered from Dispatchers.IO in parallel
  - clear() releases in-flight dedup
  - same semantics on loadMetadataBatched

Plan: commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md
2026-07-07 13:18:38 +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 57a64d258d fix(commons): guard RelayLatencyTracker.sweep against concurrent writes
`putPending` stores each relay's pending map as
`Collections.synchronizedMap(LinkedHashMap(...))` and wraps every writer
path (`putPending`, `recordSent`, `recordIncoming`, `recordDisconnect`)
in `synchronized(perRelay)`. `sweep` iterated `pending.entries.iterator()`
without taking the same lock, violating the wrapper's Javadoc contract.

Any concurrent websocket-thread write during `RelayHealthStore.reclassify`'s
sweep threw `ConcurrentModificationException` on the underlying
`LinkedHashMap$LinkedHashIterator`. Because `reclassify` schedules sweep
on the AWT dispatcher, the CME killed Amethyst Desktop's Compose render
thread and froze the UI.

Wrap both inner iterator loops in `synchronized(pending) { ... }` — the
exact synchronization the wrapper's Javadoc prescribes for manual
iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-06 16:08:18 +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
davotoula afe8c783c0 test(commons): cover deleteOrWarn and restrictToOwner helpers 2026-07-04 17:48:34 +02: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
davotoula 2b9ffb4849 Code review:
- extract shared File.deleteOrWarn helper for cache eviction
2026-07-04 15:51:41 +02: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 c85280259c feat: add reports, torrents, and addressable content to the reasonable set
Complete the "Let's be reasonable" content set:
- reports (1984) and torrents (2003/2004) — additive public events whose
  reputational weight is no greater than the arbitrary kind-1 notes an app
  can already publish.
- long-form articles (30023), wiki (30818), and the legacy addressable
  video kinds (34235/34236) — addressable content. Re-signing with the same
  d tag replaces the app's own prior version; accepted as no worse than the
  arbitrary posting a kind-1 grant already permits.

Only replaceable *configuration* (profile 0, contacts 3, 10000-range lists)
stays ASK, since a bad write there can silently wipe account settings —
distinct from addressable content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 18:13:34 +00:00
Claude e4c35523a7 feat: expand reasonable set with more public content/engagement kinds
Add the remaining regular, additive, public, plaintext content and
engagement kinds that sit in the same risk class as notes/pictures:
relay chat (9), threads (11), public messages (24), poll votes (1018) and
polls (1068), file metadata (1063), voice messages (1222) and replies
(1244), live-stream chat (1311), and code snippets (1337).

Documents the borderline kinds left at ASK on purpose: reports (1984) and
torrents (2003/2004) carry reputational/legal weight, and
addressable/replaceable content (long-form 30023, wiki 30818) can overwrite
prior versions. Test pins several of these exclusions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 18:08:48 +00:00
Claude 49e0fee162 perf: add expect/actual ConcurrentSet, use for EventDeduplicator
Part B of the dispatchers/thread-caps audit.

EventDeduplicator is fed from relay subscription callbacks
(AdvancedSearchBarState.trackRelayEvent), which arrive on multiple threads
concurrently. It backed a plain mutableSet with a single KmpLock, so every
delivery from every relay thread serialized on one monitor.

Add ConcurrentSet<E> as a KMP expect/actual util:
- jvmAndroid actual: ConcurrentHashMap.newKeySet() — lock-striped writes,
  lock-free reads, no single cross-thread monitor.
- iOS actual: a KmpLock-guarded set (no lock-free set in the K/N stdlib) —
  same behaviour as before, no regression. The win lands on JVM/Android,
  which is where the high-throughput event paths run.

Point EventDeduplicator at it. Covered by ConcurrentSetTest (commonTest,
behaviour) and ConcurrentSetConcurrencyTest (jvmTest, exactly-one-add-per-key
under 8 threads).

Scope note: the other two commonMain sites the audit flagged were left as-is
on purpose. The compose subscription managers' KmpLock is a deliberate,
documented KMP choice on a single (main-thread) writer where the lock is
effectively free; EOSECache is a bounded LRU with compound value mutation on
a per-subscription (not per-event) path. Neither is a clean fit for a
concurrent set, and converting them would fight a documented decision for
negligible gain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANuUziXKRafSTBxbh4SMoq
2026-07-02 16:53:43 +00:00
Claude a99ee8d09e feat: auto-approve video posts and NIP-42 relay auth under "Let's be reasonable"
Add video posts (kinds 21 normal, 22 short) as direct siblings of picture
posts (20) — additive, public, non-destructive content in the same risk
class as the original note set.

Also auto-approve NIP-42 relay auth (22242): an ephemeral proof-of-key
bound to a single relay + challenge (unreplayable elsewhere) that
Amethyst's own client already auto-signs for every logged-in account, so
treating it as background noise for napplets matches existing behavior.

Deliberately still ASK: NIP-98 HTTP auth (27235). Unlike relay auth it
authorizes an arbitrary HTTP request as the user — including destructive
NIP-96 blob deletes and NIP-86 relay-management admin calls — so its blast
radius is too broad to sign silently. Test pins the 42-vs-98 contrast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 16:04:12 +00:00
Claude 20a9622f27 feat: auto-approve zap requests (9734) under "Let's be reasonable"
Signing a Lightning zap request moves no money — it only fetches an
invoice. The payment itself is the separately-gated value.payInvoice
capability, which prompts on every use regardless of policy. So adding
9734 to the reasonable set drops a redundant signature prompt while the
meaningful payment prompt stays.

Nutzaps (9321) remain excluded: publishing one *is* the payment, since
the event carries the spendable ecash proofs. Test pins the contrast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 15:49:07 +00:00
Claude b02b5439d5 feat: expand "Let's be reasonable" napplet auto-approve set
Add more additive, public, non-destructive event kinds to the REASONABLE
signer policy so common apps stop prompting for every action. New kinds:
16 (generic repost), 20 (picture post), 42 (public chat message),
1111 (NIP-22 comment), 9802 (highlight), and 30315 (user status) — all in
the same risk class as the original 1/6/7 set.

Kinds that can spend money, overwrite account config (profile, contacts,
relay/mute/bookmark lists), delete content, or leak private data still
prompt. Decryption also stays ASK.

Refactors reasonableDecision() to a documented REASONABLE_SIGN_KINDS set
backed by quartz KIND constants, and adds NostrSignerPermissionLedgerTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 15:14:59 +00: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
Vitor PamplonaandGitHub 13f65dec26 Merge pull request #3450 from vitorpamplona/claude/compose-signature-field-u7rbx6
Add compose signature setting to auto-append custom text to posts
2026-07-02 08:42:20 -04: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 b0834b8d8a feat: add compose signature pre-filled in text-based post screens
Adds a Signature field to Compose Settings (global UI settings, DataStore
persisted). When opening any text-based composer — new note, reply, quote,
poll, NIP-22 comment (reply/hashtag/geohash/url), or a new long-form
article — the signature is appended to the message with a blank line,
keeping the cursor at the start so the user types above it.

Drafts, forks, and version edits are skipped since their content already
carries (or deliberately omits) a signature, and an untouched
signature-only message is treated as blank so closing the composer never
auto-saves a junk draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vq4JQPB9m62nJ8Vdp7xVLN
2026-07-02 03:41:00 +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
Claude dc9579f994 Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-07-01 18:39:59 +00: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 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 c4f647d01a feat(privacylock): foundation — state holder, settings, gate composable
Phase 1 of the messaging privacy lock. Headless cross-platform spine in
commons; no UI wiring yet.

- LockState sealed interface (Disabled / Locked / Unlocked)
- InactivityTimer enum (1m / 5m / 15m / 1h / Never; default 5m)
- DmRedactionLevel enum (Generic / Full)
- PrivacyLockSettings interface (StateFlows + mutators)
- PreferencesPrivacyLockSettings backed by java.util.prefs in jvmAndroid
  (shared by Desktop + Android; node com/vitorpamplona/amethyst/privacylock)
- MessagesLockState — app-global state holder; initial value seeded
  synchronously from prefs to close the deep-link race; LocalMessagesLockState
  CompositionLocal provided at App root
- CredentialPrompter interface + PromptResult enum +
  LocalCredentialPrompter CompositionLocal
- MessagesLockGate composable — synchronous branch select (no
  LaunchedEffect guard); LockScreen with biometric button
- IdleTimerModifier — pointerInput Initial-pass, non-consuming
- 8 unit tests covering cold-start seed, idle expiry, leave-route,
  Never timer, user-interaction reset, settings cascade,
  credential-unavailable disable. All green.
2026-07-01 11:35:51 +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
Claude 13d654f6be Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-06-30 19:23:05 +00:00