Commit Graph
525 Commits
Author SHA1 Message Date
Claude df15414424 Merge remote-tracking branch 'origin/main' into claude/awesome-pasteur-xwiwad
# Conflicts:
#	amethyst/src/main/res/values/strings.xml
2026-06-22 14:50:16 +00:00
Claude b6f9630883 refactor(napplet): single-source the web contract and feed card in commons
Two shared extractions so the future desktop host reuses the exact same
sandbox and feed UI as Android, with no chance of drift:

Shared web contract:
- Move shell.html + shim.js into commons composeResources
  (files/napplet/), read via Res.readBytes on any platform.
- New NappletWebContract (commons/commonMain) single-sources the whole
  web contract: the shell/shim loaders plus the internal origin/host/URLs
  and both Content-Security-Policies (SHELL_CSP, APP_CSP). The Android
  host preloads the bytes in onCreate and reads every origin/CSP constant
  from NappletWebContract instead of its own duplicated constants and
  assets.open() calls.

Shared feed card:
- New StaticWebsiteCard (commons/.../ui/note) renders the inert NIP-5A /
  NIP-5D preview card: self-contained with commons compose-resource
  strings, LocalUriHandler for links, and inlined card chrome. It takes
  an isNapplet flag and an onOpen launch slot, so it never executes applet
  code itself.
- amethyst's note/types/StaticWebsite.kt becomes thin event->card
  adapters that supply the sandboxed onOpen launch.

Card strings move to commons strings.xml. Both napplet test suites
(commons jvmTest + amethyst) stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 00:10:22 +00:00
Claude 0cff3bf8e2 refactor(napplet): extract host-agnostic NappletRequestRouter to commons
Move the decode → broker → encode orchestration out of Android's
NappletBrokerService.handleMessage and into a pure, transport-free
NappletRequestRouter in commons/jvmAndroid. It returns a small Outcome
(Ignore / Reply / OpenSubscription / CloseSubscription / Push) that each
host acts on, so the Android service and the future desktop host share
the routing brain and can't drift on wire behavior.

The service now resolves the broker and dispatches on the Outcome,
supplying only the Messenger transport and the live relay subscription.
openLiveSubscription takes the decoded filters from the router instead of
re-decoding the payload, and the now-redundant process() is removed.

Unit-tested in commons/jvmTest (NappletRequestRouterTest).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 23:59:53 +00:00
Claude 0b76518ef2 refactor(napplet): move wire codec to commons for desktop reuse + desktop host plan
Set the desktopApp up to host napplets/nsites by maximizing the shared
core and documenting the edge it must build.

- Moved NappletProtocolJson (the wire codec) from amethyst to
  commons/jvmAndroid (package ...commons.napplet.protocol), next to the
  NappletRequest/Response types it marshals. It depends only on quartz +
  kotlinx.serialization + java.util.Base64 (Android 26+/JVM), so a future
  desktop host marshals through the identical object — request/result/push
  shapes can't drift between platforms. amethyst host/service/tests updated
  to import it; tests stay in amethyst and still exercise it.
- Added desktopApp/plans/2026-06-21-napplet-desktop-host.md: what's already
  shared (broker, protocol, codec, resolver, the shell.html/shim.js web
  contract), what desktop must build (KCEF/JCEF engine, custom-scheme
  serving, isolation, transport, gateways, UI), the decisions to make, a
  security-parity checklist, and recommended further extractions
  (NappletRequestRouter, shared web assets, the inert feed card).

commons:jvmTest and the amethyst napplet suite pass.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7RyevA6jL1NuY7uev2agS
2026-06-20 15:00:04 +00:00
Vitor PamplonaandClaude Opus 4.8 e82237840a fix(ci): point Compose macOS signing at the CI keychain explicitly
The desktop DMG release leg (build-desktop macos, packageReleaseDmg) has never
produced a signed artifact: createReleaseDistributable fails with "Could not
find certificate for '***' in keychain []". This is independent of the v1.12.3
notarization fix, which addressed the separate amy CLI leg.

