100 Commits
Author SHA1 Message Date
Vitor PamplonaandGitHub 8d75a50df9 Merge pull request #4120 from vitorpamplona/claude/sweet-fermat-10uvw5
Update Arti to 2.6.0 and jni to 0.22, pin and enforce the NDK revision
2026-09-13 17:17:52 -04:00
Vitor PamplonaandGitHub bd1021c6f4 Merge pull request #4111 from vitorpamplona/claude/beautiful-brahmagupta-17clq5
Split commons into headless + commonsUI; fix 34 bugs
2026-09-13 12:12:38 -04:00
Vitor PamplonaandGitHub 0c59d0ef2e Merge pull request #4113 from vitorpamplona/claude/app-relay-connection-count-jo13xd
fix(relay): honest connected-relay count by finishing the WebSocket close handshake
2026-09-13 10:59:09 -04:00
Vitor PamplonaandGitHub 032546f70b Merge pull request #4114 from vitorpamplona/claude/image-blurhash-load-delay-y0o5f3
fix(media): stop a no-imeta GIF rendering as an invisible note while it loads
2026-09-13 08:49:07 -04:00
Vitor PamplonaandGitHub 41113ca259 Merge pull request #4118 from vitorpamplona/claude/confident-darwin-rbzz0f
Remove unused Guardian Project Maven repository
2026-09-13 08:04:21 -04:00
Vitor PamplonaandGitHub 1590fca9b7 Merge pull request #4117 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-13 07:52:50 -04:00
Vitor PamplonaandClaude Opus 5 38bbfa8c74 Merge PR: fix(desktop): consolidate keychain items so cold-boot prompts once
Merges nostr proposal 1c931194 (v4) into main:
- fix(commons): consolidate desktop keychain items into a single vault-v1 item
- fix(desktop): wire two-phase vault bootstrap into AccountManager
- fix(commons): make the keychain vault authoritative without blinding lookups
- fix(desktop): migrate the keychain vault before the first account-store read
- style(desktop): import CancellationException instead of inlining its name

Every nsec, bunker ephemeral and NWC URI now lives in one vault-v1 keychain
item behind a single ACL, so cold boot prompts once. One approval releases
every secret; this single-ACL model is an accepted maintainer decision.

