646 Commits
Author SHA1 Message Date
vitorpamplonaandgithub-actions[bot] bf73e0934a chore: sync winget manifests to v1.13.1 2026-07-29 14:31:13 +00:00
Vitor Pamplona 8a183d1cbd refactor(ci): move the Winget bump out of CI onto a maintainer machine
Mirrors the Homebrew cask change. The old design stored a classic `public_repo`
PAT as WINGET_TOKEN and handed it to the third-party
vedantmgoyal9/winget-releaser action: a token with write access to every public
repo the owning account can reach, given to code we do not control, in a place
any push-access collaborator could read it from (a pushed branch containing a
workflow runs with repo secrets).

- CI (bump-winget.yml, now "Sync Winget Manifest Reference") uses GITHUB_TOKEN
  only: downloads the MSI, computes the sha256, reads the ProductCode from the
  MSI Property table via msitools, and opens an in-repo PR syncing
  desktopApp/packaging/winget/. Runs on ubuntu (1x billing) rather than Windows
  since msitools reads the Property table fine.
- scripts/bump-winget.sh does the upstream PR. It needs NO new token: it drives
  `gh`, which a maintainer already has authenticated, and it does not need
  wingetcreate (Windows-only) because the manifests are plain YAML — so it runs
  from macOS or Linux.

Add the three reference manifests under desktopApp/packaging/winget/, matching
the schema 1.12.0 shape used upstream. All three validate against Microsoft's
published JSON schemas.

Validation caught one real bug worth noting: an all-digit 64-char InstallerSha256
parses as a YAML *integer* and fails the schema's `string` type, so it is written
quoted. ProductCode is re-read every release because jpackage regenerates it per
build; it is the ARP key `winget upgrade` matches on.

Drops the last package-manager PAT from the secret inventory.
2026-07-29 10:30:21 -04:00
Vitor Pamplona 8a8f58e7ad fix(release): actually notarize the macOS DMG, and verify it
The macOS leg ran only `packageReleaseDmg`, which SIGNS the DMG but does not
notarize it — notarization is a separate Compose task. The `notarization {}`
block in desktopApp/build.gradle.kts only supplies credentials; nothing invoked
it. So every release up to v1.13.1 shipped a signed but unnotarized DMG:
`xcrun stapler validate` reports no ticket on either the .app or the .dmg, and
Gatekeeper blocks it on first launch.

Append `:desktopApp:notarizeReleaseDmg` on the macOS leg. Compose 1.11.1's
AbstractNotarizationTask runs `notarytool submit --wait` and then `stapler
staple` in place, so the asset-collection step still finds the same file. Raise
that leg's inner timeout to 45m since the submit blocks on Apple.

Gate it on the cert AND all three notary secrets, so forks and credential-less
runs keep producing a plain unsigned DMG instead of failing.

Add a verify step that asserts the stapled ticket. The bug survived many
releases precisely because nothing ever checked the outcome.