Root cause: Compose's MacSignerImpl maps the signing identity to a certificate
by running `security find-certificate -a -c <identity>` with no keychain
argument. On the GitHub macOS runners that lookup does not resolve the cert that
import-macos-cert imported into a throwaway keychain and added only to the user
search list — even though bare `codesign --sign` (e.g. the signMacJarNatives
task, which succeeds in the same job) finds it fine. The "keychain []" in the
error is just the null settings.keychain being echoed.

Fix: export the throwaway keychain path from the import-macos-cert action and
feed it to Compose's `signing.keychain` via AMETHYST_MAC_SIGN_KEYCHAIN, so the
certificate lookup searches that keychain directly. Also set it as the default
keychain for good measure. No-op on local/PR builds (env unset -> Compose keeps
its previous default-search-list behavior).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:32:46 -04:00
Vitor PamplonaandClaude Opus 4.8 ee58355e6e fix(ci): sign macOS Mach-O natives embedded in bundled jars before notarization
The v1.12.2 release was the first to actually codesign + notarize the macOS
artifacts (signing was wired after v1.12.1, which shipped unsigned). Both macOS
legs failed with "Notarization status: Invalid": Apple's notary service recurses
into the bundled jars and rejects the unsigned Mach-O natives inside them
(secp256k1, sqlite-bundled, jna, skiko, jkeychain, kdroidFilter mediaplayer) —
codesign on the .app and the CLI's loose-file loop never descend into jars.

Notary log confirmed the offending entries, e.g.
  sqlite-bundled-jvm.jar/natives/osx_arm64/libsqliteJni.dylib
    -> "not signed with a valid Developer ID certificate" / "no secure timestamp"

Add scripts/sign-macos-jar-natives.sh: a shared helper that signs every macOS
Mach-O inside the bundled jars with hardened runtime + a secure timestamp,
skipping Linux ELF via a `file` Mach-O gate and no-opping when no identity is
set (local/PR builds unchanged). Wire it into:
  - the CLI notarize step (runs before the loose-file signing loop)
  - a desktop signMacJarNatives Gradle task that signs the proguarded jars
    between proguardReleaseJars and createReleaseDistributable, so Compose
    seals already-signed code.

Validated locally on arm64: clean signed createReleaseDistributable produces an
.app that passes `codesign --verify --deep --strict`, with every nested native
carrying Developer ID + hardened runtime + secure timestamp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:42:06 -04:00
Vitor PamplonaandGitHub 8ef4e42c16 Merge pull request #3278 from nrobi144/feat/desktop-relay-latency-health
feat(desktopApp): relay latency health — tracking, classifier & dashboard UI
2026-06-19 10:22:07 -04:00
David KasparandGitHub 29d6bc308e Merge pull request #3260 from nrobi144/worktree-fix-desktop-macos-keychain-proguard
fix(desktop): macOS forced re-login on cold boot — ProGuard strips java-keyring backend
2026-06-19 10:24:49 +01:00
nrobi144andClaude Opus 4.7 49929a4afa build(desktop): add osxkeychain.so regression guard task — fail release if stripped
The original "ProGuard strips the keychain backend" hypothesis turned out
to be wrong twice (PR 3260 comments document the binary PoW that refuted
both H1 strip-of-classes and H1b strip-of-native-resource). The full
117 KB osxkeychain.so resource ships intact in the proguarded
jkeychain-1.1.0-*.jar today, and Keyring.create() round-trips fine
against the proguarded classpath on macOS.

But the user-reported bug pattern (every cold boot, keychain key missing
→ forced re-login) maps so cleanly onto a hypothetical future
strip-of-native-resource that the guard is worth keeping. Cheap to run
(one unzip scan after proguardReleaseJars), wired onto every release
packaging task (DMG, MSI, DEB, RPM, current-OS distributable, runRelease)
so a regression can't slip past. Fails the build with a self-contained
explanation pointing at the next person who has to debug it.