v2-v4 fixed two account-store wipes (strict lookup ignoring the vault;
migration running after refreshAccountListOnStartup's first read), a
missing legacy fallback, and orphaned nsecs on logout. Verified with 17
mutation-checked tests and three consecutive launches against a real
macOS Keychain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHCtgMDNkAHvRDXnthuXQc
2026-09-12 22:01:52 -04:00
Vitor PamplonaandClaude Opus 5 94a229e983 style(desktop): import CancellationException instead of inlining its name
The vault bootstrap added two more inline
kotlin.coroutines.cancellation.CancellationException references next to
two existing ones; CLAUDE.md forbids fully-qualified names in function
bodies. One import, no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHCtgMDNkAHvRDXnthuXQc
2026-09-12 21:52:31 -04:00
Vitor PamplonaandClaude Opus 5 81b9ba853a fix(desktop): migrate the keychain vault before the first account-store read
Found by running the migration against a real macOS Keychain: the first
launch migrated and loaded fine, the second launch wiped the account store.

bootstrapConsolidatedVault() was called from loadSavedAccount(), and its
comment claimed it ran "before any other keychain read on the hot startup
path". It did not. Main.kt's startup DisposableEffect calls
refreshAccountListOnStartup() first, which reads accounts.json.enc and so
needs the metadata AES key.

On the first launch that read still found the legacy per-alias item and
cached the key, so the migration that followed looked harmless. On the
second launch the item was gone -- migrated into vault-v1 and deleted --
and the vault had not been activated yet, so the strict lookup answered
"definitively absent", getOrCreateKey minted a fresh AES key, wrote it back
as a legacy item, and the decrypt that followed failed with a GCM tag
mismatch. Observed exactly that: accounts.json.enc renamed to
.corrupt.<ts>, account-metadata-key resurrected as a per-alias item, and
the original key still sitting unused inside vault-v1.

Phase 1 is now a run-once, mutex-guarded ensureVaultMetadataKeyMigrated()
that every account-store entry point calls, including refreshAccountList().
Phase 2 still runs from loadSavedAccount() once the npub list is known.

Verified on macOS against the real Keychain and the real account store:
three consecutive launches across the migration boundary all load the
account, no corruption events, accounts.json.enc byte-identical throughout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-12 21:50:36 -04:00
Vitor PamplonaandClaude Opus 5 9193502748 fix(commons): make the keychain vault authoritative without blinding lookups
Three defects found reviewing the vault consolidation against current main.

1. The vault wiped every account on the first cold boot after upgrade.

   getPrivateKeyOrThrow was the only accessor without a vaultActive branch
   (savePrivateKey, getPrivateKey and deletePrivateKey all had one). The
   migration deletes the legacy per-alias items after writing vault-v1, so
   the strict path probed the OS for an item that no longer existed, read
   macOS exit 44 / NotFound as "definitively absent", and let
   DesktopAccountStorage.getOrCreateKey mint a fresh AES key over the one
   that decrypts accounts.json.enc -- the exact silent wipe proposal
   5d31b68e added that method to prevent.

   It was also unrecoverable: phase 1 preserves the original key inside the
   vault, but getOrCreateKey then persists the new key over the same alias,
   destroying the only key that could decrypt the .corrupt backup. And
   because phase 2 calls loadAccounts(), the wipe happened inside the
   bootstrap itself, so no nsec was ever folded in.

   The strict path now consults the vault first. A vault miss still falls
   through to the strict per-alias probe, so uncovered aliases keep the
   strict contract.

2. The documented legacy fallback did not exist. getPrivateKey was
   `vaultActive -> vaultGet(npub)` with no fallback, so once the vault was
   active any alias it did not cover read as absent. Phase 1 activates it
   with only the metadata key, and a phase 2 that throws is swallowed, so
   the real worst case was every nsec reading null rather than the
   advertised "old two-prompt behaviour". A vault miss now falls back to
   the legacy per-alias item.

3. deletePrivateKey left orphaned secrets. vaultDelete returned false for
   an alias outside the vault and never touched the legacy item, so logging
   out of an account whose nsec had not been folded in left the nsec in the
   OS keychain indefinitely -- still readable via the fallback in 2. It now
   unlinks the legacy item as well.

Also logs the swallowed vault-bootstrap failure; silently discarding it
made a half-migrated keychain impossible to diagnose from a user report.

All three are pinned by new tests in SecureKeyStorageVaultTest and
mutation-checked: reverting any one fix fails exactly its own test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-12 21:50:36 -04:00
Vitor PamplonaandGitHub 71f72a0910 Merge pull request #4112 from vitorpamplona/claude/intelligent-newton-bw5dmf
BOLT12 offers in the profile payment rail and zap picker, with BOLT11 fallback on refused offers
2026-09-12 20:40:50 -04:00
Vitor Pamplona ea28ea2f76 Merge remote-tracking branch 'upstream/main' into main 2026-09-12 19:06:40 -04:00
Vitor PamplonaandGitHub 8c6a38a7bf Merge pull request #4110 from vitorpamplona/claude/stoic-faraday-2qv9f5
fix: clear every compiler, Gradle and mechanical lint warning, and two things the sweep turned up
2026-09-12 19:06:31 -04:00
Vitor PamplonaandGitHub 49fb3a7a00 Merge pull request #4109 from vitorpamplona/claude/determined-volta-u4l2rf
test: run relay-backed tests against geode; fix the relay bugs that surfaced
2026-09-12 19:06:20 -04:00
Vitor PamplonaandClaude Opus 5 78fef44e2f Merge PR: fix(desktop): stop silent account wipe on keychain-access errors
Merges nostr proposal 5d31b68e into main:
- fix(desktop): stop silent account wipe on keychain errors and upgrade races
- fix(desktop): allow first-launch key bootstrap and stop caching unwritten state

Desktop lost every logged-in account whenever the OS keychain answered
ambiguously. getOrCreateKey() treated any failed lookup as "key absent" and
minted a fresh AES key, orphaning accounts.json.enc; the read path then
renamed the unreadable file to .corrupt.<ts>; and nothing serialised access,
so a Homebrew upgrade race could interleave two instances.

Now: getPrivateKeyOrThrow() distinguishes confirmed-absent from refused or
ambiguous (macOS via /usr/bin/security exit codes) and only the former
rotates; genuine corruption (AEAD tag, bad padding, malformed JSON) is
separated from transient failures, which preserve the file and surface as
StorageCorruption.TransientError; and a cross-process advisory lock plus
in-process mutexes guard the file.

Review follow-ups in the second commit: non-macOS keyring backends throw for
a genuinely absent credential, so a fresh Linux/Windows install could never
mint the key -- creation is now allowed when accounts.json.enc does not yet
exist, where there is no ciphertext to orphan. And the metadata cache is
populated only after the disk write succeeds, so a failed save no longer
leaves the session serving accounts that were never persisted.

Verified on macOS against the real Keychain and the real account store:
security lookup exit 0, accounts load and survive a restart, ciphertext
byte-identical, zero corruption events.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-12 19:03:09 -04:00
Vitor PamplonaandClaude Opus 5 1ea820e699 fix(desktop): allow first-launch key bootstrap and stop caching unwritten state
Two defects found reviewing the strict-keychain fix.

1. Fresh Linux/Windows installs could never persist an account.

   getPrivateKeyOrThrow turns any PasswordAccessException into a
   SecureStorageException on non-macOS backends, but every backend
   java-keyring ships throws that exact exception for a *genuinely absent*
   credential:

     - WinCredentialStoreBackend: CredReadA false (ERROR_NOT_FOUND) -> throw
     - FreedesktopKeyringBackend: empty object paths ->
       throwNoExistingCredentialException
     - KWalletBackend: hasEntry false -> "Password is not in wallet"

   So the strict lookup structurally cannot report "definitively absent"
   there, the create branch in getOrCreateKey was unreachable, and nothing
   between it and AccountManager.addAccountToStorage catches the throw.

   getOrCreateKey now bootstraps a fresh key when the strict lookup fails
   *and* accounts.json.enc does not exist. With no ciphertext on disk there
   is nothing a new key can orphan, so the invariant the strict contract
   protects is untouched: once the file exists the exception propagates
   exactly as before.

2. writeCachedMetadata updated the in-memory cache before the disk write,
   so a failed write (keychain refusal, I/O error, disk full) left the
   session serving accounts that were never persisted -- a save that
   reported success and vanished on the next launch. Persist first, cache
   second.

Both are pinned by new tests, and both were mutation-checked: reverting
either fix fails exactly one of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-12 17:43:12 -04:00
Vitor PamplonaandGitHub 8fa4a72a9a Merge pull request #4108 from davotoula/fix/blossom-read-auth-single-flight-race
fix(blossom): re-check the token cache after winning the in-flight slot
2026-09-12 17:18:43 -04:00
Vitor PamplonaandGitHub 08a3bab605 Merge pull request #4101 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-12 11:15:46 -04:00
Vitor PamplonaandGitHub 4a5278ee55 Merge pull request #4105 from vitorpamplona/chore/bump-amethyst-cask-v1.15.2
chore: sync amethyst-nostr cask to v1.15.2
2026-09-12 11:15:32 -04:00
Vitor PamplonaandGitHub 7aa345ba28 Merge pull request #4104 from vitorpamplona/chore/bump-winget-manifest-v1.15.2
chore: sync winget manifests to v1.15.2
2026-09-12 11:15:22 -04:00
Vitor PamplonaandGitHub 284671aa0f Merge pull request #4103 from vitorpamplona/chore/bump-amy-formula-v1.15.2
chore: sync amy Homebrew formula to v1.15.2
2026-09-12 11:15:15 -04:00
Vitor PamplonaandGitHub a894315335 Merge pull request #4102 from vitorpamplona/chore/bump-geode-formula-v1.15.2
chore: sync geode Homebrew formula to v1.15.2
2026-09-12 11:15:08 -04:00
Vitor PamplonaandGitHub a5da7e49ba Merge pull request #4100 from vitorpamplona/claude/intelligent-edison-gcwtbr
Fix single-flight guarantee for fast signers in BlossomReadAuthTokenProvider
2026-09-12 11:09:57 -04:00
Vitor PamplonaandGitHub 0629b23b1a Merge pull request #4099 from vitorpamplona/chore/release-1.15.2
chore(release): bump to 1.15.2
2026-09-12 10:34:37 -04:00
Vitor PamplonaandGitHub c0a38bc0b7 Merge pull request #4098 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-12 10:31:54 -04:00
Vitor PamplonaandClaude Opus 5 cb49caae69 chore(release): bump to 1.15.2
app 1.15.1 -> 1.15.2, appCode 459 -> 460. That single edit drives Android's
versionName/versionCode, Desktop and CLI packageVersion, quartz's Maven version
and geode's RelayInfo.VERSION.

Three substantive PRs since v1.15.1, plus Crowdin translations and the packaging
syncs the bump workflows opened after the last tag:

- #4092 media previews: an extension match now requires a real dot, so a player
  page whose path merely ends in the letters `_mp3` stops going to the video
  player, and `og:audio`/`og:video` are read and played with the page's
  `og:image` as poster. A declaration whose type is `text/html` -- YouTube's --
  is refused.
- #4095 nested NIP-22 replies: engagement subscriptions asked only for the
  lowercase `e`/`a` tags, so a comment two or more levels deep was invisible
  until ThreadScreen opened its own subscription. Each relay gets a second,
  root-scoped filter on `E`/`A`. Kind 1619 moves there too -- NIP-34 gives PR
  updates only an uppercase `E`, so it had been in a filter it could never match.
- #4096 Health Connect: a rationale screen Play requires, reachable from the
  composer, from Health Connect's permission screen and standalone without an
  account; reads moved off the UI thread; source names memoized; and the workout
  form is replaced rather than merged when a second suggestion is picked.

Verified on a Pixel 9 emulator before cutting, since two of the three are only
observable on device: the og:audio track plays in a thread with real transport
controls (00:30 / 05:00) where it used to buffer forever, and the Health Connect
rationale opens from all three routes -- including the one that matters for
review, where Health Connect's own permission screen launches our
ViewPermissionUsageActivity through the START_VIEW_PERMISSION_USAGE-guarded
filter.

RELEASE_NOTES_ID deliberately stays on the v1.15.0 note: RELEASE_OPS has it
repointed on x.y.0 only.

Left alone deliberately: everything under */packaging/ and translators.json's
tag, which the bump workflows and the Crowdin job write after the tag exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-12 10:28:52 -04:00
Vitor PamplonaandGitHub c03e7cfa5e Merge pull request #4097 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-12 10:26:57 -04:00
Vitor PamplonaandGitHub 1a812c3df5 Merge pull request #4095 from vitorpamplona/claude/note-replies-loading-mwidat
Split NIP-22 root-scope replies into separate engagement filters
2026-09-12 10:26:44 -04:00
Vitor PamplonaandGitHub 8c87745b21 Merge pull request #4096 from vitorpamplona/claude/focused-gates-w9dtcw
Health Connect: add rationale screen and improve source name caching
2026-09-12 10:24:20 -04:00
Vitor PamplonaandGitHub 17f0ee7fbe Merge pull request #4092 from vitorpamplona/fix/og-media-playback
fix(media): require a dot before a file extension, and play a player page's og:audio/og:video
2026-09-12 10:13:01 -04:00
Vitor PamplonaandGitHub 3c4ab62729 Merge pull request #4089 from vitorpamplona/chore/bump-winget-manifest-v1.15.1
chore: sync winget manifests to v1.15.1
2026-09-11 19:48:48 -04:00
Vitor PamplonaandGitHub 892aca2b12 Merge pull request #4090 from vitorpamplona/chore/bump-amethyst-cask-v1.15.1
chore: sync amethyst-nostr cask to v1.15.1
2026-09-11 19:48:39 -04:00
Vitor PamplonaandGitHub 1924da60f9 Merge pull request #4088 from vitorpamplona/chore/bump-amy-formula-v1.15.1
chore: sync amy Homebrew formula to v1.15.1
2026-09-11 19:48:27 -04:00
Vitor PamplonaandGitHub abc1bc8a82 Merge pull request #4087 from vitorpamplona/chore/bump-geode-formula-v1.15.1
chore: sync geode Homebrew formula to v1.15.1
2026-09-11 19:48:20 -04:00
Vitor PamplonaandGitHub 1008b130ee Merge pull request #4086 from vitorpamplona/chore/release-1.15.1
chore(release): bump to 1.15.1, unblocking the AAB on AGP 9.4.0
2026-09-11 18:52:14 -04:00
Vitor PamplonaandClaude Opus 5 a2f60eeeaf chore(release): bump to 1.15.1, unblocking the AAB on AGP 9.4.0
v1.15.0 was tagged but never shipped. Its `Create Release Assets` run failed in
`deploy-android` at the first packaging task:

    Execution failed for task ':amethyst:buildFdroidReleasePreBundle'
    > Entry name contains invalid characters:
      root/META-INF/zoomable-root:zoomable.kotlin_module

so no AAB, no APK, and none of the 47 assets were produced.

A `.kotlin_module` is named after the Gradle project path that produced it,
colons included. 14 of the 98 modules merged into the app carry one: zoomable,
Negentropy, vico, the seven coil3 artifacts, and four of ours -- Amethyst:quartz,
Amethyst:commons, Amethyst:quic and Amethyst:nestsClient -- so renaming our own
would not have been enough.

Bisected against the two toolchain bumps this cycle, since both landed after the
last good release: AGP 9.3.1 -> 9.4.0 and Kotlin 2.4.10 -> 2.4.20. With Kotlin
held at 2.4.20 and AGP reverted, both flavours' bundle tasks pass, so Kotlin is
not the trigger. The R8 output jar carries the identical 14 colon entries under
BOTH AGP versions -- 9.4.0 added the rejection rather than the names, in
JarFlinger.addJar, reached from PerModuleBundleTask.addHybridFolder.

`packaging.resources.excludes` was tried first and cannot work, at either
`META-INF/*.kotlin_module` or `**/*.kotlin_module`: with minification on, R8
emits the java resources itself and addHybridFolder hands JarFlinger its own
predicate, so those filters are never consulted. Confirmed by deleting the R8
output and re-running rather than reading a stale intermediate --
mergeJavaResource's jar holds zero kotlin_modules while R8's holds all 98.

So the entries are stripped from R8's jar in the moment before the bundle task
opens it, and the jar is put back exactly as R8 left it afterwards. Two details
carry their weight:

  - the strip is doFirst on the CONSUMER rather than doLast on R8, so a
    build-cache hit on R8 cannot skip it;
  - the restore is what keeps R8 up to date. Without it Gradle sees a modified
    output and re-runs R8 on every build -- measured here at ~2 min for an
    otherwise no-op build. With it, a second run reports
    minifyFdroidReleaseWithR8 UP-TO-DATE and finishes in 1s.

Pinning back to 9.3.1 was the alternative and is one line away; the catalog
comment records that, and says to drop the workaround when AGP fixes it.

Verified from a cleaned R8 output on both flavours:
buildFdroidReleasePreBundle and buildPlayReleasePreBundle both BUILD SUCCESSFUL,
each reporting "Stripped 14 colon-named entries from base.jar".

appCode 458 -> 459. RELEASE_NOTES_ID deliberately stays on the v1.15.0 note:
RELEASE_OPS has it repointed on x.y.0 only, and 1.15.1 ships that release's
contents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-11 18:42:22 -04:00
Vitor PamplonaandGitHub 9449bce038 Merge pull request #4085 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-11 17:52:09 -04:00
Vitor PamplonaandGitHub 8a3e2cd282 Merge pull request #4084 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-11 17:47:26 -04:00
Vitor PamplonaandGitHub 747be2807c Merge pull request #4083 from vitorpamplona/l10n/missing-cs-de-sv-pt-2026-09-11
chore(l10n): add missing cs, de, sv, pt-BR translations
2026-09-11 17:26:26 -04:00
Vitor Pamplona a5f0257394 update language on the IDE file 2026-09-11 17:07:44 -04:00
Vitor PamplonaandGitHub f94a74b2b5 Merge pull request #4081 from vitorpamplona/chore/release-1.15.0
chore(release): bump to 1.15.0
2026-09-11 16:59:08 -04:00
Vitor PamplonaandClaude Opus 5 e9e510b831 chore(release): bump to 1.15.0
app 1.14.0 -> 1.15.0, appCode 457 -> 458. That single edit drives Android's
versionName/versionCode, Desktop and CLI packageVersion, quartz's Maven version
and geode's RelayInfo.VERSION.

RELEASE_NOTES_ID is repointed at the v1.15.0 note
(8fce45589ea44df75e828a04c7d70bb4fabedd6ffc1946a920b2f0c7c990ff9f), which
RELEASE_OPS notes happens on x.y.0 releases and not on patches. It has to ship
in this commit rather than after it: the drawer's "Release Notes" link and the
donation card both open BuildConfig.RELEASE_NOTES_ID, so a tag cut before the
repoint ships users a link to the previous release's note. Verified present on
relay.damus.io and relay.primal.net before committing.

Adds docs/changelog/v1.15.00.md, written from the 556 commits since v1.14.0,
and its index entry. The cycle's headline is the Marmot resync: the MIP
documents were deprecated in July and MDK followed, leaving our implementation
invalid under either profile the current spec defines, so it moves onto the
adopted current profile and is now interoperable with White Noise. Marmot group
chat itself is not new -- it shipped in v1.09.0 -- and the notes say so.

Also syncs the docs that state a version rather than illustrate one, since
quartz and geode both read libs.versions.app:

- README.md, quartz-integration SKILL.md and its gradle-setup.md reference
  -> quartz 1.15.0
- geode/README.md install commands -> geode 1.15.0

The Homebrew/Winget status blocks in BUILDING.md and RELEASE_OPS.md were
re-verified rather than re-stamped, and the claim had gone stale in our favour:
both Homebrew packages are live upstream now. formulae.brew.sh answers 200 for
the amethyst-nostr cask (at 1.14.0) and for the amy formula, while geode-relay
404s and microsoft/winget-pkgs still has no VitorPamplona/Amethyst -- PR #422752
is open pending CLA. Both blocks now say that, RELEASE_OPS gains a geode-relay
row, and the section heading no longer claims Homebrew is not shipping.

Left alone deliberately: everything under */packaging/ and translators.json's
tag, which the bump workflows and the Crowdin job write after the tag exists
(bumping by hand would commit wrong hashes and a dead URL); and
cli/tests/marmot/state/mdk/Cargo.lock, where 1.14.0 is an unrelated crate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-11 16:56:30 -04:00
Vitor PamplonaandGitHub bf29cd49cf Merge pull request #4080 from vitorpamplona/fix/nav-predictive-back-transitions
fix(nav): give the back gesture the slides it lost to navigation-compose 2.10
2026-09-11 15:56:57 -04:00
Vitor PamplonaandClaude Opus 5 8bccd23bfe fix(nav): give the back gesture the slides it lost to navigation-compose 2.10
A swipe-from-edge back stopped sliding and started shrinking the outgoing
screen toward its centre. A back *button* press still slid, which is the tell:
only the gesture takes the changed branch.

navigation-compose 2.10.0 split the gesture off from the button. NavHost was:

    if (composeNavigator.isPop.value || inPredictiveBack) { … popExitTransition }

and is now:

    if (inPredictiveBack) { … predictivePopExitTransition.invoke(this, swipeEdge) }
    else if (composeNavigator.isPop.value) { … popExitTransition }

with defaults of `fadeIn` opposite `scaleOut(targetScale = 0.7f)`
(DefaultNavTransitions.android.kt). We pass no predictive pair, so every
gesture back ran that 0.7 scale instead of the per-route slides. Arrived with
the 2.9.8 -> 2.10.1 bump in 86d96dda06; not in any release.

The per-destination overrides are `internal` in 2.10.1, so the only public
lever is the NavHost-level pair — and it is handed the entries but not the
builder that declared them. PopFamilies records that as the graph is built, so
predictivePopEnter/predictivePopExit can reproduce the existing rules exactly:
a modal leaves downward, a drill-in leaves toward the end, a tab entry fades.
They call popExitToEnd/popExitToBottom/popEnterFromBehind rather than respell
them, so the large-screen tier still applies and the two paths cannot drift.

navShellFadeIn/Out are now shared with the NavHost's own enter/exit instead of
being spelled twice.

Verified on emulator-5554 (Pixel 9, API 36), gesture held at the same x=540
with `input motionevent` and screencapped mid-drag:

  - pre-fix build, Profile:   screen scaled down, Home visible around all edges
  - fixed, Profile (fromEnd): screen translated right, full size, Home revealed
  - fixed, Edit Profile (fromBottom): translated down, Profile revealed above

The bottom-family case is the one that proves the lookup resolves per family
rather than answering END for everything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VgVDQQXAg4cmzsWHoJj61k
2026-09-11 15:41:52 -04:00
Vitor PamplonaandGitHub c495c89d0f Merge pull request #4079 from vitorpamplona/fix/nwc-test-race
test(nwc): fix the flaky NwcInfoCache coalescing tests
2026-09-11 13:32:50 -04:00
Vitor PamplonaandClaude Opus 5 1adc37ba1b test(nwc): let the joining callers reach the cache before the gate opens
`concurrentGetFreshCallersShareOneFetch` failed about one run in four under
full-suite load with `expected:<1> but was:<2>`, and passed every time when
run alone -- so it read as a cache bug that was not one.

The callers joined on `Dispatchers.IO`, which only *schedules* them. The
test then opened the gate immediately, so the winner could finish first:
its `finally { inFlight.remove(key, ours) }` cleared the slot, a caller
arriving afterwards found nothing to join, and started a second fetch. The
coalescing the test is about had never been exercised on those runs.

`Dispatchers.Unconfined` runs a coroutine's body inline until its first
real suspension, so `async` now returns only once the caller has executed
`inFlight.putIfAbsent` and parked on the winner's deferred. The
interleaving the GatedFetch KDoc promises -- "caller one is provably inside
the fetch before the others arrive" -- is ordered by construction rather
than by timing.

`getFreshJoinsFetchAlreadyStartedByRefreshIfStale` carried the identical
race and is fixed the same way; it had not been observed failing. The three
remaining `Dispatchers.IO` callers in this file are left alone, because
there the caller IS the fetch winner and has nothing to join late.

Verified: the full fdroid suite (1464 tests), where the failure originally
surfaced, is green twice over, and the class passes 6/6 in isolation. The
isolated runs prove little on their own -- the unfixed test passed 5/5 that
way too -- so the load runs are the evidence that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 12:57:13 -04:00
Vitor PamplonaandGitHub de4f23b20c Merge pull request #4078 from vitorpamplona/claude/quirky-pascal-5kj5sz
Bump Kotlin to 2.4.20, Compose to 1.12.0, and other deps
2026-09-11 12:33:42 -04:00
Vitor PamplonaandGitHub 116d282b62 Merge pull request #4077 from vitorpamplona/claude/modest-keller-tsngxg
Fix relay list updates to preserve public/private visibility
2026-09-11 11:22:36 -04:00
Vitor PamplonaandGitHub d669bcc745 Merge pull request #4076 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-11 08:16:20 -04:00
Vitor PamplonaandGitHub 0a93f1da1f Merge pull request #4073 from vitorpamplona/claude/marmot-protocol-mdk-sync-bubfqq
feat(marmot): Marmot/MLS group messaging, interoperable with White Noise
2026-09-11 08:11:29 -04:00
Vitor PamplonaandGitHub 31d58b0a8c Merge pull request #4074 from vitorpamplona/claude/inspiring-euler-y6wfxc
Add NIP-44 encrypt/decrypt to NIP-07 window.nostr and NIP-52 day indexing
2026-09-10 21:28:07 -04:00
Vitor PamplonaandClaude Opus 5 76bbaf569d fix(marmot): write attachments in the adopted encrypted-media-v2 shape
Every image Amethyst sent into a Marmot group was invisible to every other
implementation. The upload was encrypted with the MIP-era scheme and
described with a MIP-era `imeta` -- `url`, `x`, `n`, `v mip04-v2` -- and
MDK 0.9.21, which White Noise embeds, knows only `encrypted-media-v1|v2`:
`locator`, `ciphertext_sha256`, `plaintext_sha256`, `nonce`. Its typed
parser rejects anything else and the caller drops the tag without a word,
so the message arrived carrying no attachment at all.

The encoder for the adopted shape was already here and already correct.
What sent the old one was the gate: v2 was used only when the group
carried the `encrypted-media-v2` component, and groups are created
WITHOUT it on purpose, so epoch 0 matches the reference implementation
byte for byte. The default path was therefore always the dead dialect.

Receivers do not require that component -- MDK pins the opposite, that an
out-of-policy locator is "kept, not dropped on ingest", because media is
authenticated by its hashes and AEAD rather than by where it sits. Only a
SENDER's own outbound validation is constrained by policy. So the cipher
is now chosen unconditionally, and a media type too malformed to
canonicalize becomes `application/octet-stream` rather than falling back:
an attachment labelled imprecisely still renders, one in a dialect nobody
reads does not.

MIP-04 stays on the READ side for messages older builds already sent.

The test pins the tag we write against MDK's own fixture
(`crates/marmot-app/src/media/tests.rs`, `valid_v2_imeta_tag`) instead of
round-tripping through our own parser, which is what let this drift
through a suite that already claimed media interop: both ends drifted
together and agreed with each other.

Verified on device, Amethyst -> White Noise Android: the chat list shows
`Photo` (their `classify_chat_list_attachments` only says that once the
imeta parses) and the conversation renders the image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 18:01:19 -04:00
Vitor PamplonaandClaude Opus 5 425e1a6470 test(marmot): let the headless harness run off Linux
Three assumptions the harness makes are Linux-only, and each stops it
before a single test runs:

- it binds the relay to the URL host, and macOS has no 127.0.0.2 alias
  ("Can't assign requested address"). `RELAY_BIND` now separates the bind
  address from the advertised host, so the relay can listen on 127.0.0.1
  while the URL names something else.
- `wnd` refuses a socket path it considers too long, and rejects any path
  containing a symlink or a directory it does not consider trusted-owned.
  `B_SOCKET`/`C_SOCKET` are overridable now, so the sockets can live in a
  short real directory while state stays in the repo.

Note for whoever runs this next: the `127.0.0.2` trick the comments
describe no longer buys anything. `RelayUrlNormalizer.isLocalHost` parses
the address now instead of matching literals, so the whole 127.0.0.0/8 is
stripped from the DM and KeyPackage relay lists -- `amy relay add
ws://127.0.0.2:8080` lands in nip65 only, and KeyPackage publishing falls
back to the public defaults. A host that resolves to loopback without
looking like it (lvh.me) survives our strip, but `wnd` rejects plain `ws://`
for a non-local host, so the two stacks currently admit no shared cleartext
relay url. That needs solving before this suite can pass end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:17:36 -04:00
Vitor PamplonaandClaude Opus 5 80aa4b31e2 fix(marmot): await the KeyPackage relay list before creating the group
"Use outbox relays" launched `saveKeyPackageRelayListFromOutbox()` into its
own coroutine and called `proceedWithCreate()` beside it, so the write the
creation depends on raced the creation itself. The loser was silent:
`isCreating` had already latched true, and the top bar gates on
`isActive = { !isCreating }`, so Create went inert for the rest of the
screen's life -- no group, no error, and only Cancel could leave.

`proceedWithCreate` now takes an optional `prepare` step that runs inside
the same coroutine and is awaited, which also puts a failure to save on the
same Toast path as a failure to create instead of dropping it in a
coroutine nobody reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:17:36 -04:00
Vitor PamplonaandClaude Opus 5 cd1e2776ee fix(marmot): sign the account identity proof with the bare signer
No Amethyst account could mint a KeyPackage. Every attempt died in
`AccountIdentityProofV2.create` with "signer altered the account identity
proof event tags", so nothing was published to relays, no group could be
created, and the failure was invisible -- the exception surfaced no toast
and, with `VERBOSE_LOGS` off, no log line either.

The account signer is a `NostrSignerWithClientTag`, which appends the
NIP-89 client tag to everything it signs; the setting defaults to on, so
this was the ordinary path on device rather than an exotic one. The check
it tripped exists so a substituting external signer cannot authorize a key
the caller never asked to authorize, and it cannot tell a decorator's
addition apart from a hostile one -- correctly, since both rewrite the
bytes about to be hashed into an id.

So the proof is signed by the signer with that decorator peeled off, which
is what `withoutClientTag()` is for and what every other "these exact bytes
were requested" caller already does. A proof is a fixed statement about a
key rather than a post: it carries no client attribution, and a verifier
would have to strip the tag again anyway.

Verified on device: the app now publishes a framed kind:30443 (466 bytes,
`0001 0005 0001 0001`), White Noise Android finds it, and a group created
there reaches the app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 17:17:36 -04:00
Vitor PamplonaandGitHub 3c6eb8ee55 Merge pull request #4070 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-10 15:57:57 -04:00
Vitor PamplonaandGitHub 9984cd3662 Merge pull request #4071 from vitorpamplona/claude/search-filter-pills-jer24y
Unify search implementation across Android and Desktop
2026-09-10 15:57:48 -04:00
Vitor PamplonaandClaude Opus 5 21dee6eb2e fix(search): take away All and People while the box holds a filter
The people search is one NIP-50 string against kind 0, so every other
token in the box is dropped without a word the moment the scope includes
People: `#bitcoin since:2025-01-01` came back with people named "bitcoin"
and no hint that the date had been thrown away.

Only `kind:` admitted this, which left the other fourteen fields keeping
the toggle enabled while doing nothing. `isEventOnly` becomes
`pinsToNotes`, and the rule is now the widest one that cannot lie: free
text and its operators (`OR`, `-exclude`, quoted phrases) leave the
toggle alone, and anything else -- authors, `from:`, kinds, pseudo-kinds,
`since:`/`until:`, hashtags, `lang:`, `domain:`, `to:`, `label:`, NIP-73
scopes, `group:` -- greys All and People.

A few of those (`domain:` against a nip05, an npub `from:`) could be
answered by a people search built to; none are today, and offering a
scope that ignores half the box is worse than not offering it.

The screen needed no wiring: `enabled = !pinnedToNotes || s == NOTES`
already covered both halves. The scope stays derived rather than written
back over the reader's pick, so deleting the chip gives back the scope
they chose -- verified on device along with each case in the table.

Tests take one case per pinning field, so a field added to SearchQuery
and forgotten there fails rather than silently doing nothing again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 15:43:44 -04:00
Vitor PamplonaandClaude Opus 5 73a9d37f2d feat(search): show which relays the search is still waiting on
The spinner could only ever say "still going", and it said it off a stopwatch:
`settled` means "1200ms since you last typed", not "the relays answered". So a
search that hung looked the same as one that had finished, and neither said why.

The sub-assemblers knew all along. They build the per-relay filters and they are
handed every EOSE — the information was reaching `EOSEByKey` for `since`
bookkeeping and going no further. `SearchQueryState` now records both halves:

- `asked`, written as the filters are built
- `answered`, written from `newEose`

Additive and keyed by the query, because the posts and the people assemblers run
off the same state; a call that replaced the set would leave whichever ran second
looking like the only one that asked anything. `newEose` had to become `open` for
a subclass to see it.

Tapping the spinner now lists the relays outstanding, with the ones already in
below them dimmed, under "Waiting on 2 of 5 relays".

The spinner itself changed with it. The timer is now only the floor — something
has to show in the moment before any relay can reply — and after that the real
signal takes over: it turns while a relay still owes an EOSE. Otherwise it would
vanish 1.2s in and the popup would be untappable, which is how I found this
worth doing.

A relay that never sends EOSE would spin forever, so there is a 12s ceiling on
how long the spinner will admit to waiting. Past it the spinner stops and the
list still names who never answered.

Verified on device: "Waiting on 2 of 5 relays", antiprimal.net and
relay.ditto.pub outstanding, nostr.wine / relay.noswhere.com / search.nos.today
dimmed as answered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 13:14:07 -04:00
Vitor PamplonaandClaude Opus 5 9b7512a22a fix(search): the spinner never stopped, because it could not see it had
`isRefreshing` read `state.settled.value` inside `derivedStateOf`. A StateFlow
read is invisible to the snapshot system, so the derivation re-ran only when
`searchValue` changed -- the one snapshot input it had. The spinner therefore lit
on the first keystroke and stayed lit for as long as the box held text, long
after the results were on screen.

Observed: type a query, wait forty seconds with results fully rendered, and the
arc is still turning.

The same flow already has a correct reader two hundred lines down, where the
empty state collects it with `collectAsStateWithLifecycle`. That is why "nothing
found" timed out properly while the spinner did not -- one consumer observed the
flow, the other sampled it once and never looked again.

Mirrored into snapshot state in the view model so both of `isRefreshing`'s
inputs invalidate it. Verified on device: spinner present a few seconds in, gone
by twenty-eight, where before it ran past forty.

Worth noting what this does not fix: `settled` is a timer, not knowledge. It says
"long enough since the query changed", not "the relays answered" -- there is no
EOSE on this path. The spinner is now honest about the heuristic it has, not
about the search.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 12:29:59 -04:00
Vitor PamplonaandClaude Opus 5 8241b44a49 fix(search): the refactor moved another field below its eager collector
Opening search crashed again, one field along from last time:

    NullPointerException: SearchQueryState.getSearchQuery()
    on a null object reference
      at SearchBarViewModel.updateDataSource(SearchBarViewModel.kt:504)
      at SearchBarViewModel$searchTerm$2.invoke(SearchBarViewModel.kt:162)

Same mechanism as the `listState` crash: `searchTerm` and `sourceWatcher` are
shared `Eagerly`, so they run `updateDataSource` inside the constructor, and the
pipeline refactor left `searchDataSourceState` declared below them. Kotlin
initialises in declaration order, so it was still null.

Worse than the first one, though. `listState` is only touched on the non-blank
branch, so an empty box survived; `searchDataSourceState` is touched on *both*
branches, so this crashed opening search from anywhere -- seeded or not, Home
included.

Moved above the collectors, next to `listState`, and the comment there is now a
rule rather than a note about one field: everything `updateDataSource` touches
is declared above that line. Two occurrences in two passes over this class say
the ordering is not obvious from reading it, so it is worth stating plainly for
whoever tidies these fields next.

Verified on device both ways: search from Home (unseeded) and from Reads
(seeded, `kind:article`) — no crash on either, and the seed still chips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 12:10:42 -04:00
Vitor PamplonaandGitHub a8e8778265 Merge pull request #4069 from believethehype/main
Introducing DVM heartbeats
2026-09-10 10:53:46 -04:00
Vitor PamplonaandClaude Opus 5 6f83f6c5ed fix(search): initialise the list before the collector that scrolls it
Opening search from any seeded screen crashed the app on the spot:

    NullPointerException: Attempt to invoke virtual method
    LazyListState.scrollToItem(...) on a null object reference
      at SearchBarViewModel.updateDataSource(SearchBarViewModel.kt:479)
      at SearchBarViewModel.<init>(SearchBarViewModel.kt:131)

`sourceWatcher` shares its flow `Eagerly`, so `onEach { updateDataSource(...) }`
runs while the constructor is still executing, and `updateDataSource` scrolls
`listState` -- which was declared *after* it. Kotlin initialises properties in
declaration order, so at that moment the field is still null.

It was latent until this branch. `updateDataSource` returns early on a blank
term, and the box always opened blank, so the scroll was never reached during
construction. Seeding the field with the screen's own filter makes the term
non-blank on the very first pass, which turns the ordering bug into a crash the
moment search opens from any seeded feed.

Moving the declaration above the collector fixes it, and the comment says why it
has to stay there -- the next person to tidy these fields alphabetically would
put it back.

Reproduced deterministically before the change (Reads -> search, fresh crash
buffer, one FATAL) and confirmed gone after, on the same tap sequence. The
feature it was blocking now works: search opened from Reads seeds `kind:article`
as a chip, tapping the chip offers Change/Remove, and Change cuts it to `kind:`
and opens the new kind picker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 18:58:35 -04:00
Vitor PamplonaandGitHub 8e3c604d45 Merge pull request #4068 from vitorpamplona/claude/remove-kind-5-62-search-7t2emu
Add DeletionEvent and RequestToVanishEvent to CacheSearch filter
2026-09-09 11:58:23 -04:00
Vitor PamplonaandGitHub 224ad016cb Merge pull request #4067 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-09 10:47:23 -04:00
Vitor PamplonaandGitHub 8bf30c90ae Merge pull request #4066 from vitorpamplona/claude/amethyst-search-field-vespa-xyao1i
feat(search): run the search box's tokens as real filters, locally and on relays
2026-09-09 10:42:58 -04:00
Vitor PamplonaandClaude Opus 5 7fc7a3e059 test(napplet): read the shell resource where a resource can be read
`shellTemplateKeepsTheOpaqueIframeAndSourceChecks` sat in `commonTest`, so it ran
on both targets: green under `:commons:jvmTest`, and red under
`:commons:testAndroidHostTest` with

    MissingResourceException: ... files/napplet/shell.html.
    Android context is not initialized.

`NappletWebContract.shellHtml()` goes through Compose Resources, whose Android
reader needs an initialised `Context`. A host unit test has none and commons does
not use Robolectric, so the same assertion was red or green depending only on
which target happened to run it. That is the worst shape for a test: it fails on
a developer's machine having passed in whatever ran last, and it kept the whole
module red regardless of the change under review.

Moved to `jvmTest` as `NappletShellResourceTest`. Nothing about it is
platform-specific -- `shell.html` is one shared file, so reading it once on the
JVM checks its contents everywhere. What is genuinely not covered is the Android
resource plumbing, which needs a `Context`; that wants an instrumented test, and
the KDoc says so rather than leaving the gap silent.

The other two contract tests never touch a resource and stay in `commonTest`,
still running on both targets. `:commons:testAndroidHostTest` now passes 1530
tests with no failures, and `:commons:jvmTest` 1913 -- the shell assertion among
them, so it is still enforced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-09 10:39:42 -04:00
Vitor PamplonaandClaude Opus 5 96133560ff test(search): stop the date tests asserting UTC when the parser means local
Six tests failed on any machine outside UTC, by exactly that machine's offset:

  QueryParserTest     sinceDate, sinceDateYearOnly, combinedQuery
  QuerySerializerTest sinceDate, untilDate, combinedQuery

They hardcoded 1735689600L -- 2025-01-01 midnight *UTC* -- but the parser now
returns the reader's own midnight, and the serializer formats through
`DateUtils.localDay`. In America/New_York the parser answered 1735707600 and the
serializer rendered "2024-12-31", both correct. The production code is right; the
assertions were left behind when the bound became local.

The same file already had it right further down, where the newer cases assert
`LocalClock.startOfDay(SearchDate(...))` under a comment explaining that a bound
is the reader's midnight and not UTC's. These six now say the same thing, so they
state the intended behaviour rather than the behaviour of a UTC build machine.

`timestampToDate2025` keeps its UTC literal deliberately: `timestampToDate` is
plain epoch arithmetic in `DateUtils`, not the local formatter `serialize` uses.
Changing it would have broken a passing test -- the two paths in `QuerySerializer`
genuinely differ.

Verified green in America/New_York, UTC, Pacific/Auckland and Asia/Kolkata --
37 + 19 tests, zero failures in each. Full quartz, commons and amethyst suites
pass locally, which they did not before this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-09 09:39:45 -04:00
Vitor PamplonaandGitHub f97b6f3b5e Merge pull request #4065 from vitorpamplona/claude/event-kind-34259-parsers-jocu9w
docs(amethyst): plan for kind-34259 entity ratings in the Home feed
2026-09-08 22:00:55 -04:00
Vitor PamplonaandClaude Opus 5 a735498a6f perf(relay): build the bulk filters in one pass
These run on every filter rebuild, several times a second, and the previous shape
was a chain of operators that each allocated an intermediate: `partition`,
`groupBy`, `map`, `distinct`, `sorted`, `chunked`, `map`, then a list concat.
`groupBy { kind to pubKeyHex }` allocated a `Pair` and a boxed `Int` per address
on top of that.

Now one pass into nested maps, sorting in place, appending straight to the output
list. `distinct()` went entirely: the input is a `Set<Address>` and `Address` is a
data class, so two entries in one (kind, author) group cannot share a `d` -- it
was dead work every call. The two group maps are allocated only if that kind of
address turns up, and `forEachChunk` hands the whole list through untouched when
it already fits, which is nearly always, instead of `chunked` building an outer
list and a copy.

Measured, same machine, JVM, against the previous implementation:

  8 addresses (typical), 200k reps : 368.8ms -> 75.8ms   4.9x
  240 addresses (a book),  5k reps : 132.5ms -> 52.0ms   2.5x

The small case gains most, which is the one that runs constantly -- 1.84us to
0.38us per call. Behaviour is unchanged: same 7 tests, and the 240-section index
still renders all its rows on device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 19:41:57 -04:00
Vitor PamplonaandClaude Opus 5 7f056f152d docs(relay): correct why the bulk filters are chunked
The previous commit justified the chunk with "relays cap the values they will
accept in a tag filter". No relay was observed doing that, and no such cap
exists: neither NIP-11 nor `RelayLimits` has a values-per-filter field, and
`LimitsPolicy` never checks one. That was inferred from the `chunked(100)` the
other bulk builders use, and stated as fact. It was not.

The mechanism that does exist is the limit clamp. `LimitsPolicy.applyLimits`
rewrites a filter's `limit` down to the relay's `maxLimit`, so the addressable
group -- which sets `limit` to its coordinate count -- would ask for 240 and be
answered with `maxLimit` of them, the remainder missing silently. Chunking keeps
each `limit` under the clamp. That is the real reason, and it is now what the
comments say.

The chunk size itself stays convention, and says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 19:32:59 -04:00
Vitor PamplonaandGitHub 6207a357e0 Merge pull request #4064 from vitorpamplona/claude/event-rendering-links-5f1vsq
Make Birdex species names clickable links to Wikidata
2026-09-08 18:32:15 -04:00
Vitor PamplonaandClaude Opus 5 69b986ef67 perf(relay): ask for many addresses in one filter instead of one filter each
`filterMissingAddressables` built a filter per address. Opening a 240-section
publication therefore put 240 filters -- each `kinds`+`authors`+one `#d`, `limit`
1 -- into a single REQ per relay, when every one of them shares a kind and an
author and differs only in `d`.

Grouping by (kind, author) turns that into one filter carrying every `d`. The
`limit` becomes the number of coordinates asked for rather than 1: these are
replaceable, so a relay holds exactly one event per coordinate and that count is
the ceiling.

Both bulk builders now chunk at 100 values, the size the other bulk filter
builders in this module already use. Relays cap the values they accept in a
filter, and a filter silently truncated loses its tail with no error to notice --
which is the failure this was reported as. The id path had the same exposure: it
already coalesced ids into one filter per relay, but unbounded.

Generic by construction: `filterMissingEventsForThread` calls these same two
functions, so threads get it without touching the thread assembler. Replaceables
with no `d` keep their own group, since kind and author alone address them.

Measured on the 240-section Aeschylus index, cold start each time: a black screen
for ~2 minutes at 120% CPU before, first paint at ~22s after. All 240 rows still
resolve to real titles, with zero "Untitled section" -- and those titles can only
come from fetched sections, since the index's `a` tags carry a relay hint in slot
2, which `fromAddressTag` correctly refuses to read as a title.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 17:03:02 -04:00
Vitor PamplonaandClaude Opus 5 6d1f679f45 feat(publications): give a chapter its book, and a way through the book
A section opened on its own said nothing about what it was a section *of*, and
offered no way to the next one -- so a book was readable only by returning to the
index and picking the next row by hand.

Sections carry the back reference already: `T` holds the index's bare `d` (59 of
the 60 live sections surveyed carry it; `c` is a second spelling the same
publisher emits). It is an identifier, not a coordinate, so `publicationAddress()`
reconstructs the index from it plus the section's own author -- an index and its
sections share one, and the alternative, scanning the cache for an index that
lists this section, costs a walk per chapter.

