- Correct the AUTH-approval persistence check: this JVM uses
MacOSXPreferences (~/Library/Preferences/com.vitorpamplona.amethyst.plist),
not ~/.java/.userPrefs. Updated T3.b/c/d to read it via plutil.
- Record session results (T1,T2,T3a,T3c,T6,T6b,T8,T12 pass) and the three
bugs found+fixed during the run.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers the review fix — recipient with NIP-65 read relays but no
kind:10050 must be treated as unreachable, not routed to the read relays.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Rewrites the manual testing sheet so every step is an executable action
or a specific observation:
- Numbered steps within each test — no jumping between reference
sections. "Click X", "run Y in a terminal", "watch for Z".
- Each observation records YES/NO/SKIPPED for the sign-off matrix.
- Setup section spells out the wipe-preferences command and the
post-restart smoke check.
- T3 broken into T3.a/b/c/d for each button and its persistence
check separately (previously bundled T3.1–T3.10 was too dense).
- T6/T7 include exact tcpdump/tshark commands for the security
observations that require packet capture.
- T13 shortened to a sanity re-check (already verified).
- Sign-off table lists every test with a checkbox.
Purpose: give the tester a self-contained document they can follow
top to bottom in ~40 min without cross-referencing other sheets.
Updates the manual walkthrough to reflect the full state of the branch
after rebase + banner padding fix + pre-send validation fix:
- Marks what's already verified (T1 startup wiring, T5 lifecycle,
T6 P0 security via upstream UI, T13 pre-send fix).
- Adds T13 covering the alignment + resolver-probe fix from
a139c0fb17 with three sub-scenarios (strict alignment, resolver
unblock, and the real 2-user bootstrap flow).
- Documents the known pre-existing issues surfaced during testing
(RelayLatencyTracker CME, CompressionQuality stale-daemon) so
they don't get filed as regressions of ours.
- Adds a summary table pointing to the automated sheet for the
Phase A greens.
- Explicit out-of-scope list mirroring the deepening synthesis.
No code changes — documentation only.
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.
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
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>
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
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.
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.
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`.
Audited all 143 plan files across the 10 plans/ folders. Each plan now
carries a Status header (shipped | in-progress | queued | abandoned)
backed by codebase evidence, and every folder has a README.md index
grouping plans by status.
Shipped plans were moved into a per-folder plans/archive/ (via git mv,
history preserved) so each plans/ folder surfaces only live work:
shipped (archived): 122 in-progress: 8 queued: 7 abandoned: 4
docs/plans/ is the frozen legacy folder; its plans were stamped and
indexed in place (48 of 52 archived) but it remains closed to new plans.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016hpUivtmq4pgzqRbY6MYrA
The `sinceLastTag.updated` field was set to `date -u` on every
`scripts/translators.sh --seed` run, but nothing ever reads it. The
release-time credit generator only consumes `.mappings` and
`.sinceLastTag.translators`.
Because the field changed on every run, the seed-translators CI job
produced a diff (and therefore a new Crowdin/seed PR) on every push to
main even when the translator set was unchanged. Drop the field from the
seed write and from the committed JSON so the file only changes when a
contributor actually appears.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSoN4DDC5F1ehwGeC33652