The actual root cause of the reported bug remains unidentified after
three refuted hypotheses (see plan doc PoW table); needs the affected
user's Console.app logs + ~/.amethyst state to make further progress.
The LoginScreen "keychain-unavailable" diagnostic banner from the
earlier commit is unchanged and still earns its keep regardless of
which failure mode eventually turns out to be the cause.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-19 10:35:13 +03:00
Róbert NagyandGitHub 5e3fea94a1 Merge branch 'main' into feat/desktop-relay-latency-health 2026-06-19 10:28:07 +03:00
Vitor PamplonaandGitHub 24e9cf2aaa Merge pull request #3263 from vitorpamplona/claude/gracious-archimedes-wfvcri
Add macOS code signing + notarization for DMG and amy CLI
2026-06-18 09:14:25 -04:00
David KasparandGitHub b73890ac96 Merge pull request #3261 from nrobi144/feat/desktop-launch-optimization
feat(desktop): launch optimization foundation + icon-decode/relay-bootstrap fixes
2026-06-18 11:38:59 +02:00
nrobi144andClaude Opus 4.7 19efb6a28e feat(desktopApp): surface relay latency in dashboard + popup + banner
Phase 3 of relay-latency-health: wire the Phase 1 tracker and Phase 2 store
into the desktop UI across three surfaces. After this commit the feature is
end-to-end usable in the running app.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-18 12:11:25 +03:00
nrobi144andClaude Opus 4.7 a1d7889be3 revert(desktop): drop ProGuard keep-rule swap — binary PoW refuted the strip hypothesis
Built ./gradlew :desktopApp:proguardReleaseJars on both main and this
branch and inspected the shrunk java-keyring-1.0.4-*.jar in
desktopApp/build/compose/tmp/main-release/proguard/. Both branches
contain byte-identical macOS Keychain backend bytecode:
OsxKeychainBackend, ModernOsxKeychainBackend,
pt/davidafsilva/apple/OSXKeychain, plus all _addGenericPassword /
_findGenericPassword / _deleteGenericPassword / loadSharedObject native
methods. ProGuard is NOT stripping the macOS backend.

The compose-rules.pro comment had misled me. pt.davidafsilva.apple IS a
real transitive runtime dep of com.github.javakeyring:java-keyring —
ModernOsxKeychainBackend has a private pt.davidafsilva.apple.OSXKeychain
field. The original keep rule was correct; restore it and clarify the
comment about the transitive relationship so the next person to read
this code doesn't repeat the same mistake.

The AccountManager keychain-unavailable diagnostic + LoginScreen banner
introduced earlier in this branch are kept — they're useful for any
future failure mode in this area, not just the (refuted) ProGuard one.

See https://github.com/vitorpamplona/amethyst/pull/3260#issuecomment-4740073787
for the full PoW jar inspection. Remaining hypotheses (H2 hardened-runtime
unsigned-dylib block, H4 jpackage stripping the bundled libosxkeychain.dylib,
H5 v1.11.0 migration gap) are documented in the plan doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-18 12:11:03 +03:00
nrobi144 96752bd21b docs(desktop): mark all in-scope launch-opt phases complete in the plan
Phase 1.4 (App() smoke test), Phase 2.4 (fixture-relay wire-up), and
Phase 5.2 (bootstrap-gate fix + regression tests) all land in commit
48a8178c9. The progress-log table, acceptance-criteria checkboxes, and
pending-work section are updated to reflect the new state. 278/278
desktopApp tests pass.