Also update the reference cask to 1.13.1 and make it pass `brew audit --new
--cask` + `brew style` cleanly: add the missing conflicts_with (the tiling
window manager's cask installs the same Amethyst.app), add depends_on :macos,
fix stanza order, drop the unnecessary `verified:` (url and homepage share a
domain), and widen the zap to the state dirs the source actually uses.

Correct BUILDING.md's macOS state table, which listed a
com.vitorpamplona.amethyst.desktop.plist that does not exist and omitted
~/.amethyst. Note that Java's Preferences API writes to a SHARED
com.apple.java.util.prefs.plist, which is why the cask must not zap it.
2026-07-29 09:59:41 -04:00
nrobi144andClaude Opus 4.8 c5ba33486a feat(desktop): format profile zap amounts compactly (210k, 1.5M)
Use commons showAmount() for the Zaps tab total and each zapper row instead of
raw sats, matching Amethyst Android and other Nostr clients.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:57 +03:00
nrobi144andClaude Opus 4.8 c204e5b485 fix(desktop): label the profile 'Mutual' tab 'Yours' to match Android
The filter (your own posts that mention this profile) matches Android's
UserProfileMutualFeedFilter, but Android's user-facing string is 'Yours'
(<string name="mutual">Yours</string>). The internal filter name stays
DesktopMutualFeedFilter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:56 +03:00
nrobi144andClaude Opus 4.8 1b70f0939d fix(desktop): group profile header actions + load Followers/Following metadata
1. Header actions were direct children of a SpaceBetween Row, so the overflow
   (⋮) menu floated to the center. Group Edit/Follow/⋮ in their own Row and put
   the overflow last (after the primary Follow action), per convention. Extract
   the Follow button into FollowButtonRegion for the reorder.
2. Followers/Following rows now subscribe to kind-0 metadata (batched, capped)
   and observe each user's metadata flow so names + avatars render instead of
   raw npubs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:56 +03:00
nrobi144andClaude Opus 4.8 dd6b779463 test(desktop): DesktopMutualFeedFilter unit tests + profile parity manual sheet
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:56 +03:00
nrobi144andClaude Opus 4.8 4ab73789a6 feat(desktop): profile Zaps received tab
- Wire real PrivateZapCache(signer) into DesktopIAccount (was a null stub)
- Subscribe to kind 9735 receipts p-tagging the profile; aggregate sats per
  zapper (private zaps decrypt only on your own profile, else fall back to the
  public zap-request pubkey)
- New ProfileZapRow; tab header shows the total sats

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:56 +03:00
nrobi144andClaude Opus 4.8 0ab1930123 feat(desktop): profile header actions — Message, Add-to-list, Share
- Message opens the Messages column + 1:1 room via a DesktopDmRoute bridge
  (observed by both deck and single-pane layouts)
- Add to list: pack picker from LocalFollowPacksState, appends via
  FollowListEvent.add + broadcast
- Share copies a nostr: profile link to the clipboard
- Actions live in the existing writeable/other-profile overflow menu, so
  self/read-only are already excluded

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:56 +03:00
nrobi144andClaude Opus 4.8 bc482cb6eb feat(desktop): five profile tabs — Followers, Following, Relays, Bookmarks, Mutual
- Scrollable tab row; Followers/Following render UserSearchCard from live
  contact-list subscriptions; Relays render a new RelayRowCard from kind 10002;
  Bookmarks (kind 10003 → DesktopBookmarkFeedFilter) and Mutual (new
  DesktopMutualFeedFilter) render FeedNoteCard
- Respects the shared mute/block hidden-set via DesktopFeedViewModel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:56 +03:00
nrobi144andClaude Opus 4.8 e862b3ecac feat(desktop): profile header polish — banner, rich-text bio, CLINK offer field
- Render kind-0 banner image in the profile card header
- Render bio as rich text (mentions/hashtags/links) via DesktopRichText
- Add CLINK offer (noffer) field to Edit Profile + shared EditProfileFields

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:55 +03:00
nrobi144andClaude Opus 4.8 b2b5bccec1 docs: desktop profile parity plan + per-phase sub-plans
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 09:39:55 +03:00
Vitor PamplonaandClaude Opus 5 1a82c9b9c8 Merge PR: fix(desktop): reload the visible page when switching accounts
Merges nostr proposal 22a8247d into main:
- Wrap MainContent in key(account.pubKeyHex) so the account-scoped subtree
  is torn down on an account switch. Deck layout and workspace state are
  remembered outside this key and survive.

Fixes a stale-identity leak, not just staleness: NotificationsScreen holds
its accumulated items in an unkeyed `remember { EventCollectionState(...) }`,
so account A's notifications stayed on screen under account B even though
the subscription below re-keyed correctly. ReadsScreen has the same unkeyed
collection, and DeckColumnContainer's ColumnNavigationState is keyed on
column.id only, so each column's screen stack also survived the switch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:45:57 -04:00
Vitor PamplonaandClaude Opus 5 9e69198623 style: import the symbols the two merged proposals referenced inline
Both nostr proposals merged just now (82e72369, bdb4b03e) referenced types
by fully-qualified name inside function bodies, which CLAUDE.md's Kotlin
style rule forbids. Merged them as authored rather than rewriting someone
else's patch mid-merge; this is the follow-up.

- NamecoinNameResolver: import kotlinx.coroutines.CancellationException.
  Both catch sites were inline-qualified (one added by the proposal, one
  already there), and the sibling resolvers in this same package
  (Nip05Client, UserHexResolver) already import it.
- desktop Main.kt: import the five notification symbols in the block the
  proposal touched — the two Preferences* factories and the three
  Local*Notification* CompositionLocals. Each occurs exactly once, so no
  fully-qualified stragglers are left behind for those names.

Deliberately scoped to that block: Main.kt has ~130 other inline
fully-qualified names, and sweeping them belongs in its own change, not
tacked onto a style follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 19:15:29 -04:00
mandClaude 74579778e2 fix(desktop): reload the visible page when switching accounts
`AccountManager.switchAccount()` correctly emits a new
`AccountState.LoggedIn` and the `remember(account, ...)` blocks around
`iAccount` / `accountRelays` in Main.kt already rebuild those. But the
Composables *inside* MainContent — feed screens, notification inbox,
profile screen, messages list — all held their own `remember { ... }`
state (LazyListState scroll position, expanded rows, per-column filter
tabs, in-flight metadata observers, view-models keyed on nothing) that
did NOT include the account as a key. So after a profile switch the
user still saw account A's feed items, notification unread state, and
follow-status overlays rendered under account B's identity, until they
navigated away and back to force the column to recompose from scratch.

Wrap the `MainContent(...)` call in `key(account.pubKeyHex) { ... }`.
This is the idiomatic Compose pattern for "identity changed — tear
down the entire subtree and rebuild it fresh": every child's
`remember { ... }` block re-runs, every `LaunchedEffect` re-enters,
every subscription restarts. Outer state (`deckState`, `workspaceManager`,
`singlePaneState`, `pinnedNavBarState`) lives above this call site so
the user's column layout / workspace / nav backstack are preserved —
only the account-owned content resets.

Concretely, after this change:

- Home Feed on account A → switch to account B → column now shows
  account B's home feed, following account B's follow-list.
- Notifications tab on A → switch to B → tab reloads with B's unread
  cursor, B's notification-kind toggles, B's mute/block enforcement.
- Direct Messages column stays on the Messages screen, but the
  chatroom list is B's, DM subscriptions restart against B's kind:10050
  inbox relays, giftwrap decryption uses B's signer.
- Profile screen viewing @alice: still viewing @alice, but the follow
  button / mute button reflect B's follow/mute state instead of A's.

No new tests: verifying this needs a Compose UI test harness Amethyst
Desktop doesn't ship yet. The scoped-teardown behaviour of `key()` is
Compose runtime contract, not app-level state to pin down.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 08:13:31 +10:00
mandClaude e9475dd079 fix(desktop): make Enable OS notifications button actually enable them
Two independent gaps meant the desktop notifications flow silently
did nothing after the user clicked the settings button:

1. `LocalNotificationSettings` and `LocalNotificationReadState` were
   declared but never `.provides()`'d anywhere. Both
   `NotificationSettingsScreen` and `NotificationsScreen` fell back to
   a fresh `PreferencesNotificationSettings()` / `PreferencesNotification-
   ReadState()` instance each time — the java.util.prefs backing store
   kept the values consistent across cold restarts, but each Composable
   held its own `MutableStateFlow`, so a `setEnabled(true)` in Settings
   never fired the collectors in the inbox banner or in the auto-
   dispatcher. The user could toggle the master switch back and forth
   and nothing observable happened until the app was restarted. The
   auto-dispatcher in Main.kt was already using its own hoisted
   `notifSettings` instance too — just never handed down through the
   composition — so it never even saw the toggle.

2. The "Enable OS notifications" button (only shown on macOS when
   permission == NotRequested) called `dispatcher.requestPermission()`
   and updated `permissionState`, but it never touched `settings.enabled`.
   So the OS prompt appeared, user clicked Allow, the UI cheerfully
   said "Permission granted" — and toasts still didn't fire because the
   master switch was still off (defaults to false, first-launch UX
   choice). The button label promised the whole flow; the code did
   half of it.

Fix:
- Hoist `notifSettings` (already existed for the auto-dispatcher) into
  the app-level `CompositionLocalProvider` as
  `LocalNotificationSettings provides notifSettings`, so every consumer
  reads/writes the same instance. Same-instance sharing means StateFlow
  emissions actually propagate.
- Add a per-account `PreferencesNotificationReadState`, keyed on
  `loggedIn?.pubKeyHex` via `remember(pubKeyHex)`, provided through
  `LocalNotificationReadState`. This also fixes the "Mark all as read"
  button in the inbox, which was `enabled = false` because the
  composition-local was always null.
- In `NotificationSettingsScreen`, if `requestPermission()` returns
  Granted and the master switch is currently off, call
  `settings.setEnabled(true)` alongside setting the status message. The
  master switch UI observes `settings.enabled` and recomposes
  automatically, so clicking one button now does the whole handshake.

Behaviour on other platforms is unchanged: Windows / Linux start in
`PermissionState.NotApplicable`, so the "Enable OS notifications" branch
never renders there — they see the "Send a test toast" button directly.
The auto-enable is guarded by `!enabled`, so users who deliberately
disabled the master switch and then re-granted permission (e.g. after
denying in System Settings) don't get overridden if the switch was
still on.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 08:11:02 +10:00
Claude 2d0ccb4d70 fix: stop FindUsersTest flaking when GC evicts weakly-cached users
DesktopLocalCache stores users in LargeSoftCache, which holds each User
via a WeakReference. FindUsersTest populated the cache but kept no strong
reference to the created users, so a GC between consumeMetadata and
findUsersStartingWith could collect them — LargeSoftCache.forEach then
skips (and evicts) the cleared entries, making the search return fewer
results. multipleUsersWithMetadata allocates the most objects and tripped
this most often (AssertionError at FindUsersTest.kt:115).

Verified with a forced-GC probe: without a strong reference all three
users were evicted (found=0); holding a reference kept all three (found=3).

Pin each test's users in a strong-reference list that stays reachable
through the assertions, mirroring how Notes/Account/follow lists keep
authors alive in the real app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X4BThbs8tz9egmFDYv2RPB
2026-07-27 23:04:04 +00:00
Claude 9bd339cba3 feat(blossom): fall back to upload when a sync/import target lacks /mirror
The File Sync / Import flow (and the mirror-on-upload fan-out) copy blobs
across the user's Blossom servers with BUD-04 `PUT /mirror`, but not every
server implements that endpoint. Blossom has no capability-discovery
mechanism, so a target without /mirror just answered 404/405/501 and the
whole copy was silently counted as failed.

Detect the "endpoint absent" statuses (404/405/501) as a typed
BlossomMirrorUnsupportedException — distinct from a mirror the server
understood but rejected (400/403/413/…) — and add BlossomClient.mirrorOrUpload,
which falls back to downloading the blob and re-uploading it (PUT /upload)
when mirror is unsupported. The downloaded bytes are verified against the
expected sha256 before re-upload, since a Blossom server is untrusted and
could substitute content, and the same t=upload auth is reused.

Wire every mirror path through mirrorOrUpload: the app-level
BlossomMirrorQueue (sync-all + import sweep), the blob manager's per-blob
mirror (including the paid-mirror retry), and UploadOrchestrator's
mirror-on-upload. Task now carries the descriptor content-type so the
fallback upload preserves the MIME.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168TWLTgrMxUR6yjjCCiBLS
2026-07-25 02:47:02 +00:00
Claude a7748dfda5 fix: clear compiler warnings across all modules
Sweep of Kotlin compiler warnings in every module's main source sets
(quartz, commons, cli, desktopApp, amethyst, nappletHost).

Genuine code fixes:
- Drop unnecessary !!/safe-calls and redundant elvis/casts (OkHttp's
  now-non-null `body`, smart-cast callbacks, non-null String receivers).
- Remove provably-redundant conditions (`canvas == null` after a
  non-null content check; `account != null` implied by `canModerate`).
- Migrate deprecated kotlinx.collections.immutable persistent ops
  (add/remove/put/addAll -> adding/removing/putting/addingAll).
- Migrate LocalClipboardManager -> LocalClipboard (+ scoped setText),
  ContextCompat.startActivity -> context.startActivity, TabRow ->
  SecondaryTabRow, and @ConsistentCopyVisibility on a private-ctor data class.
- Delete dead ReceiveDialog.onGenerate param (never invoked).
- Fix a platform-Boolean type-mismatch on a ThreadLocal read.

Deprecations with no available successor are narrowly @Suppress-ed with
a reason: androidx.security.crypto (EncryptedSharedPreferences/MasterKey),
androidx.privacysandbox.ui, WebView.databaseEnabled, BluetoothDevice
.connectGatt, media3 setEnableAudioTrackPlaybackParams, FirebaseMessaging
.token, and InputMethodManager.SHOW_IMPLICIT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Xb9YbBqhdZsHxMzitmyvn
2026-07-24 16:36:17 +00:00
Vitor PamplonaandGitHub 7c1300b735 Merge pull request #3687 from nrobi144/feat/desktop-moderation-safety
feat(desktop): Moderation & Safety — mute/block enforcement, NIP-56 report, NIP-36 content warning
2026-07-24 08:51:16 -04:00
nrobi144andClaude Opus 4.8 1a0b9eb2d9 docs(desktop): moderation test results (PASS) with published-report logs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:17:28 +03:00
nrobi144andClaude Opus 4.8 55d11acd61 feat(desktop): unify note ⋮ + right-click menus and add snackbar feedback
- rememberNoteMenuActions is now the single source for the note menu; both the
  ⋮ overflow (ShareMenu) and the feed right-click ContextMenuArea render the same
  items (copy ×5 / broadcast / mute / report).
- LocalSnackbarHost exposes the app SnackbarHostState so moderation actions show a
  confirmation: 'Muted user', 'Report sent', 'Reported & muted', 'Broadcast to
  relays', and failure toasts. Wired for the note menu + profile ⋮ actions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:04:23 +03:00
nrobi144andClaude Opus 4.8 c555265f0b docs(desktop): moderation UX + reporting manual testing sheet
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 11:57:03 +03:00
nrobi144andClaude Opus 4.8 65579ffc18 feat(desktop): log moderation publishes (report/mute) + surface zero-relay sends
Report/mute actions published silently with no feedback. Add a DesktopModeration
logger: every report/mute logs kind+id+relay-count on publish, WARNs when there
are 0 connected relays (so a dropped publish is visible instead of silent), and
signing/publish failures are caught + logged (were swallowed by the launching scope).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 11:55:36 +03:00
nrobi144andClaude Opus 4.8 df442bf7b5 fix(desktop): discoverable note moderation — right-click menu + ⋮ overflow
Manual testing showed the note moderation actions were hard to find:
- Provide LocalDesktopIAccount on MainContent's own (non-null) provider so every
  deck/feed/profile/settings surface reliably resolves the account.
- Add a right-click ContextMenuArea on feed notes: Mute user / Report… / Copy text
  (moderation items for other authors on a writeable account).
- Swap the note action-row Share icon for a MoreVert (⋮) overflow — the menu it
  opens already carries copy/broadcast + mute/report, matching the profile ⋮.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 11:51:51 +03:00
nrobi144andClaude Opus 4.8 2bad8f6a77 docs(desktop): full moderation & safety manual testing sheet (core + follow-ups)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:07:45 +03:00
nrobi144andClaude Opus 4.8 274f8c0104 docs(desktop): mark moderation follow-ups plan complete
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:05:04 +03:00
nrobi144andClaude Opus 4.8 49d0518db1 feat(desktop): moderation follow-ups — thread/profile enforcement, sensitive toggle, mgmt screens
Completes the deferred items from the moderation & safety plan:
- Thread + Profile feeds now pass the real hidden lambda (via LocalDesktopIAccount),
  so mutes hide replies-in-thread and profile-tab notes live. Thread root stays shown.
- 'Always show sensitive content' toggle: new PreferencesSensitiveContentSettings
  (commons/jvmMain, java.util.prefs) backs DesktopIAccount.showSensitiveContentSetting
  (null=blur / true=show, never false) + setAlwaysShowSensitive; unit-tested.
- ModerationSettingsSection in the Content Filters settings: the toggle + management
  lists for muted users (unmute), hidden words (add/remove), muted threads (unmute),
  driven by the live hidden-users flow so removing an entry un-hides immediately.
- Profile header overflow (MoreVert): Mute/Unmute + Report… (reuses ReportNoteDialog)
  for other users on writeable accounts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 11:04:23 +03:00
nrobi144andClaude Opus 4.8 755cfc82fe docs(desktop): moderation & safety brainstorm + plan + manual testing sheet
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:44:20 +03:00
nrobi144andClaude Opus 4.8 f4435bfe42 feat(desktop): blur NIP-36 content-warning notes with tap-to-reveal
Generalize SpamCheckedNoteRender to also gate sensitive content: a note tagged
content-warning / NSFW renders behind a collapsed scrim (reason + 'Show') unless
the account opted into 'always show sensitive content'. Reuses the same
per-note rememberSaveable reveal + forceReveal as the spam collapse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:42:44 +03:00
nrobi144andClaude Opus 4.8 d571906dd0 feat(desktop): mute + NIP-56 report actions in the note menu
Surface the account-layer moderation write path in the UI:
- LocalDesktopIAccount CompositionLocal (provided at the app content root) so
  deeply-nested note surfaces can reach mute/block/report without prop-drilling.
- ShareMenu gains 'Mute user' + 'Report…' items (writeable accounts only).
- ReportNoteDialog: NIP-56 reason picker + optional comment, with
  'Report' and 'Block & report' actions.
- DesktopIAccount.reportEvent(Event, …) overload for the menu (report(Note) delegates).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:36:49 +03:00
nrobi144andClaude Opus 4.8 ac2875025a feat(desktop): mute/block/report write actions on DesktopIAccount
Account-layer write path for moderation (UI wiring to follow):
- hideUser/showUser/hideWord/showWord/hideThread/showThread build+sign+publish
  an updated kind-10000 MuteListEvent (create/add/remove via quartz), applied
  optimistically to the local cache then broadcast.
- report(note|user, type, comment) publishes a NIP-56 kind-1984 ReportEvent.
- All are no-ops for read-only accounts.
- Add DesktopMuteEnforcementTest: proves the feed filters drop muted authors
  (even when followed) and hidden-word notes, and pass clean notes through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:04:26 +03:00
nrobi144andClaude Opus 4.8 9090dfe82c fix(desktop): enforce mute/block on feeds (was a silent no-op)
Desktop DesktopIAccount.isHidden()/isAcceptable() were stubs (false / deletion-only)
and DesktopFeedFilters never consulted them, so muting/blocking a user did nothing.

- Add DesktopHiddenUsersState: assembles the kind-10000 mute list (users, hidden
  words, muted threads) + kind-30000 block list into a live StateFlow<LiveHiddenUsers>,
  decrypting the private section via the shared Mute/PeopleListDecryptionCache.
- Wire DesktopIAccount.isHidden/isAcceptable + the content-filter fields to it.
- Chain !note.isHiddenFor(...) into every note-rendering DesktopFeedFilter
  (global/following/custom/profile/reads/search/notification + thread replies).
- DesktopFeedViewModel re-invalidates the feed when the choices change, so mutes
  hide live without a restart.
- Subscribe to the account's kind-10000 mute list in Main.kt so it hydrates.

Reuses the shared commons LiveHiddenUsers + Note.isHiddenFor; the chatroom DM list
already called isAcceptable, so DMs now enforce mutes too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:01:30 +03:00
nrobi144andClaude Opus 4.8 b2adb157ff feat(desktop): NIP-88 polls (render, vote, create) + "Polls" search facet
Adds full NIP-88 poll support to Amethyst Desktop and a search content-type
filter for polls.

Polls (DesktopPollCard):
- Render kind-1068 polls in feed + thread (and reposted/boosted polls) as an
  interactive card via NoteCard's bottomContent slot.
- Vote (single-choice radio / multi-choice checkbox), re-vote ("Change vote")
  seeded with the prior selection; hide-until-voted with a "View results" opt-in.
- Tallies reuse commons PollResponsesCache; responses are fetched from the
  poll's OWN declared relays (NIP-88 relay tags) unioned with connected relays,
  so the full tally loads regardless of the viewer's relay set. Votes are
  likewise published to the poll's relays (not just broadcastToAll).
- Result row marks the viewer's own choice (border + check), tap a row to see
  its voters, footer shows distinct-voter count + deadline/ended state, and the
  voter gallery draws the viewer front-most with a ring.
- Create polls from the composer (options, single/multi, optional deadline);
  the dialog content scrolls with a pinned Cancel/Publish row; a poll requires
  a question and >=2 options.

Wiring:
- DesktopLocalCache.consume for kind 1068/1018 (response links into pollState).
- DesktopFeedFilters + FilterBuilders surface polls; feed/thread interaction
  subscriptions fetch kind-1018 responses.
- Thread + profile pass myPubKeyHex so the viewer's vote-state renders.

Search "Polls" facet:
- KindRegistry preset + alias for kind 1068 (auto-renders the filter chip and a
  NIP-50 kind filter); SearchResultsList renders poll results interactively and
  SearchScreen fetches their responses.

Also:
- Read-only accounts see results instead of dead vote controls.
- Cold-start: the response subscription re-evaluates as relays connect.
- Pull the upstream fix for the pre-existing RelayLatencyTracker.sweep
  ConcurrentModificationException (synchronized(pending)) so relay-health
  reclassify no longer crashes the UI during search.

Ripple/shaping: clickable elements clip to their shape for bounded ripple.

Tests: commons PollResponsesCache (dedup/tally/WoT sort) + DesktopLocalCache
response-linking.

Deferred (noted in review): wall-clock re-check of a poll expiring mid-view;
mention-dropdown now inside the composer scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:09:25 +03:00
Claude a3e239d33f feat(store): cost-based FS driver pick, runtime index materialization, new relayBench shapes
Acts on the measured gaps from TagAuthorIndexBenchmark and
FsDriverSelectionBenchmark:

- FsQueryPlanner: replace the fixed tags -> kinds -> authors driver
  order with a cost-based pick. Every legal driver (each tagsAll value,
  each tags key's value union, the kind set, the author set) opens a
  lazy directory iterator; all are drained in lockstep and the first to
  exhaust (the smallest listing) drives, so a giant idx/kind tree is
  never read past ~the smallest candidate's size. Fixes the 149 ms vs
  3.4 ms (~44x at 30k events) authors+kinds+limit regression.

- EventIndexesModule.ensureOptionalIndexes + SQLiteEventStore: flag-
  gated indexes are runtime config, not schema. An idempotent
  CREATE INDEX IF NOT EXISTS pass now runs on every open, so flipping
  an IndexingStrategy flag on an existing DB builds the index without
  a user_version bump.

- Desktop LocalRelayStore: enable indexEventsByPubkeyAlone. Shared
  ViewModels (Nip65RelayList, PrivateOutboxRelayList, VanishRequests)
  replay authors-only filters that full-scanned without
  (pubkey, created_at); existing DBs pick the index up on next open.

- relayBench Scenarios: add "conversation" (tag ∩ author ∩ kind, the
  DM-room shape, 65 client assembler call sites) and "reactions-watch"
  (kind 7 + #e IN 150 hottest notes) so the uncovered archetypes get
  head-to-head numbers vs strfry.

- quartz build: forward tagBenchScale/fsBenchScale to the test JVM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 14:54:48 +00:00
Vitor PamplonaandClaude Opus 4.8 f0c21f3513 fix(nip46): remote-signer pubKey is the user identity, not the transport key
NostrSignerRemote extended NostrSigner(signer.pubKey), where `signer` is the
ephemeral NIP-46 transport keypair — so `pubKey` returned the transport key,
not the user's identity. Every self-encryption / self-authorship site keys off
`signer.pubKey`, so for bunker accounts this silently broke:
  - private NIP-51 lists (private bookmarks / mute / follows / hashtags) and
    NIP-37 drafts — an `if (signer.pubKey != event.pubKey)` guard short-circuits
    (desktop: private bookmarks always empty);
  - NIP-44 self-encrypted data (Concord list, Cashu) sealed to / read against
    the wrong peer key.
Android is unaffected (no bunker path); desktop and CLI were affected.

Make `NostrSigner.pubKey` open and have `NostrSignerRemote` return the
bunker-resolved user key: `getPublicKey()` now caches it, and `bindUserPubkey()`
sets it eagerly for a reloaded account / stored identity. Internal transport
(the response-subscription `p` filter, request addressing) keeps using the
transport keypair explicitly, so it is unchanged. No-op for local/external
signers, where signer.pubKey already equals the account key.

Wired: desktop AccountManager.loadBunkerAccount binds the resolved pubkey; CLI
Context binds identity.pubKeyHex. amy's Concord-list decrypt workaround is
dropped — `newest.decrypt(ctx.signer)` now works for a bunker. Verified live:
`amy concord import` over a bunker account decrypts the kind-13302 list and
recovers Soapbox heldRoots [0,1].

Plan: quartz/plans/2026-07-17-nip46-remote-signer-self-pubkey.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:09:59 -04:00
Claude 19f5065af4 refactor(commons): shared ClickableUrl/ClickableEmail; Desktop drops its duplicate
Desktop's ClickableLink and Amethyst's ClickableUrl were near-identical: the only
real differences are mouse-first styling (Desktop underlines + shows a hand
cursor) and the open mechanism. But LocalUriHandler.openUri opens the browser on
Android AND Desktop (and the mail client for mailto:), so the "open a link"
logic never needed to be platform-specific.

Add ClickableUrl/ClickableEmail to commons/ui/components on LocalUriHandler, with
an `underline` flag so each front end keeps its exact look. Desktop's rich-text
renderer now reuses them (underline = true) for url/email/link-preview/withdraw
and its bespoke ClickableLink is deleted. Amethyst keeps its own blossom-intent-
aware ClickableUrl (blossom never applies to plain links, so this path is
equivalent); Phone stays platform-specific (Android dials, Desktop has no dialer).

Verified: :commons JVM and :desktopApp compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
2026-07-16 22:24:27 +00:00
Claude 9eb827fed6 feat(commons): route url/email/phone through the platform strategy
Closes the last rich-text fidelity residual. url/email/phone were rendered
generically by the shared core via RichTextInteractions callbacks, losing each
front end's per-type styling and open behavior. They now go through the
RichTextSegmentRenderer strategy:
- Add Url(url, displayText)/Email(address)/Phone(number) to the contract (with
  plain-text defaults).
- Core routes LinkSegment(no-preview)/SchemelessUrl -> Url, Email -> Email,
  Phone -> Phone; drop the in-core ClickableSpan.
- Amethyst renders them with ClickableUrl/ClickableEmail/ClickablePhone (blossom
  intent + dial preserved); Desktop with ClickableLink / underlined mailto / plain
  phone text.
- RichTextInteractions now carries only onClickHashtag (the one segment the core
  draws itself, with shared icons); the onOpen* callbacks are gone.

Verified: :commons JVM, :desktopApp, :amethyst play debug compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
2026-07-16 22:12:28 +00:00
Claude 51f005d22f refactor(commons): unify the rich-text parser cache across Android + Desktop
Replaces the two forked cached parsers (amethyst CachedRichTextParser on
android.util.LruCache + desktop DesktopCachedRichTextParser on ConcurrentLruCache
with a naive isMarkdown) with one shared object in commons/jvmAndroid/richtext,
built on quartz ConcurrentLruCache and keeping amethyst's CommonMark-aware
computeIsMarkdown and content-addressed key (content+tags+callbackUri+authorPubKey).

- Add ConcurrentLruCache.trimToSize(maxItems) (+ tests) for the onTrimMemory path.
- Repoint all amethyst callers (incl. the markdown unit test) and both desktop
  callers; delete both forks.

Verified: :commons JVM, :desktopApp, :amethyst play debug + unit tests compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
2026-07-16 21:56:12 +00:00
Claude 269ae95b4b feat(desktop): render rich text through the shared core; delete the Desktop fork
Implements the cross-platform RichTextSegmentRenderer contract on Desktop
(mouse-first) and converges Desktop onto the shared commons RichTextViewer:
- DesktopRichTextSegmentRenderer draws each divergent segment with the existing
  Desktop leaves (AsyncImage media + onImageClick, RenderInvoiceCard/RenderCashuCard,
  QuotedNoteEmbed, RenderBechSegment, RenderPdfCard/RenderNowhereLinkCard,
  RenderSecretEmoji, relay copy).
- DesktopRichText replaces the hand-rolled DesktopRichTextViewer switchboard: it
  parses with DesktopCachedRichTextParser, keeps markdown on RenderMarkdown, and
  drives the shared core via the two CompositionLocals. NoteCard repointed.
- Delete the old DesktopRichTextViewer + RenderSegment + the duplicate
  RenderCustomEmojiSegment (the core now renders emoji); rename the file.
- Add a NowhereLink method to the contract so both platforms keep their
  nowhere.ink card (the core no longer flattens it to a plain link); Android
  adapter implements it via NowhereLinkCard.

Verified: :commons JVM, :desktopApp, and :amethyst play debug all compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUiGxXMbjVmgspa1X15o1V
2026-07-16 21:43:56 +00:00
nrobi144andClaude Opus 4.8 d1ed9d071b fix(desktop): auto-approve pending AUTH once the DM-inbox relay set loads
On cold boot an AUTH-required kind:10050 DM-inbox relay usually sends its
AUTH challenge before the account's own kind:10050 list has been fetched.
AuthApprovalPolicy.classify reads the trusted (self-approved) relay set
exactly once, so it classifies the user's OWN inbox relay as tier-2 and
surfaces a manual `[Once][Always][Never]` banner. Nothing re-evaluates
that pending decision when the kind:10050 list finally loads, so the user
gets a spurious AUTH prompt for a relay that should have auto-signed.

Extract the pending-approval set into a platform-agnostic
commons/AuthApprovalRequests (add/resolve/cancelAll) and add
autoApproveNowTrusted(): when the DM-inbox set updates, retroactively
settle every pending prompt whose relay is now tier-1 with ONCE — sign
this session, do not persist (trusted by identity, not an explicit grant).

DesktopAuthCoordinator now delegates its pending set to AuthApprovalRequests
and exposes onSelfApprovedRelaysChanged(); Main.kt drives it from
DesktopAccountRelays.dmRelayList (the account's kind:10050 StateFlow).

AuthApprovalRequestsTest reproduces the cold-boot race (red before the fix)
and covers the resolve / cancelAll paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:31:18 +03:00
Claude 72a70ab635 test: make AUTH approval key test OS-independent
The previous regression test drove PreferencesAuthApprovalStore against
the real java.util.prefs.Preferences.userRoot() backing store, which
threw IllegalArgumentException on the macOS CI runner (its preferences
backend rejects operations the Linux one accepts).

Extract the key derivation into a pure `authApprovalPreferenceKey`
function and assert the invariant that actually matters — every derived
key stays within Preferences.MAX_KEY_LENGTH, which is exactly the
condition put() enforces. No OS I/O, so it runs deterministically on
every platform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aJPf1AsPTdzdMYR6pZ4e2
2026-07-16 01:38:54 +00:00
Claude b3fce19ff7 fix: persist desktop AUTH approvals for long relay URLs
The desktop AUTH banner ("<relay> requires authentication to deliver
this message") kept re-appearing on every challenge even after the user
clicked Always or Never.

Root cause: PreferencesAuthApprovalStore used the raw relay URL as the
java.util.prefs.Preferences key. Preferences caps keys at
MAX_KEY_LENGTH (80 chars) and throws IllegalArgumentException from put()
for anything longer. Outbox-proxy relay URLs that embed an npub and a
query string routinely exceed that (e.g.
wss://filter.nostr.wine/npub1...?broadcast=true is 102 chars), so
setScope threw. RelayAuthenticator swallows the exception, so the
ALWAYS/BLOCKED grant was silently never persisted and the relay
re-prompted on the next AUTH challenge.

Fold any relay URL over MAX_KEY_LENGTH into a bounded sha256:-prefixed
64-char hex key. Short URLs are still stored verbatim so existing grants
keep working.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aJPf1AsPTdzdMYR6pZ4e2
2026-07-16 01:25:34 +00:00
Claude 275df2ae9c fix(desktop): drain kind:10002 back-fill fully and close on EOSE
Audit follow-ups on the DM relay-list work:

- Bug: scheduleOutboxBackfill re-scheduled itself from inside the still-active
  drain job, so the isActive guard made the tail call a no-op — any authors
  past the first 100-author batch in a burst were stranded until another kind:0
  happened to arrive. Drain in a while-loop inside one job instead, and mark it
  @Synchronized so concurrent consume-path callers can't spawn duplicate jobs.
- Perf: the back-fill held each REQ open for a fixed 8s. Use fetchAll, which
  returns on EOSE (bounded by the timeout), and reuses existing infra.
- DmInboxRelayResolver: drop the redundant `+ cachedOutbox` in the phase-2 seed
  (cached outbox is already in phase1Seed, so it was always subtracted back
  out), and bound the phase-2 fallback fetch to 5s so a cold send can't stack
  two full fan-out timeouts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3
2026-07-16 00:31:18 +00:00
Claude 2d311c9324 Merge remote-tracking branch 'origin/main' into claude/nip17-dm-relay-desktop-4shxdy
# Conflicts:
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt
2026-07-16 00:20:28 +00:00
Claude 931236e719 feat(desktop): back-fill kind:10002 on profile load and read DM inbox from outbox relays
Answers "do we load kind:10050 from each user's outbox write relays?" — now we
do, and we make sure we know where those relays are.

- DmInboxRelayResolver.resolve is now two-phase: query the curated indexers ∪
  the recipient's known write relays (via a new outboxLookup) for 10050/10002;
  if the indexers didn't carry the 10050, read it directly from the write
  relays learned from their kind:10002 — the canonical NIP-65 outbox location.
  Wired on desktop with cachedAdvertisedRelayList(pubkey).writeRelaysNorm().
- Whenever DesktopLocalCache ingests a user's kind:0, it fires
  onProfileMetadataConsumed; the subscriptions coordinator back-fills that
  user's kind:10002 (batched ≤100 authors/REQ, deduped, one REQ at a time) and
  routes it through consume() so it's saved. So learning who a user is now also
  learns where they write.

Tests: DmInboxRelayResolverOutboxTest covers reading 10050 from the recipient's
write relays (indexers only have their 10002) and the cached-outbox seed path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3
2026-07-16 00:15:28 +00:00
Claude 152fc76bc0 fix(desktop): fetch account config (incl. blossom) from outbox relays
The account-config bootstrap subscription only queried the default relays,
so a user's Blossom server list (kind 10063) — published to their own write
relays, not the defaults — was never fetched, and the UI fell back to the
default server. NIP-65 relay lists hid the same gap because they're broadcast
widely and also have a local backup.

Add a subscription that re-fetches the account-config kinds (10002/10050/
10007/10006/10063) from the user's NIP-65 outbox (write + untagged relays)
once it's known, routing kind 10063 / 10002 through justConsumeMyOwnEvent
like the bootstrap does. This matches mobile's outbox model: the user's own
data comes from their write relays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
2026-07-16 00:03:38 +00:00
Claude c5ad932c1b feat(desktop): make DM relay-list prewarm viewport-driven, not a full sweep
The first pass warmed every conversation peer on disk from the 2s refresh loop.
Scope it to what the user is actually looking at instead:

- Move the prewarm to a reusable, deduped, concurrency-capped
  DesktopIAccount.prewarmDmInboxRelays(pubkeys) any DM surface can call.
- Trigger it per-row from the conversation list's LazyColumn, which only
  composes visible rows (+ buffer), so peers warm as their row scrolls into
  view and no earlier — scrolling warms more.
- Warm a recipient the moment they're picked in the New DM dialog, so the
  kind:10050 is ready before the composer opens.
- Room open already resolves peers via ChatNewMessageState.load().

ChatroomListState.prewarmPeerRelays now just delegates to the account.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSc3LhGFSF5VZKr9h3qfn3
2026-07-15 23:54:07 +00:00
Claude f727c226c0 refactor(desktop): provide blossom servers via LocalBlossomServers
Replace the threaded blossomServers parameters with a LocalBlossomServers
CompositionLocal, provided once from the account's state holder
(iAccount.blossomServerList.flow) around the logged-in UI. Upload sites read
the list from context instead of receiving it down a parameter chain.

- Provide LocalBlossomServers in MainContent's existing provider (covers
  feeds, chats, profile, settings) and around the top-level compose dialog.
- ComposeNoteDialog, EditProfileDialog, ChatPane and the media-server
  settings section read LocalBlossomServers.current; drop the params and the
  prop-drilling through UserProfileScreen and DesktopMessagesScreen.
- This also covers quote-compose from a feed row (NoteActionsRow), which the
  parameter approach couldn't reach — the per-note card composables now get
  the list from context, so it no longer defaults to the primal server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
2026-07-15 23:44:49 +00:00
Claude 2eaaa270c3 refactor(desktop): read blossom servers from the account, not the cache
Replace the ICacheProvider.blossomServers(pubKey) cache helper with reads
straight from the account's own state holder — iAccount.blossomServerList.flow
(the shared BlossomServerListState) — threaded to each upload site. This is
the reactive, per-account source of truth and drops the cache+pubkey
indirection entirely.

- Hoist iAccount (with dmSendTracker + accountRelays) out of MainContent into
  the LoggedIn branch so the top-level compose dialog can read the account's
  blossom flow too; pass them into MainContent as params.
- Thread iAccount.blossomServerList.flow into ComposeNoteDialog (reactive:
  the server picker updates if the list loads after the dialog opens),
  EditProfileDialog (via UserProfileScreen), and ChatPane (via
  DesktopMessagesScreen).
- Reduce BlossomServers.kt to just the DEFAULT_BLOSSOM_SERVER fallback used
  when the account has published no kind-10063 list yet.

Known gap: quote-compose opened from a feed row (NoteActionsRow) still
defaults to DEFAULT_BLOSSOM_SERVER — the per-note card composables don't
carry the account handle, and threading it through the whole note-render
tree isn't worth it for that secondary path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011dkzkEY6cUsRfqEb7giHi2
2026-07-15 22:51:57 +00:00