- A crumb above the title names the book and opens it. Deliberately a crumb and
  not `PublicationHeader`: a cover, blurb and 34-row contents on top of the
  chapter you just opened would bury it.
- A pager below the body moves to the neighbours in the index's own order. Drawn
  only when the index actually lists this section, since that listing is the only
  thing that defines an order. The ends stay blank rather than disabled -- there
  is no chapter before the first, and a greyed control invites a tap that cannot
  do anything.

Verified on device across Wuthering Heights: Chapter II shows "Wuthering Heights"
above its title and CHAPTER I / CHAPTER III below, tapping CHAPTER III lands on
it, and Chapter I correctly offers CHAPTER II with no previous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 15:20:08 -04:00
Vitor PamplonaandClaude Opus 5 b8de178c91 feat(library): read the rest of a file record, and let it open
A 32176 is a manifest for a file split across Blossom servers, but the parser
stopped at five tags, so the card showed a title, a summary, a poster and a raw
byte count -- and a tap did nothing, because no URL had been read.

Everything that makes it a *piece* index went unparsed: `r` (a whole-file URL),
`blossom` (the servers holding the pieces), `x` (the file hash) and every `b`
(one piece's hash and byte count). Those are now accessors, with `pieces()`
keeping publication order because that is reassembly order and must not be
sorted.

The card gains what a reader can act on:

- the byte count humanized -- `31838839` was a number you had to decode
- the piece count, the thing the kind exists for
- the Blossom servers on the byline; without them the hashes name something
  unreachable
- a tap opens the whole-file URL. Nothing in-app reassembles pieces yet, so this
  is the one address that plays. With no `r` there is nothing to open and the
  card stays inert rather than pretending otherwise.

Verified against silberengel's "Glyfada evening tide": reads `file - 32 MB -
31 pieces` over `files.sovbit.host`, and tapping opens the video. The 31 `b`
tags sum to exactly the declared 31838839 bytes, so the piece list parses whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 14:09:56 -04:00
Vitor PamplonaandClaude Opus 5 6099ed6d03 fix(library): name nested directory entries, and read the schema.org spelling
Two things a real bookshelf turned up.

**A nested directory read "Untitled section".** `Library.kt` reuses
`PublicationSectionRow`, whose title `when` knew only the publication kinds, so a
directory listing another directory fell to `else -> null` and then to the
section placeholder -- both the wrong name and the wrong noun. A directory lists
whatever it likes, so the row now names the library kinds too, learning resources
and piece indexes included.

**A learning resource read as its `d` slug.** `title()` looked only at `title`,
but publishers that tag themselves `type: LearningResource` follow schema.org and
emit `name`/`description`. Both spellings are accepted now, `title`/`summary`
winning where an event carries both, so nothing that renders today changes.

Not a bug, checked and left alone: Laeserin's directory shows `my-book-collection`
because that event carries no `title` *or* `name` at all -- the `d` fallback is
the right answer there.

Verified on device against the same two events that showed the defects: entry 3
of `my-book-collection` now reads "nostr" instead of "Untitled section", and the
German resource reads "Caesar-Scheibe - 30 Buchstaben: A-Z plus Ae Oe Ue ss"
instead of "17xu8qb7". Two tests added for the vocabulary split, including the
precedence case; LibraryEventsTest 11/11 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 12:43:12 -04:00
Vitor PamplonaandClaude Opus 5 9ba5a7663c fix(threadview): render the six library and citation kinds when opened
`RenderNoteRow` and `NoteMaster` are two independent dispatch chains, and the
library, citation, section and relay-review kinds only reached the first. So
every one of them rendered in a feed row and then fell apart when you tapped it.

Six kinds were affected -- 31/32/33 citations, 30045 directories, 30142 learning
resources, 32176 piece indexes, 30041 publication sections and 31987 relay
reviews -- degrading two ways depending on whether the kind carries `content`:

- 30045 rendered as an avatar and an action row with nothing between them, since
  a directory's whole substance is its `a` tags.
- 30041 fell through to the generic body: the prose showed, but with no title,
  no AsciiDoc conversion and no wikilink resolution, so `RenderPublicationSection`
  never ran. That one is on a path the feature builds itself -- a publication's
  table of contents links straight into it.

Citations get one branch on the `CitationEvent` supertype, matching the feed.

Verified on device against live events for the four kinds that have any:
Laeserin's `my-book-collection` now lists its 7 entries by title, Wuthering
Heights' CHAPTER I renders titled and converted, the German learning resource and
the "Glyfada evening tide" file record both reach their own renderers. Nothing
has published a kind 31/32/33 on any of the eight relays checked, so those three
are wired and compiled but unverified against real data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 12:24:58 -04:00
Vitor PamplonaandClaude Opus 5 7ffd43e750 fix(publications): translate the summary, and stop double-framing the post
Two things wrong with how a kind-30040 index rendered as its own post.

**The summary was a bare `Text`.** It is authored prose -- a blurb, unbounded,
with no `maxLines` -- so a plain Text gave it no translate offer and rendered any
url, mention or hashtag inside it as dead literal characters. It now goes through
`TranslatableRichTextViewer`, the same overload the rating and relay-review bodies
use, which wraps `ExpandableRichTextViewer` and so also brings the Show-More
collapse that a blurb of this length wants. Threading it needs `makeItShort`,
`canPreview`, `quotesLeft` and `backgroundColor`, which puts `PublicationHeader`
and `RenderPublicationIndex` on the same parameter shape as `RenderEntityRating`.

The visible asymmetry this removes: a German *review* of a book offered
translation while the book's own German blurb did not.

**The card framed the whole post.** `replyModifier` draws the quote border used
for something cited inside a note. A 30040 carries no content beside this header,
so the border wrapped the entire post and read as a citation of something else --
a card inside the note row's own frame, with nothing outside it. Now a plain
Column, and the card's horizontal insets go with it since the note row already
indents. This is the split LongForm already makes: `LongFormHeader` keeps the
card for a feed row, `RenderLongFormHeaderForThread` drops it for the thread.

Verified on device against two of `npub1m4ny6...`'s publications: the English
Wuthering Heights index renders flush with the note frame with a Show-More
summary, and the German "Odysseus: Mythos und Wahrheit" renders its blurb with
"Auto-translated from German to English" -- which the bare Text could not do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 11:37:43 -04:00
Vitor PamplonaandClaude Opus 5 b9165f421d fix(ratings): show a user's own ratings and publications on their profile
A rating only reached its author's profile if somebody had reposted it. Both
profile gates were missing the kinds, so the events never arrived and would not
have been accepted if they had:

- `UserProfilePostKinds2` did not request 34259 or 30040, so the profile REQ
  never asked the author's outbox relays for them.
- `UserProfileNewThreadFeedFilter.acceptableEvent` did not accept either type.

The branch wired the Home feed's equivalent pair but not this one. The
addressable scan here has no kind allow-list of its own (unlike Home's
`ADDRESSABLE_KINDS`), so the DAL side is just the two type checks.

The rating check mirrors Home's: a rating with nothing to point at cannot be
rendered, so `hasTarget()` gates it rather than leaving an empty row on the
author's own profile.

Kinds deliberately left out: 30041 publication sections, because they are
chapters rather than posts and one book would bury a profile under 34 entries;
and 31987 relay reviews, which have the same shape as ratings but were never in
any feed and should be a decision of their own.

Verified on device against `npub1m4ny6...`, whose two ratings differ usefully:
the Wuthering Heights one has three kind-16 reposts and so was already visible,
while "Am Fluss der Zeiten" has none. The latter now renders on the profile as a
plain entry with no repost header, which it could only do by arriving through
these gates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 11:09:05 -04:00
Vitor PamplonaandGitHub 2e4d36a0b5 Merge pull request #4063 from vitorpamplona/fix/repost-icon-tint
fix(ui): tint the un-boosted repost icon like the rest of the action row
2026-09-08 10:52:14 -04:00
Vitor PamplonaandClaude Opus 5 88f5c30d59 fix(ui): tint the un-boosted repost icon like the rest of the action row
`RepostIcon` defaults to `tint = Color.Unspecified`, so the boost button drew
its glyph in the ambient content colour while every other idle icon in the row
-- reply, like, zap -- is drawn in the `grayTint` the row is handed. Passing it
explicitly puts the un-boosted state back in line; the boosted state is
unaffected, since `RepostedIcon` keeps its own `RepostedColor` default.

Follow-up to a31364b999, which fixed the liked and reposted colours.

Authored by Vitor Pamplona; committed from a Claude Code session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-08 09:20:25 -04:00
Vitor PamplonaandClaude Opus 5 aa54508e21 fix(ratings): fill earned stars, and render ratings and 30040s in thread view
Three defects found testing the branch on device against the two real kind-34259
events in the wild (both `m=books`, both five stars, from `npub1m4ny6...`).

**The stars were never filled.** Section 11.1 is right that `Star` and
`StarBorder` sharing U+F09A is not a bug, but the conclusion drawn from it --
that tint alone can carry the score -- does not survive contact with the screen:
a five-star review drew five *hollow* stars, and since `showLabel` is false
without a decimal there was no numeral either, so a full score read as an empty
row. Worse, `StarHalf` (U+E839) *is* a distinct half-solid glyph, so 4.5 would
have drawn four hollow stars beside one half-filled one -- the half star looking
more earned than the full ones. Now passes `filled = isOn` to draw the earned
stars solid off the font's FILL axis.

**Tapping a rating showed no rating.** The `EntityRatingEvent` branch went into
`NoteCompose.RenderNoteRow`, but a thread's focused note is drawn by `NoteMaster`
in `ThreadFeedView`, which has its own dispatch chain -- so opening a rating fell
through to the generic body: no stars, no cover, no publication. `HighlightEvent`,
the precedent section 4 cites for the no-`computeReplyTo` design, *is* in that
chain; the rating was not. Added beside it.

**The rated publication was a dead end.** `PublicationIndexEvent` is parsed but
had no renderer at either seam, so the one tappable thing on a rating card led to
a note showing nothing but a hashtag. `Publication.kt` renders the index: cover,
title, author, `type - version - N sections`, summary and topics. Built on the
`LongFormHeader` idiom but with a 2:3 portrait cover -- NKBIP-01's default type
is `book`, and the wide hero letterboxes every jacket. Wired at both seams.

It renders the card, not a reader: a 30040 carries no content of its own, and the
30041 sections it points at are still unparsed, exactly as sections 7 and 11.3
intended. `type` is shown as published rather than mapped through a string table,
because the spec calls that vocabulary open-ended and a table would blank every
value it has not enumerated. The publication branch sits in the body chain rather
than the header `when` above it: a 30040 has no body, so the generic renderer
would otherwise add a second copy of the topics the card already shows.

No font regeneration: the cover placeholder reuses `MaterialSymbols.MenuBook`,
already in the subset and already this feature's book icon.

Verified on device (Pixel 9, API 36) against the Wuthering Heights rating: solid
stars in both the thread and list paths, and the publication resolving to its
title, author, cover, blurb and `Book - 1.0 - 34 sections`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-07 22:19:34 -04:00
Vitor PamplonaandClaude Opus 5 87a44b97b2 feat(icons): let Material Symbols draw solid via the FILL axis
Material Symbols expresses fill through a variable **FILL axis**, not through a
second codepoint: `star`, `star_border`, `star_outline` and `grade` all map to
`f09a`. The bundled variable font carries that axis (`FILL 0..1`), but
`ProvideMaterialSymbols` pinned it to `MaterialSymbolsDefaults.FILL` (0) for the
whole tree, so there was no way to draw any symbol solid — a "filled" icon and
an "empty" one differed only by tint.

Adds a second family at FILL=1 on its own CompositionLocal, built beside the
outline one so both are allocated once per subtree rather than per call site,
and threads `filled: Boolean = false` through `rememberMaterialSymbolPainter`
and `Icon`. The two Font constructions collapse into one `symbolFont(weight,
fill)` helper so the variants cannot drift on the other three axes.

`filled` is ignored when the caller supplies its own `family`: that font is the
caller's (Amethyst's own icon font, via `AmethystIconGlyph`) and need not have
the axis at all.

Additive with a default, so no existing call site changes. Requires API 26 for
`FontVariation`, which is the project minSdk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wwk8tDEaEvsNoGbtrjZavz
2026-09-07 22:18:07 -04:00
Vitor PamplonaandGitHub 1b4acc15b5 Merge pull request #4062 from vitorpamplona/claude/gif-loading-freeze-yt06sl
fix(images): stop copying whole GIFs into RAM before decoding them
2026-09-07 21:56:39 -04:00
Vitor PamplonaandGitHub 33ef4f6305 Merge pull request #4060 from vitorpamplona/claude/amethyst-commons-migration-ua8ma8
Move the thread, badges, app-recommendations, connected-apps and home datasources to commons/relayClient
2026-09-06 13:44:03 -04:00
Vitor PamplonaandGitHub 15b6aace52 Merge pull request #4059 from vitorpamplona/claude/amethyst-commons-migration-ua8ma8
Move the relay-filter subassemblies and datasource assemblers to commons/relayClient
2026-09-05 17:13:19 -04:00
Vitor Pamplona a22bc0db14 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2026-09-04 11:50:33 -04:00
Vitor Pamplona a31364b999 Fixes lilked and reposted colors 2026-09-04 11:48:56 -04:00
Vitor Pamplona 9b589b4d66 Updates AGP to 9.4.0 2026-09-03 21:57:02 -04:00
Vitor PamplonaandGitHub f5e2f2bb59 Merge pull request #4057 from vitorpamplona/claude/amythyst-blue-background-e3slhf
Sync in-app theme to system per-app night mode for splash screen
2026-09-03 18:22:04 -04:00
Vitor PamplonaandGitHub fe4a982b9f Merge pull request #4056 from vitorpamplona/claude/compose-escaping-fix-f7g5em
ci(crowdin): convert Android escaping to Compose escaping during the sync
2026-09-03 16:45:25 -04:00
Vitor PamplonaandGitHub d3fd42e74f Merge pull request #4054 from vitorpamplona/claude/compose-escaping-fix-f7g5em
Fix smart quotes in localization strings across all languages
2026-09-03 16:25:07 -04:00
Vitor PamplonaandGitHub 161a771b23 Merge pull request #4053 from vitorpamplona/claude/text-input-string-bounds-d7z8ww
Fix TextFieldState races by dispatching UI-thread writes to Main
2026-09-03 16:06:27 -04:00
Vitor PamplonaandGitHub 48efe1adb7 Merge pull request #4047 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-09-03 15:58:31 -04:00
Vitor PamplonaandGitHub 0385592c68 Merge pull request #4048 from vitorpamplona/claude/concord-relay-limit-error-1zk89e
Remove 3-relay cap from invite links; support up to 255
2026-09-03 15:55:53 -04:00
Vitor PamplonaandGitHub 8fa40c1475 Merge pull request #4049 from vitorpamplona/claude/amethyst-navigation-crash-ulffj5
Cap route text arguments to prevent navigation matching failures
2026-09-03 15:55:43 -04:00
Vitor PamplonaandGitHub 0a735d0359 Merge pull request #4050 from vitorpamplona/claude/recycled-bitmap-mediasession-wzgfil
Fix MediaSession artwork scaling crash on some OEM ROMs
2026-09-03 15:54:32 -04:00
Vitor PamplonaandGitHub e6e05c5915 Merge pull request #4051 from vitorpamplona/claude/mlkit-genai-nullpointer-nge2pm
Fix ML Kit GenAI crash on future cancellation
2026-09-03 15:54:08 -04:00
Vitor PamplonaandClaude Opus 5 9585e66e60 ci(strings): gate the Compose catalog escaping check
Wires compose_escaping_check.py into the two layers that already police the
identical orphan-strings desync, mirroring pre-push-orphan-strings.sh:

  - the fast `lint` job in build.yml, next to the orphan check
  - a PreToolUse hook, so a push or PR from an agent session is gated too

CI is the layer that matters here. As amethyst/src/main/res/CLAUDE.md records for
the orphan desync, these arrive through bot-authored PRs -- the Crowdin sync
reintroduced 2,068 escaped apostrophes across 40 locales twice in two days, with
no local session anywhere in the path.

Verified end to end: a payload with no push exits 0 without spawning python; a
push against a clean tree exits 0; a push with values-tr-rTR restored to its
pre-repair state exits 1 and names the file with a per-escape breakdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-03 14:19:04 -04:00
Vitor PamplonaandClaude Opus 5 f9baab0e92 fix(strings): re-apply escape conversion, and add a check so it stops recurring
The Crowdin sync reintroduced Android escaping into the Compose catalog a second
time (PR #4046, commit 2129e16044): the same 2,068 escaped apostrophes and 749
escaped quotes across 40 locale files, always the apostrophe-heavy regional
variants -- uz-rUZ 949, fr-rFR 341, fr-rCA 326, tr-rTR 189. Compose resolves
neither, so those strings render with a literal backslash.

Repairing after each sync is not a fix: Crowdin holds the Android-escaped source,
so every import brings it back. Add .claude/hooks/compose_escaping_check.py, a
sub-second scan of the Compose catalog for \' \" \? \@ and tools: attributes,
mirroring orphan_strings_check.py -- same shape of bug, same fix. \n, \t, \uXXXX
and \\ are left alone because Compose resolves those itself, and Android res trees
are not scanned because there the escaping is correct.

Verified in both directions: clean tree exits 0; restoring the regressed
values-fr-rFR makes it exit 1 and name the file with a per-escape breakdown.

Not yet wired into CI or the pre-push hook -- that is a maintainer call about
where the gate lives. As amethyst/src/main/res/CLAUDE.md notes for the identical
orphan-strings desync, the CI lint job is the layer that matters: these arrive
through bot-authored PRs with no local session in the path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-03 14:07:48 -04:00