Only follow-up enhancements remain — the cold-fork shell driver, a
Compose-driving benchmark variant, and Phase 5.3 (sequential remember
chain in MainContent). None are required for the in-scope set.
2026-06-18 12:10:58 +03:00
nrobi144 48a8178c98 feat(desktop): App() Compose UI smoke tests + Phase 5.2 regression tests
Adds a `LaunchTestOverrides` bundle (default null in production) so
`App()` can be driven from `createComposeRule()` against the in-process
fixture relay instead of the OkHttp + kmp-tor + DesktopHttpClient stack
it normally constructs via `remember { … }`. `DesktopRelayConnectionManager`
gains a secondary constructor taking a `WebsocketBuilder` so the
`LocalRelayManager` composition local (typed as
`DesktopRelayConnectionManager?` and consumed by ~20 screens) does not
have to be relaxed.

`AppStateMachineTest` exercises four scenarios:

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-18 11:31:29 +03:00
nrobi144 d10c875d88 docs(desktop): refresh launch-opt plan pending list with App() blocker
Phase 1.4 / 2.4 / the four Phase 5.2 regression tests are all blocked on
the same broader App() dependency-injection refactor — relayManager,
localCache, localRelayStore, and subscriptionsCoordinator are still
constructed inside App() via remember { … }. The torManager slot has
been loosened to ITorManager in preparation, but the rest is wider work
than this session can absorb.
2026-06-18 11:14:28 +03:00
nrobi144 d2d044a634 refactor(desktop): relax App() torManager param to ITorManager interface
App() only consumes torManager.status, which is on ITorManager.
Loosening the parameter type lets future tests substitute a fake without
having to construct the concrete DesktopTorManager (which eagerly builds
a kmp-tor TorRuntime on first status access). No production behavior
change: DesktopTorManager already implements ITorManager and the existing
call site at Main.kt:633 upcasts naturally.

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

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

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

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

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

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

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

All 274 desktopApp tests pass. No flaky tests introduced.
2026-06-18 11:10:14 +03:00
Claude c81dbb0127 feat(desktop): wire macOS Developer ID signing + notarization
Add gated code-signing + notarization for the macOS desktop DMG so it can
clear Gatekeeper and stay in Homebrew's main cask (unsigned casks are
rejected after 2026-09-01).

- desktopApp/build.gradle.kts: macOS signing{}/notarization{} blocks, gated
  on the AMETHYST_MAC_SIGN_IDENTITY env var. Absent => unsigned DMG, exactly
  as before, so local dev and PR CI are unaffected.
- create-release.yml: import a Developer ID cert into a throwaway keychain on
  the macOS leg and export the signing/notary env. Soft-gated on the
  MAC_CERTIFICATE_P12 secret — no secret => unsigned build.
- BUILDING.md: document the six MAC_* secrets, how to generate them, and flip
  the unsigned-cask fallback note to reflect the wiring is now in place
  (pending Apple credentials).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sso31DfSF9B6EFCVkEqWD
2026-06-17 23:06:07 +00:00
nrobi144 48726ee4df docs(desktop): mark phases 1.1/1.2/1.3/5.1 complete in launch-opt plan
Add a Progress Log table to the plan summarizing what landed in this
worktree, with commit refs and a clear pointer to the next critical-path
work (Phase 1.4 App() smoke test, then Phase 2 relay seam, then Phase 3
benchmark harness, then Phase 4 baseline, then Phase 5.2 bootstrap-gate
fix). Phase 5.1 ships but its end-to-end delta is still pending the
benchmark harness.
2026-06-17 11:41:29 +03:00
nrobi144 b338d7db4b feat(desktop): collapse icon decoding to a single memoized lazy
Phase 5.1 of the launch-optimization plan: the cold-boot critical path
loaded /icon.png up to four separate times (taskbar setup, Window icon,
Tor splash, account-loading splash). Two of those sites also paid an
ImageIO.read to obtain a BufferedImage, and the Window-icon site
additionally round-tripped the image back through ImageIO.write so Skia
could re-decode it.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 18:29:03 -04:00
Vitor PamplonaandClaude Opus 4.8 3a21df7775 fix(desktop): exclude leaked kotlinx-coroutines-test from release dmg
composemediaplayer 0.10.0 publishes kotlinx-coroutines-test as a runtime
dependency in its POM. That jar ships a META-INF/services registration for
kotlinx.coroutines.CoroutineExceptionHandler -> ExceptionCollectorAsService.
The release-only ProGuard pass strips the unreferenced provider class but
keeps the services manifest, so the packaged dmg crashed at startup with a
ServiceConfigurationError the first time the coroutine exception handler
loaded (DesktopHttpClient.<init>). Dev runs were unaffected since they don't
run ProGuard.

Exclude the test-only artifact so it never lands on the production classpath.
Verified: rebuilt release dmg no longer bundles the jar and launches clean.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs PR #3175 review by @davotoula.
2026-06-11 17:46:12 +03:00
Róbert NagyandGitHub e3441157ad Merge branch 'main' into feat/desktop-vlcj-to-kdroidfilter 2026-06-11 13:50:42 +03:00
nrobi144 704f4f44ee feat(desktop): replace vlcj with kdroidFilter ComposeMediaPlayer + JCodec/FFmpeg
Drops uk.co.caprica:vlcj 4.8.3 (GPL-3.0) from desktopApp and replaces it
with an MIT-dominant stack:

- Video / audio playback: io.github.kdroidfilter:composemediaplayer:0.10.0
  (MIT) — OS-native backends (Media Foundation on Windows, AVFoundation on
  macOS, GStreamer on Linux). First-class Compose VideoPlayerSurface.
- Thumbnail extraction: org.jcodec:jcodec(+javase):0.2.5 (BSD-2) primary
  H.264 path, raw ProcessBuilder FFmpeg fallback for HEVC / VP9 / AV1 /
  HLS / non-faststart MP4.
- Binary SPDX: MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0.
  rpmLicenseType updated accordingly (previously misdeclared as MIT
  while shipping GPLv3 vlcj).

Code changes:
- Deleted: VlcjPlayerPool, MacOsVlcDiscoverer, BundledVlcDiscoverer,
  VlcResourceResolver
- Rewrote: GlobalMediaPlayer (kdroidFilter engine + snapshotFlow-based
  state sync into the preserved MediaPlaybackState contract);
  VideoThumbnailCache (JCodec → ProcessBuilder ffmpeg cascade, with a
  hard 4 MiB download cap, Content-Type sniff to reject HTML error
  pages, and cleanup of zero-byte cache entries); DesktopVideoPlayer
  (mounts VideoPlayerSurface for the active URL, codec/network error
  UX with "Open in default player" fallback)
- Updated: NowPlayingBar + GlobalFullscreenOverlay to render
  VideoPlayerSurface directly (drops the videoFrame ImageBitmap relay)
- Main.kt: drops vlcj pre-init / shutdown calls (kdroidFilter lazy-loads
  natives + registers its own shutdown hook on Windows)

Build / packaging:
- Removes ir.mahozad.vlc-setup plugin + vlcSetup{} block + the per-OS
  bundled VLC tree + the -Dvlc.plugin.path JVM arg
- Adds NOTICE.md + per-component LICENSE-*.txt under appResources/common
  (LGPL-2.1 license text is a placeholder — replace with verbatim FSF
  text before release)
- Adds per-OS LGPL FFmpeg drop-in directories with README pointing at
  the recommended LGPL binary source (osxexperts.net / Crigges Windows
  LGPL build)
- Adds Flathub manifest skeleton (Gitnuro-style: org.freedesktop.Platform
  24.08 + openjdk21 extension + org.freedesktop.Platform.ffmpeg-full
  add-extension for patent codecs)
- AppRun: drops VLC LD_LIBRARY_PATH / VLC_PLUGIN_PATH env wiring

Verified on macOS arm64:
- ./gradlew :desktopApp:compileKotlin                BUILD SUCCESSFUL
- ./gradlew :desktopApp:test                         BUILD SUCCESSFUL
- ./gradlew :desktopApp:spotlessApply                clean
- Smoke launch: no VLC/vlcj/libvlc log lines, kdroidFilter native
  library extracts to ~/.cache/composemediaplayer/native/, thumbnail
  cache populates at ~/.cache/amethyst-desktop/video-thumbs/ with the
  4 MiB cap enforced
- H.264 MP4 playback (active + thumbnail extraction) confirmed
- VP9-in-WebM playback fails on macOS as AVFoundation cannot decode it —
  expected codec gap; surfaced via PlaybackErrorMessage + "Open in
  default player" handoff in DesktopVideoPlayer

Docs:
- docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md
- docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md
2026-06-11 13:37:33 +03:00
Vitor PamplonaandGitHub d8a37dfb0d Merge pull request #3163 from vitorpamplona/claude/nifty-cray-h4ctiu
Add NIP-14 group subjects and improve DM group creation UX
2026-06-09 17:08:53 -04:00
Claude 85f9998463 revert: drop ChatroomList.changes flow and the screen's reactive use
Backs out the reactive list-refresh plumbing added in the prior commit:
removes the `changes` SharedFlow from the shared `ChatroomList` (restoring
it to its original form) and reverts Desktop `ChatroomListState` to its
original 2s poll. Room assembly is expected to move to a LocalCache.observe
approach on both platforms later, which would supersede this.

Keeps the independent Desktop list improvements (per-room unread tracking
and the mute/acceptable filter), which don't depend on the flow.

https://claude.ai/code/session_01VEukNczAYxNLBjLnqVEoZd
2026-06-09 20:57:45 +00:00
Claude 1fbefb2efe feat(desktop): bring group DM parity with Android
Makes the Desktop DM client a first-class group participant and tightens
the shared/Desktop DM paths so they match Android behavior.

- commons ChatNewMessageState: actually attach the composed NIP-14 subject
  to sent messages (the field was previously collected but dropped).
- commons ChatroomList: emit a `changes` SharedFlow on add/remove so list
  UIs can refresh reactively; dedupe the User overloads onto the room ones.
- Desktop NewDmDialog: multi-recipient selection (chips + confirm button) so
  a Desktop user can start a group, not only a 1:1.
- Desktop ChatPane/ChatroomHeader: show a group's NIP-14 subject in the
  header and add a rename dialog that broadcasts a subject change to all
  members.
- Desktop Main.kt DM ingest: route any ChatroomKeyable inner event into the
  room (covers kind 14/15 and future variants) and store self-authored
  NIP-37 drafts instead of dropping them.
- Desktop ChatroomListState: refresh reactively off ChatroomList.changes
  (with a slower safety poll), track real per-room unread via a last-seen
  mark, and hide rooms whose latest message isn't acceptable (mute/filter).

https://claude.ai/code/session_01VEukNczAYxNLBjLnqVEoZd
2026-06-09 20:16:55 +00:00
Claude 10f369c43c feat(model): count NIP-18 quote-reposts in the repost counter
A kind:1 note carrying a `q` tag is a quote-repost of the quoted note,
but Amethyst's reaction-row repost counter reads `Note.boosts`, which
only collected kind:6/kind:16 reposts. Quote-reposts were treated purely
as inline citations (stripped by `tagsWithoutCitations()`), so they never
appeared in the quoted note's repost count.

LocalCache now adds a `q`-tagged note as a boost of each quoted note when
consuming text notes/comments, and detaches it on deletion. The quoted
note is deliberately kept out of `replyTo` so the quote still renders as a
root post in the home feed (`Note.isNewThread`). The same wiring is added
to DesktopLocalCache for parity, with tests pinning the behavior using the
exact event reported.
2026-06-09 20:15:19 +00:00
nrobi144 c21317912e fix(commons): make UploadOrchestrator backward-compatible for non-image uploads
The pre-existing desktopApp:UploadOrchestratorTest started failing
after the desktop image-compression feature landed:

  - uploadCallsClientWithCorrectParameters (2x2 PNG, no quality set)
  - uploadPassesAuthHeaderToClient (.txt)
  - uploadPassesSameFileWhenNoStripExif (.txt)
  - uploadComputesMetadata (.txt)

The orchestrator was unconditionally calling ImageReencoder.reencode,
which (a) reencoded PNGs to JPEG even when the caller did not opt into
compression and (b) threw UnsupportedFormat for any file the sniffer
could not classify (.txt, voice memos, video files, DM attachments —
the orchestrator is the upload path for everything, not just images).

Two changes restore the orchestrator's original "upload as-is"
behavior for callers that have not opted into compression:

1. UploadOrchestrator.upload's quality parameter is now nullable
   (CompressionQuality? = null). Null means "do not reencode" —
   matches the orchestrator's behavior before this feature, so the
   Android, CLI, and any non-image upload path keeps working
   unchanged. The desktop compose flow continues to pass a non-null
   CompressionQuality so it still runs the reencoder.

2. ImageReencoder no longer throws UnsupportedFormat for
   ImageFormat.Unknown — it returns PassThrough(NotAnImage) instead.
   AVIF and HEIC still throw (those are recognized formats we
   explicitly refuse). The new PassReason.NotAnImage is rendered in
   the preview dialog as "Not an image · uploaded as-is" / "Metadata
   preserved (non-image — no re-encode applies)".

My UploadOrchestratorTest.refusesAvifWithUnsupportedFormat is updated
to pass quality = MEDIUM explicitly so it still exercises the refuse
path under the new opt-in model.
2026-06-09 12:25:41 +03:00
nrobi144 194a109a73 feat(desktop): copy Blossom URL on image click + hover tooltip + snackbar
In the lightbox/carousel:

  - Hover over the image → Material3 PlainTooltip shows the full
    Blossom URL above the image (TooltipAnchorPosition.Above, 8 dp
    gap). Same TooltipBox pattern already used in
    MediaServerSettings.

  - Single-click on the image → copies the URL to the system
    clipboard via AWT Toolkit, then surfaces a green snackbar
    banner at the top: "Copied <url> to clipboard". The banner
    slides in from above, sits below the download banner if both
    fire simultaneously, and auto-dismisses after 2.5 s
    (LaunchedEffect on the message state).

  - Double-click still resets zoom — unchanged.

  - The MoreOptionsMenu's "Copy URL" rows on both the image and
    video paths now route through the same copyUrlToClipboard
    helper so they also trigger the snackbar (previously they
    copied silently with no user feedback).

ZoomableImage gains an `onTap: (() -> Unit)?` parameter; null
keeps the old "consume single tap" behavior, set means the caller
handles the click (lightbox uses it for the copy action).
2026-06-09 11:42:01 +03:00
nrobi144 19e5b3f8de fix(desktop): rename preview dialog Cancel -> Back
"Cancel" implies the post is being abandoned. The actual behavior
is to return to the compose dialog with attachments still attached
so the user can adjust quality, swap files, or change copy before
re-triggering Preview. "Back" matches the semantic.
2026-06-09 11:42:01 +03:00
nrobi144 150117241b fix(desktop): preview "skip" now means "upload original", not "drop"
Reworked the per-row toggle in CompressionPreviewDialog to match the
user's actual intent. Previously the Switch meant "exclude this
attachment from the post entirely"; now it means "upload the original
bytes instead of the compressed version" — which is the only
meaningful per-row choice once you've already attached something.

Behavior:
  - Toggle off the compression on a Reencoded row → orchestrator
    uses bypassReencode=true (= upload original), the cached
    compressed temp is deleted right before upload so it never
    leaks.
  - The Publish button no longer changes count or disables —
    everything attached gets uploaded.
  - Cancel still cleans up every cached compressed temp.

Layout fix the user called out:
  - Only the compressed half of the row dims (thumbnail + arrow).
    The original thumbnail stays full-color because that's what's
    actually being uploaded when "use original" is on.
  - The stats/savings line is replaced by "compression skipped —
    original uploads as-is" when toggled.
  - The metadata-strip sub-line now flips dynamically:
      compressed → "All EXIF, GPS, camera tags stripped (re-encoded)"
      original + strip ON + JPEG → "EXIF, GPS, camera tags stripped
                                    from original before upload"
      original + strip ON + non-JPEG → red warning: "Metadata
                                       preserved — strip only runs
                                       on JPEG; original is non-JPEG"
      original + strip OFF → "Metadata preserved (EXIF strip off
                              in settings)"

Style fix: replaced the chunky Switch with a small TextButton —
"Use original" by default (muted color) → "Using original — undo"
when active (error color). Matches the rest of the dialog's
TextButton + DropdownMenu vocabulary; reads as a desktop action,
not a mobile preference.

The toggle is intentionally removed from PassThrough / Failed /
NonImage rows — those have no per-row choice (always-as-original
by design) and a control there would be deceptive.

API change: CompressionPreviewDialog.onPublish is now
(List<PreviewItem>, useOriginalPaths: Set<String>) -> Unit.
runPublish in ComposeNoteDialog routes Reencoded items in the
useOriginalPaths set through orchestrator.upload(bypassReencode =
true) and deletes the unused compressed temp inline.
2026-06-09 11:42:01 +03:00
nrobi144 b73f7fe4e2 feat(desktop): per-row skip toggle + explicit metadata-strip status in preview
Two manual-testing asks landed together — they share the same row
template inside CompressionPreviewDialog.

Per-row skip toggle:
  - Every preview row gains a Switch labeled "Include" / "Skipped"
    (the verb is shown so the user can't misread a bare switch).
  - Skipped rows dim the thumbnail (0.4 alpha) and tone down the
    surface, hide the "Click to compare" hint, and disable the
    click-to-zoom.
  - Publish button label now reflects the included count —
    "Publish (4)" when nothing skipped, "Publish (3 of 5)" with
    skips, "Nothing to publish" + disabled state when all skipped.
  - On Publish, the dialog calls cleanupPreviewTemps(skippedItems)
    so dropped re-encodes don't leak in ~/.amethyst/tmp/. The
    included subset is handed off to UploadOrchestrator via the
    preCompressed param as before.
  - onPublish signature changed: (List<PreviewItem>) -> Unit, and
    runPublish in ComposeNoteDialog now takes the filtered list
    rather than reading pendingPreview directly.

Explicit metadata-strip status on every row:
  - Reencoded rows: "All EXIF, GPS, camera tags stripped
    (re-encoded to JPEG)" in the tertiary color. Re-encode wipes
    metadata regardless of the strip-EXIF setting because we
    don't preserve any metadata in the JPEG writer.
  - PassThrough rows:
      Animated → "Metadata preserved (animated — re-encode would
                  drop frames)"
      Vector   → "No raster metadata (SVG)"
      Bypass   → "Metadata preserved per your override"
  - Failed rows (going to send original):
      JPEG + strip on → "EXIF, GPS, camera tags stripped before
                         upload" in tertiary color
      non-JPEG + strip on → "Metadata preserved — strip only runs
                             on JPEG; this is <Format>" in error
                             color (privacy warning)
      strip off → "Metadata preserved (EXIF strip off in settings)"
  - NonImage rows: "Metadata preserved — EXIF strip applies to
                    JPEG only"

The explicit per-row wording makes the strip-EXIF toggle's actual
behavior visible at the moment the user is deciding whether to
publish, rather than buried in the Settings panel.
2026-06-09 11:42:01 +03:00